diff --git a/engineering/pid/symbols_vessels.py b/engineering/pid/symbols_vessels.py index f3342c7..9ffc9e2 100644 --- a/engineering/pid/symbols_vessels.py +++ b/engineering/pid/symbols_vessels.py @@ -1 +1,235 @@ -"""Parametric vessels - filled in by Task 10.""" +# engineering/pid/symbols_vessels.py +"""Parametric vessels (ISO 10628-2): size and nozzles come from the caller. + +Unlike the fixed-size catalogue, a vessel's block definition depends on its +parameters, so the block name carries an 8-hex hash of the validated parameter +set (spec 4.5): identical parameters share one definition, different ones get +their own. Nozzles become stub lines plus named ports; the default set is +``N1`` top, ``N2`` bottom, ``N3`` left (0.7 up the wall), ``N4`` right (0.3). +""" + +from __future__ import annotations + +from .geometry import arc, line, polyline +from .symbols import Port, SymbolSpec, make_spec, params_hash, register, tag_attdefs + +VESSELS = ("vertical_vessel", "horizontal_vessel", "tank", "column", "reactor", "hopper") +SIDES = ("top", "bottom", "left", "right") +DEFAULT_NOZZLES = [ + {"name": "N1", "side": "top", "fraction": 0.5}, + {"name": "N2", "side": "bottom", "fraction": 0.5}, + {"name": "N3", "side": "left", "fraction": 0.7}, + {"name": "N4", "side": "right", "fraction": 0.3}, +] +_DEFAULTS: dict[str, dict] = { + "vertical_vessel": {"width": 20.0, "height": 40.0}, + "horizontal_vessel": {"width": 40.0, "height": 20.0}, + "tank": {"width": 24.0, "height": 24.0, "roof": "flat"}, + "column": {"width": 16.0, "height": 60.0, "trays": 0}, + "reactor": {"width": 24.0, "height": 36.0, "jacketed": False}, + "hopper": {"width": 24.0, "height": 24.0}, +} +PARAMS_SCHEMA = { + "width": "float > 0 (mm)", + "height": "float > 0 (mm)", + "nozzles": "[{name, side: top|bottom|left|right, fraction: 0..1}]", + "roof": "tank only: flat | cone", + "trays": "column only: int >= 0", + "jacketed": "reactor only: bool", +} +#: Nozzle stub length beyond the vessel wall (symbol-local mm). +STUB = 3.0 +#: Gap between a jacketed reactor's wall and its jacket line. +JACKET = 2.0 + + +def _is_number(value) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def validate_vessel_params(symbol: str, params: dict | None) -> dict: + """Merge ``params`` over the vessel's defaults and validate every key. + + Raises ``ValueError`` naming the offending key (and nozzle index) before + anything is built; the returned dict is the canonical parameter set the + block name is hashed from. + """ + if symbol not in VESSELS: + raise ValueError(f"unknown vessel {symbol!r}; valid: {', '.join(VESSELS)}") + merged = dict(_DEFAULTS[symbol]) + merged["nozzles"] = [dict(n) for n in DEFAULT_NOZZLES] + if symbol == "hopper": + merged["nozzles"] = [dict(n) for n in DEFAULT_NOZZLES[:2]] + if params is not None and not isinstance(params, dict): + raise ValueError(f"{symbol}: params must be a dict") + for key, value in (params or {}).items(): + if key not in merged: + raise ValueError( + f"{symbol}: unknown parameter {key!r}; valid: {', '.join(sorted(merged))}" + ) + merged[key] = value + for key in ("width", "height"): + v = merged[key] + if not _is_number(v) or v <= 0: + raise ValueError(f"{symbol}: {key} must be a number > 0") + merged[key] = float(v) + if "roof" in merged and merged["roof"] not in ("flat", "cone"): + raise ValueError(f"{symbol}: roof must be 'flat' or 'cone'") + if "trays" in merged: + trays = merged["trays"] + if isinstance(trays, bool) or not isinstance(trays, int) or trays < 0: + raise ValueError(f"{symbol}: trays must be an int >= 0") + if "jacketed" in merged and not isinstance(merged["jacketed"], bool): + raise ValueError(f"{symbol}: jacketed must be a bool") + nozzles = merged["nozzles"] + if not isinstance(nozzles, list): + raise ValueError(f"{symbol}: nozzles must be a list") + names: set[str] = set() + clean = [] + for i, n in enumerate(nozzles): + if not isinstance(n, dict) or not isinstance(n.get("name"), str) or not n["name"]: + raise ValueError(f"{symbol}: nozzles[{i}].name must be a non-empty string") + if n["name"] in names: + raise ValueError(f"{symbol}: nozzles[{i}].name {n['name']!r} is repeated") + names.add(n["name"]) + if n.get("side") not in SIDES: + raise ValueError(f"{symbol}: nozzles[{i}].side must be one of {', '.join(SIDES)}") + f = n.get("fraction", 0.5) + if not _is_number(f) or not 0.0 <= f <= 1.0: + raise ValueError(f"{symbol}: nozzles[{i}].fraction must be within 0..1") + clean.append({"name": n["name"], "side": n["side"], "fraction": float(f)}) + merged["nozzles"] = clean + return merged + + +def _nozzles( + params: dict, half_w: float, half_h: float, extra: float = 0.0 +) -> tuple[list[dict], list[Port]]: + """Nozzle stub primitives and ports on a box of ±half_w × ±half_h. + + ``extra`` lengthens the stubs (a jacketed reactor's nozzles pass through + the jacket) so every port still sits on the symbol's outer edge. + """ + prims: list[dict] = [] + ports: list[Port] = [] + stub = STUB + extra + for n in params["nozzles"]: + side, f = n["side"], n["fraction"] + if side == "top": + x, y0, y1 = -half_w + f * 2 * half_w, half_h, half_h + stub + prims.append(line(x, y0, x, y1)) + ports.append(Port(n["name"], x, y1, 90.0)) + elif side == "bottom": + x, y0, y1 = -half_w + f * 2 * half_w, -half_h, -half_h - stub + prims.append(line(x, y0, x, y1)) + ports.append(Port(n["name"], x, y1, 270.0)) + elif side == "left": + y, x0, x1 = -half_h + f * 2 * half_h, -half_w, -half_w - stub + prims.append(line(x0, y, x1, y)) + ports.append(Port(n["name"], x1, y, 180.0)) + else: # right + y, x0, x1 = -half_h + f * 2 * half_h, half_w, half_w + stub + prims.append(line(x0, y, x1, y)) + ports.append(Port(n["name"], x1, y, 0.0)) + return prims, ports + + +def _capsule_vertical(w: float, h: float) -> list[dict]: + """Cylinder with dished ends, axis vertical: two walls and two half-circle heads.""" + r = w / 2 + body = h / 2 - r + return [ + line(-r, -body, -r, body), + line(r, -body, r, body), + arc(0, body, r, 0, 180), + arc(0, -body, r, 180, 360), + ] + + +def _capsule_horizontal(w: float, h: float) -> list[dict]: + r = h / 2 + body = w / 2 - r + return [ + line(-body, -r, body, -r), + line(-body, r, body, r), + arc(body, 0, r, 270, 90), + arc(-body, 0, r, 90, 270), + ] + + +def build_vessel(symbol: str = "vertical_vessel", params: dict | None = None) -> SymbolSpec: + p = validate_vessel_params(symbol, params) + w, h = p["width"], p["height"] + half_w, half_h = w / 2, h / 2 + extra = 0.0 + if symbol == "vertical_vessel": + prims = _capsule_vertical(w, h) + elif symbol == "horizontal_vessel": + prims = _capsule_horizontal(w, h) + elif symbol == "tank": + roof = w / 6 if p["roof"] == "cone" else 0.0 + prims = [ + polyline( + [ + (-half_w, -half_h), + (half_w, -half_h), + (half_w, half_h - roof), + (-half_w, half_h - roof), + ] + ) + ] + if roof: + prims.append( + polyline( + [(-half_w, half_h - roof), (0, half_h), (half_w, half_h - roof)], + closed=False, + ) + ) + elif symbol == "column": + prims = _capsule_vertical(w, h) + trays = p["trays"] + for i in range(trays): + y = -half_h + (i + 1) * h / (trays + 1) + prims.append(line(-half_w + 1, y, half_w - 1, y)) + elif symbol == "reactor": + prims = _capsule_vertical(w, h) + if p["jacketed"]: + body = half_h - half_w + prims += [ + line(-half_w - JACKET, -body, -half_w - JACKET, body), + line(half_w + JACKET, -body, half_w + JACKET, body), + ] + extra = JACKET + else: # hopper: flat top, walls converging to a narrow outlet + prims = [ + polyline([(-half_w, half_h), (half_w, half_h), (w / 6, -half_h), (-w / 6, -half_h)]) + ] + nozzle_prims, ports = _nozzles(p, half_w, half_h, extra) + prims += nozzle_prims + xs = [-half_w - extra, half_w + extra] + [pt.x for pt in ports] + ys = [-half_h, half_h] + [pt.y for pt in ports] + box = (min(xs), min(ys), max(xs), max(ys)) + return make_spec( + f"PID_VESSEL_{symbol.upper()}_{params_hash(p)}", + "vessel", + symbol, + None, + prims, + tag_attdefs(box[3]), + ports, + "ISO 10628-2:2012 vessels", + bbox=box, + parametric=True, + params=p, + ) + + +def _vessel_builder(symbol: str): + def build(params: dict | None = None) -> SymbolSpec: + return build_vessel(symbol, params) + + return build + + +for _symbol in VESSELS: + register(_symbol, "vessel", _vessel_builder(_symbol), params_schema=PARAMS_SCHEMA) diff --git a/tests/test_pid_symbols.py b/tests/test_pid_symbols.py index e2ed4ce..c923cba 100644 --- a/tests/test_pid_symbols.py +++ b/tests/test_pid_symbols.py @@ -348,3 +348,81 @@ def test_equipment_families_register(): 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") + + +# ── parametric vessels (Task 10) ───────────────────────────────────────────── + +from engineering.pid.symbols_vessels import ( # noqa: E402 + VESSELS, + build_vessel, + validate_vessel_params, +) + + +def test_vessels_are_parametric_and_hash_named(): + assert len(VESSELS) == 6 + v = build_vessel("vertical_vessel") + assert v.parametric is True and v.name.startswith("PID_VESSEL_VERTICAL_VESSEL_") + assert len(v.name.rsplit("_", 1)[1]) == 8 + assert {p.name: p.direction_deg for p in v.ports} == { + "N1": 90.0, + "N2": 270.0, + "N3": 180.0, + "N4": 0.0, + } + same = build_vessel("vertical_vessel", {"width": 20, "height": 40}) + assert same.name == v.name, "identical parameters share one definition" + other = build_vessel("vertical_vessel", {"width": 30}) + assert other.name != v.name + + +def test_vessel_nozzles_are_where_the_parameters_say(): + v = build_vessel( + "vertical_vessel", + { + "width": 20, + "height": 40, + "nozzles": [{"name": "F", "side": "right", "fraction": 0.25}], + }, + ) + port = v.ports[0] + assert (port.name, port.x, port.y, port.direction_deg) == ("F", 13.0, -10.0, 0.0) + assert v.bbox == (-10.0, -20.0, 13.0, 20.0) + + +def test_vessel_variants_draw_their_features(): + column = build_vessel("column", {"trays": 5}) + assert sum(1 for p in column.primitives if p["type"] == "line") >= 5 + 2 + cone = build_vessel("tank", {"roof": "cone"}) + assert any(p["type"] == "polyline" and not p["closed"] for p in cone.primitives) + jacketed = build_vessel("reactor", {"jacketed": True}) + assert jacketed.bbox[2] == pytest.approx(12.0 + 5.0), "half width 12 + jacket 2 + stub 3" + hopper = build_vessel("hopper") + assert {p.direction_deg for p in hopper.ports} == {90.0, 270.0} + + +@pytest.mark.parametrize( + "params, key", + [ + ({"width": 0}, "width"), + ({"height": -1}, "height"), + ({"nozzles": [{"name": "A", "side": "up", "fraction": 0.5}]}, "side"), + ({"nozzles": [{"name": "A", "side": "top", "fraction": 1.5}]}, "fraction"), + ( + { + "nozzles": [ + {"name": "A", "side": "top", "fraction": 0.5}, + {"name": "A", "side": "top", "fraction": 0.2}, + ] + }, + "name", + ), + ({"trays": -2}, "trays"), + ({"roof": "dome"}, "roof"), + ({"bogus": 1}, "bogus"), + ], +) +def test_vessel_params_are_validated(params, key): + symbol = "column" if "trays" in params else ("tank" if "roof" in params else "vertical_vessel") + with pytest.raises(ValueError, match=key): + validate_vessel_params(symbol, params)