The Tube in a Box

Following on from my previous posts on AgentScript and Google Maps, I’ve fixed the performance problem when zooming in and built a model of the London Underground to play with:

tube_in_a_box

An AgentScript model of the London Underground using data for 27 January 2014 at 15:42:00

I’m not going to include the modified code here as it’s grown a bit too long for a blog post, but the aim is to tidy it up and publish it on GitHub as something which other people can use as a library. The zooming in problem with my previous examples occurs because the Canvas used by AgentScript doubles in size each time you zoom in. Google Maps works by using tiles of a fixed size, but AgentScript isn’t designed to use tiles as it uses the vector based drawing methods of the Canvas object. My original idea for fixing the zooming in problem was to include a clip rect on all the Canvas elements which AgentScript adds. This doesn’t work and the only solution seems to be to limit the size of the Canvas to just what is visible on the screen. The new code contains a lot of transformation calculations to change the size of the Canvas as you pan and zoom. When the map is panned you can see the new visible area being drawn when the drag is released (see following YouTube video).

The only drawback of this is that the drawing Canvas for the turtle’s pen can’t be preserved between drag and zoom as it’s being clipped to the visible viewport. You can also see that the station circles aren’t circles as AgentScript is drawing in a Cartesian system which I’m fitting to a Mercator box. These are problems I hope to overcome in a future version.

Now that I’ve got a model of the London Underground in a box, I can start experimenting with it. The code to run the model is as follows:

	#######################################################
	#AgentScript
	#######################################################
	u = ABM.util # shortcut for ABM.util
	class MyModel extends ABM.Model
		#this is a kludge to get the bounds to the model - really need a class to encapsulate this
		constructor: (div, size, minX, maxX, minY, maxY, isTorus, hasNeighbors, bounds) ->
			@bounds_=bounds
			super(div,size,minX,maxX,minY,maxY,isTorus,hasNeighbors)
		setup: -> # called by Model constructor
			#console.log(@)
			#console.log(@gis(52,48))
			#@anim.setRate(10) #one frame a second (default is 30)
			@lineColours =
				B: [0xb0,0x61,0x10]
				C: [0xef,0x2e,0x24]
				D: [0x00,0x86,0x40]
				H: [0xff,0xd2,0x03] #this is yellow!
				J: [0x95,0x9c,0xa2]
				M: [0x98,0x00,0x5d]
				N: [0x23,0x1f,0x20]
				P: [0x1c,0x3f,0x95]
				V: [0x00,0x9d,0xdc]
				W: [0x86,0xce,0xbc]
				#lineY colour?

			#create nodes and drivers agents (drivers move between nodes)
			@agentBreeds "nodes drivers"
			@nodes.setDefault "shape", "circle"
			@nodes.setDefault "size", .2
			@nodes.setDefault "color", [0,0,0]
			@drivers.setDefault "size", 0.5
			@links.setDefault "thickness", 0.5

			#optimisations
			@refreshPatches = false
			@refreshLinks = false
			# @patches.usePixels()
			# @patches.cacheAgentsHere()
			@agents.setUseSprites() # 24 -> 36
			# @agents.cacheLinks()

			# globals
			#@numNodes = 30
			@numDrivers = 10

			#load tube station data from csv file
			xhr = u.xhrLoadFile('data/station-codes.csv','GET','text',(csv)=>
				#there are no quotes in my station list csv file, so parse it the easy way
				#jQuery csv or http://code.google.com/p/csv-to-array/ might be better alternatives
				lines = csv.split(/\r\n|\r|\n/g)
				for line in lines
					if line[0]!='#'
						data = line.split(',')
						stn = data[0]
						lon = data[3]
						lat = data[4]
						lon=parseFloat(lon)
						lat=parseFloat(lat)
						if !(isNaN(lat) and isNaN(lon))
							pxy = @gisLatLonToPatchXY lat, lon
							#ABM.Agent.hatch 1, @nodes
							#	@x=pxy.patchx
							#	@y=pxy.patchy
							#@nodes.hatch 1
							@patches.patchXY(Math.round(pxy.patchx),Math.round(pxy.patchy)).sprout 1, @nodes, (a) =>
								a.x=pxy.patchx
								a.y=pxy.patchy
								a.name=stn
			)

			#load network graph from json file
			xhr2 = u.xhrLoadFile('data/tube-network.json','GET','json',(json)=>
				#it looks like this returns a json object directly
				#test = JSON.parse json #newer browers support this, otherwise use var objJSON = eval("(function(){return " + strJSON + ";})()");
				#wait for both files (stations+network) to be loaded before making the links between station nodes
				u.waitOnFiles(()=>
					#console.log("xhr2 wait",@nodes.length)
					#json file has ['B'], ['C'], ['D'] etc array at top level for all lines
					#each of these contain { '0': zero direction array, '1': one direction array }
					#where each array is a list of OD links as follows: { d: "STK", o: "BRX", r: 120 }
					#d=destination, o=origin and r=runtime in seconds
					for linecode in [ 'B', 'C', 'D', 'H', 'J', 'M', 'N', 'P', 'V', 'W' ]
						#console.log("line data",json[linecode]['0'])
						for dir in [0, 1]
							for v in json[linecode][dir]
								agent_o = @nodes.with("o.name=='"+v.o+"'")
								agent_d = @nodes.with("o.name=='"+v.d+"'")
								@links.create agent_o[0], agent_d[0], (lnk) =>
									lnk.lineCode = linecode
									lnk.direction = dir
									lnk.runlink = v.r
									lnk.color = @lineColours[linecode]
									#now add a pre-created velocity for this link based on distance and runlink seconds
									dx=lnk.end2.x-lnk.end1.x
									dy=lnk.end2.y-lnk.end1.y
									dist=Math.sqrt(dx*dx+dy*dy)
									lnk.velocity = dist/lnk.runlink

					#NEW CODE TO LOAD POSITIONS FROM CSV
					@loadPositions()
				)
			)
			null # avoid returning "for" results above
		loadPositions: ->
			#get current positions of tubes from the web service
			xhr = u.xhrLoadFile('data/trackernet_20140127_154200.csv','GET','text',(csv)=>
				#set data time here - needed for interpolation
				lines = csv.split(/\r\n|\r|\n/g)
				for i in [1..lines.length-1]
					data = lines[i].split(',')
					if data.length==15
						#line,trip,set,lat,lon,east,north,timetostation,location,stationcode,stationname,platform,platformdirectioncode,destination,destinationcode
						for j in [0..data.length-1]
							data[j]=data[j].replace(/\"/g,'') #remove quotes from all columns
						lineCode = data[0]
						tripcode=data[1]
						setcode=data[2]
						stationcode = data[9]   #.replace(/\"/g,'') #remove quotes
						dir = parseInt(data[12])
						agent_d = @nodes.with("o.name=='"+stationcode+"'") #destination node station
						#find a link with the correct linecode that connects o to d
						if (agent_d.length>0)
							for l in agent_d[0].myInLinks()
								#console.log("l: ",l)
								if l.lineCode==lineCode and l.direction==dir
									#OK, so l is the link that this tube is on and we just have to position between end1 and end2
									#now hatch a new agent driver from this node and place in correct location
									#nominally, the link direction is end1 to end2
									l.end1.hatch 1, @drivers, (a) => #hatch a driver from a node
										a.name=l.lineCode+'_'+tripcode+"_"+setcode #unique name to match up to next data download
										a.fromNode = l.end1
										a.toNode = l.end2
										a.face a.toNode
										a.v = l.velocity #use pre-created velocity for this link
										a.direction = l.direction
										a.lineCode = l.lineCode
										a.color=@lineColours[l.lineCode]
			)
			null
		step: ->
			for d in @drivers
				d.face d.toNode
				d.forward Math.min d.v, d.distance d.toNode
				if .01 > d.distance d.toNode # or (d.distance d.toNode) < .01
					d.fromNode = d.toNode
					#choose new node to move towards
					#d.toNode = u.oneOf d.toNode.linkNeighbors() #.oneOf()
					#console.log(d)
					#console.log(d.fromNode.myOutLinks())
					#lnks = ABM.AgentSet.asSet(u.oneOf d.fromNode.myOutLinks())
					#vlnks = lnks.with("o.line=='V' && o.direction==1")
					#if (vlnks.length>0)
					#	d.toNode = vlnks.oneOf()
					###########################################

					#pick a random one of the outlinks from this node
					#NOTE: the agent's myOutLinks code go through all links to find any with from=me i.e. it's inefficient
					#also, you can't use "with" as it returns an array
					lnks = (lnk for lnk in d.fromNode.myOutLinks() when lnk.lineCode==d.lineCode and lnk.direction==d.direction)
					#console.log("LINKS: ",lnks)
					if (lnks.length>0)
						l = lnks[u.randomInt lnks.length]
						d.toNode = l.end2
						d.v = l.velocity
					else
						#condition when we've got to the end of the line and need to change direction - drop the direction constraint
						lnks = (lnk for lnk in d.fromNode.myOutLinks() when lnk.lineCode==d.lineCode)
						if (lnks.length>0)
							l = lnks[0]
							d.direction=l.direction #don't forget to change the direction - otherwise everybody gets stuck on the last link
							d.toNode = l.end2
							d.v = l.velocity
						else
							#should never happen
							console.log("ERROR: no end of line choice for driver: ",d)
							#d.die ?

			null # avoid returning "for" results above

The interesting thing about this is that when you’ve been running the model for a while, you start to notice that the tubes begin to bunch up together:

tube_compression

Snapshot of the London Underground model showing gaps opening up and bunching of trains

Compression waves aren’t supposed to exist in the tube network, but the graphic above clearly shows how a gap has formed in the District line to Wimbledon (green), while the Northern Line to Morden (black) shows three trains travelling south together. It’s more apparent on the YouTube video as you can see how this builds up from the starting condition (27th Jan 2014 15:42), where the tubes are evenly spaced. What I suspect is happening is a function of the network and the random choices that are being made when a train gets to a decision point. The model uses a random number generator (uniform) to make the route choice, so the lines with the most complex branches (e.g. Northern) are showing this problem as a result of the random shuffling of trains. Crucially, the Victoria Line doesn’t exhibit this phenomena as it’s a single piece of straight track.

So, based on the fact that I suspect this is a fault of the model, why would it be of interest in the real tube network? If the route decisions were made correctly based on service frequency and not a highly suspect but supposed to be uniform Javascript random number generator, then you would still see a form of this effect in real life. It must happen just because you can’t guarantee when a train from a connecting branch will join behind another one. The spacings are so close that any longer than average wait at a station will cause problems behind. Line controllers limit this problem by asking trains to wait at stations to maintain the spacing. This is completely missing from the model, which has no feedback of this kind, and so we see the network diverging. The key point is that we can measure how much intervention is required to keep the network in its ideal state, which is where the archives of real life running data come into play. By looking at data from the real network it should be possible to see where these sorts of interventions are being made and compare it to our model. It’s not difficult to add wait times at stations to simulate loading in the rush hour.

Link to YouTube video: http://youtu.be/owT3PfR5CWM