fix(task-8): actuators stack on the body's real top and every stem meets its glyph

Two authoring slips in the valve x actuator composition:

- The diaphragm actuator's stem stopped at y=4 while the dome's flat base
  sat at y=6, so the canonical control-valve symbol (PID_VALVE_*_DIAPHRAGM,
  9 catalogue entries) shipped with its dome floating 2 mm above the stem.
- Actuators were placed at a fixed y=4 regardless of the body, so on the
  needle body (top 5.6) the piston/motor/solenoid glyphs were drawn over
  the knob and the hand bar across the needle's stem; the stem line(0,0,0,4)
  also duplicated the needle's own axial line (and the butterfly disc).

Placement is now body-relative: `_body` reports `axis_top` (where its own
geometry on x=0 ends) and the stem runs from there to the glyph base at
`body_top + STEM_CLEARANCE`; the diaphragm dome's base is the stem top
(base + DIAPHRAGM_YOKE), and the FAIL attribute follows the glyph base.
The reference 8x4 bodies keep their published numbers (globe+diaphragm
signal at y=9.0, bbox top 9.0).

Tests: per-combination check that the stem starts at the body's derived
axis top, the glyph's lowest edge equals the stem top, the glyph clears
the body, and the signal port is the topmost edge; plus a catalogue-wide
no-duplicate-primitives gate. 33 combinations fail on the old geometry.
The plan's Step 3 snippet is synced with the fixed module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-15 15:51:20 +03:00
co-authored by Claude Opus 5
parent 41e60bd4d0
commit 352ce21030
3 changed files with 261 additions and 68 deletions
@@ -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**
+62 -30
View File
@@ -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)
+81 -1
View File
@@ -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"