feat(pid): six parametric vessel symbols with nozzle ports
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
240d9b3d9d
commit
661258004f
@@ -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)
|
||||||
|
|||||||
@@ -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 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("reducer").name == "PID_MISC_REDUCER_CONCENTRIC"
|
||||||
assert resolve("agitator").ports[0] == Port("shaft", 0.0, 0.0, 270.0, "process")
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user