fix(task-10): nozzle stubs meet the drawn outline, impossible capsules and nozzle typos refused
Nozzle stubs started at the bounding-box edge, so on a dished head, a hopper's sloped wall or a cone roof they floated off the vessel (default horizontal_vessel N3/N4 by 0.8 mm, reactor N3/N4 by 0.06 mm, unbounded for user fractions). The port now stays STUB outside the bbox and the stub is drawn from there in to where its axis-aligned line meets the outline, via a new geometry.scan_hits helper; jacketed reactors' stubs pass through the jacket to the wall. validate_vessel_params refuses height <= width for vertical capsules (vertical_vessel, column, reactor), width <= height for horizontal_vessel and height <= width/6 for a cone-roof tank: those folded the heads through each other and produced geometry outside the declared bbox. Unknown keys inside a nozzle dict are refused naming nozzles[i] and the key, like the top-level parameters already were. Tests: a fuzz over every accepted vessel shape x 28 nozzle positions checks the declared bbox and that each stub's inner end lies on the outline (exact point-to-arc/segment distance); the default-symbol repros and the refusals are pinned. 137 of the new cases fail against the previous module. 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
661258004f
commit
8a3b8735b9
@@ -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] = []
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user