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:
co-authored by
Claude Opus 5
parent
41e60bd4d0
commit
352ce21030
@@ -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**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user