src/converter/MorpheusWriter.js

import Writer from "./Writer.js"

class MorpheusWriter extends Writer {

	constructor( model, config ){
		super( model, config )

		// property set by initXML.
		this.xml = undefined
		this.initXML()

		this.cellTypeTagIndex = {}

		this.fieldsToDraw = []

		this.logString = "Hi there! Converting " + this.model.from + " to Morpheus XML...\n\n"

	}

	write(){
		if( typeof this.target !== undefined ){
			this.target.innerHTML = this.writeXML()
		} else {
			//eslint-disable-next-line no-console
			console.log( this.writeXML() )
		}
		this.writeLog()
	}

	writeXML(){
		this.writeDescription()
		this.writeGlobal()
		this.writeSpace()
		this.writeTime()
		this.writeCellTypes()
		this.writeCPM()
		this.writeConstraints()
		this.writeCellPopulations()
		this.writeAnalysis()
		return this.formatXml( new XMLSerializer().serializeToString(this.xml) )
	}

	formatXml(xml, tab) { // tab = optional indent value, default is tab (\t)
		let formatted = "", indent= ""
		tab = tab || "\t"
		xml.split(/>\s*</).forEach(function(node) {
			if (node.match( /^\/\w/ )) indent = indent.substring(tab.length) // decrease indent by one 'tab'
			formatted += indent + "<" + node + ">\r\n"
			if (node.match( /^<?\w[^>]*[^/]$/ )) indent += tab             // increase indent
		})
		return formatted.substring(1, formatted.length-3)
	}

	initXML(){
		let xmlString = "<MorpheusModel></MorpheusModel>"
		let parser = new DOMParser()
		this.xml = parser.parseFromString( xmlString, "text/xml" )
		this.setAttributesOf( "MorpheusModel", {version: "4" } )
	}

	/* ==========	METHODS TO MANIPULATE XML STRUCTURE =========== */

	setAttributesOf( node, attr, index = 0 ){
		for( let a of Object.keys( attr ) ){
			this.xml.getElementsByTagName( node )[index].setAttribute( a, attr[a] )
		}
	}
	attachNode( parentName, nodeName, value = undefined, attr= {}, index = 0 ){
		let node = this.makeNode( nodeName, value, attr )
		this.addNodeTo( node, parentName, index )
	}
	makeNode( nodeName, value = undefined, attr= {} ){
		let node = this.xml.createElement( nodeName )
		if( typeof value !== "undefined" ){
			node.innerHTML = value
		}
		for( let a of Object.keys( attr ) ){
			node.setAttribute( a, attr[a] )
		}
		return node

	}
	addNodeTo( node, parentName, index = 0 ){
		let parent = this.xml.getElementsByTagName( parentName )[index]
		parent.appendChild(node)
	}
	setNode( nodeName, value, index = 0 ){
		this.xml.getElementsByTagName( nodeName )[index].innerHTML = value
	}

	/* ==========	OTHER HELPER METHODS =========== */

	toMorpheusCoordinate( coordinate, fillValue = 0 ){
		while( coordinate.length < 3 ){
			coordinate.push( fillValue )
		}
		return coordinate.toString()
	}

	/* ==========	WRITING THE MAIN XML MODEL COMPONENTS OF MORPHEUS =========== */

	writeDescription(){
		this.attachNode( "MorpheusModel", "Description" )
		this.attachNode( "Description", "Title",
			this.model.modelInfo.title )
		this.attachNode( "Description", "Details",
			this.model.modelInfo.desc )
	}

	writeGlobal(){
		this.attachNode( "MorpheusModel", "Global" )
	}

	writeSpace(){
		// Set <Space> tag and children
		this.attachNode( "MorpheusModel", "Space" )
		this.attachNode( "Space", "Lattice", undefined,
			{"class":this.model.grid.geometry } )
		this.attachNode( "Space", "SpaceSymbol", undefined,
			{symbol: "space" } )

		// Set the lattice properties.
		this.attachNode( "Lattice", "Size", undefined,
			{ symbol : "size",
				value : this.toMorpheusCoordinate( this.model.grid.extents ) } )
		this.attachNode( "Lattice", "Neighborhood" )

		if( typeof this.model.grid.neighborhood.order !== "undefined" ){
			this.attachNode( "Neighborhood", "Order",
				this.model.grid.neighborhood.order )
		} else if ( typeof this.model.grid.neighborhood.distance !== "undefined") {
			this.attachNode( "Neighborhood", "Distance",
				this.model.grid.neighborhood.distance )
		} else {
			this.conversionWarnings.grid.push( "Unknown neighborhood order; " +
				"reverting to default order 2 instead." )
			this.attachNode( "Neighborhood", "Order", 2 )
		}

		this.attachNode( "Lattice", "BoundaryConditions" )

		const dimNames = ["x","y","z"]
		const knownBounds = { periodic: true, noflux : true, constant: true }
		for( let d = 0; d < this.model.grid.boundaries.length; d++ ){
			let bType = this.model.grid.boundaries[d]
			if( !knownBounds.hasOwnProperty( bType ) ){

				this.conversionWarnings.grid.push(
					"Unknown boundary type : " + bType + "; setting to" +
					"default 'periodic' instead."
				)
				bType = "periodic"

			}
			this.attachNode( "BoundaryConditions", "Condition",
				undefined, { boundary: dimNames[d], type: bType } )
		}
	}

	writeTime(){
		this.attachNode( "MorpheusModel", "Time" )
		this.attachNode( "Time", "StartTime", undefined,
			{value: this.model.timeInfo.start } )
		this.attachNode( "Time", "StopTime", undefined,
			{value: this.model.timeInfo.stop } )

		if( typeof this.model.kinetics.seed !== "undefined" ){
			this.attachNode( "Time", "RandomSeed", undefined,
				{ value: this.model.kinetics.seed } )
		}

		this.attachNode( "Time", "TimeSymbol", undefined,
			{ symbol: "time" } )

	}

	writeCellTypes(){
		this.attachNode( "MorpheusModel", "CellTypes" )
		const ck = this.model.cellKinds

		for( let ki = 0; ki < ck.count; ki++ ){
			// Special case: background is always the first (index ki = 0 )
			if( ki === 0 ){
				this.attachNode( "CellTypes", "CellType", undefined,
					{ class : "medium", name : ck.index2name[ ki.toString() ] } )
			} else {
				this.attachNode( "CellTypes", "CellType", undefined,
					{ class : "biological", name : ck.index2name[ ki.toString() ] } )
			}
			this.cellTypeTagIndex[ this.model.getKindName( ki ) ] =
				this.xml.getElementsByTagName( "CellType" ).length - 1
		}
	}

	writeCPM(){
		this.attachNode( "MorpheusModel", "CPM" )

		this.attachNode( "CPM", "Interaction" )
		this.setAdhesion()

		this.attachNode( "CPM", "MonteCarloSampler", undefined,
			{stepper:"edgelist"})
		this.attachNode( "MonteCarloSampler" , "MCSDuration", undefined,
			{value:1})
		this.attachNode( "MonteCarloSampler", "Neighborhood" )
		let neighIndex = this.xml.getElementsByTagName("Neighborhood").length - 1
		this.attachNode( "Neighborhood", "Order", 2, {}, neighIndex )
		this.attachNode( "MonteCarloSampler", "MetropolisKinetics", undefined,
			{ temperature: this.model.kinetics.T } )
		this.attachNode( "CPM", "ShapeSurface", undefined,
			{scaling: "none" } )
		this.attachNode( "ShapeSurface", "Neighborhood" )
		neighIndex++
		this.attachNode( "Neighborhood", "Order", 2, {}, neighIndex )

	}

	writeConstraints(){
		const constraints = this.model.constraints.constraints
		for (let cName of Object.keys( constraints ) ){

			switch( cName ){

			case "Adhesion" :
				// is actually handled by this.writeCPM()
				break

			case "VolumeConstraint" :
				this.setVolumeConstraint( constraints[cName] )
				break

			case "PerimeterConstraint" :
				this.setPerimeterConstraint( constraints[cName] )
				break

			case "ActivityConstraint" :
				this.setActivityConstraint( constraints[cName] )
				break

			case "LocalConnectivityConstraint" :
				this.setConnectivityConstraint( constraints[cName], cName )
				break

			case "ConnectivityConstraint" :
				this.setConnectivityConstraint( constraints[cName], cName )
				break

			case "SoftConnectivityConstraint" :
				this.setConnectivityConstraint( constraints[cName], cName )
				break

			case "SoftLocalConnectivityConstraint" :
				this.setConnectivityConstraint( constraints[cName], cName )
				break

			case "BarrierConstraint" :
				this.setBarrierConstraint( constraints[cName] )
				break

			case "PersistenceConstraint" :
				this.setPersistenceConstraint( constraints[cName] )
				break

			case "PreferredDirectionConstraint" :
				this.setPreferredDirectionConstraint( constraints[cName] )
				break

			case "ChemotaxisConstraint" :
				this.setChemotaxisConstraint( constraints[cName] )
				break

			default :
				this.conversionWarnings.constraints.push( "Constraint :" +
					cName + " doesn't exist in Morpheus. Making the model anyway " +
					"without it; behaviour of the model may change so please " +
					"check manually for alternatives in Morpheus."
				)
			}

		}


	}

	multipleConstraintsWarning( constraintName ){
		this.conversionWarnings.constraints.push(
			"It appears as if your model has multiple constraints of type " +
			constraintName + "; ignoring all but the first."
		)
	}

	setAdhesion( ){
		if( this.model.constraints.constraints.hasOwnProperty("Adhesion")) {
			const JMatrix = this.model.constraints.constraints.Adhesion[0].J
			for (let ki = 0; ki < this.model.cellKinds.count; ki++) {
				for (let kj = 0; kj <= ki; kj++) {
					const j1 = JMatrix[ki][kj], j2 = JMatrix[kj][ki]
					if (!isNaN(j1) && (j1 !== j2)) {
						this.conversionWarnings.constraints.push(
							"Your adhesion matrix is not symmetrical, which is not" +
							"supported by Morpheus. Please check <Interaction> <Contact> values and " +
							"modify if required."
						)
					}
					let J = j1
					if (isNaN(J)) {
						J = 0
					}
					const iName = this.model.getKindName(ki)
					const jName = this.model.getKindName(kj)
					this.attachNode("Interaction", "Contact", undefined,
						{type1: iName, type2: jName, value: J})
				}
			}
		}
	}

	setVolumeConstraint( confArray ){
		if( confArray.length > 1 ){
			this.multipleConstraintsWarning( "VolumeConstraint" )
		}
		const conf = confArray[0]
		const lambda = conf.LAMBDA_V
		const target = conf.V

		for( let k = 0; k < lambda.length; k++ ){
			// only add constraint to CellType for which it is non-zero.
			if( lambda[k] > 0 ){
				const kName = this.model.getKindName(k)
				let constraintNode = this.makeNode( "VolumeConstraint",
					undefined, {target: target[k], strength: lambda[k]})
				this.addNodeTo( constraintNode, "CellType", this.cellTypeTagIndex[kName] )
			}
		}
	}

	setPerimeterConstraint( confArray ){
		if( confArray.length > 1 ){
			this.multipleConstraintsWarning( "PerimeterConstraint" )
		}
		const conf = confArray[0]
		const lambda = conf.LAMBDA_P
		const target = conf.P

		for( let k = 0; k < lambda.length; k++ ){
			// only add constraint to CellType for which it is non-zero.
			if( lambda[k] > 0 ){
				const kName = this.model.getKindName(k)
				let constraintNode = this.makeNode( "SurfaceConstraint",
					undefined, {mode: "surface", target: target[k], strength: lambda[k]})
				this.addNodeTo( constraintNode, "CellType", this.cellTypeTagIndex[kName] )
			}
		}
	}

	setActivityConstraint( confArray ){
		if( confArray.length > 1 ){
			this.multipleConstraintsWarning( "ActivityConstraint" )
		}
		const conf = confArray[0]
		const lambda = conf.LAMBDA_ACT
		const maximum = conf.MAX_ACT
		const actMean = conf.ACT_MEAN
		if( actMean !== "geometric" ){
			this.conversionWarnings.constraints.push( "You have an ActivityConstraint with" +
				" ACT_MEAN = 'arithmetic', but this is not supported in Morpheus. " +
				"Switching to 'geometric'. Behaviour may change slightly; please " +
				"check if this is a problem and adjust parameters if this is the case." )
		}

		for( let k = 0; k < lambda.length; k++ ){
			// only add constraint to CellType for which it is non-zero.
			if( lambda[k] > 0 ){
				const kName = this.model.getKindName(k)
				// Add the protrusion plugin to the celltype
				let constraintNode = this.makeNode( "Protrusion",
					undefined, {field: "act", maximum: maximum[k], strength: lambda[k]})
				this.addNodeTo( constraintNode, "CellType", this.cellTypeTagIndex[kName] )
				// We also need to add an activity Field to the <Global> tag.

				let actField = this.makeNode( "Field", undefined,
					{symbol: "act", value: "0", name: "actin-activity" } )
				let diff = this.makeNode( "Diffusion", undefined, {rate:"0"})
				actField.appendChild( diff )
				this.addNodeTo( actField, "Global" )
			}
		}

		this.fieldsToDraw.push( "act" )
	}

	setConnectivityConstraint( confArray, cName ){
		if( confArray.length > 1 ){
			this.multipleConstraintsWarning( "ConnectivityConstraint" )
		}
		const conf = confArray[0]

		// Hard constraint
		if( conf.hasOwnProperty( "CONNECTED" ) ){
			const conn = conf.CONNECTED

			for( let k = 0; k < conn.length; k++ ){
				if( conn[k] ){
					let constraintNode = this.makeNode( "ConnectivityConstraint" )
					this.addNodeTo( constraintNode, "CellType",
						this.cellTypeTagIndex[this.model.getKindName(k)] )
				}
			}
			if( cName === "LocalConnectivityConstraint" ){
				this.conversionWarnings.constraints.push( "Your artistoo " +
					"model has a LocalConnectivityConstraint, which is not " +
					"supported in Morpheus. Converting to the Morpheus " +
					"ConnectivityConstraint; behaviour may change slightly " +
					"so please check your model." )
			}
		}

		// Or the soft constraint
		else if ( conf.hasOwnProperty ( "LAMBDA_CONNECTIVITY" ) ){

			// add only to the cells for which it is non-zero.
			const lambda = conf.LAMBDA_CONNECTIVITY
			for( let k = 0; k < lambda.length; k++ ){
				if( lambda[k] > 0 ){
					let constraintNode = this.makeNode( "ConnectivityConstraint" )
					this.addNodeTo( constraintNode, "CellType",
						this.cellTypeTagIndex[this.model.getKindName(k)] )
				}
			}

			this.conversionWarnings.constraints.push( "Your artistoo " +
				"model has a " + cName + ", which is not " +
				"supported in Morpheus. Converting to the Morpheus " +
				"ConnectivityConstraint; this is a hard constraint so " +
				" behaviour may change slightly -- please check your model." )
		}

	}

	setBarrierConstraint( confArray ){
		if( confArray.length > 1 ){
			this.multipleConstraintsWarning( "BarrierConstraint" )
		}
		const conf = confArray[0]
		const barr = conf.IS_BARRIER

		// Add to cells for which it is set to true.
		for( let k = 0; k < barr.length; k++ ){
			if( barr[k] ){
				let constraintNode = this.makeNode( "FreezeMotion",
					undefined, { condition: "1"})
				this.addNodeTo( constraintNode, "CellType",
					this.cellTypeTagIndex[this.model.getKindName(k)] )
			}
		}
	}

	setPersistenceConstraint( confArray ){
		if( confArray.length > 1 ){
			this.multipleConstraintsWarning( "PersistenceConstraint" )
		}
		const conf = confArray[0]
		const lambda = conf.LAMBDA_DIR
		const dt = conf.DELTA_T || this.model.initCellKindVector(10)
		const prob = conf.PERSIST

		for( let k = 0; k < lambda.length; k++ ){
			// only add constraint to CellType for which it is non-zero.
			if( lambda[k] > 0 ){
				const kName = this.model.getKindName(k)
				let constraintNode = this.makeNode( "PersistentMotion",
					undefined, {decaytime: dt[k], strength: lambda[k]})
				this.addNodeTo( constraintNode, "CellType", this.cellTypeTagIndex[kName] )
				if( prob[k] !== 1 ){
					this.conversionWarnings.constraints.push( "Your model has a " +
						"PersistenceConstraint with PERSIST = " + prob[k] + ", but " +
						"Morpheus only supports PERSIST = 1. Reverting to this setting " +
						"instead, please check your model carefully." )
				}
			}
		}
	}

	setPreferredDirectionConstraint( confArray ){
		if( confArray.length > 1 ){
			this.multipleConstraintsWarning( "PreferredDirectionConstraint" )
		}
		const conf = confArray[0]
		const lambda = conf.LAMBDA_DIR
		const dir = conf.DIR

		for( let k = 0; k < lambda.length; k++ ){
			// only add constraint to CellType for which it is non-zero.
			if( lambda[k] > 0 ){
				const kName = this.model.getKindName(k)
				const direction = this.toMorpheusCoordinate( dir[k] )
				let constraintNode = this.makeNode( "DirectedMotion",
					undefined, {direction: direction, strength: lambda[k]})
				this.addNodeTo( constraintNode, "CellType", this.cellTypeTagIndex[kName] )
			}
		}

	}

	setChemotaxisConstraint( confArray ){

		for( let i = 0; i < confArray.length; i++ ){

			const conf = confArray[i]
			const index = i+1
			const fieldName = "U" + index

			const lambda = conf.LAMBDA_CH
			const field = conf.CH_FIELD

			// Warn for a CoarseGrid
			if( typeof field.upscale !== "undefined" && field.upscale !== 1 ){
				this.conversionWarnings.constraints.push(
					"Your ChemotaxisConstraint is linked to a 'CoarseGrid' with " +
					"a different resolution than the original CPM grid. This is not " +
					"supported in Morpheus. Adding a Field anyway, but you may have " +
					"to check and scale the diffusion rate."
				)
			}

			// Add the constraint to celltypes for which lambda nonzero.
			for( let k = 0; k < lambda.length; k++ ){
				if( lambda[k] > 0 ){
					const kName = this.model.getKindName(k)
					let constraintNode = this.makeNode( "Chemotaxis",
						undefined, {field: fieldName, strength: lambda[k]})
					this.addNodeTo( constraintNode, "CellType", this.cellTypeTagIndex[kName] )
				}
			}

			// For this to work, the concentration field 'U' needs to exist in 'global'.
			let fieldNode = this.makeNode( "Field", undefined,
				{symbol: fieldName, value : 0 } )
			let diffNode = this.makeNode( "Diffusion", undefined,
				{rate:0.1} )
			this.conversionWarnings.constraints.push( "Adding a ChemotaxisConstraint " +
				"with an attached field, but cannot find parameters like diffusion rate, " +
				"chemokine production rate, and decay rate automatically. Adding some " +
				"default values; please adapt these manually (by configuring constants " +
				"and properties under 'Global' and where " +
				"relevant under the 'CellTypes')" )
			fieldNode.appendChild( diffNode )
			this.addNodeTo( fieldNode, "Global" )

			// Also add the production/decay equation in a system
			let sysNode = this.makeNode( "System", undefined,
				{solver: "Euler [fixed, O(1)]", "time-step":1 } )
			let eqnNode = this.makeNode( "DiffEqn", undefined,
				{ "symbol-ref" : fieldName })
			let expr = "P" + index + " - d" + index +"*" + fieldName
			let exprNode = this.makeNode( "Expression", expr )
			eqnNode.appendChild( exprNode )
			sysNode.appendChild( eqnNode )

			// Add the constant degradation used in the equation
			let constNode = this.makeNode( "Constant", undefined,
				{ symbol: "d"+index, value : "0", name: "degradation "+fieldName } )
			sysNode.appendChild( constNode )

			// Add the production, space-dependent so use a field
			let productionField = this.makeNode( "Field", undefined,
				{"symbol": "P"+index, value : 0} )
			let eqn2Node = this.makeNode( "Equation", undefined,
				{ "symbol-ref" : "P"+index, name: "production "+fieldName })
			const randX = Math.floor( Math.random() * this.model.grid.extents[0] )
			const randY = Math.floor( Math.random() * this.model.grid.extents[1] )
			let expr2Node = this.makeNode( "Expression",
				"if( space.x == "+randX+" and space.y == "+randY+", 10, 0 )" )
			eqn2Node.appendChild( expr2Node )
			this.addNodeTo( eqn2Node, "Global" )
			this.addNodeTo( productionField, "Global" )
			this.addNodeTo( sysNode, "Global" )

			this.fieldsToDraw.push( fieldName )

		}



	}

	writeCellPopulations(){
		this.attachNode( "MorpheusModel", "CellPopulations" )

		let objects = {} // key for each kind, array of objects in InitCellObjects as value.
		let ID = 1

		for( let init of this.model.setup.init ){
			const k = init.kindName
			if( !objects.hasOwnProperty(k) ){ objects[k] = [] }

			switch( init.setter ){
			case "circleObject" :
				// objects added per cellkind below the loop.
				objects[k].push(this.makeNode("Sphere", undefined,
					{center: this.toMorpheusCoordinate( init.center ),
						radius: init.radius}))
				break

			case "boxObject" :
				// objects added per cellkind below the loop.
				objects[k].push(this.makeNode("Box", undefined,
					{origin: this.toMorpheusCoordinate( init.bottomLeft ),
						size: this.toMorpheusCoordinate( init.boxSize ) } ) )
				break

			case "cellCircle" : {
				let popNode = this.makeNode( "Population", undefined, {type:k } )
				let initNode = this.makeNode( "InitCircle",
					undefined, { mode: "random", "number-of-cells": init.nCells })
				let dimNode = this.makeNode( "Dimensions", undefined,
					{center : this.toMorpheusCoordinate( init.center ),
						radius: init.radius} )
				initNode.appendChild( dimNode )
				popNode.appendChild( initNode )
				this.addNodeTo( popNode, "CellPopulations" )
				break
			}

			case "pixelSet" : {
				let popNode = this.makeNode( "Population", undefined, {type:k } )
				let cellNode = this.makeNode( "Cell",
					undefined, { id: ID, name: ID })
				let ww = this
				let pixelList = init.pixels.map( function(p){
					return ww.toMorpheusCoordinate(p)
				} )
				let nodeNode = this.makeNode( "Nodes",
					pixelList.join(";") )
				ID++

				cellNode.appendChild( nodeNode )
				popNode.appendChild( cellNode )
				this.addNodeTo( popNode, "CellPopulations" )
				break
			}

			default :
				this.conversionWarnings.init.push( "Unknown initializer : " + init.setter +
				", ignoring; you may have to check the CellPopulations settings of your model" +
					"manually.")

			}
		}

		for( let k of Object.keys( objects ) ){

			if( objects[k].length > 0 ) {

				let popNode = this.makeNode("Population", undefined, {type: k})
				let initNode = this.makeNode("InitCellObjects",
					undefined, {mode: "distance"})


				let objArr = objects[k]
				for (let obj of objArr) {
					let arrNode = this.makeNode("Arrangement", undefined,
						{displacements: "0,0,0", repetitions: "1,1,1"})
					arrNode.appendChild(obj)
					initNode.appendChild(arrNode)
				}
				popNode.appendChild(initNode)
				this.addNodeTo(popNode, "CellPopulations")

			}
		}

	}

	writeAnalysis(){
		this.conversionWarnings.analysis.push(
			"Auto-conversion of plots and other output is not (yet) supported." +
			"Adding some default outputs, but please check and adjust these " +
			"manually."
		)
		this.attachNode( "MorpheusModel", "Analysis" )

		let gnuPlot = this.makeNode( "Gnuplotter", undefined, {"time-step":50} )
		gnuPlot.appendChild(
			this.makeNode( "Terminal", undefined, {name:"png"})
		)
		let plot = this.makeNode( "Plot" )
		plot.appendChild(
			this.makeNode( "Cells", undefined,
				{opacity:"0.2", value: "cell.type" })
		)

		// Plot the fields to draw
		for( let f of this.fieldsToDraw ){
			plot.appendChild( this.makeNode( "Field", undefined,
				{"symbol-ref": f } ) )
		}

		gnuPlot.appendChild( plot )
		this.addNodeTo( gnuPlot, "Analysis" )

	}

}

export default MorpheusWriter