feat(pid): rotating, heat-transfer and inline equipment symbols

Task 9 of the v1.6 P&ID plan: engineering/pid/symbols_equipment.py fills
the stub with three ISO 10628-2 families registered on import — rotating
(centrifugal_pump, pd_pump, compressor, fan, agitator, ejector), heat
(shell_tube_hx, plate_hx, air_cooler, fired_heater) and misc inline
fittings (filter, strainer_y, reducer with shape=concentric|eccentric,
flange_pair, spectacle_blind, orifice_plate with a signal port,
drain_vent). Every port sits on the declared bbox edge and every symbol
carries the TAG/DESC attribute pair; the catalogue harness now covers
103 specs.

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 16:02:54 +03:00
co-authored by Claude Opus 5
parent 352ce21030
commit 240d9b3d9d
2 changed files with 280 additions and 1 deletions
+259 -1
View File
@@ -1 +1,259 @@
"""Rotating equipment, heat transfer and miscellaneous inline symbols - filled in by Task 9."""
# engineering/pid/symbols_equipment.py
"""Rotating equipment, heat transfer and inline miscellany (ISO 10628-2).
Three families, each authored once in symbol-local mm and registered with the
catalogue on import:
* ``rotating`` — centrifugal / positive-displacement pumps (reference Ø8),
compressor, fan, agitator (shaft port on the bottom edge, motor bubble on
top), ejector (motive + suction + out).
* ``heat`` — shell-and-tube (four ports: tube pair on the ends, shell pair on
the long faces), plate exchanger, air cooler (fan below the bundle), fired
heater (flue port on top of the stack).
* ``misc`` — inline fittings a process line runs through: filter, Y-strainer,
reducer (``shape`` = concentric | eccentric, the eccentric outlet dropped
1 mm), flange pair, spectacle blind, orifice plate (a ``signal`` port on top
for the DP instrument), drain/vent stub (a single ``in`` port).
Every symbol carries the ``TAG`` / ``DESC`` attribute pair above its bbox and
every port sits on the declared bbox boundary, so a line can leave it along
the port's outward direction without crossing the glyph.
"""
from __future__ import annotations
from .geometry import circle, line, polyline, solid, text
from .symbols import Port, SymbolSpec, make_spec, register, tag_attdefs
ROTATING = ("centrifugal_pump", "pd_pump", "compressor", "fan", "agitator", "ejector")
HEAT = ("shell_tube_hx", "plate_hx", "air_cooler", "fired_heater")
MISC = (
"filter",
"strainer_y",
"reducer",
"flange_pair",
"spectacle_blind",
"orifice_plate",
"drain_vent",
)
REDUCER_SHAPES = ("concentric", "eccentric")
SOURCE = "ISO 10628-2:2012"
_IN = Port("in", -4.0, 0.0, 180.0)
_OUT = Port("out", 4.0, 0.0, 0.0)
# (primitives, ports, declared bbox)
Body = tuple[list[dict], tuple[Port, ...], tuple[float, float, float, float]]
def _rotating(symbol: str) -> Body:
if symbol == "centrifugal_pump":
# Ø8 casing, tangential discharge nozzle on top, impeller arrow inside.
return (
[circle(0, 0, 4), line(0, 4, 0, 6), polyline([(-2, -1), (-2, 1), (1, 0)])],
(Port("suction", -4.0, 0.0, 180.0), Port("discharge", 0.0, 6.0, 90.0)),
(-4, -4, 4, 6),
)
if symbol == "pd_pump":
return (
[circle(0, 0, 4), polyline([(-2, -1.5), (2, -1.5), (0, 2)])],
(_IN, _OUT),
(-4, -4, 4, 4),
)
if symbol == "compressor":
# Trapezoid narrowing toward the discharge.
return ([polyline([(-4, -3), (4, -1.5), (4, 1.5), (-4, 3)])], (_IN, _OUT), (-4, -3, 4, 3))
if symbol == "fan":
return (
[circle(0, 0, 4), line(-2.8, -2.8, 2.8, 2.8), line(-2.8, 2.8, 2.8, -2.8)],
(_IN, _OUT),
(-4, -4, 4, 4),
)
if symbol == "agitator":
# Shaft from the bottom edge up to an "M" motor bubble; paddle at the shaft foot.
return (
[line(0, 0, 0, 8), circle(0, 10, 2), text("M", 0, 10, 2.0), line(-3, 0, 3, 0)],
(Port("shaft", 0.0, 0.0, 270.0),),
(-3, 0, 3, 12),
)
if symbol == "ejector":
# Motive fluid enters on the left, suction from below, mixed flow leaves right.
return (
[
polyline([(-4, -1.5), (0, -1.5), (4, -0.75), (4, 0.75), (0, 1.5), (-4, 1.5)]),
line(0, -1.5, 0, -4),
],
(Port("motive", -4.0, 0.0, 180.0), Port("suction", 0.0, -4.0, 270.0), _OUT),
(-4, -4, 4, 1.5),
)
raise ValueError(f"unknown rotating equipment {symbol!r}")
def _heat(symbol: str) -> Body:
if symbol == "shell_tube_hx":
# Shell 24 × 8 with tube sheets 3 mm in from each end.
return (
[
polyline([(-12, -4), (12, -4), (12, 4), (-12, 4)]),
line(-9, -4, -9, 4),
line(9, -4, 9, 4),
],
(
Port("tube_in", -12.0, 0.0, 180.0),
Port("tube_out", 12.0, 0.0, 0.0),
Port("shell_in", -4.0, 4.0, 90.0),
Port("shell_out", 4.0, -4.0, 270.0),
),
(-12, -4, 12, 4),
)
if symbol == "plate_hx":
return (
[
polyline([(-4, -6), (4, -6), (4, 6), (-4, 6)]),
line(-4, -6, 4, 6),
line(-4, 6, 4, -6),
],
(
Port("hot_in", -4.0, 3.0, 180.0),
Port("hot_out", 4.0, -3.0, 0.0),
Port("cold_in", 4.0, 3.0, 0.0),
Port("cold_out", -4.0, -3.0, 180.0),
),
(-4, -6, 4, 6),
)
if symbol == "air_cooler":
# Tube bundle 20 × 6 with the fan (circle + cross) hung underneath.
return (
[
polyline([(-10, -3), (10, -3), (10, 3), (-10, 3)]),
circle(0, -6, 2.5),
line(-1.8, -7.8, 1.8, -4.2),
line(-1.8, -4.2, 1.8, -7.8),
],
(Port("in", -10.0, 0.0, 180.0), Port("out", 10.0, 0.0, 0.0)),
(-10, -8.5, 10, 3),
)
if symbol == "fired_heater":
# Firebox 12 × 12 with a 4-wide stack; the coil zigzags inside the box.
return (
[
polyline([(-6, -8), (6, -8), (6, 4), (2, 4), (2, 10), (-2, 10), (-2, 4), (-6, 4)]),
polyline([(-4, -6), (4, -4), (-4, -2), (4, 0), (-4, 2)], closed=False),
],
(
Port("in", -6.0, -4.0, 180.0),
Port("out", 6.0, 2.0, 0.0),
Port("flue", 0.0, 10.0, 90.0),
),
(-6, -8, 6, 10),
)
raise ValueError(f"unknown heat-transfer equipment {symbol!r}")
def _misc(symbol: str, shape: str = "concentric") -> Body:
if symbol == "filter":
return (
[polyline([(-4, -4), (4, -4), (4, 4), (-4, 4)]), line(-4, 4, 4, -4)],
(_IN, _OUT),
(-4, -4, 4, 4),
)
if symbol == "strainer_y":
# The run is the top edge (y = 0); the strainer leg hangs below it.
return (
[line(-4, 0, 4, 0), line(0, 0, 3, -4), line(2, -4.7, 4, -3.3)],
(_IN, _OUT),
(-4, -5, 4, 0),
)
if symbol == "reducer":
if shape == "eccentric":
# Flat bottom, the outlet centreline dropped to y = -1.
return (
[polyline([(-3, -2), (3, -2), (3, 0), (-3, 2)])],
(Port("in", -3.0, 0.0, 180.0), Port("out", 3.0, -1.0, 0.0)),
(-3, -2, 3, 2),
)
if shape != "concentric":
raise ValueError(
f"reducer: shape must be one of {', '.join(REDUCER_SHAPES)}, got {shape!r}"
)
return (
[polyline([(-3, -2), (3, -1), (3, 1), (-3, 2)])],
(Port("in", -3.0, 0.0, 180.0), Port("out", 3.0, 0.0, 0.0)),
(-3, -2, 3, 2),
)
if symbol == "flange_pair":
return (
[line(-0.6, -2.5, -0.6, 2.5), line(0.6, -2.5, 0.6, 2.5)],
(Port("in", -0.6, 0.0, 180.0), Port("out", 0.6, 0.0, 0.0)),
(-0.6, -2.5, 0.6, 2.5),
)
if symbol == "spectacle_blind":
# Open ring on top, filled (blind) disc below, joined by the web line.
return (
[
line(0, -3.5, 0, 3.5),
circle(0, 2, 1.5),
solid([(0, -0.5), (1.5, -2), (0, -3.5), (-1.5, -2)]),
],
(Port("in", -1.5, 0.0, 180.0), Port("out", 1.5, 0.0, 0.0)),
(-1.5, -3.5, 1.5, 3.5),
)
if symbol == "orifice_plate":
return (
[line(-0.5, -3, -0.5, 3), line(0.5, -3, 0.5, 3)],
(
Port("in", -0.5, 0.0, 180.0),
Port("out", 0.5, 0.0, 0.0),
Port("signal", 0.0, 3.0, 90.0, "signal"),
),
(-0.5, -3, 0.5, 3),
)
if symbol == "drain_vent":
# A stub hanging off a line: the single port is where the line meets it.
return (
[line(0, 0, 0, -4), line(-1.5, -4, 1.5, -4)],
(Port("in", 0.0, 0.0, 90.0),),
(-1.5, -4, 1.5, 0),
)
raise ValueError(f"unknown inline equipment {symbol!r}")
def build_equipment(symbol: str, shape: str = "concentric") -> SymbolSpec:
"""The SymbolSpec of one equipment symbol; ``shape`` applies to ``reducer`` only."""
if symbol in ROTATING:
family, (prims, ports, box) = "rotating", _rotating(symbol)
elif symbol in HEAT:
family, (prims, ports, box) = "heat", _heat(symbol)
elif symbol in MISC:
family, (prims, ports, box) = "misc", _misc(symbol, shape)
else:
raise ValueError(
f"unknown equipment {symbol!r}; valid: {', '.join(ROTATING + HEAT + MISC)}"
)
variant = shape if symbol == "reducer" else None
name = f"PID_{family.upper()}_{symbol.upper()}" + (f"_{variant.upper()}" if variant else "")
return make_spec(
name, family, symbol, variant, prims, tag_attdefs(box[3]), ports, SOURCE, bbox=box
)
def _equipment_builder(symbol: str):
def build() -> SymbolSpec:
return build_equipment(symbol)
return build
def _reducer_builder(shape: str = "concentric") -> SymbolSpec:
return build_equipment("reducer", shape)
for _symbol in ROTATING + HEAT + MISC:
if _symbol == "reducer":
register(_symbol, "misc", _reducer_builder, variants={"shape": list(REDUCER_SHAPES)})
elif _symbol in ROTATING:
register(_symbol, "rotating", _equipment_builder(_symbol))
elif _symbol in HEAT:
register(_symbol, "heat", _equipment_builder(_symbol))
else:
register(_symbol, "misc", _equipment_builder(_symbol))
+21
View File
@@ -327,3 +327,24 @@ def test_hand_actuator_has_no_fail_attribute_or_signal_port():
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"
# ── rotating equipment, heat transfer, inline miscellany (Task 9) ────────────
from engineering.pid.symbols_equipment import HEAT, MISC, ROTATING # noqa: E402
def test_equipment_families_register():
assert len(ROTATING) == 6 and len(HEAT) == 4 and len(MISC) == 7
pump = resolve("centrifugal_pump")
assert pump.family == "rotating" and pump.layer_class == "PROCESS-EQUIPMENT"
assert {p.name: p.direction_deg for p in pump.ports} == {"suction": 180.0, "discharge": 90.0}
hx = resolve("shell_tube_hx")
assert {p.name for p in hx.ports} == {"shell_in", "shell_out", "tube_in", "tube_out"}
orifice = resolve("orifice_plate")
assert next(p for p in orifice.ports if p.name == "signal").kind == "signal"
ecc = resolve("reducer", shape="eccentric")
assert ecc.name == "PID_MISC_REDUCER_ECCENTRIC"
assert next(p for p in ecc.ports if p.name == "out").y == pytest.approx(-1.0)
assert resolve("reducer").name == "PID_MISC_REDUCER_CONCENTRIC"
assert resolve("agitator").ports[0] == Port("shaft", 0.0, 0.0, 270.0, "process")