From 240d9b3d9dc84609fd74b8d5e49428be417c7aff Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 15 Sep 2026 16:02:54 +0300 Subject: [PATCH] feat(pid): rotating, heat-transfer and inline equipment symbols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/pid/symbols_equipment.py | 260 ++++++++++++++++++++++++++- tests/test_pid_symbols.py | 21 +++ 2 files changed, 280 insertions(+), 1 deletion(-) diff --git a/engineering/pid/symbols_equipment.py b/engineering/pid/symbols_equipment.py index b0c84a2..25d5234 100644 --- a/engineering/pid/symbols_equipment.py +++ b/engineering/pid/symbols_equipment.py @@ -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)) diff --git a/tests/test_pid_symbols.py b/tests/test_pid_symbols.py index 15619a9..e2ed4ce 100644 --- a/tests/test_pid_symbols.py +++ b/tests/test_pid_symbols.py @@ -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")