Layout, paths and vehicles¶
When distance matters -- a warehouse, a yard, a hospital corridor, an AGV aisle -- guessing a travel time is the weakest part of the model. Put the assets where they really are and let the geometry produce the time instead.
Three pieces, and deliberately no more: a path network, mover fleets that drive on it, and conveyors.
The path network¶
One section, network, with nodes at layout coordinates and segments joining them:
"network": {
"length_factor": 1.0,
"nodes": [{"name": "dock", "x": 0, "y": 10}, {"name": "aisle_w", "x": 20, "y": 10}],
"segments": [{"from": "dock", "to": "aisle_w", "length": 20, "two_way": true}]
}
Coordinates are metres once the background image declares a scale (View > Set scale from two points...); otherwise they are whatever unit you mean by them. The engine only ever divides a length by a speed, so the two must simply agree.
A segment's length defaults to the straight-line distance between its endpoints times
length_factor -- 1.0 for straight aisles, 1.25 for "the real route is a quarter longer than
the crow flies". Give it explicitly for a ramp, a curve or a detour.
Attach a block to a node with a STATION's node parameter. That is what makes a station a
place: travel to and from it is the shortest path along the segments.
Reading a distance¶
DISTANCE("a", "b") is the shortest path between two places, where either name may be a node, a
STATION block or a station alias:
DISTANCE(entity.station, "Shipping") / 1.4 a picker walking at 1.4 m/s
TABLE("Handling", entity.e_type) + DISTANCE("dock", "pick") / 60
The network is built once per run and every shortest-path tree is cached, so a repeated call is a dict lookup.
Quickest, not shortest, once a speed limit exists
A segment's optional speed caps how fast a vehicle crosses it, and it also weights the
route: a vehicle takes the quickest way at its own speed, so a short aisle behind a 1 m/s
limit loses to a longer free one. One route tree is cached per (start, speed), and most models
have one or two distinct speeds. A network that declares no limit anywhere routes by length,
which is the same ordering. DISTANCE() and the nearest dispatch rule stay by length --
"nearest" is about metres.
Walking: ROUTE in network mode¶
A ROUTE block normally samples a transfer-time distribution. Set travel to "network" and it
divides the shortest path between the two stations' nodes by the entity's own speed
expression:
{"type": "ROUTE", "params": {"travel": "network", "speed": "entity.walk_speed"}}
That is people and self-propelled loads: no vehicle is requested, nothing queues.
Driving: mover fleets and TRANSPORT¶
A fleets entry is a pool of vehicles:
{"name": "Forklifts", "count": 3, "speed": 50, "speed_empty": 70,
"load_time": "0.4", "unload_time": "0.4", "home": "dock",
"park": "stay", "dispatch": "nearest", "select": "nearest"}
A fleet is a resource. It registers under its own name, so everything resources already do
works on it unchanged: a capacity schedule puts trucks on shift, a failure breaks one down,
NQ("Forklifts") and IDLE("Forklifts") read it in an expression, and utilization, seize
counts and the three cost fields report in the ordinary resource_summary. What a fleet adds is
position per vehicle, which is what dispatching needs.
A TRANSPORT block runs the whole cycle:
request a vehicle -> it drives empty to the entity -> load -> it drives loaded to the destination -> unload -> free
The destination is the next station of the entity's routing, exactly as ROUTE would choose it,
or the STATION the block names in destination. When the routing is finished, TRANSPORT
forwards along its own drawn link instead, like ROUTE.
The two rules¶
| Rule | Chooses | Values |
|---|---|---|
dispatch |
whose request is served when a vehicle frees | fifo, nearest (shortest empty drive) |
select |
which idle vehicle takes a granted request | nearest, longest_idle, cyclic |
A TRANSPORT's priority is the primary key under both rules, so an urgent load always outranks
the geometry. "Longest waiting" needs no rule of its own: that is what fifo is.
park: "home" sends a vehicle back to its home node once it is empty, and it is available the
whole way: a request arriving mid-return diverts it from exactly where it has got to, turning
round if that is quicker. The return counts as empty travel, not idle time. park: "stay" (the
default) leaves it where it dropped off, which is what makes nearest dispatching worth having.
A capacity schedule or a failure takes a named vehicle out of service -- the one that has been standing still longest, wherever it is standing -- so the rest of the fleet keeps its positions while one is out. It still waits for a free vehicle rather than interrupting a job, so the down time itself is unchanged.
Multi-load tours¶
"loads": 3 lets one vehicle carry up to three entities per trip. When a vehicle is granted to a
TRANSPORT, up to two more TRANSPORTs already waiting on the fleet ride along (in dispatch
order, priority first). The vehicle visits every pick-up in nearest-neighbour order from where it
stands, then every drop in nearest-neighbour order, and each entity goes on from its own drop.
It never waits to fill, the fleet still counts vehicles (utilization is vehicle time), and the
fleet_summary column Loads per Trip shows how full the trips were. A worker fleet carries one load.
Gantry cranes¶
"motion": "gantry" (default "network") is a different kind of fleet: a bridge crane that
moves on its own two axes, not the path network.
{"name": "Crane", "motion": "gantry", "speed": 1.0, "speed_x": 2.0, "speed_y": 1.5,
"hoist_time": 5.0, "home": "cell_1"}
Travel between two nodes is max(|dx| / speed_x, |dy| / speed_y) + hoist_time -- the two axes
move at once, so the hop is bounded by the SLOWER one, not their combined distance, plus a fixed
hoist_time for raising and lowering the load. A gantry fleet needs no segment between its
nodes at all (node coordinates are all it reads) and ignores segment capacity: it is never on
one to begin with. TRANSPORT, tours and dispatch/select work exactly as above -- nearest
simply ranks by this same travel time instead of network distance. The fleet_summary distance
columns still report the honest straight-line distance driven, never the (longer) sum the two
axis moves would imply.
What you get back¶
fleet_summary: trips, loaded and empty distance, the four-way split of vehicle time
(loaded / empty / loading-unloading / idle) and the wait for a vehicle. segment_summary:
traversals and distance driven per aisle -- where the kilometres actually went -- plus, for a
segment with a capacity, Wait Time (total time vehicles stood waiting to enter it) and
Max Occupancy.
Segment traffic¶
A segment's capacity is how many vehicles may be on it at once; a two-way segment counts both
directions together. It is zone control: a vehicle asks for its next limited segment at the
end of the one it is on, and waits there -- still holding that one -- until there is room. Workers,
tours and park: "home" returns obey it too; a return diverted while waiting gives its places
back. Walking entities ignore it.
Two vehicles meeting head-on on a two-way capacity-1 aisle each wait for the other for ever.
Validation warns (W_SEGMENT_DEADLOCK_RISK) whenever such a segment sits on a network with more
than one vehicle, and a run that really locks up stops with a deadlock error naming who waits
for which segment. Make the aisle one-way, give it capacity 2, or add a passing place.
Empty distance is the number a layout study attacks. Every metre of it is a vehicle moving no work.
Workers¶
A fleet with "kind": "worker" is a pool of people, not vehicles: a SEIZE or PROCESS names it
with worker_fleet (and, optionally, worker_skill) instead of TRANSPORT carrying a part.
{"name": "Ann", "kind": "worker", "speed": 10, "home": "A",
"units": [{"name": "Ann", "skills": ["weld"]}, {"name": "Bob", "skills": ["paint"]}]}
units replaces count when a fleet needs named people and per-unit skills; each unit's home
defaults to the fleet's. The block must sit on a node (the place the worker walks to). Order at
the block: its own resource first (so balking, reneging and queue_capacity still apply to the
machine), then the worker request, restricted to units whose skills include worker_skill -- a
request nobody idle qualifies for is skipped, not queued behind, so a free painter is not blocked
by a waiting weld job. The worker walks empty to the node, is held for the service (and any
changeover), and is freed when the work ends (or, with worker_when_blocked: "stays", when a
blocked station hands its part on); parking
follows the fleet's park rule exactly like a vehicle. A worker fleet is still a fleet, so it is a resource: utilization, schedules, failures,
costs and energy need no worker-specific reading.
Conveyors¶
A CONVEYOR is not a DELAY. Three numbers define it:
{"length": 0, "speed": 12, "spacing": 1.4, "accumulating": true,
"exit_resource": "Loaders", "from_node": "pick", "to_node": "ship"}
- capacity =
floor(length / spacing)-- how many entities fit on the belt; - travel time =
length / speed; - entry rhythm = consecutive entries are at least
spacing / speedapart, so the belt can never feed faster thanspeed / spacing.
length: 0 takes the length from the network, between from_node and to_node.
What blocks the exit. A block downstream always accepts an entity, so a conveyor accumulates
only against something the model names: exit_resource, the end stop. An entity at the end must
take one unit of that resource before it may leave, and it carries the resource onward -- so the
natural pairing is a PROCESS with action "Delay Release" on the same resource at the far end.
With no exit_resource the exit never blocks, which is what a free-running belt is.
Then the two modes differ:
| Mode | When the exit blocks |
|---|---|
accumulating (default) |
the blocked entity stops at the end and the ones behind pack against it, each taking spacing of belt; the belt keeps loading while cells remain |
| non-accumulating | the whole belt stops -- nobody moves, nothing new gets on, and each entity resumes exactly where it stopped |
conveyor_summary reports entered, exited, average and maximum occupancy, blocked time and
average transit time.
A full belt stops the machine feeding it
A conveyor's cells are a finite space, exactly like a buffer's places: a station that has
finished a part and finds the belt full -- or stopped, or inside the entry rhythm
spacing / speed -- keeps holding the part and stays blocked, and its blocked share shows
up in buffer_summary. The part leaves the machine when it is physically on the belt.
Ceilings in this build¶
Recorded so a model written today keeps meaning the same thing later:
- segment capacity binds vehicles only: walking entities (ROUTE
travel = "network") pass freely, and there is no deadlock avoidance -- only detection; - no way to hold a vehicle across several legs outside a TRANSPORT; a multi-load tour is planned nearest-neighbour (not the shortest tour) and never waits for more loads;
- only the entity at a conveyor's end can block the belt, so a belt with mid-line diverts needs one conveyor per zone;
- gantry cranes sharing a runway do not collide with each other -- there is no ordering constraint on the shared axis, only each crane's own two-axis geometry.
Drawing coordinates¶
A block keeps its schematic position and may have a layout_position and a layout_rotation
(degrees): where it stands on the floor plan. A submodel's own path-network nodes turn with it, so
the distances to a rotated one change accordingly. Nothing else in a run reads them.
Worked example: the bundled model warehouse_forklifts.