diff --git a/engineering/pid/geometry.py b/engineering/pid/geometry.py index 12fc410..1e5905d 100644 --- a/engineering/pid/geometry.py +++ b/engineering/pid/geometry.py @@ -128,6 +128,62 @@ def _arc_extent(cx, cy, r, start_deg, end_deg): return pts +def scan_hits(primitives, axis: str, c: float, eps: float = 1e-9) -> list[float]: + """Where the scan line ``x = c`` (``axis="x"``) or ``y = c`` (``axis="y"``) + meets the drawn geometry: the *other* coordinate of every crossing. + + A segment lying on the scan line contributes both of its ends; a tangent + arc contributes its touching point. Text has no outline and is skipped; + bulged polylines are refused rather than silently flattened to chords. + """ + if axis not in ("x", "y"): + raise ValueError(f"scan_hits: axis must be 'x' or 'y', got {axis!r}") + along_x = axis == "x" + hits: list[float] = [] + + def segment(x1: float, y1: float, x2: float, y2: float) -> None: + # a = scanned coordinate, b = reported coordinate + a1, b1, a2, b2 = (x1, y1, x2, y2) if along_x else (y1, x1, y2, x2) + if abs(a2 - a1) <= eps: + if abs(a1 - c) <= eps: + hits.extend((b1, b2)) + return + t = (c - a1) / (a2 - a1) + if -eps <= t <= 1.0 + eps: + hits.append(b1 + t * (b2 - b1)) + + def circle_arc(cx: float, cy: float, r: float, start: float, end: float) -> None: + ca, cb = (cx, cy) if along_x else (cy, cx) + d2 = r * r - (c - ca) ** 2 + if d2 < -eps: + return + s = math.sqrt(max(d2, 0.0)) + sweep = (end - start) % 360.0 or 360.0 + for b in {cb - s, cb + s}: + x, y = (c, b) if along_x else (b, c) + rel = (math.degrees(math.atan2(y - cy, x - cx)) - start) % 360.0 + if rel <= sweep + 1e-6 or rel >= 360.0 - 1e-6: + hits.append(b) + + for p in primitives: + kind = p["type"] + if kind == "line": + segment(p["x1"], p["y1"], p["x2"], p["y2"]) + elif kind == "circle": + circle_arc(p["cx"], p["cy"], p["r"], 0.0, 360.0) + elif kind == "arc": + circle_arc(p["cx"], p["cy"], p["r"], p["start_deg"], p["end_deg"]) + elif kind in ("polyline", "solid"): + if kind == "polyline" and p.get("bulges"): + raise ValueError("scan_hits: bulged polylines are not supported") + pts = list(p["points"]) + if kind == "solid" or p["closed"]: + pts.append(pts[0]) + for (x1, y1), (x2, y2) in zip(pts, pts[1:], strict=False): + segment(x1, y1, x2, y2) + return hits + + def bbox_of(primitives) -> tuple[float, float, float, float]: """Extent of the drawn geometry (text counts by its insertion point only).""" xs: list[float] = [] diff --git a/engineering/pid/symbols_vessels.py b/engineering/pid/symbols_vessels.py index 9ffc9e2..c311000 100644 --- a/engineering/pid/symbols_vessels.py +++ b/engineering/pid/symbols_vessels.py @@ -6,11 +6,16 @@ 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). + +A nozzle's port sits ``STUB`` outside the vessel's bounding box on the side it +was asked for; its stub is drawn from the port inward to the point where that +axis-aligned line meets the *drawn* outline (a dished head, a hopper's sloped +wall, a cone roof), so no stub ever floats off the vessel or pierces it. """ from __future__ import annotations -from .geometry import arc, line, polyline +from .geometry import arc, line, polyline, scan_hits from .symbols import Port, SymbolSpec, make_spec, params_hash, register, tag_attdefs VESSELS = ("vertical_vessel", "horizontal_vessel", "tank", "column", "reactor", "hopper") @@ -29,15 +34,18 @@ _DEFAULTS: dict[str, dict] = { "reactor": {"width": 24.0, "height": 36.0, "jacketed": False}, "hopper": {"width": 24.0, "height": 24.0}, } +#: Capsule bodies (cylinder with two dished heads) whose axis is vertical. +_VERTICAL_CAPSULES = ("vertical_vessel", "column", "reactor") PARAMS_SCHEMA = { - "width": "float > 0 (mm)", - "height": "float > 0 (mm)", + "width": "float > 0 (mm); vertical_vessel/column/reactor need width < height", + "height": "float > 0 (mm); horizontal_vessel needs height < width; cone tank height > width/6", "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). +#: Distance of a nozzle port beyond the vessel's bounding box (symbol-local mm); +#: the stub runs from there in to the drawn outline, so it is at least this long. STUB = 3.0 #: Gap between a jacketed reactor's wall and its jacket line. JACKET = 2.0 @@ -73,8 +81,23 @@ def validate_vessel_params(symbol: str, params: dict | None) -> dict: if not _is_number(v) or v <= 0: raise ValueError(f"{symbol}: {key} must be a number > 0") merged[key] = float(v) + w, h = merged["width"], merged["height"] + if symbol in _VERTICAL_CAPSULES and h <= w: + raise ValueError( + f"{symbol}: height {h} must exceed width {w} — the dished heads are half-circles " + "of the width, so a shorter axis folds them through each other" + ) + if symbol == "horizontal_vessel" and w <= h: + raise ValueError( + f"{symbol}: width {w} must exceed height {h} — the dished heads are half-circles " + "of the height, so a shorter axis folds them through each other" + ) if "roof" in merged and merged["roof"] not in ("flat", "cone"): raise ValueError(f"{symbol}: roof must be 'flat' or 'cone'") + if merged.get("roof") == "cone" and h <= w / 6: + raise ValueError( + f"{symbol}: height {h} must exceed width/6 ({w / 6}) — the cone roof is that tall" + ) if "trays" in merged: trays = merged["trays"] if isinstance(trays, bool) or not isinstance(trays, int) or trays < 0: @@ -92,6 +115,11 @@ def validate_vessel_params(symbol: str, params: dict | None) -> dict: if n["name"] in names: raise ValueError(f"{symbol}: nozzles[{i}].name {n['name']!r} is repeated") names.add(n["name"]) + for key in n: + if key not in ("name", "side", "fraction"): + raise ValueError( + f"{symbol}: nozzles[{i}]: unknown key {key!r}; valid: fraction, name, side" + ) 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) @@ -103,34 +131,42 @@ def validate_vessel_params(symbol: str, params: dict | None) -> dict: def _nozzles( - params: dict, half_w: float, half_h: float, extra: float = 0.0 + nozzles: list[dict], shell: list[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. + """Nozzle stub primitives and ports for a vessel whose outline is ``shell``. - ``extra`` lengthens the stubs (a jacketed reactor's nozzles pass through - the jacket) so every port still sits on the symbol's outer edge. + The port sits ``STUB + extra`` outside the ±half_w × ±half_h box (``extra`` + is a jacketed reactor's jacket, which the stub passes through so the port + still sits on the symbol's outer edge); the stub starts where the port's + axis-aligned line meets the outermost point of the drawn outline. """ prims: list[dict] = [] ports: list[Port] = [] stub = STUB + extra - for n in params["nozzles"]: + for n in nozzles: side, f = n["side"], n["fraction"] - if side == "top": - x, y0, y1 = -half_w + f * 2 * half_w, half_h, half_h + stub + if side in ("top", "bottom"): + x = -half_w + f * 2 * half_w + hits = scan_hits(shell, "x", x) + if not hits: + raise RuntimeError(f"nozzle {n['name']!r}: x={x} meets no outline") + if side == "top": + y0, y1, d = max(hits), half_h + stub, 90.0 + else: + y0, y1, d = min(hits), -half_h - stub, 270.0 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 + ports.append(Port(n["name"], x, y1, d)) + else: + y = -half_h + f * 2 * half_h + hits = scan_hits(shell, "y", y) + if not hits: + raise RuntimeError(f"nozzle {n['name']!r}: y={y} meets no outline") + if side == "left": + x0, x1, d = min(hits), -half_w - stub, 180.0 + else: + x0, x1, d = max(hits), half_w + stub, 0.0 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)) + ports.append(Port(n["name"], x1, y, d)) return prims, ports @@ -162,13 +198,13 @@ def build_vessel(symbol: str = "vertical_vessel", params: dict | None = None) -> 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) + if symbol in _VERTICAL_CAPSULES: + shell = _capsule_vertical(w, h) elif symbol == "horizontal_vessel": - prims = _capsule_horizontal(w, h) + shell = _capsule_horizontal(w, h) elif symbol == "tank": roof = w / 6 if p["roof"] == "cone" else 0.0 - prims = [ + shell = [ polyline( [ (-half_w, -half_h), @@ -179,32 +215,30 @@ def build_vessel(symbol: str = "vertical_vessel", params: dict | None = None) -> ) ] if roof: - prims.append( + shell.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) + else: # hopper: flat top, walls converging to a narrow outlet + shell = [ + polyline([(-half_w, half_h), (half_w, half_h), (w / 6, -half_h), (-w / 6, -half_h)]) + ] + prims = list(shell) + if symbol == "column": 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)]) + elif symbol == "reactor" and 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), ] - nozzle_prims, ports = _nozzles(p, half_w, half_h, extra) + extra = JACKET + nozzle_prims, ports = _nozzles(p["nozzles"], shell, 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] diff --git a/tests/test_pid_symbols.py b/tests/test_pid_symbols.py index c923cba..9c76d11 100644 --- a/tests/test_pid_symbols.py +++ b/tests/test_pid_symbols.py @@ -426,3 +426,139 @@ 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) + + +def _distance_to_outline(x: float, y: float, primitives) -> float: + """Exact distance from a point to the nearest drawn line, arc or polyline edge.""" + + def to_segment(x1, y1, x2, y2): + dx, dy = x2 - x1, y2 - y1 + if dx == 0.0 == dy: + return math.hypot(x - x1, y - y1) + t = max(0.0, min(1.0, ((x - x1) * dx + (y - y1) * dy) / (dx * dx + dy * dy))) + return math.hypot(x - (x1 + t * dx), y - (y1 + t * dy)) + + best = math.inf + for p in primitives: + kind = p["type"] + if kind == "line": + best = min(best, to_segment(p["x1"], p["y1"], p["x2"], p["y2"])) + elif kind == "arc": + cx, cy, r = p["cx"], p["cy"], p["r"] + sweep = (p["end_deg"] - p["start_deg"]) % 360.0 or 360.0 + rel = (math.degrees(math.atan2(y - cy, x - cx)) - p["start_deg"]) % 360.0 + if rel <= sweep + 1e-9: + best = min(best, abs(math.hypot(x - cx, y - cy) - r)) + for deg in (p["start_deg"], p["end_deg"]): + a = math.radians(deg) + best = min(best, math.hypot(x - (cx + r * math.cos(a)), y - (cy + r * math.sin(a)))) + elif kind == "polyline": + pts = list(p["points"]) + ([p["points"][0]] if p["closed"] else []) + for (x1, y1), (x2, y2) in zip(pts, pts[1:], strict=False): + best = min(best, to_segment(x1, y1, x2, y2)) + return best + + +def _nozzle_stubs(spec): + """(stub line, port) pairs — the stubs are the last primitives, one per port.""" + stubs = list(spec.primitives[-len(spec.ports) :]) + return list(zip(stubs, spec.ports, strict=True)) + + +def _vessel_fuzz(): + fractions = (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0) + nozzles = [ + {"name": f"{side[0].upper()}{i}", "side": side, "fraction": f} + for side in ("top", "bottom", "left", "right") + for i, f in enumerate(fractions) + ] + cases = [] + for symbol in VESSELS: + for w in (4.0, 10.0, 20.0, 40.0): + for h in (2.0, 10.0, 25.0, 40.0): + for extra in ({}, {"roof": "cone"}, {"jacketed": True}, {"trays": 3}): + params = {"width": w, "height": h, "nozzles": nozzles, **extra} + try: + validate_vessel_params(symbol, params) + except ValueError: + continue + cases.append(pytest.param(symbol, params, id=f"{symbol}-{w:g}x{h:g}-{extra}")) + return cases + + +@pytest.mark.parametrize("symbol, params", _vessel_fuzz()) +def test_every_accepted_vessel_shape_is_drawn_inside_its_bbox_with_stubs_on_the_wall( + symbol, params +): + spec = build_vessel(symbol, params) + # The shell comes first: two walls + two heads, a box (+ cone roof), or the hopper. + n_shell = {"tank": 2 if params.get("roof") == "cone" else 1, "hopper": 1}.get(symbol, 4) + body = list(spec.primitives[:n_shell]) + xmin, ymin, xmax, ymax = bbox_of(spec.primitives) + dxmin, dymin, dxmax, dymax = spec.bbox + assert dxmin - EPS <= xmin and dymin - EPS <= ymin + assert xmax <= dxmax + EPS and ymax <= dymax + EPS + for stub, port in _nozzle_stubs(spec): + assert stub["type"] == "line" + outer = {(stub["x1"], stub["y1"]), (stub["x2"], stub["y2"])} + assert (port.x, port.y) in outer, f"{port.name}: stub does not end at its port" + (ix, iy) = next(pt for pt in outer if pt != (port.x, port.y)) + assert _distance_to_outline(ix, iy, body) < 1e-6, ( + f"{symbol} {params['width']:g}x{params['height']:g} {port.name}: stub inner end " + f"({ix:.3f}, {iy:.3f}) floats {_distance_to_outline(ix, iy, body):.3f} mm off the outline" + ) + assert math.hypot(port.x - ix, port.y - iy) >= 3.0 - EPS, "stub shorter than STUB" + + +def test_default_vessel_stubs_start_on_the_dished_head_not_the_bbox(): + # horizontal_vessel N3 (left, 0.7): y = 4 on a head of r = 10 centred (-10, 0). + n3 = next(s for s, p in _nozzle_stubs(build_vessel("horizontal_vessel")) if p.name == "N3") + assert n3["y1"] == n3["y2"] == pytest.approx(4.0) + assert min(n3["x1"], n3["x2"]) == pytest.approx(-23.0), "port stays STUB outside the bbox" + assert max(n3["x1"], n3["x2"]) == pytest.approx(-10.0 - math.sqrt(100.0 - 16.0)) + # reactor N3/N4 land on the heads too (|y| = 7.2 > cylindrical half-length 6). + for stub, port in _nozzle_stubs(build_vessel("reactor")): + if port.name in ("N3", "N4"): + inner = min(abs(stub["x1"]), abs(stub["x2"])) + assert inner == pytest.approx(math.sqrt(144.0 - (7.2 - 6.0) ** 2)) + # A jacketed reactor's stub passes through the jacket to the vessel wall. + n1 = next( + s for s, p in _nozzle_stubs(build_vessel("reactor", {"jacketed": True})) if p.name == "N1" + ) + assert sorted((n1["y1"], n1["y2"])) == [pytest.approx(18.0), pytest.approx(23.0)] + # Hopper side nozzle meets the sloped wall; cone-roof top nozzle meets the roof. + hopper = build_vessel("hopper", {"nozzles": [{"name": "L", "side": "left", "fraction": 0.5}]}) + assert max(hopper.primitives[-1]["x1"], hopper.primitives[-1]["x2"]) == pytest.approx(-8.0) + cone = build_vessel( + "tank", {"roof": "cone", "nozzles": [{"name": "T", "side": "top", "fraction": 0.25}]} + ) + assert min(cone.primitives[-1]["y1"], cone.primitives[-1]["y2"]) == pytest.approx(10.0) + + +@pytest.mark.parametrize( + "symbol, params, key", + [ + ("vertical_vessel", {"width": 20, "height": 5}, "height"), + ("vertical_vessel", {"width": 20, "height": 20}, "height"), + ("column", {"width": 16, "height": 10}, "height"), + ("reactor", {"width": 24, "height": 12}, "height"), + ("horizontal_vessel", {"width": 20, "height": 40}, "width"), + ("tank", {"roof": "cone", "width": 24, "height": 4}, "height"), + ( + "vertical_vessel", + {"nozzles": [{"name": "F", "side": "right", "fractoin": 0.25}]}, + "fractoin", + ), + ( + "hopper", + {"nozzles": [{"name": "A", "side": "top", "fraction": 0.5, "colour": 1}]}, + r"nozzles\[0\]", + ), + ], +) +def test_vessel_shapes_that_cannot_be_drawn_and_nozzle_typos_are_refused(symbol, params, key): + with pytest.raises(ValueError, match=key): + validate_vessel_params(symbol, params) + # Flat-roof and hopper proportions are free; the capsule limit is strict. + assert validate_vessel_params("tank", {"width": 24, "height": 4})["height"] == 4.0 + assert validate_vessel_params("hopper", {"width": 40, "height": 4})["height"] == 4.0