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
168 lines
7.2 KiB
Python
168 lines
7.2 KiB
Python
# engineering/pid/symbols_valves.py
|
||
"""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, 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",
|
||
)
|
||
ACTUATORS = ("none", "diaphragm", "piston", "motor", "solenoid", "hand")
|
||
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) -> 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, 0.0
|
||
if body == "globe":
|
||
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, 1.8
|
||
if body == "butterfly":
|
||
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), 2.5
|
||
if body == "needle":
|
||
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, 1.0
|
||
if body == "diaphragm":
|
||
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), 0.0
|
||
if body == "angle":
|
||
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), 6.0
|
||
raise ValueError(f"unknown valve body {body!r}; valid: {', '.join(VALVE_BODIES)}")
|
||
|
||
|
||
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 [line(0, axis_top, 0, base), line(-2.5, base, 2.5, base)], None, base
|
||
if actuator == "diaphragm":
|
||
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":
|
||
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":
|
||
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":
|
||
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:
|
||
"""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 (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 ())
|
||
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:
|
||
# 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)
|
||
|
||
|
||
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", _valve_builder(_body_name), variants={"actuator": list(ACTUATORS)}
|
||
)
|