Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

pytrackplan

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')

Model

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.

Named ends

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 section

Ports print using their alias (W1.east), and element.port("East") accepts either name, in any case.

Queries

  • 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 one Route for every way into a next block, under any turnout setting.
  • L.routes(port, to=block) returns every route into a block. Each Route has .blocks, .settings, .set(), .is_set() and .conflicts_with(other), where two routes conflict if they share any track element.
  • block.members / block.boundary list what's in a block and the ports that lead out of it.
  • L.validate() / L.describe() give warnings and a readable summary.

Drawing

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.

Saving and loading

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 does

L.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})

Facts that aren't track

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.

Reading an olcbweb track plan

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.

The TMRC layout

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.

Custom elements

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")

Development

pip install -e '.[dev]'
pytest
python examples/figure_eight.py

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages