Symbol interface design guide
docs/DECISIONS.md.
Change list
| Date | Version | Change |
|---|---|---|
| 2026-10-16 | 0.14.0 | measure takes attach={slot_id: Symbol}: the attached symbol's keep-out box is placed at the turned slot and joins Measure.slots and Measure.keepout; an unknown key raises UnknownNameError (D91). |
| 2026-10-16 | 0.13.0 | New measure_font (a regular and a bold TTF, as bytes, into FontMetrics); [font] may name files, which symdef build measures; Library.with_settings replaces module, font, heights and line widths and runs text-outside-body; [lines] and Library.lines give a width in millimetres per weight (D87 to D89). |
| 2026-10-16 | 0.12.0 | symdef raises its own errors under SymdefError (InvalidValueError, UnknownNameError, UnknownSymbolError, LibraryError; none subclasses a builtin) (D80). New whole-division types GridPoint, GridBox and GridPort (D81). New measure(symbol, orientation, library, texts=None, text_height=None) returning Measure; FontMetrics.advances is one table per weight and thick_factor is gone (D82). New to_placed_fragment; to_fragment_mm is listed for removal in 0.13 (D83). The authoring names (build, check, init, lint, write_build, stale_build, RULES, finding_key, LibraryConfig) move to symdef.authoring (D84). surface.toml and docs/CHANGELOG.md (D85). Grid.wire_pitch and wire_pitch_fine with the loader checks, and FontMetrics.heights from [font.heights] (D86). |
| 2026-10-16 | 0.11.0 | A library declares [grid] (module_mm, divisions_per_module) and [font] (name, metrics and advances) in library.toml, and the bundle carries them: Library.font and Library.grid. New: FontMetrics, text_width, Grid, slot_text, TextPlacement, to_divisions, from_divisions, ceil_divisions, orient_box, orient_point and orient_direction (now public), Box.union, slots_box, pole_pitch, pole_port and nodes_of (now public). body_box, keepout_box and slot_box take font=None (slot_box also text and height), and lint takes grid; with none, output is byte-identical. load_library and load_bundle return read-only symbols (D73 to D78). |
| 2026-10-16 | 0.10.1 | A non-C slot keeps its position when its box spans a cut: the position moves by the shares of the cuts it lies beyond and the box grows, and only a C slot sits at the middle of its stretched box (D72). |
| 2026-10-16 | 0.10.0 | A symbol may carry several [[stretch]] cuts per axis, each with a weight (default 1) that sets its share of the growth; slots follow the cuts, so a slot box across a cut grows by that cut's share; slot side C centres the box and the text on at; a line or polyline may say between = true, and table() draws it in rows 1 to n-1 only (D67 to D71). The rule count stays 35. |
| 2026-10-16 | 0.9.0 | to_fragment and to_fragment_mm take texts, the string to draw in each slot, and required_slots, the slots that must have one; a slot may say turn = true to read bottom-up. symdef never measures, shrinks or wraps a string (D63 to D66). The rule count stays 35. |
| 2026-10-15 | 0.8.0 | leads(symbol, ports) drops the bound leads of the ports the caller leaves unwired; style = "dash_dot" draws a dash-dot line, and to_fragment_mm takes its four lengths in dash_dot_mm; to_fragment and to_fragment_mm take muted, the colour of a symbol drawn as not installed (D59 to D62). The rule count stays 35. |
| 2026-10-15 | 0.7.0 | [[stretch]] and stretch() move everything beyond a cut line; table() places rows with their own ports and slots; lint rule stretch-cut makes 35 rules; [[wire]] in library.toml and wire() draw wires in a named style; fill = "tint" and the tint keyword of the output functions take the caller's colour (D53 to D57). bundle.json gains an optional wires key, the serialized [[wire]] styles, written only when the set has any. |
| 2026-10-13 | 0.6.0 | to_fragment_mm(symbol, *, module_mm, weights_mm, dash_mm, font_family, font_mm, cap_ratio) writes a fragment in millimetres with no scale transform: coordinates times the module; stroke widths, dash lengths and font size are the caller's mm; text capitals centred on the anchor by the caller's capital ratio; to_svg and to_fragment output unchanged (D52). |
| 2026-10-07 | 0.5.0 | A set declares its own words: library.toml gains [vocabulary] (path_kinds, potentials, links) and [rules] required_slots (D46). PathKind and Potential leave the API; Path.kind and Node.potential are strings. Library gains vocabulary and required_slots, and lint takes required_slots (sections 5, 6, 9, 11, 12). |
| 2026-10-07 | 0.5.0 | The JSON Schema ships in the package and has an $id on the documentation site; RULES and finding_key join the API (D47). |
| 2026-10-07 | 0.5.0 | The newcomer's path: symdef init, build and check, also functions (D48). The module build is now files. |
| 2026-10-07 | 0.5.0 | No runtime dependency: the contract library is gone and a gate script checks purity (D49). repeat's preconditions raise ValueError. |
| 2026-10-07 | 0.5.0 | Section 1 and sections 12 to 14 no longer describe predecessors or one data repo; the section 12 layout and gates are the generic ones. library.toml may carry package (D45). |
1. Why: one object, three jobs
A symbol library that makes one object do three jobs has the same recurring problems. All of them trace back to that.
| Layer | What it is | Example | Owner |
|---|---|---|---|
| Symbol | the graphic concept in the standard | make contact, S00227 | the symbol set (a data repo) |
| Device terminal marking | numbers printed on the real part (EN 50005 / EN 50011) | 13/14, 23/24, A1/A2 | the drawing's device or catalog model |
| Placement | tag, position, orientation, rendered text | -K1 at (40, 120) | the drawing library |
- Port identity drifted.
no_contact()had ports13/14,no_contact(poles=2)had1/2/3/4, and keys were remapped after construction. Resolvers broke on ids that were not port keys. - Text was baked into geometry. Tags and pin numbers were text elements placed by heuristics, so layout could not tell label space from body space and labels collided.
- Wiring geometry was per-symbol luck. No shared port pitch, no guaranteed straight-through axis, no guaranteed clear exit for a wire.
- Variants were hand-written geometry.
inverted=True, multi-pole and contact-plus-actuator forms each copied coordinates, so a correction had to be repeated everywhere. - Electrical function lived in docstrings. Every consumer re-encoded "these four ports are one net" and "this is a switch".
2. Principles
- A symbol is a definition. It has no tag, no terminal numbers, no position and no orientation. Those belong to the placement.
- Port ids are roles. Stable, lowercase, semantic, never terminal numbers, never dependent on configuration.
- Electrical function is data. Nodes and typed paths, so consumers netlist and check without per-symbol knowledge.
- Text goes in slots. The symbol reserves boxes for the tag and for each port's marking. The consumer renders the text.
- Data is the source of truth. One TOML file per symbol, validated by a published JSON Schema. Any language can consume the resolved JSON.
- Composition mirrors the standard. IEC 60617 builds symbols from elements and qualifying symbols. Composites reference parts by anchor and contain no copied coordinates.
- The wiring contract is strict and linted in all 8 orientations. A router never repairs a symbol.
- Every gate is proven able to fail with a deliberately broken fixture.
3. Conventions
| Item | Rule |
|---|---|
| Unit | The module M. Default M = 2.5 mm (ISO 81714-1, ISO 14617). Files never contain millimetres. |
| Axes | SVG axes: x right, y down. Angles in degrees, clockwise on screen from +x. |
| Drawing grid | 0.125 M. Every shape coordinate, radius, text height, anchor, slot point and slot box size is a multiple of it. 0.125 is exact in binary floating point. |
| Wiring grid | 1 M. Every port position is a multiple of it. The origin is on it by definition. |
| Pole pitch | 4 M (10 mm). The only pitch repeat uses by default. A symbol too wide for it declares pole_pitch, a multiple of 4. |
| Same-side port spacing | Two ports with the same dir are a multiple of 2 M apart, measured along the side (x for N and S ports, y for E and W ports). |
| Base orientation | The through path (section 5) is vertical: its two ports are at (0, -a) pointing N and (0, a) pointing S, with a a whole number of M. Horizontal use is an orientation, never a second file. |
| Line weight | normal = 0.1 M (ISO 81714-1, width : module = 1 : 10), thick = 0.2 M (provisional). |
| Line style | solid, dashed or dash_dot. Dashed means dash 0.5 M, gap 0.25 M; dash-dot means dash 1 M, gap 0.25 M, dot 0.0625 M, gap 0.25 M; renderers implement the pattern, files never spell out dash segments. |
| Text elements | Text that is part of the symbol itself (the M in a motor). Height at most 1 M. Stays upright in every orientation; only its position transforms. |
| Extents | All boxes ignore stroke width. A line or polyline extends over its points, a circle over its full diameter, an arc over its true swept extent. A text element counts as a box centred on at, height tall and 0.6 × height × character count wide. |
| Overlap | Two shapes overlap when their open interiors intersect. Touching along an edge or at a corner is not overlap. For overlap tests a stroke is a zero-width curve, a filled shape is its area, and an unfilled closed outline is only its outline (so a slot may sit inside an empty body rectangle). |
| Ids | Port, anchor and part ids match ^[a-z][a-z0-9_]*$. The dot is reserved for namespacing by composition and repeat (contact.in, 2.out). |
Orientations
There are exactly 8: R0 R90 R180 R270 MR0 MR90 MR180 MR270. M mirrors first (flip x: (x, y) -> (-x, y)), then Rn rotates clockwise on screen by n degrees.
A 90 degree clockwise rotation is (x, y) -> (-y, x). Directions and slot sides transform the same way as vectors.
Circle and arc centres transform as points and radii are unchanged. Rotation by n adds n to an arc's start and end. Mirroring maps an angle t to 180 - t and swaps start with end, so the arc is still swept clockwise. Angles are normalised to [0, 360).
Text elements and slot boxes never rotate or mirror; only their at point moves.
Flipping top to bottom is MR180. There is no other transform in the interface.
Port id vocabulary
| Case | Ids |
|---|---|
| Any two-terminal series symbol (contact, fuse, resistor, coil, lamp) | in (top in base orientation), out. No polarity is implied. |
| Polarity carries meaning | anode/cathode, plus/minus |
| Change-over contact | com, nc, no |
| Three-phase and protective earth | u, v, w, n, pe |
| Windings | pri_in, pri_out, sec_in, sec_out |
| Junction-like symbols | n, e, s, w by compass side |
A1/A2, 13/14, X1/X2 are terminal markings, not roles. They never appear in a symbol file.
Generic consumers should chain symbols using the through path's from and to, not by assuming the ids in/out.
4. The symbol file
One file per symbol: symbols/<number>.toml in the data repo. Symbol elements and qualifying symbols from IEC 60617 Part 2 are files too, with a different kind.
Top-level keys
| Key | Type | Required | Meaning |
|---|---|---|---|
schema | integer | yes | Schema version. Currently 1. |
name | string | yes | The standard's English name, verbatim. |
kind | symbol | element | qualifier | yes | symbol is placeable on its own. element and qualifier exist to be used as parts; slot and through-path rules do not apply to them. |
status | unverified | verified | yes | verified only after the geometry is compared with the standard's own drawing. |
reference | table | yes | standard, number, optional edition, form. Identity is number; it equals the file stem. |
ports | array of tables, or a rename map in composites | no | Atomic: id, at, dir (N E S W), optional description. Composite: { new_id = "part.port" }. |
nodes | array of tables | no | ports (list of ids) and optional potential. See section 5. |
paths | array of tables | no | from, to, kind, optional through. See section 5. |
anchors | array of tables | no | id, at, dir. Attachment points for composition. |
elements | array of tables | atomic: yes | Geometry. See below. |
parts | array of tables | composite: yes | See section 7. A file has elements, parts, or both. |
slots | table of tables | kind = "symbol": yes | See section 6. |
pole_pitch | integer | no | Multiple of 4. Only for symbols that cannot fit the 4 M pitch. |
stretch | array of tables | no | axis (x or y), at (number) and optional weight (number above 0, default 1). Several per axis are allowed, each at its own at. The cut lines that stretch() moves everything beyond; see section 7. |
lint_allow | array of tables | no | rule, reason. The only way to be exempt from a lint rule. |
Elements
Each element is a table with exactly one shape key. Optional on all: weight (normal | thick). Optional on line, polyline, arc: style (solid | dashed | dash_dot). Optional on line: port, the id of the port the line leads to; see the wiring contract, section 8, and the lead-off-port rule, section 9. Optional on line and polyline: between (boolean, default false), for a tick that table() draws in rows 1 to n-1 and leaves out of row n; a symbol drawn on its own shows it as authored (section 7).
| Shape | Form |
|---|---|
| Line | { line = [[x1, y1], [x2, y2]], port = "in" } (port optional) |
| Polyline | { polyline = [[x, y], ...], closed = true, fill = "solid" } (closed default false, fill none | solid | tint, default none; tint only on a closed polyline, drawn in the colour the caller passes, D57) |
| Circle | { circle = [cx, cy], r = 0.5, fill = "none" } |
| Arc | { arc = [cx, cy], r = 0.5, start = 180, end = 0 } (clockwise from start to end) |
| Text | { text = "M", at = [0, 0], height = 1 } (always middle-anchored, always upright) |
Example: make contact (atomic, simplest case)
schema = 1
name = "Make contact, general symbol"
kind = "symbol"
status = "unverified"
reference = { standard = "IEC 60617", number = "S00227" }
ports = [
{ id = "in", at = [0, -2], dir = "N" },
{ id = "out", at = [0, 2], dir = "S" },
]
paths = [
{ from = "in", to = "out", kind = "switch_open", through = true },
]
anchors = [
{ id = "link", at = [-0.5, 0], dir = "W" },
]
elements = [
{ line = [[0, -2], [0, -1]] },
{ line = [[0, 2], [0, 1]] },
{ line = [[0, 1], [-1, -1]] },
]
[slots]
tag = { at = [-1.5, 0], side = "W", box = [6, 1] }
"marking.in" = { at = [0.25, -1.5], side = "E", box = [1.5, 1] }
"marking.out" = { at = [0.25, 1.5], side = "E", box = [1.5, 1] }
Example: change-over contact (two paths, one through)
schema = 1
name = "Change-over break before make contact"
kind = "symbol"
status = "unverified"
reference = { standard = "IEC 60617", number = "S00230" }
pole_pitch = 8 # 5.5 M wide with its markings, so it cannot repeat at 4 M
ports = [
{ id = "com", at = [0, 2], dir = "S", description = "common" },
{ id = "no", at = [0, -2], dir = "N", description = "closes on actuation" },
{ id = "nc", at = [-2, -2], dir = "N", description = "opens on actuation" },
]
paths = [
{ from = "no", to = "com", kind = "switch_open", through = true },
{ from = "nc", to = "com", kind = "switch_closed" },
]
anchors = [
{ id = "link", at = [-0.5, 0], dir = "W" },
]
elements = [
{ line = [[0, 2], [0, 1]] },
{ line = [[0, -2], [0, -1]] },
{ line = [[-2, -2], [-2, -1]] },
{ line = [[-2, -1], [-1, -1]] },
{ line = [[0, 1], [-1.125, -1.25]] },
]
[slots]
tag = { at = [-4, 0], side = "W", box = [6, 1] }
"marking.com" = { at = [0.25, 1.5], side = "E", box = [1.5, 1] }
"marking.no" = { at = [0.25, -1.5], side = "E", box = [1.5, 1] }
"marking.nc" = { at = [-2.25, -1.5], side = "W", box = [1.5, 1] }
Example: relay coil (impedance)
schema = 1
name = "Operating device, general symbol; relay coil, general symbol"
kind = "symbol"
status = "unverified"
reference = { standard = "IEC 60617", number = "S00305" }
ports = [
{ id = "in", at = [0, -3], dir = "N" },
{ id = "out", at = [0, 3], dir = "S" },
]
paths = [
{ from = "in", to = "out", kind = "impedance", through = true },
]
anchors = [
{ id = "link", at = [2, 0], dir = "E" },
]
elements = [
{ line = [[0, -3], [0, -1]] },
{ line = [[0, 3], [0, 1]] },
{ polyline = [[-2, -1], [2, -1], [2, 1], [-2, 1]], closed = true },
]
[slots]
tag = { at = [-2.5, 0], side = "W", box = [6, 1] }
"marking.in" = { at = [0.25, -2], side = "E", box = [1.5, 1] }
"marking.out" = { at = [0.25, 2], side = "E", box = [1.5, 1] }
Example: connection point (one node, declared exceptions)
schema = 1
name = "Connection point"
kind = "symbol"
status = "unverified"
reference = { standard = "IEC 60617", number = "S00016" }
ports = [
{ id = "n", at = [0, 0], dir = "N" },
{ id = "e", at = [0, 0], dir = "E" },
{ id = "s", at = [0, 0], dir = "S" },
{ id = "w", at = [0, 0], dir = "W" },
]
nodes = [
{ ports = ["n", "e", "s", "w"] },
]
elements = [
{ circle = [0, 0], r = 0.25, fill = "solid" },
]
lint_allow = [
{ rule = "port-on-body-edge", reason = "junction: wires meet at the centre of the dot" },
{ rule = "port-lane-clear", reason = "junction: wires start inside the dot" },
{ rule = "slot-missing", reason = "a junction carries no tag or markings" },
]
[slots]
The examples in this guide are normative for the format and have been machine-checked: they parse as TOML, every reference resolves, and the four atomic ones pass
port-off-geometry, port-on-body-edge, port-lane-clear, port-spacing, through-axis, slot-overlap-body, slot-overlap-slot, pitch-overflow and allow-unused in all 8 orientations, using a throwaway checker written from the definitions in this guide.
The toolkit's first test fixture is these five files: they must load and lint clean. If the real linter disagrees with an example, stop and resolve which one is wrong before going on. The shapes themselves are unverified against IEC drawings, like all geometry.
5. Connectivity model
This is what keeps the electrical function explicit. It is declarative data only; the toolkit never simulates anything.
- Node. A set of ports that are the same electrical point inside the symbol. Every port belongs to exactly one node. A port not listed under
nodesis a node of its own. Ports may share a position only if they share a node. - Node potential (optional): a word the set declares in
[vocabulary] potentials, for exampleearth,protective_earth,functional_earthorframe. This is how an earth symbol says what it does:nodes = [{ ports = ["pe"], potential = "protective_earth" }]. A word the set did not declare is a load error naming the word, the file and the line. - Path. A two-terminal relation between ports of two different nodes, with a kind. At most one path per unordered node pair.
- Through path. At most one path per symbol has
through = true. It is the axis series chains run along, and it fixes the base orientation:fromis the N port,tois the S port. Akind = "symbol"file with two or more nodes and no potential should have one.
The path kinds of an electrical set. The toolkit has no built-in list: a set declares its own words in [vocabulary] path_kinds (a pipe set might declare pipe), and a kind not declared is a load error naming the word, the file and the line. | |||
|---|---|---|---|
| Path kind | Meaning | Direction matters | Examples |
conductor | always conducts | no | fuse, link, shunt |
switch_open | open in the drawn (rest, de-energised) state | no | make contact, circuit breaker |
switch_closed | closed in the drawn state | no | break contact |
impedance | a load or passive element: consumes or stores | no | resistor, capacitor, coil, lamp, motor winding, meter |
source | delivers energy; to is the positive terminal where polarity exists | yes | battery, generator |
diode | conducts from from to to only | yes | diode, LED, thyristor main path |
What a consumer can do with this and nothing else: build a netlist across placed symbols, find nets with a source and no load, find nets that are shorted through conductors, trace which switch states complete a circuit, and know that a terminal's or junction's ports are one net. Tag letter codes (IEC 81346) and device behaviour stay in the consumer: they classify devices, not symbols.
6. Text slots and boxes
A slot reserves room for text the consumer will draw. A symbol never contains tag or marking text.
| Slot | Meaning |
|---|---|
tag | The reference designation (-K1). Required for kind = "symbol" when the set lists it in [rules] required_slots. |
"marking.<port id>" | The terminal marking for that port (13). Required for every port of a kind = "symbol" file when the set lists "marking.<port>" in [rules] required_slots. |
value | Optional. Rating or value text (10 A). |
Each slot is { at = [x, y], side = "N|E|S|W|C", box = [w, h] } with w and h positive, plus an optional turn = true. Text is upright unless the slot is turned (below). side says which way the box grows from at, and therefore the alignment:
side | Box, for at = (x, y), box = [w, h] | Text alignment |
|---|---|---|
E | x in [x, x + w], y in [y - h/2, y + h/2] | start, vertically centred |
W | x in [x - w, x], y in [y - h/2, y + h/2] | end, vertically centred |
N | x in [x - w/2, x + w/2], y in [y - h, y] | middle, sitting above at |
S | x in [x - w/2, x + w/2], y in [y, y + h] | middle, hanging below at |
C | x in [x - w/2, x + w/2], y in [y - h/2, y + h/2] | middle, the capital height centred on at |
Drawing slot text. to_fragment and to_fragment_mm draw the strings the caller passes in texts (slot id to string), one <text> per slot in slot id order, after the elements. The alignment is the table above, and the baseline of a string sits half a capital below at for E and W, on at for N one capital below it for S and half a capital below it for C, which centres the string on at both ways. The font is the caller's: to_fragment_mm draws at font_mm in font_family, and symdef never measures, shrinks or wraps a string. to_fragment sizes a string to fit the box, at most 1 M. An empty string, or no string, draws nothing. A key that is no slot id of the symbol raises KeyError; a slot named in required_slots (entries as in lint, marking.<port> meaning one per port) that has no key raises ValueError, and an empty string satisfies it. The text is black, or in the muted colour, with no class.
Turned slots. With turn = true the string reads bottom-up, along the height of box, which is the footprint as drawn. Along the reading direction the side that points along it anchors the text: N starts at at, S ends there, E and W centre the text on it, with the box beside it, and C centres it across the reading line as well, the baseline half a capital east of at. The text element carries rotate(-90) about its baseline point, the only transform symdef writes. turn is fixed to the page: orient moves at and side but never changes turn, and text is never mirrored. A text that must follow a rotated symbol is a variant symbol with its own slot. A turned box wider than it is tall is allowed; fitting is the caller's check.
Orientation. at transforms as a point and side as a direction; C has no direction and stays. box stays [w, h], because the text stays upright. So one declaration covers all 8 orientations,
and because the box does not rotate with the symbol, slot rules must be linted in every orientation, not just the base one.
Two boxes for layout engines. body_box is the extent of the elements. keepout_box is the body box united with every slot box.
A layout engine that keeps keep-out boxes apart can never produce a label collision between symbols.
Consumer duty. The box is a maximum extent. A consumer that draws text larger than the box has broken the contract on its side; it should shrink or report, never overflow silently.
7. Composition
Parts
A composite lists parts. Each part is { as, use, ... }: a local id and the number of another file.
| Key | Meaning |
|---|---|
as | Local part id. Namespaces the part's ports, anchors and slots: contact.in. |
use | Reference number of the part's file. Any kind. |
orient | Optional orientation applied to the part before placing. Default R0. |
attach, to, length, via | Place by anchor. The part's anchor attach is put at the point length M from the target anchor to ("part.anchor"), along the target's direction. The two anchors must face each other after orient; otherwise it is a lint error. via = "mechanical_link" adds a dashed line between them. length defaults to 0, via to none. |
at | Alternative to attach: place the part's origin at [x, y]. For qualifiers drawn inside a body. The first part defaults to at = [0, 0]. Every later part must have exactly one of attach or at. |
repeat | Optional pole count. Applied to the part first, before orient and placement. Its ports are then part.1.in, part.2.in, and so on. |
Placement, exactly. Parts are resolved in file order, and to may only name an anchor of an earlier part. Let T and dT be the target anchor's position and unit direction in the composite's frame,
and A and dA the attaching anchor's position and direction in the part's own frame after repeat and orient. It is an error unless dA = -dT.
The part is translated by T + length × dT - A. With via set to a word the set declares in [vocabulary] links (for example "mechanical_link") the composite gains the element { line = [T, T + length × dT], style = "dashed" }; length must then be positive.
The flattened result is linted like any other symbol, so a placement that pushes a port off the wiring grid fails there.
What a composite inherits
- Ports: only through the explicit rename map
ports = { new = "part.port" }. Every port of every part must be exported. There is no implicit export. - Nodes and paths: inherited automatically through the renames. A path declared in the composite's own
pathsreplaces the inherited path between the same nodes. If more than one inherited path is markedthrough, the composite must redeclare the one that stays; thethroughflag is then cleared on all the others. - Anchors: not inherited. A composite declares its own if it is meant to be a part.
- Slots: not inherited silently. A slot value may be a string reference (
"marking.in" = "contact.marking.in") or a new table. A part's tag slot is usually where the next part goes, so composites normally declare a newtag. - Elements: all parts' elements, plus the composite's own
elementsif present.
Example: push-button (no coordinates except the tag slot)
schema = 1
name = "Switch, manually operated, push-button, automatic return"
kind = "symbol"
status = "unverified"
reference = { standard = "IEC 60617", number = "S00254" }
parts = [
{ as = "contact", use = "S00227" },
{ as = "actuator", use = "S00171", attach = "link", to = "contact.link", length = 2, via = "mechanical_link" },
]
ports = { in = "contact.in", out = "contact.out" }
[slots]
tag = { at = [-3.5, 0], side = "W", box = [6, 1] }
"marking.in" = "contact.marking.in"
"marking.out" = "contact.marking.out"
S00171 stands for the Part 2 qualifier "operated by pushing". Its number, like every number in this guide, must be confirmed against the IEC database overview before the file is committed.
repeat: the only multi-pole mechanism
repeat(symbol, n) places n copies along +x in base orientation and returns an ordinary symbol. Pole k (counting from 1) is the symbol translated by ((k - 1) × pitch, 0), where pitch is the symbol's pole_pitch, default 4. It requires n >= 1 and a symbol with a through path; repeat(symbol, 1) still renames the ports.
- Ports, and their marking slots, are prefixed with the pole number:
1.in,2.in,"marking.2.in". Nodes and paths repeat per pole. Pole 1's through path stays the through path; the flag is cleared on the other poles. The result'spole_pitchisn × pitch. - The
tagslot is taken from pole 1 only. Avalueslot likewise. - If the symbol has an anchor with the id
linkandn > 1, the result gains one element: a dashed line from pole 1'slinkanchor to pole n's. It may cross the poles in between; that is how the standard draws it. Pole 1's anchors become the result's anchors; the other poles' anchors are dropped. - It is available as a toolkit function for consumers, and as a data op in a part:
{ as = "main", use = "S00227", repeat = 3 }.
There are no poles=, pins= or inverted= parameters anywhere. Pole count is repeat, markings belong to the placement, and inversion is the orientation MR180.
table(row, n, axis, head=None): rows of cells
Returns n copies of row, copy k (from 1) stepped by the row's body extent along axis. Ports, nodes, paths and bound leads are prefixed k.. Every slot is kept: marking.<port> becomes marking.k.<port> and any other id becomes k.<id>. No through path is needed. head, if given, keeps its own ids and is placed once before the rows, as authored in the row's frame; with a stretch on axis it is first stretched to span the rows. n is at most 64. A between element of the row is drawn in rows 1 to n-1 and left out of row n, so a single row shows none; a between element of the head is drawn as authored. The result has no stretch and keeps the row's pole_pitch (D54, D70).
stretch(symbol, axis, length, offsets=None): a cut line
Shares length minus the natural extent among the symbol's stretch cuts on axis in proportion to their weight, and moves each point by the shares of the cuts it lies beyond: element points, ports, anchors and slot corners. A line or polyline across a cut grows by that cut's share. A slot box is two corners: each moves like a point, so a box across a cut grows by that cut's share, and the position of a C slot stays the centre of its box. With one cut the whole growth goes to it, whatever its weight. offsets is unchanged and applies after the shares. A circle, arc or text across any cut raises ValueError, as does a length below the natural extent, a symbol with no stretch on axis, and an offset that names no port, a port not on the edge across the axis, or a place off the wiring grid or outside the stretched body. offsets places each named port, with its bound lead and its marking.<port> slot, at a coordinate along the axis. Two cuts at the same place on one axis are a stretch-cut finding. The result has no stretch on that axis, so a frame is stretched on both axes by two calls (D53).
leads(symbol, ports): the wired ports' leads
Returns the symbol without every Line whose port is set and is not in ports, an iterable of port ids. A line with no port, a line bound to a listed port and every other element stay, in order; ports, anchors, slots and every other field are unchanged. A listed id that names no port of the symbol raises KeyError naming it; a listed port with no bound lead is not an error. After table or repeat the ids are the prefixed ones (1.a). The caller decides which ports are wired; the toolkit knows no net (D59).
8. Wiring contract
What every flattened symbol guarantees a router, in all 8 orientations:
- Every port is on the 1 M wiring grid.
- Every port is the free end of a lead (or lies on the outline) and sits on the side of the
body_boxthat matches its direction. Its direction is that side's outward normal. - The wire lane of every port is clear. For a port at
Pwith directiond, the lane is the open, unbounded region of pointsP + t × d + u × nwitht > 0and|u| < 0.25, wherenis perpendicular tod. No element and no slot box overlaps it. The port's own lead ends att = 0and a marking box 0.25 M beside the wire only touches the lane, so both are fine. A wire that leaves a port straight crosses nothing of its own symbol. - The through path is collinear on x = 0 with ports at
(0, -a)and(0, a). Series chains wire dead straight, and chained symbol heights are whole M. - Same-side ports are a multiple of 2 M apart, and repeated poles are 4 M apart, so a 3-pole breaker, a 3-pole contactor and a motor's
u v wline up without jogs. - For a symbol with a through path, the x extent in base orientation of the body box united with every slot box except
tagandvalueis at most its pole pitch, so repeated poles never overlap each other.
A symbol that cannot comply declares each exemption in lint_allow with a reason. An exemption whose rule would not have fired is itself an error, so exemptions cannot rot.
9. Lint rules
lint(symbol, required_slots=()) runs on a flattened symbol and returns findings: rule, severity, message, optional location, optional orientation.
Geometric rules run in all 8 orientations. Composition rules run in the resolver and are reported the same way. All are errors unless marked.
Data repo gates require zero findings of either severity, so a warning is simply a finding that more often deserves a lint_allow entry; exemptions work the same for both.
slot-missing, through-missing and pitch-overflow apply only to kind = "symbol"; every other rule applies to every kind.
| Group | Rule id | Fires when |
|---|---|---|
| File | schema | the file fails JSON Schema validation |
metadata | empty name, reference.standard differs from library.toml, number not matching its number_pattern, or number differs from the file stem | |
id-format | a port, anchor or part id does not match the id pattern | |
| Geometry | off-drawing-grid | any coordinate, radius, text height, anchor, slot point or slot box size is not a multiple of 0.125 |
degenerate | zero-length line, non-positive radius, polyline with fewer than 2 points (3 when closed), arc with equal angles, exact duplicate element, slot box with a non-positive side | |
text-too-large | a text element is taller than 1 M | |
stretch-cut | a circle, arc or text crosses a stretch cut, a cut lies outside body_min <= at < body_max on its axis, or two cuts on one axis lie at the same at | |
| Ports | port-duplicate-id | two ports share an id |
port-off-wiring-grid | a port position is not a multiple of 1 M | |
port-off-geometry | a port is not at a line or polyline endpoint, on a circle or arc, or on a closed outline | |
port-on-body-edge | a port is not on the body-box side matching its direction | |
port-lane-clear | an element or slot box overlaps a port's wire lane (section 8) | |
port-spacing | two ports with the same dir are not a multiple of 2 M apart along their side | |
port-position-shared | two ports of different nodes share a position | |
lead-off-port | a line's port names a port that does not exist, or the line does not reach the port's position along its facing axis | |
| Connectivity | node-invalid | a node names an unknown port, or a port is listed in two nodes |
path-invalid | a path names an unknown port, joins two ports of one node, or duplicates another path between the same nodes | |
through-count | more than one through path | |
through-axis | the through path's ports are not at (0, -a) N and (0, a) S in base orientation | |
through-missing (warning) | a kind = "symbol" file has two or more nodes, no potential, and no through path | |
port-isolated (warning) | a port is in no path, in no multi-port node, and its node has no potential | |
| Slots | slot-missing | a kind = "symbol" file lacks a slot the set lists in [rules] required_slots, such as tag or a marking slot for some port |
slot-unknown-port | a marking slot names a port that does not exist | |
slot-overlap-body | a slot box overlaps an element, by the overlap definition in section 3 | |
slot-overlap-slot | two slot boxes overlap | |
pitch-overflow | the extent defined in section 8 point 6 exceeds the pole pitch; or pole_pitch is not a positive multiple of 4 | |
| Anchors | anchor-duplicate-id | two anchors share an id |
anchor-off-geometry (warning) | an anchor lies on no element | |
| Composition | part-unknown | use names a file that does not exist |
part-cycle | parts reference each other in a cycle | |
part-anchor | an unknown anchor, or anchors that do not face each other | |
part-port-unexported | a part's port is missing from the rename map | |
export-unknown | the rename map or a slot reference names something that does not exist | |
| Exemptions | allow-unknown | lint_allow names a rule that does not exist, or has an empty reason |
allow-unused | an exempted rule would not have fired |
Every rule ships with a deliberately broken fixture file that makes it fire, and a test that asserts it does.
10. Resolved output
The build flattens every file and writes JSON. This is the interface for consumers that are not Python, and for simple consumers of any kind: they never implement composition.
build/resolved/<number>.json: one flattened symbol. Same schema as the source, withpartsgone, compositeportsexpanded to the array form, slot references expanded to tables,nodeslisting every node explicitly, and two computed keys:body_boxandkeepout_box, each[[min_x, min_y], [max_x, max_y]]in base orientation. Astretchkey, an array of{ "axis", "at" }, is written only when set.src/<package>/bundle.json: the bundle, shipped as package data:{ "schema": 1, "standard": "IEC 60617", "symbols": { "S00227": { ... }, ... } }. It is generated and tracked likebuild/, andstale_buildcovers it. An optionalwiresarray of{ "id", "weight", "style" }is written only when the set has wire styles.- Output is deterministic: UTF-8, LF line endings, 2-space indent, object keys sorted, arrays in source order, a whole number written without a decimal point and any other number in its shortest exact decimal form, one trailing newline. A symbol change shows up as a reviewable diff.
A consumer in another language needs the bundle, the schema, and the orientation and slot-box rules from sections 3 and 6. That is about thirty lines of code.
11. Toolkit API and consumer usage
All types are frozen dataclasses. All functions are pure except those in files, project and cli. Transforms are free functions, never methods.
Point(x, y) Box(min, max) Direction: N E S W Centre: C Orientation: R0 .. MR270
Vocabulary(path_kinds, potentials, links)
Line | Polyline | Circle | Arc | Text = Element
Port(id, position, direction, description)
Node(ports, potential) Path(from_port, to_port, kind, through)
Anchor(id, position, direction) Slot(id, position, side, box)
Reference(standard, number, edition, form)
Symbol(name, kind, status, reference, elements, ports, nodes, paths, anchors, slots, pole_pitch, stretch)
Library(standard, title, number_pattern, symbols, vocabulary, required_slots, wires) get(number), __iter__ (sorted by number), __len__
Finding(rule, severity, message, location, orientation)
Stretch(axis, at, weight) WireStyle(id, weight, style)
load_library(root) -> Library read library.toml and symbols/*.toml, validate, resolve, flatten
load_bundle(json_path) -> Library read a resolved bundle
orient(symbol, orientation) -> Symbol ports, nodes, paths, anchors and slots all carried along
translate(symbol, dx, dy) -> Symbol used by composition and repeat; consumers place symbols themselves
repeat(symbol, n) -> Symbol
stretch(symbol, axis, length, offsets=None) -> Symbol section 7
table(row, n, axis, head=None) -> Symbol section 7
leads(symbol, ports) -> Symbol section 7
wire(library, style_id, points) -> Symbol the wire styles of library.toml
body_box(symbol) -> Box keepout_box(symbol) -> Box slot_box(slot) -> Box
lint(symbol, required_slots=()) -> tuple[Finding, ...]
to_svg(symbol, *, module_mm=2.5, annotate=False, texts=None, tint=None) -> str
write_build(library, root) -> tuple[Path, ...] writes build/ and the bundle
stale_build(library, root) -> tuple[Path, ...] files that differ from what write_build would write, stale extras included
init(root) -> tuple[Path, ...] build(root) -> tuple[Path, ...] check(root) -> tuple[Finding, ...] the three verbs of the symdef command
to_fragment and to_fragment_mm also take the keywords texts: Mapping[str, str] | None = None and required_slots: Sequence[str] = (); section 6 says what they draw and raise (D63 to D65). With texts=None the output is that of 0.8.0.
annotate=True draws the grid, ports with ids and lanes, anchors, slot boxes, and both boxes. texts maps slot ids to sample strings so the gallery can show a filled-in symbol.
to_fragment_mm writes the same placeable <g> fragment as to_fragment, in millimetres, with no scale transform, but for the rotate(-90) of a turned slot's text (D52, D65). Each coordinate and radius is multiplied by module_mm. Stroke widths come from weights_mm, one entry per weight, dash lengths from dash_mm, and the font size from font_mm; these are the caller's millimetres. Text capitals are centred on the anchor by cap_ratio, the capital height as a fraction of the font size. The caller passes the font family. A dash_dot element needs the keyword dash_dot_mm, the four lengths in mm of dash, gap, dot and gap; to_fragment_mm raises ValueError when one is drawn without it (D60). to_svg and to_fragment are unchanged.
to_svg, to_fragment and to_fragment_mm take the keyword tint, the colour of every fill = "tint" shape. It must be #rgb, #rrggbb or a letters-only name, or the call raises ValueError; with None a tinted shape draws fill="none". Output without a tinted shape does not change (D57).
to_fragment and to_fragment_mm also take the keyword muted, a colour string checked like tint, or None (the default, which draws as before). A muted symbol is drawn as not installed: every stroke, every text and every solid or tint fill in that colour, every line style replaced by a dash and a gap each as long as the normal stroke width, and no stroke on a text; an unfilled shape stays unfilled. dash_mm and dash_dot_mm are then not used (D61).
A set declares wire styles in library.toml as [[wire]] tables with id, weight (normal | thick) and style (solid | dashed | dash_dot). wire returns an element symbol of one open polyline in that style, with no ports; points are Points or (x, y) pairs, at least two (D56).
A data repo's Python package is a thin wrapper: from my_symbols import LIBRARY, loaded from the bundled JSON shipped as package data.
What a drawing library does
from symdef import Orientation, keepout_box, orient, repeat
from my_symbols import LIBRARY
breaker = orient(repeat(LIBRARY.get("S00287"), 3), Orientation.R0)
# The placement is the consumer's own type. The symbol stays a definition.
placed = Placement(
symbol=breaker,
at=Point(40, 120),
tag="-Q1",
markings={"1.in": "1", "1.out": "2", "2.in": "3", "2.out": "4", "3.in": "5", "3.out": "6"},
)
- Layout keeps
keepout_boxrectangles apart and aligns through axes. - Routing starts each wire at a port, along its direction. The lane is guaranteed clear.
- Text is drawn into
slot_box(slot)with the alignment given by the slot's side. - Netlisting and checks read
nodesandpaths. - Markings are keyed by port id. The keys are validated against the symbol's ports, so a marking for a port that does not exist fails loudly at placement.
12. Repo layout, build and gates
symdef/ standard-agnostic toolkit
src/symdef/
schema/symbol.schema.json the published contract
model.py geometry.py units.py types
load.py resolve.py vocabulary.py TOML and JSON in, flattening, declared words
orient.py repeat.py boxes.py pure operations
lint/ one module per rule group
svg.py rendering
files.py project.py cli.py the impure modules: files, the three verbs, the command
tests/fixtures/broken/ one failing file per lint rule
my-symbols/ a data repo: data only (`symdef init` writes it)
library.toml standard, title, number_pattern, vocabulary, rules
symbols/resistor.toml ... source of truth
src/mysymbols/bundle.json generated, tracked
build/ generated by `symdef build`, tracked
resolved/ svg/ annotated/ README.md
The toolkit knows nothing about any standard. Everything standard-specific is in the data repo's library.toml, including its words and its required slots; an optional package key names the folder under src/ that holds the bundle:
standard = "IEC 60617"
title = "IEC 60617 symbols"
number_pattern = '^S\d{5}$'
[vocabulary]
path_kinds = ["conductor", "switch_open", "switch_closed", "impedance", "source", "diode"]
potentials = ["earth", "protective_earth", "functional_earth", "frame"]
links = ["mechanical_link"]
[rules]
required_slots = ["tag", "marking.<port>"]
Any other standard is a sibling data repo that reuses the toolkit and every gate.
Gates in a data repo (symdef check)
- Every file validates against the schema and resolves.
- Every flattened symbol lints with zero findings, warnings included, in all 8 orientations.
- Every symbol with a through path also lints clean as
repeat(symbol, 3). stale_buildreports nothing, sobuild/always matchessymbols/.- The packaged bundle holds the source's symbols, and they lint clean.
- Reference numbers are unique, match the standard's pattern and equal the file stems.
Gates in the toolkit
- Every lint rule has a fixture that makes it fire.
orientround-trips: applying an orientation and its inverse returns the same symbol (property test).- The five example files in this guide load and, once the rules exist, lint clean.
- The JSON Schema and the Python loader agree: a file accepted by one is accepted by the other (fixture-driven).
- Every pure module is free of file and console I/O, the clock, randomness and state writes (
scripts/fp_purity_gate.py).
Constraints carried over
Python >=3.14, uv only, every dependency pinned to the exact latest stable version, ruff, ty, pytest with hypothesis, vulture. There is no runtime dependency. TOML is read with the standard library's tomllib.
Geometry is unverified until compared with the IEC database drawings; the gallery shows the status next to each symbol.
13. What is not built
- Never build: Python symbol factories, a
kitmodule, adashed_linehelper (dashed is a line style), aRegistryof factories, or aPorts:docstring convention. The symbol file is the documentation.
14. Deferred and open
- Coupling between paths (a coil actuating contacts, magnetically coupled windings). In detached representation this lives between separately placed symbols, so it belongs to the consumer's device model. Revisit for attached composites such as a contactor drawn as one symbol.
- Conventional marking hints. EN 50005 function digits (make = 3/4, break = 1/2, change-over = 1/2/4) could be optional per-port data so consumers without a catalog get sensible defaults. Not in schema 1.
- Connections between parts inside a composite (merging two parts' nodes). Not needed for IEC composites found so far.
- Small symbols such as the terminal (S00017), whose natural port positions are on a 0.5 M circle. Decide per symbol: leads to the wiring grid, or a declared exemption as for the connection point.
- Reference numbers quoted here (S00230, S00171, S00287) come from memory of the IEC database overview and must be confirmed before use.
- Verification. The IEC drawings are behind a subscription. Until bought, every symbol stays
unverified.