diff --git a/docs/superpowers/plans/2026-09-15-v1.6-pid-implementation.md b/docs/superpowers/plans/2026-09-15-v1.6-pid-implementation.md index 38c52c9..4592d3b 100644 --- a/docs/superpowers/plans/2026-09-15-v1.6-pid-implementation.md +++ b/docs/superpowers/plans/2026-09-15-v1.6-pid-implementation.md @@ -2853,95 +2853,176 @@ Expected: FAIL (`ImportError: cannot import name 'ACTUATORS'`). - [ ] **Step 3: Write the module** +Actuator placement is body-relative: the stem starts at the body's `axis_top` (where its own geometry on x = 0 ends) and the glyph base sits `STEM_CLEARANCE` above the body's top edge, so the needle body (top 5.6) is never overprinted and the diaphragm dome's base coincides with the stem top (no floating dome). + ```python # engineering/pid/symbols_valves.py -"""Valve bodies (ISO 10628-2) composed with actuators (ISA-5.1 Table 5.5).""" +"""Valve bodies (ISO 10628-2) composed with actuators (ISA-5.1 Table 5.5). + +Every body is authored once in symbol-local mm (reference size 8 × 4, process +ports on the ``in`` / ``out`` faces) and any actuator stacks on top of it along +the stem at x = 0: the stem starts where the body's own geometry on that axis +ends (``axis_top``) and the actuator glyph sits ``STEM_CLEARANCE`` above the +body's top edge, so a tall body (needle, diaphragm) pushes the actuator up +instead of being overprinted by it. An actuated valve gains a ``signal`` port +on its topmost edge and a ``FAIL`` attribute; check and relief valves take no +actuator. +""" from __future__ import annotations -from .geometry import arc, attdef, circle, line, polyline, text +from .geometry import arc, attdef, bbox_of, circle, line, polyline, text from .symbols import DESC_HEIGHT, Port, SymbolSpec, make_spec, register, tag_attdefs -VALVE_BODIES = ("gate", "globe", "ball", "butterfly", "check", "needle", "plug", - "diaphragm", "three_way", "angle", "relief") +VALVE_BODIES = ( + "gate", + "globe", + "ball", + "butterfly", + "check", + "needle", + "plug", + "diaphragm", + "three_way", + "angle", + "relief", +) ACTUATORS = ("none", "diaphragm", "piston", "motor", "solenoid", "hand") -NO_ACTUATOR = {"check", "relief"} +NO_ACTUATOR = frozenset({"check", "relief"}) +SOURCE = "ISO 10628-2:2012 valves; ISA-5.1-2009 Table 5.5 actuators" _LEFT = polyline([(-4, -2), (-4, 2), (0, 0)]) _RIGHT = polyline([(4, -2), (4, 2), (0, 0)]) _BOTTOM = polyline([(-2, -4), (2, -4), (0, 0)]) _IN = Port("in", -4.0, 0.0, 180.0) _OUT = Port("out", 4.0, 0.0, 0.0) +_BOX = (-4.0, -2.0, 4.0, 2.0) +# Gap between a body's top edge and the actuator glyph — the visible stem on the +# reference 8 × 4 body (stem 0..4, glyph from y = 4). +STEM_CLEARANCE = 2.0 +# Extra stem (the yoke) between the clearance point and a diaphragm dome's base. +DIAPHRAGM_YOKE = 2.0 + +# (primitives, process ports, declared bbox, axis_top) — ``axis_top`` is the y +# where the body's own geometry on the x = 0 axis ends, i.e. where an actuator +# stem may start without overprinting it. +Body = tuple[list[dict], tuple[Port, ...], tuple[float, float, float, float], float] -def _body(body: str) -> tuple[list[dict], tuple[Port, ...], tuple[float, float, float, float]]: +def _body(body: str) -> Body: + """Primitives, process ports, declared bbox and axis top of a bare valve body.""" std = [_LEFT, _RIGHT] - box = (-4.0, -2.0, 4.0, 2.0) if body == "gate": - return std, (_IN, _OUT), box + return std, (_IN, _OUT), _BOX, 0.0 if body == "globe": - return std + [circle(0, 0, 1.0)], (_IN, _OUT), box + return std + [circle(0, 0, 1.0)], (_IN, _OUT), _BOX, 1.0 if body == "ball": - return std + [circle(0, 0, 1.8)], (_IN, _OUT), box + return std + [circle(0, 0, 1.8)], (_IN, _OUT), _BOX, 1.8 if body == "butterfly": - return std + [line(0, -2, 0, 2), circle(0, 0, 0.8)], (_IN, _OUT), box + return std + [line(0, -2, 0, 2), circle(0, 0, 0.8)], (_IN, _OUT), _BOX, 2.0 if body == "check": - return std + [arc(0, 0, 2.5, 20, 160)], (_IN, _OUT), (-4.0, -2.0, 4.0, 2.5) + return std + [arc(0, 0, 2.5, 20, 160)], (_IN, _OUT), (-4.0, -2.0, 4.0, 2.5), 2.5 if body == "needle": - return std + [line(0, 0, 0, 5), circle(0, 5, 0.6)], (_IN, _OUT), (-4.0, -2.0, 4.0, 5.6) + prims = std + [line(0, 0, 0, 5), circle(0, 5, 0.6)] + return prims, (_IN, _OUT), (-4.0, -2.0, 4.0, 5.6), 5.6 if body == "plug": - return std + [polyline([(-1, -1), (1, -1), (1, 1), (-1, 1)])], (_IN, _OUT), box + return std + [polyline([(-1, -1), (1, -1), (1, 1), (-1, 1)])], (_IN, _OUT), _BOX, 1.0 if body == "diaphragm": - return std + [arc(0, 2, 2, 0, 180)], (_IN, _OUT), (-4.0, -2.0, 4.0, 4.0) + return std + [arc(0, 2, 2, 0, 180)], (_IN, _OUT), (-4.0, -2.0, 4.0, 4.0), 4.0 if body == "three_way": - return std + [_BOTTOM], (_IN, _OUT, Port("branch", 0.0, -4.0, 270.0)), (-4.0, -4.0, 4.0, 2.0) + ports = (_IN, _OUT, Port("branch", 0.0, -4.0, 270.0)) + return std + [_BOTTOM], ports, (-4.0, -4.0, 4.0, 2.0), 0.0 if body == "angle": - return [_LEFT, _BOTTOM], (_IN, Port("out", 0.0, -4.0, 270.0)), (-4.0, -4.0, 2.0, 2.0) + ports = (_IN, Port("out", 0.0, -4.0, 270.0)) + return [_LEFT, _BOTTOM], ports, (-4.0, -4.0, 2.0, 2.0), 0.0 if body == "relief": - spring = polyline([(0, 0), (-1, 1.5), (1, 2.5), (-1, 3.5), (1, 4.5), (0, 6)], closed=False) - return [_BOTTOM, _RIGHT, spring], (Port("in", 0.0, -4.0, 270.0), _OUT), (-2.0, -4.0, 4.0, 6.0) + spring = polyline( + [(0, 0), (-1, 1.5), (1, 2.5), (-1, 3.5), (1, 4.5), (0, 6)], + closed=False, + ) + ports = (Port("in", 0.0, -4.0, 270.0), _OUT) + return [_BOTTOM, _RIGHT, spring], ports, (-2.0, -4.0, 4.0, 6.0), 6.0 raise ValueError(f"unknown valve body {body!r}; valid: {', '.join(VALVE_BODIES)}") -def _actuator(actuator: str, body_top: float) -> tuple[list[dict], Port | None, float]: - """Primitives above the body, the signal port (or None), and the new top y.""" - stem = line(0, 0, 0, 4) +def _actuator( + actuator: str, body_top: float, axis_top: float = 0.0 +) -> tuple[list[dict], Port | None, float]: + """Primitives above the body, the signal port (or None), and the new top y. + + The stem (always the first primitive) runs from ``axis_top`` — where the + body's axial geometry ends — to the glyph base at + ``body_top + STEM_CLEARANCE``; every glyph's lowest edge sits exactly on + the stem's top, so an actuator is never drawn detached from, or over, its + body. + """ if actuator == "none": return [], None, body_top + base = body_top + STEM_CLEARANCE if actuator == "hand": - return [stem, line(-2.5, 4, 2.5, 4)], None, max(body_top, 4.0) + return [line(0, axis_top, 0, base), line(-2.5, base, 2.5, base)], None, base if actuator == "diaphragm": - return [stem, arc(0, 6, 3, 0, 180), line(-3, 6, 3, 6)], Port("signal", 0.0, 9.0, 90.0, "signal"), 9.0 + dome = base + DIAPHRAGM_YOKE + prims = [line(0, axis_top, 0, dome), arc(0, dome, 3, 0, 180), line(-3, dome, 3, dome)] + return prims, Port("signal", 0.0, dome + 3.0, 90.0, "signal"), dome + 3.0 + stem = line(0, axis_top, 0, base) if actuator == "piston": - return [stem, polyline([(-2, 4), (2, 4), (2, 8), (-2, 8)])], Port("signal", 0.0, 8.0, 90.0, "signal"), 8.0 + top = base + 4.0 + prims = [stem, polyline([(-2, base), (2, base), (2, top), (-2, top)])] + return prims, Port("signal", 0.0, top, 90.0, "signal"), top if actuator == "motor": - return [stem, circle(0, 6, 2), text("M", 0, 6, 2.0)], Port("signal", 0.0, 8.0, 90.0, "signal"), 8.0 + top = base + 4.0 + prims = [stem, circle(0, base + 2.0, 2), text("M", 0, base + 2.0, 2.0)] + return prims, Port("signal", 0.0, top, 90.0, "signal"), top if actuator == "solenoid": - return [stem, polyline([(-1.5, 4), (1.5, 4), (1.5, 7), (-1.5, 7)]), text("S", 0, 5.5, 1.8)], \ - Port("signal", 0.0, 7.0, 90.0, "signal"), 7.0 + top = base + 3.0 + box = polyline([(-1.5, base), (1.5, base), (1.5, top), (-1.5, top)]) + prims = [stem, box, text("S", 0, base + 1.5, 1.8)] + return prims, Port("signal", 0.0, top, 90.0, "signal"), top raise ValueError(f"unknown actuator {actuator!r}; valid: {', '.join(ACTUATORS)}") def build_valve(body: str = "gate", actuator: str = "none") -> SymbolSpec: - prims, ports, box = _body(body) + """Compose ``body`` with ``actuator`` into a ``SymbolSpec``. + + Raises ``ValueError`` for an unknown body or actuator, and for an actuator + on a check or relief valve (they are self-acting). + """ + prims, ports, box, axis_top = _body(body) + if actuator not in ACTUATORS: + raise ValueError(f"unknown actuator {actuator!r}; valid: {', '.join(ACTUATORS)}") if actuator != "none" and body in NO_ACTUATOR: - raise ValueError(f"{body} valves take no actuator") - extra, signal, top = _actuator(actuator, box[3]) + raise ValueError(f"{body} valves take no actuator (got {actuator!r})") + glyph_base = box[3] + STEM_CLEARANCE + extra, signal, top = _actuator(actuator, box[3], axis_top) prims = list(prims) + extra ports = tuple(ports) + ((signal,) if signal else ()) - box = (box[0], box[1], box[2], max(box[3], top)) + if extra: + # An actuator can be wider than a narrow body (angle: xmax 2 vs. a hand + # bar at ±2.5 or a diaphragm at ±3), so union its real extent, not just y. + ax0, ay0, ax1, ay1 = bbox_of(extra) + box = (min(box[0], ax0), min(box[1], ay0), max(box[2], ax1), max(box[3], top, ay1)) attdefs = list(tag_attdefs(box[3])) if signal is not None: - attdefs.append(attdef("FAIL", 3.5, 5.5, DESC_HEIGHT, align="left")) + # Beside the actuator glyph (spec 4.7): right of its widest extent (±3 + # for a dome), 1.5 above the glyph base. + attdefs.append(attdef("FAIL", 3.5, glyph_base + 1.5, DESC_HEIGHT, align="left")) variant = None if actuator == "none" else actuator name = f"PID_VALVE_{body.upper()}" + (f"_{actuator.upper()}" if variant else "") - return make_spec(name, "valve", body, variant, prims, attdefs, ports, - "ISO 10628-2:2012 valves; ISA-5.1-2009 Table 5.5 actuators", bbox=box) + return make_spec(name, "valve", body, variant, prims, attdefs, ports, SOURCE, bbox=box) + + +def _valve_builder(body: str): + def build(actuator: str = "none") -> SymbolSpec: + return build_valve(body, actuator) + + return build for _body_name in VALVE_BODIES: - register(_body_name, "valve", (lambda b: (lambda actuator="none": build_valve(b, actuator)))(_body_name), - variants={"actuator": list(ACTUATORS)}) + register( + _body_name, "valve", _valve_builder(_body_name), variants={"actuator": list(ACTUATORS)} + ) ``` - [ ] **Step 4: Run the whole catalogue harness** diff --git a/engineering/pid/symbols_valves.py b/engineering/pid/symbols_valves.py index 07e92c6..8f59e4d 100644 --- a/engineering/pid/symbols_valves.py +++ b/engineering/pid/symbols_valves.py @@ -3,8 +3,12 @@ Every body is authored once in symbol-local mm (reference size 8 × 4, process ports on the ``in`` / ``out`` faces) and any actuator stacks on top of it along -the stem at x = 0. An actuated valve gains a ``signal`` port on its topmost -edge and a ``FAIL`` attribute; check and relief valves take no actuator. +the stem at x = 0: the stem starts where the body's own geometry on that axis +ends (``axis_top``) and the actuator glyph sits ``STEM_CLEARANCE`` above the +body's top edge, so a tall body (needle, diaphragm) pushes the actuator up +instead of being overprinted by it. An actuated valve gains a ``signal`` port +on its topmost edge and a ``FAIL`` attribute; check and relief valves take no +actuator. """ from __future__ import annotations @@ -35,63 +39,88 @@ _BOTTOM = polyline([(-2, -4), (2, -4), (0, 0)]) _IN = Port("in", -4.0, 0.0, 180.0) _OUT = Port("out", 4.0, 0.0, 0.0) _BOX = (-4.0, -2.0, 4.0, 2.0) +# Gap between a body's top edge and the actuator glyph — the visible stem on the +# reference 8 × 4 body (stem 0..4, glyph from y = 4). +STEM_CLEARANCE = 2.0 +# Extra stem (the yoke) between the clearance point and a diaphragm dome's base. +DIAPHRAGM_YOKE = 2.0 -Body = tuple[list[dict], tuple[Port, ...], tuple[float, float, float, float]] +# (primitives, process ports, declared bbox, axis_top) — ``axis_top`` is the y +# where the body's own geometry on the x = 0 axis ends, i.e. where an actuator +# stem may start without overprinting it. +Body = tuple[list[dict], tuple[Port, ...], tuple[float, float, float, float], float] def _body(body: str) -> Body: - """Primitives, process ports and declared bbox of a bare valve body.""" + """Primitives, process ports, declared bbox and axis top of a bare valve body.""" std = [_LEFT, _RIGHT] if body == "gate": - return std, (_IN, _OUT), _BOX + return std, (_IN, _OUT), _BOX, 0.0 if body == "globe": - return std + [circle(0, 0, 1.0)], (_IN, _OUT), _BOX + return std + [circle(0, 0, 1.0)], (_IN, _OUT), _BOX, 1.0 if body == "ball": - return std + [circle(0, 0, 1.8)], (_IN, _OUT), _BOX + return std + [circle(0, 0, 1.8)], (_IN, _OUT), _BOX, 1.8 if body == "butterfly": - return std + [line(0, -2, 0, 2), circle(0, 0, 0.8)], (_IN, _OUT), _BOX + return std + [line(0, -2, 0, 2), circle(0, 0, 0.8)], (_IN, _OUT), _BOX, 2.0 if body == "check": - return std + [arc(0, 0, 2.5, 20, 160)], (_IN, _OUT), (-4.0, -2.0, 4.0, 2.5) + return std + [arc(0, 0, 2.5, 20, 160)], (_IN, _OUT), (-4.0, -2.0, 4.0, 2.5), 2.5 if body == "needle": - return std + [line(0, 0, 0, 5), circle(0, 5, 0.6)], (_IN, _OUT), (-4.0, -2.0, 4.0, 5.6) + prims = std + [line(0, 0, 0, 5), circle(0, 5, 0.6)] + return prims, (_IN, _OUT), (-4.0, -2.0, 4.0, 5.6), 5.6 if body == "plug": - return std + [polyline([(-1, -1), (1, -1), (1, 1), (-1, 1)])], (_IN, _OUT), _BOX + return std + [polyline([(-1, -1), (1, -1), (1, 1), (-1, 1)])], (_IN, _OUT), _BOX, 1.0 if body == "diaphragm": - return std + [arc(0, 2, 2, 0, 180)], (_IN, _OUT), (-4.0, -2.0, 4.0, 4.0) + return std + [arc(0, 2, 2, 0, 180)], (_IN, _OUT), (-4.0, -2.0, 4.0, 4.0), 4.0 if body == "three_way": ports = (_IN, _OUT, Port("branch", 0.0, -4.0, 270.0)) - return std + [_BOTTOM], ports, (-4.0, -4.0, 4.0, 2.0) + return std + [_BOTTOM], ports, (-4.0, -4.0, 4.0, 2.0), 0.0 if body == "angle": - return [_LEFT, _BOTTOM], (_IN, Port("out", 0.0, -4.0, 270.0)), (-4.0, -4.0, 2.0, 2.0) + ports = (_IN, Port("out", 0.0, -4.0, 270.0)) + return [_LEFT, _BOTTOM], ports, (-4.0, -4.0, 2.0, 2.0), 0.0 if body == "relief": spring = polyline( [(0, 0), (-1, 1.5), (1, 2.5), (-1, 3.5), (1, 4.5), (0, 6)], closed=False, ) ports = (Port("in", 0.0, -4.0, 270.0), _OUT) - return [_BOTTOM, _RIGHT, spring], ports, (-2.0, -4.0, 4.0, 6.0) + return [_BOTTOM, _RIGHT, spring], ports, (-2.0, -4.0, 4.0, 6.0), 6.0 raise ValueError(f"unknown valve body {body!r}; valid: {', '.join(VALVE_BODIES)}") -def _actuator(actuator: str, body_top: float) -> tuple[list[dict], Port | None, float]: - """Primitives above the body, the signal port (or None), and the new top y.""" - stem = line(0, 0, 0, 4) +def _actuator( + actuator: str, body_top: float, axis_top: float = 0.0 +) -> tuple[list[dict], Port | None, float]: + """Primitives above the body, the signal port (or None), and the new top y. + + The stem (always the first primitive) runs from ``axis_top`` — where the + body's axial geometry ends — to the glyph base at + ``body_top + STEM_CLEARANCE``; every glyph's lowest edge sits exactly on + the stem's top, so an actuator is never drawn detached from, or over, its + body. + """ if actuator == "none": return [], None, body_top + base = body_top + STEM_CLEARANCE if actuator == "hand": - return [stem, line(-2.5, 4, 2.5, 4)], None, max(body_top, 4.0) + return [line(0, axis_top, 0, base), line(-2.5, base, 2.5, base)], None, base if actuator == "diaphragm": - prims = [stem, arc(0, 6, 3, 0, 180), line(-3, 6, 3, 6)] - return prims, Port("signal", 0.0, 9.0, 90.0, "signal"), 9.0 + dome = base + DIAPHRAGM_YOKE + prims = [line(0, axis_top, 0, dome), arc(0, dome, 3, 0, 180), line(-3, dome, 3, dome)] + return prims, Port("signal", 0.0, dome + 3.0, 90.0, "signal"), dome + 3.0 + stem = line(0, axis_top, 0, base) if actuator == "piston": - prims = [stem, polyline([(-2, 4), (2, 4), (2, 8), (-2, 8)])] - return prims, Port("signal", 0.0, 8.0, 90.0, "signal"), 8.0 + top = base + 4.0 + prims = [stem, polyline([(-2, base), (2, base), (2, top), (-2, top)])] + return prims, Port("signal", 0.0, top, 90.0, "signal"), top if actuator == "motor": - prims = [stem, circle(0, 6, 2), text("M", 0, 6, 2.0)] - return prims, Port("signal", 0.0, 8.0, 90.0, "signal"), 8.0 + top = base + 4.0 + prims = [stem, circle(0, base + 2.0, 2), text("M", 0, base + 2.0, 2.0)] + return prims, Port("signal", 0.0, top, 90.0, "signal"), top if actuator == "solenoid": - prims = [stem, polyline([(-1.5, 4), (1.5, 4), (1.5, 7), (-1.5, 7)]), text("S", 0, 5.5, 1.8)] - return prims, Port("signal", 0.0, 7.0, 90.0, "signal"), 7.0 + top = base + 3.0 + box = polyline([(-1.5, base), (1.5, base), (1.5, top), (-1.5, top)]) + prims = [stem, box, text("S", 0, base + 1.5, 1.8)] + return prims, Port("signal", 0.0, top, 90.0, "signal"), top raise ValueError(f"unknown actuator {actuator!r}; valid: {', '.join(ACTUATORS)}") @@ -101,12 +130,13 @@ def build_valve(body: str = "gate", actuator: str = "none") -> SymbolSpec: Raises ``ValueError`` for an unknown body or actuator, and for an actuator on a check or relief valve (they are self-acting). """ - prims, ports, box = _body(body) + prims, ports, box, axis_top = _body(body) if actuator not in ACTUATORS: raise ValueError(f"unknown actuator {actuator!r}; valid: {', '.join(ACTUATORS)}") if actuator != "none" and body in NO_ACTUATOR: raise ValueError(f"{body} valves take no actuator (got {actuator!r})") - extra, signal, top = _actuator(actuator, box[3]) + glyph_base = box[3] + STEM_CLEARANCE + extra, signal, top = _actuator(actuator, box[3], axis_top) prims = list(prims) + extra ports = tuple(ports) + ((signal,) if signal else ()) if extra: @@ -116,7 +146,9 @@ def build_valve(body: str = "gate", actuator: str = "none") -> SymbolSpec: box = (min(box[0], ax0), min(box[1], ay0), max(box[2], ax1), max(box[3], top, ay1)) attdefs = list(tag_attdefs(box[3])) if signal is not None: - attdefs.append(attdef("FAIL", 3.5, 5.5, DESC_HEIGHT, align="left")) + # Beside the actuator glyph (spec 4.7): right of its widest extent (±3 + # for a dome), 1.5 above the glyph base. + attdefs.append(attdef("FAIL", 3.5, glyph_base + 1.5, DESC_HEIGHT, align="left")) variant = None if actuator == "none" else actuator name = f"PID_VALVE_{body.upper()}" + (f"_{actuator.upper()}" if variant else "") return make_spec(name, "valve", body, variant, prims, attdefs, ports, SOURCE, bbox=box) diff --git a/tests/test_pid_symbols.py b/tests/test_pid_symbols.py index 1315137..15619a9 100644 --- a/tests/test_pid_symbols.py +++ b/tests/test_pid_symbols.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import math import re @@ -208,7 +209,13 @@ def test_list_symbols_reports_variants_and_ports(): # ── valves (Task 8) ────────────────────────────────────────────────────────── -from engineering.pid.symbols_valves import ACTUATORS, VALVE_BODIES, build_valve # noqa: E402 +from engineering.pid.symbols_valves import ( # noqa: E402 + ACTUATORS, + NO_ACTUATOR, + STEM_CLEARANCE, + VALVE_BODIES, + build_valve, +) def test_valve_bodies_and_actuators_enumerate(): @@ -247,3 +254,76 @@ def test_three_way_angle_and_relief_ports(): assert {p.name: p.direction_deg for p in angle.ports} == {"in": 180.0, "out": 270.0} relief = build_valve("relief") assert {p.name: p.direction_deg for p in relief.ports} == {"in": 270.0, "out": 0.0} + + +def _axis_top(primitives) -> float: + """Highest y at which a body's own geometry touches the x = 0 axis.""" + ys = [0.0] + for p in primitives: + kind = p["type"] + if kind == "line" and p["x1"] == 0.0 == p["x2"]: + ys += [p["y1"], p["y2"]] + elif kind == "circle" and p["cx"] == 0.0: + ys.append(p["cy"] + p["r"]) + elif kind == "arc" and p["cx"] == 0.0: + if (90.0 - p["start_deg"]) % 360.0 <= (p["end_deg"] - p["start_deg"]) % 360.0: + ys.append(p["cy"] + p["r"]) + elif kind == "polyline": + pts = list(p["points"]) + ([p["points"][0]] if p["closed"] else []) + for (x1, y1), (x2, y2) in zip(pts, pts[1:], strict=False): + if x1 == 0.0 == x2: + ys += [y1, y2] + elif min(x1, x2) <= 0.0 <= max(x1, x2): + ys.append(y1 + (y2 - y1) * (0.0 - x1) / (x2 - x1)) + return max(ys) + + +def _actuated(): + return [ + (body, actuator) + for body in VALVE_BODIES + if body not in NO_ACTUATOR + for actuator in ACTUATORS + if actuator != "none" + ] + + +@pytest.mark.parametrize("body,actuator", _actuated(), ids=lambda v: v) +def test_actuator_glyph_is_connected_to_its_stem_and_clear_of_the_body(body, actuator): + bare = build_valve(body) + composed = build_valve(body, actuator) + extra = list(composed.primitives[len(bare.primitives) :]) + assert composed.primitives[: len(bare.primitives)] == bare.primitives + stem, glyph = extra[0], extra[1:] + assert stem["type"] == "line" and stem["x1"] == 0.0 == stem["x2"] + stem_bottom, stem_top = sorted((stem["y1"], stem["y2"])) + # The stem starts where the body's own axial geometry ends — never + # coincident with a body line (butterfly disc, needle stem) or inside a + # body circle / dome. + assert stem_bottom == pytest.approx(_axis_top(bare.primitives)) + # The glyph's lowest edge sits on the stem's top (no floating dome) ... + assert bbox_of(glyph)[1] == pytest.approx(stem_top) + # ... and the whole glyph is above the body, with the standard clearance. + assert bbox_of(glyph)[1] >= bare.bbox[3] + STEM_CLEARANCE - EPS + # The FAIL attribute sits beside the glyph, not on the body. + if actuator != "hand": + fail = next(a for a in composed.attdefs if a["tag"] == "FAIL") + assert fail["y"] > bare.bbox[3] + if actuator != "hand": + signal = next(p for p in composed.ports if p.name == "signal") + assert signal.y == pytest.approx(composed.bbox[3]) + + +def test_hand_actuator_has_no_fail_attribute_or_signal_port(): + for body in VALVE_BODIES: + if body in NO_ACTUATOR: + continue + hand = build_valve(body, "hand") + assert all(a["tag"] != "FAIL" for a in hand.attdefs) + assert all(p.name != "signal" for p in hand.ports) + + +@pytest.mark.parametrize("spec", all_specs(), ids=_ids()) +def test_no_duplicate_primitives(spec): + seen = [json.dumps(p, sort_keys=True) for p in spec.primitives] + assert len(seen) == len(set(seen)), "duplicate overlapping geometry inside the block"