Describe model railroad layouts in plain Python: track sections, turnouts, crossings, detection blocks and signals, and how they join together. There are no runtime dependencies.
from pytrackplan import Layout, Hand
L = Layout("Passing siding", section_ends=("west", "east"))
main = L.block("Main") # a detection block...
lead = L.section("Lead", 60, block=main)
t1 = L.turnout("T1", Hand.LEFT, block=main) # ...with a turnout inside it
thru = L.section("Thru", 120, block=main)
siding = L.section("Siding", 120, block="Siding") # a string names (or creates) a block
beyond = L.section("Beyond", 90, block="Beyond")
lead.east >> t1.points # ">>" joins two track ends
t1.through >> thru.west
t1.diverge >> siding.west
thru.east >> beyond.west
L.signal("S1", thru.east) # governs trains leaving Main eastward
L.next_block(lead.east).block # Block('Beyond'): passes through T1 and Thru, both in Main
t1.throw()
L.next_block(lead.east).block # Block('Siding')Track elements have named ports (their ends). A layout is the set of elements plus the joints between pairs of ports.
| Element | Ports | Internal paths |
|---|---|---|
Section |
a, b |
a–b |
Turnout |
points, through, diverge |
points–through when THROUGH, points–diverge when DIVERGE |
Crossing |
a1, a2, b1, b2 |
a1–a2, b1–b2 (never across) |
Turntable |
track0, track1… |
the bridge, joining the track it's lined up with to the one opposite |
A Block is a detection block: a named group of elements that report
occupancy together. It has no ends of its own, so it can hold turnouts and
crossings as well as sections. Elements with no block are undetected track,
which queries pass straight through.
A Signal stands at a port and governs trains leaving through it. It
belongs at a block boundary, and validate() warns if one isn't.
Ganged turnouts move together, like the two halves of a crossover on one
control: after L.gang(t1, t2), throwing either throws both, and route
search only uses positions the whole gang can be in.
A Turntable is a bridge that turns to line up with any of the tracks
around its pit: L.add(Turntable("TT", 16, opposite={0: 8, ...}, block=pit)).
Its ports are the tracks around the pit and its state names the one the bridge
is lined up with; opposite says which track faces which across the pit, and
only there does the bridge carry a train right over. Lined up with anything
else it's a stub, and a train that runs on stops on it. The turntable is the
bridge, so the block you give it is what a train on the bridge occupies.
tt.step() turns it to the next position and tt.step(-1) back, and
tt.positions lists them all. Like anything with OCTILINEAR = False, it's
drawn at its true angles instead of being squared off.
Which tracks face each other is a fact about the railroad, and a schematic
usually can't be trusted to show it, so opposite is always something you
declare rather than something the drawing is read for. validate() checks
what you declared against where the tracks are drawn: it warns if a declared
pair is more than tolerance degrees (5 by default) off being diametric, and
if two tracks of one turntable are drawn meeting the pit at the same point.
Blocks and sections have no built-in direction. Every query takes the port you're leaving through, so ovals, figure eights and reverse loops need no special handling.
A section's a and b ends can also go by compass (or any other) names:
w1 = L.section("W1", ends=("west", "east")) # w1.west is w1.a, w1.east is w1.b
n1 = L.section("N1", ends=("south", "north"))
L = Layout(section_ends=("west", "east")) # default for every sectionPorts print using their alias (W1.east), and element.port("East") accepts
either name, in any case.
L.walk(port)follows the track as it is currently set. It stops at the end of track, at a turnout set against you, or when it gets back to where it started.L.next_block(port)/signal.next_block()return the hop where the train enters the next block. It returns None if a turnout is set against the train first.L.next_blocks(port)returns oneRoutefor every way into a next block, under any turnout setting.L.routes(port, to=block)returns every route into a block. EachRoutehas.blocks,.settings,.set(),.is_set()and.conflicts_with(other), where two routes conflict if they share any track element.block.members/block.boundarylist what's in a block and the ports that lead out of it.L.validate()/L.describe()give warnings and a readable summary.
Give track ends positions on a schematic grid (x to the right, y down), then write an interactive page:
e1 = L.section("E1", at=[(0, 0), (2, 1), (4, 1)]) # a end, bends..., b end
L.place(t1.diverge, 6, 2) # any single port
L.to_html("plan.html")The two ports at a joint always share one position, so placing one moves the
other. Turnouts and crossings usually need no positions of their own, because
they take them from the section ends they're joined to. You only need place
where a turnout joins another turnout directly (crossovers, yard ladders).
validate() lists anything unplaced once you've started placing, and
to_html() refuses to draw until every port has a position.
When two joined ends can't be drawn at one point, for instance track that
leaves one edge of the plan and comes back at the other, join them with
L.jump(p, q) instead. The page marks each end with a chevron naming where
the track continues.
The page is a single file with no external dependencies. Blocks are colored, block boundaries and buffer stops are marked, and the leg of each turnout not in use is dashed.
Track is drawn like a circuit board, using only horizontal, vertical and 45°
segments. A segment that isn't already one of those becomes a 45° piece plus
a straight run. The 45° piece sits next to a turnout's points, so its legs
diverge there, and in the middle of the segment everywhere else, so a
crossing's two legs meet in the middle. Add bends to a section
(section(at=...)) to put corners exactly where you want them, or pass
octilinear=False to to_html() to draw straight lines between points.
Track never turns more sharply than 45° at a corner of the drawing's own
making: where a segment can be laid out more than one way, the one that
follows on from the segment before and leads into the one after is used. Some
corners are forced by where you put things — two sections meeting at a right
angle, say — and those validate() reports rather than redrawing, naming the
corner inside an element or the joint where two of them meet.
Text that isn't track, such as town, yard or industry names, goes on with
L.label("Gifford City", x, y, size=20). The text is centred at (x, y), and
size is its height in grid units, so it scales with the zoom and is hidden
when too small to read. Leave size out to keep the text one size on screen.
Labels never join anything, and element names are placed around them.
In the page you can:
- click a turnout to throw it, or a turntable to step it round (shift-click steps back);
- click a signal to highlight the route to its next block, or to see which turnout is set against it;
- shift-click a signal to change its aspect;
- hover over track to see its name and state and highlight its block;
- drag to pan, scroll to zoom, and use Fit to reset the view.
The drawing fills the browser window.
The page works on a copy of the layout, so throwing a turnout there doesn't
change your Python objects. The Changes as Python box gives the lines
(L["T1"].throw() and so on) that bring your layout into line with the page.
A layout goes to JSON and comes back exactly as it was:
from pytrackplan import load, save
save(L, "layout.json") # a path, or an open file
L2 = load("layout.json") # answers every query as L doesL.to_dict() and Layout.from_dict(data) are the same thing without a file,
and dumps/loads work on text. The file holds everything the model knows:
element types, names, blocks, states, port aliases, positions and bends, the
joints (jumps marked as such), gangs, labels, signals, the turntable's
opposite tracks, section_ends and the layout's name.
Keys are sorted and lists keep the layout's own order, so the same layout
always writes the same bytes and a file can be hashed to tell whether anything
has changed. The format carries a small version number and a file of another
version is refused rather than guessed at. Nothing in a layout file is ever
executed -- it is plain data, with no pickling and no eval -- so a file from
somewhere else is safe to read.
Custom element classes are written by class name and read back through
types:
load("layout.json", types={"DoubleSlip": DoubleSlip})Elements, blocks, signals and the layout each have a meta dictionary for
whatever the railroad needs that pytrackplan has no opinion about -- the
events a detector sends, the output that powers a block, which end of it is
positive, what a signal drives. It is plain JSON data, saved and loaded
untouched, and nothing in pytrackplan looks inside it:
L.block("Main").meta["lcc"] = {"occupied": "02.01.57.00.00.01.03.00",
"clear": "02.01.57.00.00.01.03.10",
"output": {"node": "02.01.57.00.00.01", "channel": 0},
"positive": "Thru.east", "invert": False}
L["T1"].meta["lcc"] = {"throw": "...", "close": "..."}Keep one dictionary per program under its own key, as above, and two programs hanging facts on one layout will not tread on each other.
pytrackplan.compat.olcbweb reads the grid schematic olcbweb draws, where
each cell holds one piece joining some of its eight ports:
from pytrackplan.compat.olcbweb import from_grid_plan
notes = []
L = from_grid_plan(plan, panel=panel, notes=notes) # panel: {"sensors": [...], "turnouts": [...]}Runs of joined plain cells become one Section each, turnout pieces become
Turnouts (a piece marked invert has its diverging leg wired to the
through port, so "closed" still means THROUGH), crossings and diamonds
become Crossings, a pair of link pieces becomes a jump(), bumpers are dead
ends, labels become Labels, and turnouts driven by one Panel turnout -- the
halves of a crossover -- are ganged. The cells are carried through as
drawing positions, so a converted plan can still be drawn.
The Panel's ids are the names: a block is named for its sensor and a turnout
for its Panel turnout, with names generated from the cell where there is none.
Nothing is dropped: every element's meta["olcbweb"] holds the pieces it was
made from and maps each of its ports to olcbweb's own "x,y,port" key, and
the plan's size, revision, modules and label pieces are in L.meta["olcbweb"]
-- a Label keeps only its text and where it goes, but a label piece may also
carry a rotation or the module it belongs to, so those are kept whole too.
Anything that could not be represented is appended to notes.
A turntable bigger than a cell is not a piece at all: a cell has eight sides,
each a whole cell from its middle, so a roundhouse fan of sixteen radials at
their own angles will not go on one however it is drawn. The plan declares
those beside its pieces, in a turntables list -- where the pit is and how
big, and for each radial track where it runs out to and which cell's end of
track it runs on from -- and each one becomes a Turntable with a Section
per radial, drawn at its true angle. opposite is declared there or left out,
never read back from the drawing.
examples/tmrc.py is the Tech Model Railroad Club layout: 168 blocks, 189
turnouts, three crossings and a turntable, written out as plain pytrackplan
calls. It was translated once from SCab2's display file (git/SCab2/full.nlay),
which stored a drawing rather than a track plan, so its connections are the
ones implied by lines meeting at a point. The turntable declares no opposite
tracks: SCab snapped every rim point to a small-integer slope, so the drawn
angles are lattice artifacts and can't say which stalls face each other. Until
they're declared, a loco can run onto the bridge but not straight over it. Run
it to print a summary and write examples/tmrc.html.
Slips, wyes and three-way turnouts are ordinary Element subclasses:
from pytrackplan import Element, Leg
class DoubleSlip(Element):
PORTS = ("a1", "a2", "b1", "b2")
STATES = ("straight", "diverging")
LEGS = (
Leg("a1", "a2", "straight"), Leg("b1", "b2", "straight"),
Leg("a1", "b2", "diverging"), Leg("b1", "a2", "diverging"),
)
slip = L.add(DoubleSlip("DS1", block=L.block("Slip")))
beyond.east >> slip.port("a1")pip install -e '.[dev]'
pytest
python examples/figure_eight.py