A standalone library that records component diagrams in a small JSON format and renders them to DXF and SVG with ezdxf.
pip install ezdxf networkxezdxf writes the DXF and SVG. networkx is needed only by
OrthogonalRenderer, which uses its planarity test and its minimum-cost-flow
solver; every other part of the library depends on ezdxf alone. Run from this directory, or put it on PYTHONPATH.
DotRenderer calls the graphviz dot binary (brew install graphviz or
apt install graphviz). When dot is not on the path it falls back to
BarycentreRenderer and records a note in renderer.notes.
python3 examples/build_demo_document.pyIt writes examples/out/demo_document.json, .dxf and .svg for a
dual-source data-centre one-line of 16 components. examples/build_symbol_sheet.py renders every symbol in the
library on one review sheet.
A document is a JSON list of component records. A record holds four fields and nothing else.
[
{
"name": "circuit_breaker",
"instance_id": "CB-001",
"label": ["XT4N 250 EKIP LSI IN=250A", "OFF"],
"connections": [["in", "UTIL-001", "out"], ["out", "XFMR-001", "primary"]]
}
]nameselects the component class.instance_idis the display name plus a three-digit number.labelis a list of free-text lines lettered under the instance id, one per line, such as["S203P C1,6 NA", "Fire detection"]. Every component carries aLabelobject holding them; it is empty by default, and a record that leaves the field out loads as empty. An open device is marked with anOFForN.O.line rather than a separate symbol, as IEEE 315 note 9.4.4A allows.- each connection is
[local_terminal, external_instance_id, external_terminal].
Every connection appears twice, once in each component it affects. Position and scale are never stored; the renderer decides them.
| Class | Role |
|---|---|
GeneralComponent |
Abstract base. Class-level draw, terminals, name, display_name, description, symbol_source, input_terminals, output_terminals; instance-level instance_id, xy_position, scale, connections. |
| 73 concrete components | All direct children of GeneralComponent, listed below. |
InstanceIdMinter |
Issues BUS-001, BUS-002, and so on. |
ComponentRegistry |
Maps a record name to its class. |
ConnectionChecker, ConnectionFinding |
Reports bad terminal names, missing mirrors, and unknown components. |
JSONStorer, JSONLoader |
Convert between component objects and JSON. |
DXFLineSet |
Strictly orthogonal connection segments; rejects a sloped segment. |
GeneralRenderer |
Abstract renderer: abstract arrange_components, concrete render. |
AlgorithmDescription, DescriptionWriter |
The plain-language account of one layout algorithm, and the writer that prints it wrapped to the console. |
TextStyleBuilder |
Registers the MINIWATT and MINIWATT-BOLD text styles, so labels and sheet text are lettered the way Mini-Watt letters a sheet. |
SheetPagePlanner |
Plans the exported page so the sheet plots at true size rather than being stretched to fill it. |
BarycentreRenderer |
JSON to DXF and SVG; depth rows ordered in one upstream barycentre pass. This is the default. |
LayeredRenderer |
Full four-phase layered Sugiyama. A rotatable one-in one-out device whose downstream neighbour is already fed by something else is lifted out of the ranking; where its two neighbours then land in the same band, the device is turned a quarter turn and seated between them as a level tie. |
DotRenderer |
Seventh alternative. Hands every component to the graphviz dot binary as one sized node, pins sources to the top rank and loads to the bottom, keeps tie-joined peers level and adjacent through rank=same groups and invisible chain edges, turns each tie in such a group a quarter turn so the cross-tie is drawn as a horizontal run between its two buses, then renumbers bus taps by neighbour x and wires each row gap through planned channels. |
Symbols follow United States one-line drafting practice, using the IEEE 315 vocabulary: geometry only, with the instance id lettered beside the glyph rather than inside it. Render the review sheet to see all of them at once:
python3 examples/build_symbol_sheet.py| Tag | Class | JSON name |
Terminals |
|---|---|---|---|
AHF |
ActivePowerConditioner |
active_power_conditioner |
in |
ATS |
TransferSwitch |
transfer_switch |
emergency_in, normal_in, out |
ATSI |
InlineTransferSwitch |
transfer_switch_inline |
emergency_in, normal_in, out |
BATT |
BatterySource |
battery_source |
out |
BUS |
Bus |
bus |
in_1.. / out_1.. (as needed) |
BUSWAY |
Busway |
busway |
in, out, tap_1, tap_2, tap_3 |
BWYBUS |
BuswayBus |
busway_bus |
in_1.. / out_1.. (as needed) |
CAP |
Capacitor |
capacitor |
in, out |
CBLV |
LowVoltageDrawoutBreaker |
breaker_lv_drawout |
in, out |
CBMV |
MediumVoltageBreaker |
breaker_mv |
in, out |
CBMVDO |
MediumVoltageDrawoutBreaker |
breaker_mv_drawout |
in, out |
CONT |
Contactor |
contactor |
in, out |
CPT |
ControlPowerTransformer |
cpt |
in |
CT |
CurrentTransformer |
ct |
in, out |
CTM |
MultiRatioCt |
ct_multi |
in, out |
DCDC |
DcConverter |
dc_dc_converter |
in, out |
DISC |
DisconnectSwitch |
disconnect_switch |
in, out |
EQP |
EquipmentLoad |
equipment_load |
in |
EVSE |
VehicleSupplyEquipment |
evse |
in |
FC |
FuelCellSource |
fuel_cell_source |
out |
FLY |
FlywheelStorage |
flywheel |
in |
FSW |
FusedSwitch |
fused_switch |
in, out |
FU |
Fuse |
fuse |
in, out |
GEN |
EngineGenerator |
generator_source |
out |
GFD |
GroundFaultDetector |
gfd |
in |
GND |
GroundingElectrode |
ground |
in |
GXFMR |
GroundingTransformer |
grounding_transformer |
in |
HF |
HarmonicFilter |
harmonic_filter |
in |
HTR |
HeaterLoad |
heater_load |
in |
ILK |
InterlockedSwitches |
interlocked_switches |
in_1, in_2, out_1, out_2 |
INV |
Inverter |
inverter |
in, out |
IT |
InformationTechnologyLoad |
it_load |
in |
LB |
LoadBank |
load_bank |
in |
LBE |
LoadBreakElbow |
load_break_elbow |
in |
LBS |
LoadBreakSwitch |
load_break_switch |
in, out |
LTG |
LightingLoad |
lighting_load |
in |
MCCB |
MoldedCaseBreaker |
breaker_mccb |
in, out |
MOT |
Motor |
motor |
in |
MTR |
Meter |
meter |
in |
NGR |
NeutralGroundingResistor |
ngr |
in |
NOPT |
NormallyOpenPoint |
normally_open |
in, out |
PDU |
PowerDistributionUnit |
pdu |
in, out |
PDUBUS |
PduBus |
pdu_bus |
in_1.. / out_1.. (as needed) |
PNL |
Panelboard |
panel |
in, out_1, out_2, out_3, out_4, out_5, out_6 |
PNLBUS |
PanelboardBus |
panelboard_bus |
in_1.. / out_1.. (as needed) |
PT |
PotentialTransformer |
pt |
in |
PV |
PhotovoltaicArray |
pv_array |
out |
RCL |
Recloser |
recloser |
in, out |
RCPT |
ReceptacleLoad |
receptacle_load |
in |
RCT |
Reactor |
reactor |
in, out |
RECT |
Rectifier |
rectifier |
in, out |
RLY |
ProtectiveRelay |
relay |
in |
RPP |
RemotePowerPanel |
rpp |
in, out_1, out_2, out_3 |
RPPBUS |
RemotePanelBus |
remote_panel_bus |
in_1.. / out_1.. (as needed) |
RVSS |
SoftStarter |
soft_starter |
in, out |
SEC |
Sectionalizer |
sectionalizer |
in, out |
SEPDN |
SeparatedDownstreamComponent |
separated_downstream |
in |
SEPUP |
SeparatedUpstreamComponent |
separated_upstream |
out |
SPD |
SurgeArrester |
surge_arrester |
in |
SPL |
CableSplice |
splice |
in, out |
SRC |
GenericSource |
generic_source |
out |
STS |
StaticTransferSwitch |
static_transfer_switch |
emergency_in, normal_in, out |
SWGR |
Switchgear |
switchgear |
in_1, in_2, out_1, out_2, out_3, out_4, out_5, out_6 |
SWGRBUS |
SwitchgearBus |
switchgear_bus |
in_1.. / out_1.. (as needed) |
TERM |
CableTermination |
cable_termination |
out |
TIE |
TieBreaker |
tie_breaker |
in, out |
TIENO |
NormallyOpenTieBreaker |
tie_breaker_normally_open |
in, out |
UPS |
UninterruptiblePowerSupply |
ups |
in, out |
UPSD |
DualFeedUps |
ups_dual_input |
bypass_in, in, out |
UPSR |
RotaryUps |
ups_rotary |
in, out |
UTIL |
UtilitySource |
utility_source |
out |
VFD |
VariableFrequencyDrive |
variable_frequency_drive |
in, out |
WTG |
WindTurbineGenerator |
wind_generator_source |
out |
XFMR |
PowerTransformer |
transformer_2w |
primary, secondary |
XFMRDY |
DeltaGroundedWyeTransformer |
transformer_delta_grounded_wye |
primary, secondary |
The glyphs were first converted from the Mini-Watt one-line symbol library,
then every one was checked against IEEE Std 315-1975 (R1993) and its 1986
supplement. Every class records the answer in a symbol_source string of at
most 30 characters, which the base class requires the same way it requires
name and description:
symbol_source |
Meaning | Count |
|---|---|---|
IEEE 315 <clause> p<page> |
The 1975 standard names a symbol for the device. | 49 |
IEEE 315A <clause> p<page> |
The 1986 supplement does. | (included above) |
Mini-Watt symbol library |
Neither standard has a symbol; the glyph is Mini-Watt's. | 25 |
unverified |
Neither. Nothing has been checked. | 1 |
The page is the page printed on the sheet, not the PDF page. ieee315_review.md
gives the rule used, how the page numbers and the Mini-Watt derivations were
established, and one row per component. The one unverified glyph is
Switchgear.
examples/build_symbol_sheet.py letters symbol_source under every symbol on
sheet.svg in green for a standard, blue for Mini-Watt and red for unverified,
so an unchecked glyph is visible at a glance.
Choices made where the standard leaves room:
- The medium-voltage breakers are open squares (9.4.4), not solid ones. A filled box reads as closed-status on a utility one-line, and this library carries no device state to assert.
- The meter and the relay carry placeholder text inside the circle,
WH(12.1) and51(9.5.1), so the two cannot be confused; a project legend should replace them with the real function letter or device number. - The panelboard, the remote power panel, and the busway gained the branch and tap terminals their glyphs already draw. Without them a distribution device could not feed anything downstream.
- Winding connections are carried by a distinct class rather than by a mark you
attach to a transformer:
DeltaGroundedWyeTransformerdraws the delta and grounded-wye marks inside the two winding circles (6.4.15.1, 13.3.5, 13.3.4).PowerTransformerstays available where the connection is not stated. The bare wye, grounded-wye and delta marks are not components of their own, since they have no terminals. - Equipment that source data often models as a bus has a bus-form twin, so the
drawing can show either:
Switchgear/SwitchgearBus,Panelboard/PanelboardBus,RemotePowerPanel/RemotePanelBus,PowerDistributionUnit/PduBus,Busway/BuswayBus. Each bus form is its own class holding a copy of the bus geometry, not a subclass ofBus.SwitchgearandSwitchgearBusshare thein_N/out_Nnaming, so one can replace the other without touching any connection, up to the eight terminals the enclosure draws. - A bus has as many terminals as it needs, named
in_1upward andout_1upward, created on demand. A terminal also takes any number of connections, so fan-out is never limited.
JSONConverter reads a Mini-Watt engineering document and writes this
library's format.
python3 examples/convert_miniwatt_document.py path/to/miniwatt-02.jsonIt writes the converted JSON, a DXF, an SVG, and a map file pairing every
source id with the instance id it became (MSWGR-A to SWGRBUS-001), so the
drawing can be traced back to the document it came from.
What it does with the source document:
- Topology comes only from
electrical_connections. Theconnected_bus,input_busandprimary_busfields repeat the same links, so they are ignored rather than wired twice. - A
circuitis an edge in the source document, not a device, so a closed one collapses into a direct connection between the two things it joins. - A normally-open circuit becomes a
NormallyOpenTieBreakerbetween them, because on a one-line an open tie has to be visible. - A transformer's stated
primary_switchbecomes a component in the primary path, so the bus feeds the switch and the switch feeds the winding. - A bus is drawn as a bus-form variant when its own name says what it is:
Main Switchgear Abecomes aSwitchgearBus,PDU AaPduBus. lineandloadterminals becomeinandout;outputbecomesout;inputbecomesin;primaryandsecondarykeep their names.- A source document's single
busterminal has to become a real terminal on our bus, so the converter reserves one per connection: a feed takes the next input, an outgoing circuit the next output. Nothing is ever reused, and the bus grows to fit. protective_devicesare drawn bysymbol_variant(lv_drawout,mccb,mv,fuse), falling back todevice_type. Switch typesswitch_disconnectorandcontactormap to the disconnect and the contactor;automatic_transfer_switch,transfer_switchandchangeover_switch_disconnectorall map to the transfer switch, since the IR authors every changeover as one record withline,alternate_lineandload.alternate_linelands onemergency_in.- An
interlocksrecord is letteredINTERLOCK - <name>on each member and not drawn, as IEEE 315 4.29 directs. - A UPS with a
static_bypass_inputorbypassconnection becomes aDualFeedUps; both terminals land onbypass_in. - An
equipmentrow is drawn byequipment_type:panelboard,switchboardorpad_mounted_switchgear,ups,ground_fault_detector; anything else is a general equipment load with a note. - A transfer switch whose
alternate_linehas no feed in the document gets aSeparatedUpstreamComponentlabelledALTERNATE SOURCE, plus thenot_stated.alternate_linetoken when the row names one, so the second inlet is visible and traceable to the document's own unknown. - Labels are composed from the row: product id and name for devices, name and
P = …for loads, name andline_voltagefor buses, and name, rating and product id for sources, transformers and UPSs. A normally-open device or connection adds anOFFline; a collapsed circuit's cable text is lettered on the device it feeds.
Every decision that is not a direct mapping is recorded as a ConversionNote,
readable through describe_notes(), so nothing is inferred silently. The
converter does not carry voltages, ratings or fault currents, since this
format holds only names, instance ids and connections; the map file is what
ties the drawing back to that data.
from component_render import InstanceIdMinter, JSONStorer, MoldedCaseBreaker, Renderer, UtilitySource
minter = InstanceIdMinter()
utility = minter.create_component(UtilitySource)
breaker = minter.create_component(MoldedCaseBreaker)
utility.connect_to("out", breaker, "in")
components = [utility, breaker]
JSONStorer().write_json_file(components, "document.json")
renderer = Renderer()
renderer.render_json_file("document.json", dxf_path="document.dxf", svg_path="document.svg")
print(renderer.describe_findings())A large one-line is often drawn in parts: side A on one sheet, side B on another, or two drawings side by side on one sheet. The part not drawn is stood in for by one of two single-terminal components:
| Class | Terminal | Arrow | Stands in for |
|---|---|---|---|
SeparatedUpstreamComponent |
out |
points toward the device it feeds | something upstream, drawn elsewhere |
SeparatedDownstreamComponent |
in |
points away from the device that feeds it | something downstream, drawn elsewhere |
Both draw the IEEE 315 one-way flow arrow (1.7.1, filled head) and both are rotatable, so they can meet a rotated device from the side. Say where the other end is with the label:
from_side_b = minter.create_component(SeparatedUpstreamComponent)
from_side_b.apply_label_lines(["FROM SIDE B"])
from_side_b.connect_to("out", transfer_switch, "emergency_in")Because the upstream stand-in has only an output and the downstream one only an
input, every layout algorithm already ranks the first as a source and the second
as a load; no renderer needed to change. examples/build_separated_drawing.py
builds the A side of a two-sided transfer scheme this way and writes
examples/out/separated/side-a.svg.
Subclass GeneralComponent directly, set name, display_name, description,
symbol_source, symbol_width, symbol_height, input_terminals,
output_terminals, then implement draw and terminals. The base class checks
the lengths and checks that every declared terminal is drawn as soon as the class
is defined. symbol_source is the one text field you may leave out: it defaults
to unverified, so a new component shows up red on the review sheet until
someone traces its glyph. Never guess a clause to turn it green.
A bus is the component whose terminal set and size are not fixed. It starts with no terminals at all and grows as connections are made:
bus.connect_to(bus.reserve_output_terminal(), panel, "in")reserve_input_terminal() and reserve_output_terminal() hand out in_1,
in_2, ... and out_1, out_2, ..., and ensure_terminal(name) grows the
counts when a stored document refers to a terminal that is not there yet, so
loading a document rebuilds the same bus it came from.
Positions follow the counts:
- A single input, or a single output, sits at the middle of the bus.
- Two or more are evenly spaced end to end.
- Inputs sit on one side of the drawn line and outputs on the other, half a line width off the centre, so the two sets never fight over the same point.
Length follows the busier side:
length = length_scale_factor * max(input_count, output_count) * distance_per_connection
capped at upper_length_limit and floored at minimum_length.
VisibilityRenderer is the one sanctioned exception to that formula: its whole
algorithm is stretching a bus to span the children hanging under it, so it sizes
a bus from its feeder geometry instead. It still honours the same cap and floor.
Every other renderer leaves the formula alone. The defaults are
a scale factor of 1.0, 12 units per terminal, a 20-unit floor and a 1200-unit
cap. The floor is declared once, on GeneralComponent, so every bus-like
class inherits the same value; the rest are class attributes you can tune
per class. arrange_components sets the length of every bus before it
places anything.
Because both the length and the counts are per instance, draw on a bus-like
class takes a fourth parameter and terminals takes three more: length,
input_count and output_count. Every other component keeps the plain form,
and the instance supplies its own values. Use component.measure_width() and
component.measure_height() rather than symbol_width and symbol_height
when you need the space a particular instance occupies, and
component.list_input_terminals() / list_output_terminals() rather than the
class tuples when you need roles.
Switchgear is deliberately not variable: its rectangle is an enclosure
outline, and stretching an enclosure to encode a connection count would
misstate the equipment.
InterlockedSwitches is variable in length but not in terminal count. It is
one symbol holding two switches, and the two switches sit at the ends of the
length, which is what lets a mechanical interlock span the sheet the way IEEE
315A 14.5.5 and the drawings that follow it draw it: two switches on two
separate feeders, joined by a line with a triangle at its midpoint. Because it
is length-variable it is bus-like to every renderer, so each of its two poles
gets its own upstream chain and its own drop to the bus below - a utility
branch on one side and a generator branch on the other, rather than both
incomers collapsed into one column. Its counts are pinned at two inputs and
two outputs, so the formula above gives a fixed span of 120 units, and in_1 /
out_1 are the left pole while in_2 / out_2 are the right one.
A component may also declare display_length, the length used when it is drawn
for show rather than for a diagram - on the symbol review sheet and in the
sheet legend, where a 120-unit span would set the width of every cell in the
grid. It defaults to default_length; only InterlockedSwitches overrides it.
Two connection routes that touch anywhere except a shared terminal are
ambiguous: a reader cannot tell a junction from a coincidence. DXFLineSet
therefore refuses to let that happen silently. Every route enters through one of
add_segment, add_path, add_route_points or add_elbow_route, each of which
is one connection between two terminals, and each is checked by
CoincidenceGuard against every route already placed before it is kept as a
RouteRecord. The guard names eight ways two routes can coincide:
| Kind | Meaning |
|---|---|
overlap |
two collinear segments share more than a point |
terminal_on_bend, bend_on_terminal, bend_on_bend |
a vertex of one route sits on a vertex of the other, and they are not both terminals |
terminal_on_route, bend_on_route |
a vertex of the new route sits on the interior of a placed segment |
route_on_terminal, route_on_bend |
the new route passes through a vertex of a placed route |
collinear_at_terminal |
two routes share a terminal and leave it along the same line, so they read as one wire passing through |
Two routes may share a terminal and leave it in different directions - a bus tap accepts any number of connections - and two routes may cross at right angles; neither is a conflict.
find_route_conflict(points) asks without placing, so a router can try again;
add_route places regardless and records any conflict, so a route is never
dropped from the drawing, and the renderer exposes what it found through
count_route_conflicts() and describe_route_conflicts(components), which
names the terminals at each end of both routes. The sheet transforms a line set
by copying its routes and conflicts through the same scale and shift, not by
re-adding segments, so the check runs once per route.
Every engine acts on the answer before it draws. The line set offers two
guarded entry points: add_route_if_clear(points) places a route only if it is
clear and otherwise places nothing, records a RouteRefusal and returns it; and
add_first_clear_route(candidates) tries an ordered list of point lists and
places the first clear one, recording exactly one refusal only when none is. No
engine calls the unconditional add_segment, add_path, add_route_points or
add_elbow_route any more. Each engine builds its own candidate families -
channel heights, side steps, terminal approaches, corridors - because each
engine routes differently; the per-engine notes below say what each one tries,
in what order, and why. Measured on the ABB side A document (Orthogonal on
miniwatt-02, since it does not finish side A):
| Engine | Conflicts before | After | Refusals | Connections unrouted |
|---|---|---|---|---|
TemplateRenderer |
74 | 0 | 0 | 0 |
BarycentreRenderer |
34 | 0 | 0 | 0 |
VisibilityRenderer |
17 | 0 | 0 | 0 |
OrthogonalRenderer |
11 | 0 | 0 | 0 |
DotRenderer |
4 | 0 | 0 | 0 |
SlotRenderer |
2 on miniwatt-02 |
0 | 0 | 15, pre-existing |
LayeredRenderer |
0 | 0 | 0 | 0 |
AnnealingRenderer |
not measured | 0 | 0 | 0 |
Slot's fifteen unrouted connections are the nine From Side B arrows and three
UPS-bypass chains it strands in its leftover row; they never had a route, before
or after. Two lessons recurred across engines. Two of them had built their own
coincidence check on channel heights alone, blind to vertices and to x extent;
deleting it in favour of the guard fixed the bend conflicts and, in Visibility's
case, made the arrange nearly four times faster. And the hardest conflicts in
three engines were the same geometry: a bus input and output sit 0.7 units apart
on opposite sides of the bus line and so share a column, which no channel height
can separate - the route has to approach the terminal from the side.
Where taps and feeders line up, none of this is needed. VisibilityRenderer
sizes a bus to its feeders, and with the cap raised to 1200 units it now puts
15 of the 16 taps on BUS-004 directly above their feeder; with the old
240-unit cap the same bus was 180 units long under 900 units of feeders.
TemplateRenderer used to place every conductor and count the damage
afterwards: on the ABB side A document it recorded 74 coincidences, the worst
of the eight engines. All 74 were the same fault. A bus fanning twenty feeders
sent every one of them down into the same horizontal channel halfway between
the bus and the row of breakers below, and the channel's own retry was capped
so tightly - a seven-unit margin off each end of a twenty-three-unit gap - that
only three heights existed for twenty-five runs. Where the fan overlapped in x,
the runs were drawn one on top of another and read as a single wire.
The engine now asks before it draws. Its six placement sites - the plumb drop,
the drop that has to pass a symbol box, the level run, the level run that has
to pass over a box, the elbow through a horizontal channel, and the side lane
for a run that goes backwards - each build a family of genuine alternatives and
hand the whole ordered list to add_first_clear_route, which places the first
one the CoincidenceGuard accepts. None of them calls add_segment,
add_path, add_route_points or add_elbow_route any more, so nothing is
placed without being checked first.
Each family is a StepLadder, which counts alternating outward steps away from
the position the draftsman would have picked:
| Ladder | First choice | Alternatives |
|---|---|---|
ChannelLadder |
the channel halfway down | channels stepped up and down about it, then the two L shapes that turn at one end's own height |
PlumbDropLadder |
the plain plumb line | a jog to one side and back, then the same jog arriving at the lower terminal sideways |
LevelRunLadder |
straight across | the run lifted or dropped by one step |
OverBoxLadder |
just above the box | higher, a step at a time |
BoxBypassLadder |
down the nearer side of the box | further out on that side, then the far side |
SideLaneLadder |
the nearer sheet edge | further out, the far edge, then a deeper drop into each |
A ladder returns its candidates as ordered families rather than one flat list, because the fallback shapes have to stay behind every ordinary one. Mixing them cost six connections on side A: an L that happened to miss every label box was tried ahead of a stepped channel that only just grazed one, and the L then stood in the way of the six runs behind it.
ClearanceSorter keeps the obstacle preference the engine already had - a
candidate clear of every symbol and label box first, one clear of only the
symbols next, one that hits something last - and applies it inside each family,
so a channel is still chosen for the same reasons as before and only reordered
among equals.
Order matters as much as the alternatives. RouteOrderer places the plumb
drops first, since a drop between two terminals on one vertical line cannot
move at all; then the level runs, then the elbows, then the side lanes, which
have the whole width of the sheet to play with. Inside the elbow group it walks
each channel band from the top of the sheet down and each band from left to
right, which is the order that colours an interval graph with the fewest
colours - so a fan of twenty feeders uses as few channel heights as the fan
actually needs.
The channel margin came down from seven units to one and a half and the step from three to two, which is what makes room for the eight or nine heights the densest fans on side A want inside a twenty-three-unit gap. The midpoint is still the first candidate, so a diagram that never conflicts is drawn exactly where it was drawn before.
Measured on the two documents, counting a connection as lost when no route ends on both of its terminals:
| Document | Pairs | Routes | Conflicts | Refusals | Unrouted |
|---|---|---|---|---|---|
| ABB side A, before | 176 | 176 | 74 | 0 | 0 |
| ABB side A, after | 176 | 176 | 0 | 0 | 0 |
miniwatt-02, before |
33 | 33 | 0 | 0 | 0 |
miniwatt-02, after |
33 | 33 | 0 | 0 | 0 |
Ninety-two of side A's 176 runs still take their first choice; the deepest any run has to reach is the twelfth candidate. The old global list of used channel heights is gone, because the guard answers the question the list was guessing at: two taps that fan opposite ways off one bus may now share a height, and only the ones whose spans actually overlap are pushed apart.
One shape earns its place twice. Two feeders into a single load terminal - a
dual-corded PDU, say - coincide when both arrive plumb, because they share the
last vertical leg as well as the terminal, and no channel height separates
them: the two routes meet before the channel matters. The fallback families are
the answer. ChannelLadder's L turns at the destination's own height, so the
second feeder arrives along a horizontal leg, and PlumbDropLadder's side
approach does the same for a drop. The guard allows two routes to share a
terminal when they leave it in different directions, and a reader can tell them
apart. That L is what clears the two refusals converted-layout-02 was left
with before the fallback families existed.
BarycentreRenderer no longer places a route and reports the damage afterwards.
Every connection is now offered to the line set as an ordered list of candidate
paths through add_first_clear_route, which places the first one that coincides
with nothing already drawn and withholds the connection only when the whole list
fails. The engine calls none of add_segment, add_path, add_route_points or
add_route, so no coincident line can reach the drawing by accident.
The candidates come from four families, tried in that order:
| Family | Shape | What it frees |
|---|---|---|
| straight | two points | the plumb pair, tried first when the terminals share an x |
| elbow | four points, one horizontal channel | the channel height |
| jog | six points, two channels and a side leg | a second channel and a sideways offset |
| side departure | five points, leaving one terminal horizontally | the direction a route leaves a shared terminal |
ElbowChannelBand holds the range a channel may move inside - strictly between
the two terminals' y values, or one row gap below a pair that sits level - and
its first choice, the midpoint. ChannelHeightLister walks that band twice:
fine steps of channel_step either side of the midpoint, then a sweep of the
whole band in channel_sweep_divisions parts, ordered by nearness to the
midpoint and de-duplicated against the steps. The fine steps answer the crowded
case; the sweep answers the tall one. BUS-003 on ABB side A fans 20 taps
through a 71-unit gap, which needs more than 17 channels, so channel_step
dropped from 4.0 to 2.0; and the feed into SEPUP-005 needs a channel 800 units
from its own midpoint, which only the sweep reaches.
Two of the eight coincidence kinds cannot be answered by moving a channel. Two
routes leaving the same terminal both start with a vertical stub at that
terminal's x, so they overlap however high the channel sits - the side departure
family exists for that, and the guard explicitly allows two routes to share a
terminal and leave in different directions. And a terminal that sits on the
interior of a route already placed is unreachable from any direction: no
candidate can help, because the conflict is at the endpoint itself.
TerminalObstacleIndex heads that off by indexing every component terminal by
rounded x and y and sorting the candidates so that any path running through a
terminal which is not one of its own two ends is tried last. That is what stops
the feeder into BUS-002 from being drawn straight over the BUS-005
terminals, which would have stranded both of BUS-005's own connections.
On the six documents in the repository the engine now records no conflicts and no refusals, and every connection pair reaches the paper:
| Document | Components | Conflicts before | Conflicts after | Refusals | Unrouted |
|---|---|---|---|---|---|
| ABB side A | 171 | 34 | 0 | 0 | 0 |
miniwatt-02 |
31 | 0 | 0 | 0 | 0 |
converted-layout-01 |
14 | 2 | 0 | 0 | 0 |
converted-layout-02 |
22 | 2 | 0 | 0 | 0 |
| demo document | 16 | 1 | 0 | 0 | 0 |
| separated drawing | 6 | 1 | 0 | 0 | 0 |
ABB side A carries 176 unique connection pairs and 176 placed routes, so the unrouted count is measured, not assumed.
Arranging ABB side A, 171 components and 176 connections, takes about one second.
Avoiding coincidence also improved the drawing on measures it was not aiming
at. Scored the way examples/compare_layouts.py scores a layout, ABB side A
went from 26784 to 21189 units of conductor, from 741 to 511 crossings and from
175 to 36 symbol pierces, on the same 522 segments and the same 176 routes.
Most of that is the terminal preference: a candidate that ran through a foreign
terminal was usually running through the symbol that terminal belongs to.
VisibilityRenderer no longer places a connection and then reports what it hit.
Every route is offered to the line set first, through is_route_clear, and only
a route that coincides with nothing already placed is kept, through
add_route_if_clear. Nothing in the engine calls add_route_points,
add_segment, add_path or add_elbow_route any more.
Refusing a route would make the drawing wrong in a way a reader cannot see - a
connection that exists in the data and not on the paper - so the engine offers
alternatives before it gives up. RouteCandidateSet builds them in five tiers
and hands each tier to RoutePlacer, which takes the first clear candidate in
the tier and only moves on when the whole tier is coincident:
| Tier | Candidates |
|---|---|
| 0 | the straight drop, and the level run under the labels of a tie |
| 1 | the upright jog, its horizontal channel swept across the gap between the two terminals |
| 2 | the sideways jog, its vertical channel swept across the columns between them |
| 3 | the detour, corridor by corridor around the symbol boxes |
| 4 | the same jogs again with the symbol-clearance test dropped, ending in a plain elbow |
ChannelSweeper is what makes tiers one and two a family rather than a single
line. It takes the two end values and the natural channel, insets the interval
so no candidate can land on a terminal, and returns the natural channel first
and then steps alternately above and below it. So the second connection into a
crowded gap is drawn one step off the first, the third one step the other way,
and a bus fanning out to ten breakers ends up with ten distinct channels rather
than ten copies of one.
Sweeping the vertical channel in tier two matters for a reason of its own: every
tier-one candidate leaves the start terminal vertically, so two connections
sharing one terminal can never both be drawn as upright jogs without reading as
a single wire passing through - the collinear_at_terminal case. The sideways
jog leaves that terminal horizontally instead. Neither the ABB side A document
nor miniwatt-02 reaches tier two, because a bus reserves a terminal per
connection, but the demo document and the separated drawing both hang two wires
on one breaker terminal and both take a sideways jog for the second.
Where the two terminals sit in one column - the ordinary case for that second
wire off a breaker - the sweep between them has nowhere to go, and the same
holds for the crossing height when they sit in one row. In that case the sweep
is replaced by the obstacle list: RouteFinder.list_corridors gives the columns
just clear of every symbol box and list_rows gives the heights, both ordered
outward from the midpoint. So the wire steps out to the nearest free column,
runs down it and comes back, instead of being refused.
What the old engine did instead was keep its own list of horizontal channel
heights it had already used, used_channels, and reject any elbow whose run sat
at a height already in the list. That list was the source of the
route_on_bend and bend_on_route conflicts unique to this engine. It was too
coarse in one direction - a height was blocked across the whole sheet, however
far away, which pushed fifteen connections into detours they did not need - and
too narrow in another: it recorded only horizontal runs, by height alone, and a
detour whose horizontal leg collapsed to zero length recorded nothing at all
while still leaving a corner behind. A later elbow then ran straight through
that corner. The list is gone; the guard does the work.
On the ABB side A document the engine now records no conflict, no refusal and no
unrouted connection, against seventeen conflicts before - ten overlap, four
route_on_bend and three bend_on_route. Every one of the 176
connections is on the paper. The route mix moved with it: 113 straight drops and
one level run are unchanged, the fifteen detours fell to one, and the seventeen
routes that were previously drawn blocked - placed on top of another route
because nothing else was tried - fell to none, since a swept channel was
available for all of them. miniwatt-02 records no conflict and no refusal too.
OrthogonalRenderer no longer places a connection and reports the damage
afterwards. Its one DXFLineSet placement site, WireRouter.place_route, hands
a family of candidate routes to add_first_clear_route and keeps the first that
CoincidenceGuard finds clear; add_route_points is not called from this engine
any more. A connection is withheld only when every candidate coincides, and the
RouteRefusal is counted in the engine report as route_refusals.
Almost none of it came from the shape stage. The router emitted one route per graph edge, and a graph edge is not a connection.
- A bus is modelled as a chain of tap vertices joined by
kind="chain"edges. Those chain edges were drawn as connection routes, so every bus spine was re-drawn as a row of separate routes lying end to end on one line. Each neighbouring pair shared a tap and left it along the same line, which iscollinear_at_terminal, and any feeder landing on a middle tap sat on one of those routes' terminals or interiors. The bus symbol draws that line itself, so the chain routes were duplicates of symbol geometry. Onminiwatt-02they accounted for nine of the eleven recorded conflicts. - Planarisation splits a connection at every crossing dummy and every port jog,
and each half was drawn as its own route. At a degree-four crossing that put
four route ends on one point, which reads as a junction rather than a crossing
and which the guard calls
collinear_at_terminal.
So the repair is upstream of any nudging. ConnectionPathFinder walks the
planarised graph and chains the split edges back into one path per connection -
pairing the two half-edges at a crossing dummy by their opposite headings, and
following the only other neighbour at a degree-two jog - and skips the bus chain
edges entirely. One connection is then one route, a crossing is drawn as a
crossing, and the bus line is left to the bus symbol. That step alone took
miniwatt-02 from eleven recorded conflicts to none, with one connection still
refused, and from 54 drawn segments to 36 with nothing lost from the sheet.
One coincidence on miniwatt-02 was real: the compaction stage gave two
independent vertical wires the same column, so a long tie ran down the column a
short link already occupied. That is the metrics stage collapsing two edges onto
one line, and no amount of chaining fixes it.
RouteCandidateFamily answers it with alternatives that keep both terminals and
both departure axes fixed:
- the base route the shape and metrics stages produced, which stands unless another candidate is measurably clearer of symbols and terminals;
- the base with one interior segment - one that touches neither terminal - moved
perpendicular to itself by one to six
channel_steps either way, longest segment first, which changes the corridor without moving a bend onto a terminal; - the same for a detour frame built where the base has no interior segment: a two-point route gains a sidestep and a single-corner route gains a staircase, each with a free middle run for the shift to act on;
- a lead jog at either end, which steps the terminal-adjacent run sideways just past the terminal, so a route can leave a contested column even where the only overlap is on its first or last segment.
RouteClearanceRanker orders that family before it is offered: candidates are
sorted by how many other components' terminals they run over, then by how many
symbol boxes they cut through, then by their position in the family, so the base
still wins every tie. This matters twice over - it keeps the detours out of
symbols, and it stops a route from being drawn through a terminal that a later
connection will need, which is what turned the one refusal on the stacked-tie
case into a clean drawing.
LayoutRanker now refuses any rotated retry that withholds or coincides more
than the upright sheet, and read_rank sorts on refusals and conflicts before
crossings, so the turn stage can never trade a connection for a tidier page.
| Document | Before | After |
|---|---|---|
miniwatt-02 (31 components, 33 connections) |
50 routes, 11 conflicts, 2 unrouted | 33 routes, 0 conflicts, 0 refusals, 0 unrouted |
miniwatt-01 (16 connections) |
23 routes, 3 conflicts | 16 routes, 0 conflicts, 0 refusals, 0 unrouted |
shared-column synthetic (9 connections) |
13 routes, 3 conflicts | 9 routes, 0 conflicts, 0 refusals |
crossed-feeds synthetic (7 connections) |
10 routes, 1 conflict | 7 routes, 0 conflicts, 0 refusals |
stacked-tie synthetic (12 connections) |
20 routes, 5 conflicts | 12 routes, 0 conflicts, 0 refusals |
The three synthetic cases were built to exercise one situation each - four feeders off one bus, two feeds that must cross, and a tie running past two intervening buses - and are not committed to this repository.
Route count falls because the bus spines are no longer counted as connections.
symbol_intrusions stays at zero on miniwatt-02 and non_orthogonal at zero
everywhere. The engine report gains route_conflicts, route_refusals,
detoured_routes - connections whose base route was not clear - and
broken_chains, which is the number of connections whose walk through the
dummies could not be completed and should stay at zero.
This engine still does not finish the ABB side A document, so none of these numbers come from it.
DotRenderer no longer places a connection and then reports the coincidence.
Every one of its four route shapes is now offered to DXFLineSet as an ordered
list of candidates through add_first_clear_route, which places the first
candidate that CoincidenceGuard finds clear and places nothing at all if none
is. The unconditional add_segment, add_path, add_route_points and
add_elbow_route are no longer called by this engine.
The first candidate in every list is the shape the engine drew before, so a drawing with no coincidence to resolve is unchanged. The alternatives behind it are built by one small class each:
| Class | What it offers |
|---|---|
ChannelStepper |
channel heights, planned height first, then a coarse sweep by channel_step and a fine sweep at a quarter of it, every height kept strictly between the two ends; the midpoint if the gap is too tight for any of them |
StraightCandidateBuilder |
the direct run between two aligned terminals, then doglegs that step out to one side and back, alternating sides by growing multiples of the side gap |
LevelCandidateBuilder |
the elbow the rotation calls for, then the other elbow, then bows through corridors above and below the level |
ElbowCandidateBuilder |
one elbow per offered channel height, first rising into the end terminal in its own column and then rising beside it and stepping in sideways |
TerminalApproachLister |
the x positions a route may rise along before that sideways step, the terminal's own column first |
ReturnCandidateBuilder |
the stub, side step, channel and drop that reaches a terminal above its own source, over every channel height and terminal approach, then with the side leg further out and the stub further down |
The sideways approach is what the ABB side A document needed. Its four
interlocks sit below the bus they feed, so a return route rose into BUS.in_1
along the same column that the feeder leaving BUS.out_1 drops through, and the
two overlapped for the whole length of the feeder. Rising four units beside the
terminal and stepping in along the bus input line clears it. That took
DotRenderer from 4 recorded conflicts to none, with no refusal and no
connection missing: 176 pairs, 176 routes, 0 conflicts, 0 refusals, 0 unrouted.
The cost is four extra segments and 44 more drawing units out of 17,742, because
only the four blocked routes changed shape. miniwatt-02 was already clear and
stays clear.
A refused route would still be a connection that exists in the data and not on
the paper, so the candidate lists are deliberately long and the refusal is the
last resort. Every route this engine places, and every refusal it records, now
carries a route_id reading START.terminal to END.terminal, so a withheld
connection can be named from the record itself rather than looked up by
coordinate.
SlotRenderer now asks before every line it draws. Each of its four placement
sites - the feeder elbow, the straight drop between two terminals on one column,
the run between two devices in a tie lane, and the tie leg between a lane and a
bus - builds a list of candidate point lists and hands the whole list to
add_first_clear_route. The line set places the first candidate that is clear
and, if none is, places nothing and records one RouteRefusal. The engine no
longer calls add_segment, add_path, add_route_points or add_elbow_route,
so it can no longer put a knowingly coincident line on the paper.
Three candidate families feed that list.
- Channel heights. For an elbow,
ChannelRetrierstill steps the horizontal channel bychannel_step, away from the bus on an approach and alternating about the midpoint between two devices, staying strictly between the two ends. This is the family the engine already had; it is now expressed as candidates rather than as its own retry loop. - Sidesteps. For a run that is straight - two terminals sharing an x, or two
tie devices sharing a lane -
JogPlanneroffers the plain line first and then detours whose middle third is offset to one side and then the other, by onecolumn_clearancefor a drop and onechannel_stepfor a horizontal run. - Bus approaches. A tie leg that lands on a bus terminal on the far side of
the bus from its lane - the usual case, since a tie lane hangs below the band
and a bus input sits above the bus line - can rise clear of the bus end and
come in from the terminal's own side.
BusApproachPlannerbuilds those: outboard of the bus edge byapproach_gapplus multiples ofchannel_step, over the terminal by the same ladder, then down onto it. A leg whose terminal faces its own lane is offered no such candidate, because its riser never crosses the bus.
That third family is what the two remaining conflicts needed. On miniwatt-02
the tie into SWGRBUS-001.in_2 rose from the lane at the input's own x, which is
also the x of the out_3 feeder dropping out of the same bus, so the riser and
the feeder ran along each other for sixteen units. The tie now rises eight units
outboard of the bus end, crosses above the bus, and drops onto the input.
TIENO-002 into SWGRBUS-002.in_2 is the same case mirrored, and takes the
second height because the first was itself occupied. miniwatt-02 is now 33
routes, no conflicts and no refusals, with every one of its 33 connections drawn.
Side A is untouched by the change: the direct route is always the first
candidate, so a leg that was already clear is placed exactly where it was. It
still arranges as 161 routes with no conflicts and no refusals. Its 15
connections without a route are the nine From Side B arrows and the three
UPS-bypass chains that never get a cell and sit in the leftover row; that is a
placement gap, not a refusal, and this change neither fixes nor worsens it.
LayeredRenderer now asks before it draws. Every connection it places - each
feeder edge and each of the two level leads of a lateral tie - goes in through
DXFLineSet.add_first_clear_route, which takes a list of candidate point lists,
places the first one CoincidenceGuard calls clear, and if none is clear places
nothing and records a single RouteRefusal. The engine no longer calls
add_segment, add_path, add_route_points or add_elbow_route, so it can no
longer put a coincident line on the sheet.
The freedom it trades on is the height of the horizontal run. A feeder hop
leaves its upper terminal straight down, runs sideways at one height, and drops
into its lower terminal, so moving that height sideways-shifts nothing and
changes only where the two stems end. RunHeightStepper lists heights starting
with the first choice and then stepping away from it, alternately above and
below, discarding any step that leaves the span it is given. EdgeRouteLister
uses it twice for each hop: first inside the band, between the two component
rows, where the drawing stays tidy, and then - only once those are exhausted -
across the hop's own span, ending with the two terminal heights themselves,
which draw the feeder as a plain L that leaves its terminal sideways at once.
LeadRouteLister does the same for a tie lead between the neighbour terminal
and the device terminal. Candidates that simplify to the same point list are
listed once.
TerminalCoverChecker then reorders the list. A candidate whose stem runs over
a terminal belonging to some other component - the classic case is a vertical
busway where the upper tap's stem runs down the bus face across the lower tap -
is pushed behind the candidates that cover no foreign terminal. It never drops a
candidate, only demotes it.
The first candidate is still exactly the point list the channel planner chose,
so a document that never collides draws exactly as it did before: on
A-Side-direct.json the 176 routes are geometrically identical to the ones the
unguarded engine produced, with zero conflicts, zero refusals and no connection
missing from the sheet, and miniwatt-02 is likewise unchanged at zero and
zero. The change shows up only where the old engine would have overlapped. On a
corpus of 600 generated documents (6443 connections, one connection per named
terminal) the planned channel alone loses 126 routes to coincidence; the
alternative family recovers all 126 and refuses nothing.
The smallest case that exercises the retry is four components: a
utility_source feeding a busway, and the busway's tap_1 and tap_2 each
feeding a breaker_mv. The busway is drawn vertically, so both taps sit at one
x. The first tap's stem descends the bus face; the second tap's planned channel
would run down the same line, and every in-band height still overlaps it. The
hop-span tier supplies a run at the tap's own height, which leaves the bus
sideways and clears the first stem, so all three connections are drawn. With the
candidate list cut back to the planned channel, that third connection is
refused.
What no run height can fix is a coincidence at a shared terminal: two feeders
ending on one terminal both approach it along the same line whatever their run
height, so the guard reports collinear_at_terminal and the second is withheld
and named. That is a topology the terminal reservation in GeneralComponent is
meant to prevent, not something the router can route around.
AnnealingRenderer places every route through add_first_clear_route, at the
one site RouteCommitter.commit_plans, with the engine's own route_index as
the route id. RouteCandidateFamily builds the ordered alternatives for one
planned connection, the planned route always first: elbows whose channel is
shifted outward from the plan by half channel_step at a time and kept inside
the plan's band, then terminal steps that move the first vertical stub sideways
before dropping to the channel, then six-point side steps that offset the middle
third of the run. A connection routed along its own row gets the direct L and
the same side steps. channel_retry_limit bounds every family.
The annealer also sees coincidence while it searches. CoincidenceCounter
counts, with numpy, every meeting of two routes' vertices that is not two
terminals leaving in different directions, and every vertex that lands on
another route's segment; LayoutScorer charges weight_coincidence (20.0) per
count, alongside the pierce, crossing, overlap, bend, length, extent and balance
terms. So a collision can be relieved by moving a component, not only by
rerouting a wire once the components are fixed.
Measured on the ABB side A document: 176 of 176 connections routed, 0 conflicts,
0 refusals, in 147.9 s for the arrange alone. On miniwatt-02: 0 conflicts,
0 refusals. This section was written from the agent's committed code after its
session stalled; the numbers were re-measured independently.
Touching is not the only way two wires become one. CoincidenceGuard also
refuses a route whose segment runs parallel to a placed segment of another route
closer than parallel_clearance (2.0 drawing units) while the two overlap along
their length; the conflict kind is too_close. Perpendicular passes and
end-to-end runs are untouched, and a gap of exactly the clearance is allowed, so
an engine whose channel step is 2.0 or more clears it without change. Engines
that sweep channels in finer steps have to widen the step or skip the candidates
inside the clearance.
A SeparatedUpstreamComponent or SeparatedDownstreamComponent stands in for
something drawn elsewhere; it has one terminal and one connection, and its only
job is to mark where that connection leaves the sheet. Every engine used to lay
it out as a source or a load in its own right - nine From Side B arrows in a
row across the top of the sheet, each with a route across the whole drawing to
the transfer switch it belongs to.
SatellitePlacer takes that decision away from the engines. Before
arrange_components runs, GeneralRenderer.arrange_with_satellites detaches
every such component - it is left out of the list the engine sees and its mirror
connection is lifted off the partner - and once the engine has placed everything
else, the placer restores the connection, gives the stand-in its partner's
scale, and hangs it a short lead beyond the partner's terminal on the axis that
terminal faces, rotated so its own terminal points back at the partner. The
stub between them is placed through the guard, trying leads of 6, 9, 12, 16 and
20 units before recording a refusal. is_satellite_placement_applied turns the
pass off for an engine that wants to place them itself. Invariant 25 still
holds: arrange_components receives a list and returns a DXFLineSet; the list
is just shorter.
On side A the nine arrows now sit directly above their transfer switches as the source drawing draws them, every engine's satellite reach is the 6-unit lead, and Slot's stranded leftover row fell from twelve components to three.
examples/evaluate_layouts.py <document.json> [Engine ...] arranges the document
on the plot sheet with each engine, label placement off, and tabulates:
| Column | Meaning |
|---|---|
routes, confl, refus, unrtd |
routes placed, conflicts recorded, refusals recorded, connection pairs left without a route |
cross |
right-angle crossings between segments of different routes |
bends |
segments minus routes |
length |
total wire length in drawing units |
tight |
parallel segment pairs of different routes closer than 4 units |
jog/taps |
bus taps whose route does not drop straight to its feeder, over all bus taps |
span |
mean of drawn bus length over the spread of its feeders; 1.0 means the bus reaches its feeders |
sat_mean, sat_max |
distance from a stand-in's terminal to its partner's, in units at scale 1 |
fill, aspect |
bounding box of the drawing over the diagram region, and its width over height |
Measured on the label-free side A document before the per-engine work (Annealing took 132 s; Orthogonal does not finish the document):
| Engine | cross | bends | length | tight | jog/taps | span | fill | aspect |
|---|---|---|---|---|---|---|---|---|
| Slot | 90 | 164 | 18517 | 0 | 80/86 | 0.48 | 0.86 | 2.28 |
| Barycentre | 359 | 324 | 41882 | 0 | 85/86 | 0.55 | 0.72 | 1.92 |
| Layered | 96 | 172 | 24983 | 3 | 75/86 | 0.49 | 0.69 | 3.23 |
| Template | 290 | 320 | 28401 | 89 | 84/86 | 0.37 | 0.52 | 4.32 |
| Visibility | 28 | 75 | 16611 | 19 | 28/86 | 0.60 | 0.37 | 6.01 |
| Dot | 99 | 161 | 18605 | 1 | 37/86 | 0.55 | 0.47 | 4.73 |
| Annealing | 109 | 298 | 28963 | 0 | 86/86 | 0.66 | 0.69 | 1.84 |
Two things the table says at once. Almost every bus tap jogs, because every engine but Visibility sizes a bus by its terminal count while its feeders spread five times wider - invariant 47 now lets any engine lengthen a bus to span the feeders it places. And the drawings are two to six times wider than tall while the diagram region is about 1.8, so the fit is limited by width and Visibility, the one engine with straight taps, fills only 37 percent of the region because its wide buses sit side by side instead of on separate rows.
IEEE 315 does not tie a symbol's meaning to its orientation on the page, so a series device may be turned to align with the conductor it sits in. US one-lines draw power top to bottom, so most runs are vertical, but bus ties, cross-ties and taps off a horizontal bus run sideways, and a device in one of those is drawn sideways too.
Every component carries rotation, in degrees counter-clockwise, defaulting to
0.0. Only a class that sets is_rotatable = True accepts a value, and only
one of three quarter turns: 0.0, 90.0 or -90.0, held in
allowed_rotations. Anything else is refused, so a renderer cannot drift onto a
diagonal, and 180 degrees in particular cannot silently reverse the power flow
of a symmetric two-terminal device:
breaker.apply_rotation(90.0) # a molded-case breaker in a horizontal run
breaker.apply_rotation(45.0) # raises: 45.0 is not one of (0.0, 90.0, -90.0)
transformer.apply_rotation(90.0) # raises: XFMR-001 may not be rotatedThe test for whether a class may rotate is whether its glyph holds a mark that has to be read the right way up, or is only a shape in the current path. Nineteen classes pass it:
CAP, CBLV, CBMV, CBMVDO, CONT, CT, CTM, DISC, FSW, FU,
LBS, MCCB, NOPT, RCL, RCT, SEC, SPL, TIE, TIENO.
Four groups are held upright, each for its own reason:
- A readable mark. The delta and grounded-wye winding marks, the AC and DC
waveforms inside a converter box, and the letters in
MTR,RLY,GFDandUPSare read to know what the device is, so the glyph cannot turn. - One terminal only. Every source and every load is its own pictograph and ends a run rather than sitting in one, so there is no conductor to align to.
- Sides that must stay apart.
ATS,ATSI,STSandUPSDhave to keep the normal inlet, the emergency inlet and the bypass distinguishable, and the enclosuresSWGR,PNL,RPPandBUSWAYdraw an internal arrangement. - Bus-like classes. A bus puts its inputs on one side of the drawn line and its outputs on the other, along a length that runs across the page, so turning one is a larger change than turning a symbol.
Text never turns. rotation moves where a label or an internal legend such as
TIENO's N.O. is anchored, but the letters stay horizontal. Rotation reaches
the geometry through GeneralComponent.place_point, which every drawing helper
already calls, and draw_body_arc also adds the angle to its start and end
bearings.
Extents follow the rotation. measure_local_half_width,
measure_local_half_height, measure_width and measure_height return the
axis-aligned box of the turned symbol, so a device at ninety degrees reports its
height as its width and the instance label clears it instead of landing on top
of it:
CBLV symbol box 5.6 wide by 16 tall
0 deg label anchored at local x = 4.800
90 deg label anchored at local x = 10.000
A quarter turn can leave two terminals neither level nor plumb, which
DXFLineSet.add_segment refuses. add_route_points is the way to draw such a
run: it plans the whole route first, inserts a corner wherever a hop is sloped,
turning after the direction the route was already travelling, checks the plan,
and only then commits. Nothing is written if the plan is bad, unlike add_path,
which commits every hop before the one that fails.
measure_base_half_width and measure_base_half_height keep the upright
numbers for anything that needs them. Every renderer and the sheet reach the
extents through these methods rather than through symbol_height, so a rotated
component is measured correctly wherever bounds, obstacle boxes or node sizes
are computed. At a rotation of 0.0 every one of them returns exactly what it
returned before, so no existing drawing moves.
Six of the eight layout algorithms now set a rotation, and every one of them
uses it for the same thing: a bus tie between two buses that belong on one
level. DotRenderer, SlotRenderer, VisibilityRenderer, TemplateRenderer
and AnnealingRenderer turn all three ties of the miniwatt-02 switchgear
lineup; LayeredRenderer turns the one whose buses it can level.
OrthogonalRenderer deliberately turns nothing: it prescribes a north or south
heading for every device port, so its flow solver already reaches zero bends
and a quarter turn has nothing to remove. BarycentreRenderer, the default,
is not part of this work.
What each of them found is that the gain is in the ranking, not the glyph. Turning a tie on its own only adds bends; the drawing improves once the tie's two buses sit on one level, and the quarter turn is what makes that level run drawable.
A level run sits at the height where an upright component's label is lettered,
so a renderer that lays one names the components it passes in
raised_label_ids and GeneralRenderer lifts those labels clear. A turned
component's own label is lifted without being named.
DotRenderer carries one further tidy-up, on by default and switched off with
is_alignment_pass_applied = False. Dot balances a parent over its children,
so a run whose parent and child sit in different columns is wired through a
channel and gains two bends it does not need. The pass groups every
parent-child run into one column class through OffsetClasses, keeping the
offset between a bus terminal and the device below it, then solves one x per
class by longest path with ConstraintGraphSolver. Dot's own column is a lower
bound in that solve, so a component in no run stays where dot put it rather
than being repacked. A join is refused where it would drive two components in
one row onto the same x, and every component named in raised_label_ids is
pinned so a level tie run is never pulled out of shape.
The pass keeps its result only if it pays. LayoutAuditor measures both
layouts; a rise in symbol pierces or sloped segments rejects the aligned one
outright, and otherwise the two are compared on `crossings * crossing_penalty
- segments
, with conductor length as the tiebreak. Onminiwatt-02that trade is 69 segments down to 59 for one extra crossing, which the default penalty of 4.0 accepts; onminiwatt-01the bend count ties and the aligned layout wins on length alone. Raisecrossing_penalty` to make the pass more conservative.
python3 examples/build_symbol_sheet.py marks each rotatable class with a
small pink circular arrow under its instance id.
Every component carries label_xy_offset, a page-space nudge applied to its
instance label, defaulting to (0.0, 0.0). It is stored per instance and
scales with the component, so an offset means the same thing at every drawing
scale:
breaker.apply_label_offset(-8.0, 5.0) # eight left, five up
breaker.clear_label_offset() # back to the class default
breaker.is_label_offset_set()The offset is in page space, not symbol-local space, because a label never
rotates: a local offset would have to be turned back on every read. It is added
on top of the anchor the renderer already chose, so it composes with the lift
that raised_label_ids applies rather than replacing it.
LabelPlacer sets the offsets. It runs once inside render, after the layout
has settled and the conductors are routed, because where a label can go depends
only on the finished drawing and not on the algorithm that produced it. For
each component it tries eight positions — right, above, below, left, the two
left diagonals and two further-out variants — scores each against the symbol
boxes, the routed segments and the labels already placed, and writes the winner
into label_xy_offset. Components are placed top row first so the busiest part
of the sheet is settled before the rest.
A renderer contributes what it knows through list_label_preferences, not
through its own placement code. The base class prefers above for a turned
component and for anything a renderer names in level_run_ids, which is how
the renderers that draw a level bus tie say that a label must clear the run.
A preference only decides between positions that collide equally, so it never
puts a label on top of a conductor.
Measured on miniwatt-02, counting a label as colliding when it overlaps a
conductor within 0.3 label heights, a symbol, or another label:
| renderer | on a wire | on a symbol | on a label |
|---|---|---|---|
| barycentre | 6 → 0 | 6 → 0 | 0 → 0 |
| layered | 2 → 0 | 0 → 0 | 0 → 0 |
| slot | 4 → 0 | 0 → 0 | 0 → 0 |
| visibility | 7 → 0 | 0 → 0 | 0 → 0 |
| orthogonal | 12 → 6 | 0 → 1 | 0 → 0 |
| annealing | 7 → 0 | 0 → 0 | 0 → 0 |
| template | 6 → 0 | 0 → 0 | 0 → 0 |
| dot | 8 → 0 | 1 → 0 | 0 → 0 |
Seven of the eight clear every collision. OrthogonalRenderer halves them and
gains one symbol overlap: at 84 units wide and 285 tall it simply has nowhere
to put a label, which is its aspect ratio rather than a placement failure.
Set is_label_placement_applied = False to letter every label to the right and
leave the offsets alone. Placement runs after the fit loop, so a label offset
does not feed back into the scale the drawing was fitted at; the fit uses the
default reach, which is the conservative one.
SLDRenderer puts a diagram on the same bordered sheet Mini-Watt's one-lines
use, so the output is a drawing rather than a bare diagram:
python3 examples/build_sld_sheet.py path/to/miniwatt-02.jsonThe sheet is ANSI D, 34 by 22 inches at 100 units to the inch, divided the same
way: a 0.75 inch margin, a 250-unit title-block strip down the right edge, and
the remaining content split into a six by five module grid. SheetLayout
computes each region, and its numbers match Mini-Watt's exactly - border
(75, 75, 3325, 2125), diagram (95, 505, 3055, 1715), notes in the top-right
module, legend across the bottom band.
What lands on the sheet:
- The border and the corner sheet number.
- The title block: project, client and location; project and drawing number,
date, scale, design revision and generation date; designed, drawn, checked
and approved initials; a signature zone; the sheet title; and the sheet
number set large at the foot of the strip. An undeclared field prints
----rather than repeating its own label. - A
PRELIMINARY / NOT FOR CONSTRUCTIONstamp untilSheetConfig.stageis set tofinal. - General notes and keynotes in the top-right module.
SCALE: NONE, since a one-line has no measurable scale.- The legend across the bottom band: one glyph per component class actually used, its name spelled out beside it, then the nomenclature note and the abbreviation list on the right.
SLDRenderer does not lay the diagram out itself. It composes a Renderer,
measures the diagram region, and asks that renderer to fit inside it:
line_set = self.renderer.arrange_components(components, max_width, max_height)Renderer.arrange_components now takes optional max_width and max_height.
When either is given it measures the arranged bounds, including the reach of
every instance label, and if the result overflows it reduces the scale by the
overflow ratio and arranges again, up to fit_attempt_limit times. Gaps,
channels and row pitch all scale with it, so the whole drawing shrinks
uniformly rather than just the symbols. SLDRenderer also sets
renderer.base_scale first, so a small diagram grows to fill the region
instead of sitting tiny in one corner.
Widths are carried as DXF lineweights, which are plot properties in hundredths of a millimetre rather than geometry. Each role declares the width it should plot at when the diagram is drawn at 1:1, one drawing unit to one millimetre:
| Role | Declared lineweight |
|---|---|
| Bus lines, sheet border | 0.70 mm |
| Component symbols, connection routes | 0.35 mm |
| Instance labels, sheet text | 0.25 mm |
A lineweight is not geometry, so on its own it would not follow the drawing. That is wrong on a plot sheet, where the diagram is shrunk to fit: one drawing unit is 1/100 inch, so 0.254 mm, and a dense sheet is fitted at a drawn scale near 1, which put a 0.35 mm stroke on a symbol only 2.8 mm tall. The same symbol on the review sheet is 10 mm tall, so the identical stroke read as four times heavier on the sheet than in the symbol library.
LineweightScaler closes that gap. After the diagram is drawn, every entity on
the SYMBOLS, CONNECTIONS and LABELS layers has its lineweight multiplied
by the drawing's plot factor and snapped to the nearest lineweight AutoCAD
accepts, with a floor of 0.09 mm so nothing disappears. The factor is
GeneralRenderer.lineweight_scaling, which is 1.0, so a bare diagram is
untouched; SLDRenderer overrides it with millimetres per drawing unit times
the drawn diagram scale, which is the ratio between what the symbol was
designed at and what it plots at. Two worked sheets:
| Sheet | Components | Drawn scale | Symbols | Buses | Border |
|---|---|---|---|---|---|
miniwatt-02 |
31 | 3.31 | 0.30 mm | 0.60 mm | 0.70 mm |
| ABB side A | 171 | 1.11 | 0.09 mm | 0.20 mm | 0.70 mm |
The sheet's own furniture - border, title block, notes, keynotes, sheet text -
is drawn at sheet scale and never scaled, so the frame stays at its full
0.70 mm however dense the diagram inside it is. The legend is the one place
where the two meet: its symbols sit on the SYMBOLS layer at a fixed scale of
3.0, so on a dense sheet they letter at the diagram's thinner width.
The SVG export asks the ezdxf frontend for LineweightPolicy.ABSOLUTE, so the
exported strokes honour the scaled weights; the DXF carries the same numbers
for AutoCAD to plot.
SLDRenderer plots the same way Mini-Watt's own one-line does: black ink on a
white ground, lettered in the same face.
| Property | Value | Where Mini-Watt sets the same thing |
|---|---|---|
| Sheet background | white | a full-sheet fill="#ffffff" rect in its SVG writer |
| Lines and text | black | ACI 7 by layer, which resolves to black over white |
| Text style | MINIWATT, arial.ttf, width factor 0.8 |
doc.styles.add("MINIWATT", font="arial.ttf") in its DXF writer |
| Text heights | 9.375 / 12.5 / 25 units | TEXT_MIN / TEXT_TYPICAL / TEXT_TITLE |
The white ground comes from background_policy = BackgroundPolicy.WHITE on
SLDRenderer. Because entities are ACI 7, ezdxf resolves them against that
background and draws them black; over the default dark ground the same
entities come out white. Only SLDRenderer sets the policy, so a bare diagram
from any other renderer keeps the ezdxf default.
TextStyleBuilder registers the two styles on every document, and
GeneralRenderer.draw_one_label and SLDRenderer.draw_sheet_text ask for
MINIWATT by name. So every instance label and every piece of sheet text is
lettered in Arial at a 0.8 width factor, in the DXF and in the SVG alike.
MINIWATT-BOLD is registered for parity with Mini-Watt; nothing in this
library draws bold yet.
Text drawn inside a symbol is deliberately left on Standard: the WH of
a meter, the 51 of a relay, the UPS of a supply and the N.O. of an open
tie are symbol geometry from IEEE 315, not sheet lettering, and they belong to
the glyph rather than to the drawing's type.
The sheet plots at true size: one drawing unit is 1/100 inch, so 0.254 mm, and
SLDRenderer exports with fit_page=False at that scale onto a 34 by 22 inch
page. The sheet's own 0.75 inch margin then shows as an equal offset on all
four sides, and the drawing measures what it says it measures.
The default is different, and deliberately so: GeneralRenderer exports with
ezdxf's own settings, which scale the content to fill the page. That suits a
bare diagram, which has no sheet and no margin of its own, but on a sheet it
does two visible things wrong. It stretches the border to whichever page edge
the content aspect hits first, so the offset from the page is uneven - on an
ANSI D sheet the border touched the left and right edges while a gap of about
a third of the true margin was left above and below. And a border stroke lying
exactly on the page edge loses the outer half of its width to the viewBox, so
the left and right borders plotted at half the thickness of the top and bottom
ones. Plotting at true size fixes both at once, because nothing then sits on
the page edge.
Two honest differences from Mini-Watt remain. Its SVG keeps live, selectable
<text> carrying a font-family stack, while ezdxf's SVG backend converts
text to filled outlines - the shapes match, the selectability does not. And its
stack prefers osifont, a single-stroke drafting face, which falls back to
Arial when it is not installed; this library asks for Arial directly.
arrange_components is the only layout decision point, so an algorithm is just
a GeneralRenderer that implements it. Eight ship with the library:
| Class | Algorithm |
|---|---|
BarycentreRenderer |
depth rows, one upstream barycentre pass (the default) |
LayeredRenderer |
full four-phase layered Sugiyama, with a rotatable cross-tie lifted out of the ranking so its two neighbours share one band and the tie is turned sideways into the level run between them |
SlotRenderer |
slot-based, semi-automatic, hand-overridable; a same-band bus tie leaves the slots and is drawn as a horizontal run of quarter-turned devices |
VisibilityRenderer |
visibility representation, buses stretched to span their children, bus ties turned sideways into a horizontal run between two bus bars on one row |
OrthogonalRenderer |
Tamassia topology-shape-metrics; every face is tried as the outside and bends are minimised exactly by minimum-cost flow, then each rotatable device is turned to face the way its own wires left and the sheet is re-solved |
AnnealingRenderer |
simulated-annealing post-pass over a snapped grid, with the quarter turn of a device as a search variable |
TemplateRenderer |
hand-drafted block templates placed as rigid rectangles, a slot carrying the quarter turn it is drafted at, so a tied bus row lays its ties down flat along the run |
DotRenderer |
graphviz dot placement, with every same-level tie turned a quarter turn and drawn as a horizontal cross-tie between its two buses, then an optional alignment pass that pulls each parent-child run onto one column |
SLDRenderer takes whichever one you pass and defaults to
default_renderer_class:
SLDRenderer() # BarycentreRenderer
SLDRenderer(renderer=LayeredRenderer()) # any of the eightIt only needs arrange_components(components, max_width, max_height) from its
engine, so it measures, grows and shifts the result itself. Compare them all on
one document with:
python3 examples/compare_layouts.py path/to/miniwatt-02.jsonEvery renderer can print an account of its own algorithm, so you can read what a layout does without reading its code:
python3 examples/describe_layouts.py # all of them
python3 examples/describe_layouts.py DotRenderer # one of themLayeredRenderer().describe_algorithm()describe_algorithm is declared empty on GeneralRenderer, so it is optional:
a new renderer that does not implement it prints nothing and returns None. A
renderer that does implement it builds an AlgorithmDescription - a title, a
plain-language summary, numbered steps, honest notes on the limits, and
references to the papers or standards the algorithm comes from - and calls
print_to_console(). The text is module-level data at the top of each renderer
file, so the method itself stays short.
SLDRenderer is the one special case. It runs no layout of its own, so it
describes what it does to the sheet and then calls describe_algorithm on
whichever engine it was given.
arrange_components is the only layout decision point. Subclass
GeneralRenderer (or Renderer) and return your own DXFLineSet to try a
different approach; everything else stays the same. Renderer currently
places components in rows by longest path from a source, orders each row by the
average position of its upstream neighbours, and routes each connection through
a horizontal channel between the two rows.
Known limit of that arrangement: a connection may cross a bus or another component, since routing has no obstacle awareness yet.
See invariants.txt for the rules the code holds to and TESTS.md for the
tests that would cover it.
Two SlotRenderer gaps are closed:
A bus's length used to come only from its tap count (invariant 47's
count-times-distance formula), which is usually far narrower than the spread
its feeder columns actually need, so most taps jogged sideways to reach their
column. measure_cell_columns now stretches each bus, once its feeder columns
are known, to the span its widest feeder group needs
(stretch_bus_to_feeder_spread, still inside the class's cap and floor), so a
tap's own terminal position lines up with its column and the jog disappears.
A dual-feed device's second input (a UPS's bypass_in, fed by its own breaker
off the same bus as the main feed) used to have nowhere to go: CellTracer
walked straight through any input terminal to the device's output, so the
second feed's chain re-entered the same shared downstream run as the main feed
and got discarded as already claimed. CellTracer.walk_chain now stops at a
component's destination as soon as it arrives through any input terminal
after the first one declared on that class (is_secondary_input), so the
second feed becomes its own feeder cell that ends at the device rather than
passing through it, and its breaker is placed and routed like any other tap.
LayeredRenderer sizes a length-variable bus twice. resize_for_connections
gives it a starting length from its tap count alone, before any feeder is
placed. Once CoordinateAssigner and ChainStraightener have settled every
node's x position, BusSpanStretcher runs a second pass: for each real bus
node it reads the x position of the column each attached feeder already
straightened onto, and if the spread between the outermost columns is wider
than the bus's current length, it regrows the bus to that spread with
apply_length, still clamped inside the bus class's own minimum_length and
upper_length_limit (invariant 47). A bus already as wide as its feeders is
left alone. This shortens the diagonal a tap's lead has to run to reach its
feeder without moving the bus's own position, so it never has to renegotiate
clearance against a sibling on the same layer.