diff --git a/README.md b/README.md index 1f8489b..ff73de0 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ fish-screen --flow 0.05 --sensitive-species # eels / sm fish-screen --flow-cfs 1.5 --opening 3.0 # imperial flow + opening check fish-screen --flow 0.05 --json # machine-readable output fish-screen --batch intakes.csv # many intakes from CSV +fish-screen --flow 0.05 --geometry cylinder --dim D=0.3 --solve-for L # size a screen shape ``` Batch CSV columns: `name`, `flow_m3s` *or* `flow_cfs`, `water_type`, @@ -110,8 +111,24 @@ Min effective area: 1.214 m^2 (13.06 ft^2) Min gross screen area: 3.034 m^2 (32.66 ft^2) ``` +**4. Sizing a cylindrical (T-screen body) intake.** The six screen shapes +from the standard's Figure 2 (§3.7) — disc, panel, box, cylinder, cone, +half-barrel — can be dimensioned against the required gross area. Fix all +dimensions but one and solve it (rounded up to a 1 mm build increment), or +fix everything to check a proposed screen; `--units N` splits the flow across +identical units: + +``` +$ fish-screen --flow 0.05 --geometry cylinder --dim D=0.3 --solve-for L --units 2 +Geometry: Cylindrical (T-screen body) — A = π·D·L +Screen units: 2 +Solved L: 1.895 m (D = 0.300 m fixed) +Gross area provided: 2 × 1.786 = 3.572 m^2 — PASS (required 3.571 m^2) +``` + Add `--json` to any invocation for machine-readable output (imperial runs -include `flow_cfs` and `*_ft2` fields). +include `flow_cfs` and `*_ft2` fields; geometry runs include a `geometry` +object). ## Primary deliverable — `fish-screen-tool.html` diff --git a/TASKS.md b/TASKS.md index 2458b02..7412709 100644 --- a/TASKS.md +++ b/TASKS.md @@ -84,7 +84,12 @@ as a scriptable/batch backend. If kept, the original backlog applies: `proposed_opening_mm` → PASS/FAIL vs the sensitive-aware limit) - ✅ Imperial units (cfs / ft²) with conversion (2026-08-23: `--flow-cfs` input; areas also reported in ft²; conversions in `units.py`) -- ⬜ Cylindrical/T-screen and other geometries (HTML tool has six) +- ✅ Cylindrical/T-screen and other geometries (2026-08-23: `geometry.py` + ports all six Figure-2 shapes from the HTML tool — disc, panel, box, + cylinder, cone, half-barrel — with solve-one-dimension (1 mm round-up) + and check-as-fixed modes, multi-unit support; CLI `--geometry/--dim/ + --solve-for/--units`) +- ⬜ Geometry columns in batch CSV mode (single-intake CLI only for now) - ✅ Structured JSON output on the CLI (2026-08-23: `--json`; imperial runs include `flow_cfs` / `*_ft2` fields) - ✅ Batch mode: read intake parameters from CSV (2026-08-23: `--batch FILE`, diff --git a/src/fish_screen/cli.py b/src/fish_screen/cli.py index 7b5ef0f..5c5c2a1 100644 --- a/src/fish_screen/cli.py +++ b/src/fish_screen/cli.py @@ -15,6 +15,7 @@ DEFAULT_OPEN_AREA_RATIO, WATER_TYPES, ) +from .geometry import GEOMETRIES, GeometryResult, size_screen from .units import cfs_to_m3s, m2_to_ft2, m3s_to_cfs @@ -72,6 +73,28 @@ def build_parser() -> argparse.ArgumentParser: "--blockage-allowance", type=float, default=DEFAULT_BLOCKAGE_ALLOWANCE, help=f"Clogging allowance (default: {DEFAULT_BLOCKAGE_ALLOWANCE}).", ) + parser.add_argument( + "--geometry", default=None, choices=sorted(GEOMETRIES), + help="Screen shape (Figure 2, §3.7): dimension it against the " + "required gross area. Provide dimensions with --dim; solve one " + "with --solve-for or fix all to check a proposed screen.", + ) + parser.add_argument( + "--dim", action="append", default=None, metavar="KEY=METRES", + help="Screen dimension in metres, e.g. --dim D=0.3 --dim L=1.0. " + "Repeatable. Keys per geometry: disc D; panel W1,W2; " + "box L,W1,W2; cylinder/halfbarrel D,L; cone r,L.", + ) + parser.add_argument( + "--solve-for", default=None, metavar="KEY", + help="Dimension key to solve for (others fixed via --dim); solved " + "value is rounded up to 1 mm. Omit to check fixed dimensions.", + ) + parser.add_argument( + "--units", type=int, default=1, metavar="N", + help="Number of identical screen units sharing the flow " + "(default: 1).", + ) parser.add_argument( "--json", action="store_true", help="Emit the full result as JSON instead of the text report.", @@ -79,6 +102,24 @@ def build_parser() -> argparse.ArgumentParser: return parser +def _parse_dims(pairs: list[str] | None) -> dict[str, float]: + dims: dict[str, float] = {} + for pair in pairs or []: + key, sep, raw = pair.partition("=") + key = key.strip() + if not sep or not key: + raise ValueError( + f"--dim {pair!r}: expected KEY=METRES (e.g. --dim D=0.3)." + ) + try: + dims[key] = float(raw) + except ValueError: + raise ValueError( + f"--dim {pair!r}: {raw!r} is not a number." + ) from None + return dims + + def _spec_as_dict(spec: ScreenSpec, imperial: bool) -> dict[str, object]: out = dataclasses.asdict(spec) if imperial: @@ -124,6 +165,32 @@ def _print_report(spec: ScreenSpec, imperial: bool) -> None: print(f"NOTE: {APPROACH_VELOCITY_CONFLICT_NOTE}") +def _print_geometry(geo: GeometryResult) -> None: + g = GEOMETRIES[geo.geometry] + print(f"Geometry: {g.label} — {g.formula}") + if geo.units > 1: + print(f"Screen units: {geo.units}") + fixed = ", ".join( + f"{k} = {v:.3f} m" for k, v in geo.dims.items() if k != geo.solved_key + ) + if geo.mode == "solve": + assert geo.solved_key is not None + print( + f"Solved {geo.solved_key}: " + f"{geo.dims[geo.solved_key]:.3f} m" + + (f" ({fixed} fixed)" if fixed else "") + ) + per_unit = ( + f"{geo.units} × {geo.gross_area_unit_m2:.3f} = " + if geo.units > 1 else "" + ) + verdict = "PASS" if geo.area_sufficient else "FAIL" + print( + f"Gross area provided: {per_unit}{geo.gross_area_total_m2:.3f} " + f"m^2 — {verdict} (required {geo.required_gross_area_m2:.3f} m^2)" + ) + + def _print_batch(results: list[BatchResult], as_json: bool) -> int: failures = [r for r in results if r.error is not None] if as_json: @@ -167,14 +234,22 @@ def _print_batch(results: list[BatchResult], as_json: bool) -> int: def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) if args.batch is not None: + if args.geometry or args.dim or args.solve_for: + print("error: --geometry/--dim/--solve-for are not supported " + "with --batch.") + return 2 try: results = run_batch(args.batch) except (OSError, ValueError) as exc: print(f"error: {exc}") return 2 return _print_batch(results, args.json) + if (args.dim or args.solve_for) and args.geometry is None: + print("error: --dim/--solve-for require --geometry.") + return 2 imperial = args.flow_cfs is not None flow_m3s = cfs_to_m3s(args.flow_cfs) if imperial else args.flow + geo: GeometryResult | None = None try: spec = calculate_screen_spec( flow_m3s=flow_m3s, @@ -185,13 +260,26 @@ def main(argv: list[str] | None = None) -> int: open_area_ratio=args.open_area_ratio, blockage_allowance=args.blockage_allowance, ) + if args.geometry is not None: + geo = size_screen( + required_gross_area_m2=spec.gross_area_m2, + geometry=args.geometry, + dims=_parse_dims(args.dim), + solve_for=args.solve_for, + units=args.units, + ) except ValueError as exc: print(f"error: {exc}") return 2 if args.json: - print(json.dumps(_spec_as_dict(spec, imperial), indent=2)) + out = _spec_as_dict(spec, imperial) + if geo is not None: + out["geometry"] = dataclasses.asdict(geo) + print(json.dumps(out, indent=2)) else: _print_report(spec, imperial) + if geo is not None: + _print_geometry(geo) return 0 diff --git a/src/fish_screen/geometry.py b/src/fish_screen/geometry.py new file mode 100644 index 0000000..7561530 --- /dev/null +++ b/src/fish_screen/geometry.py @@ -0,0 +1,237 @@ +"""Screen geometries: gross surface-area formulas per Figure 2 (§3.7). + +Mirrors the ``GEOMETRIES`` block in ``fish-screen-tool.html`` — the same six +shapes, formulas, and dimension solvers. Dimensions are metres; ``area`` +returns the gross geometric area of ONE screen unit. A T-screen's body is +modelled as a cylinder. + +Two usage modes, matching the HTML tool: + +* **solve** — fix all dimensions but one and solve the free dimension so one + unit provides its share of the required gross area. The solved value is + rounded UP to a 1 mm build increment (never down: a larger dimension only + adds area, so rounding up can't turn a passing design into a failing one). +* **check** — fix every dimension and compare the provided gross area + against the requirement. +""" + +from __future__ import annotations + +import math +from collections.abc import Callable, Mapping +from dataclasses import dataclass + +#: Solved dimensions are rounded up to this build increment (1 mm). +BUILD_INCREMENT_M = 0.001 + +_Dims = Mapping[str, float] + + +@dataclass(frozen=True) +class Geometry: + """One screen shape: its area formula and closed-form dimension solver.""" + + key: str + label: str + formula: str + dim_keys: tuple[str, ...] + area: Callable[[_Dims], float] + # solve(dim_key, target_area, fixed_dims) -> raw dimension in metres + solve: Callable[[str, float, _Dims], float] + + +def _solve_disc(key: str, area: float, d: _Dims) -> float: + return math.sqrt(4 * area / math.pi) + + +def _solve_panel(key: str, area: float, d: _Dims) -> float: + return area / d["W2"] if key == "W1" else area / d["W1"] + + +def _solve_box(key: str, area: float, d: _Dims) -> float: + if key == "L": + return area / (2 * (d["W1"] + d["W2"])) + if key == "W1": + return area / (2 * d["L"]) - d["W2"] + return area / (2 * d["L"]) - d["W1"] + + +def _solve_cylinder(key: str, area: float, d: _Dims) -> float: + return area / (math.pi * (d["L"] if key == "D" else d["D"])) + + +def _solve_cone(key: str, area: float, d: _Dims) -> float: + return area / (math.pi * (d["L"] if key == "r" else d["r"])) + + +def _solve_halfbarrel(key: str, area: float, d: _Dims) -> float: + return 2 * area / (math.pi * (d["L"] if key == "D" else d["D"])) + + +GEOMETRIES: dict[str, Geometry] = { + g.key: g + for g in ( + Geometry( + key="disc", + label="Circular flat disc", + formula="A = (π/4)·D²", + dim_keys=("D",), + area=lambda d: math.pi / 4 * d["D"] ** 2, + solve=_solve_disc, + ), + Geometry( + key="panel", + label="Square / rectangular flat panel", + formula="A = W1·W2", + dim_keys=("W1", "W2"), + area=lambda d: d["W1"] * d["W2"], + solve=_solve_panel, + ), + Geometry( + key="box", + label="Square box / barrel (4 sides)", + formula="A = 2·L·(W1+W2)", + dim_keys=("L", "W1", "W2"), + area=lambda d: 2 * d["L"] * (d["W1"] + d["W2"]), + solve=_solve_box, + ), + Geometry( + key="cylinder", + label="Cylindrical (T-screen body)", + formula="A = π·D·L", + dim_keys=("D", "L"), + area=lambda d: math.pi * d["D"] * d["L"], + solve=_solve_cylinder, + ), + Geometry( + key="cone", + label="Conical", + formula="A = π·r·L (r = base radius, L = slant length)", + dim_keys=("r", "L"), + area=lambda d: math.pi * d["r"] * d["L"], + solve=_solve_cone, + ), + Geometry( + key="halfbarrel", + label="Semi-hemispherical / half-barrel", + formula="A = ½·π·D·L (= ½ cylindrical)", + dim_keys=("D", "L"), + area=lambda d: 0.5 * math.pi * d["D"] * d["L"], + solve=_solve_halfbarrel, + ), + ) +} + + +@dataclass(frozen=True) +class GeometryResult: + """Dimensioning outcome for one geometry against a required gross area.""" + + geometry: str + mode: str # "solve" or "check" + units: int + dims: dict[str, float] # final dimensions (solved value rounded up) + solved_key: str | None + solved_raw_m: float | None # exact solution before the 1 mm round-up + gross_area_unit_m2: float + gross_area_total_m2: float + required_gross_area_m2: float + area_sufficient: bool + + +def _get_geometry(geometry: str) -> Geometry: + try: + return GEOMETRIES[geometry] + except KeyError as exc: + valid = ", ".join(sorted(GEOMETRIES)) + raise ValueError( + f"Unknown geometry {geometry!r}. Valid options: {valid}." + ) from exc + + +def size_screen( + required_gross_area_m2: float, + geometry: str, + dims: Mapping[str, float], + solve_for: str | None = None, + units: int = 1, +) -> GeometryResult: + """Solve one free dimension, or check fully-fixed dimensions. + + Args: + required_gross_area_m2: Total required gross screen area across all + units (e.g. ``ScreenSpec.gross_area_m2``). + geometry: One of ``GEOMETRIES`` (disc, panel, box, cylinder, cone, + halfbarrel). + dims: Dimensions in metres. In solve mode the ``solve_for`` entry + may be omitted; every other dimension of the shape is required. + solve_for: Dimension key to solve for, or None to check as-fixed. + units: Number of identical screen units sharing the flow (>= 1). + """ + geo = _get_geometry(geometry) + if units < 1: + raise ValueError(f"units must be >= 1, got {units}.") + if required_gross_area_m2 <= 0: + raise ValueError( + f"required_gross_area_m2 must be > 0, got {required_gross_area_m2}." + ) + if solve_for is not None and solve_for not in geo.dim_keys: + keys = ", ".join(geo.dim_keys) + raise ValueError( + f"Cannot solve for {solve_for!r}: {geo.key} has dimensions {keys}." + ) + needed = [k for k in geo.dim_keys if k != solve_for] + missing = [k for k in needed if k not in dims] + if missing: + raise ValueError( + f"Missing dimension(s) for {geo.key}: {', '.join(missing)}." + ) + bad = [k for k in needed if not dims[k] > 0] + if bad: + raise ValueError( + f"Dimension(s) must be > 0: {', '.join(bad)}." + ) + + required_unit = required_gross_area_m2 / units + final: dict[str, float] = {k: dims[k] for k in needed} + + if solve_for is None: + area_unit = geo.area(final) + area_total = area_unit * units + return GeometryResult( + geometry=geo.key, + mode="check", + units=units, + dims=final, + solved_key=None, + solved_raw_m=None, + gross_area_unit_m2=area_unit, + gross_area_total_m2=area_total, + required_gross_area_m2=required_gross_area_m2, + # tolerance so a dimension solved at exactly the requirement + # re-checks as sufficient despite float rounding + area_sufficient=area_total >= required_gross_area_m2 * (1 - 1e-9), + ) + + raw = geo.solve(solve_for, required_unit, final) + if not math.isfinite(raw) or raw <= 0: + raise ValueError( + f"Cannot solve for {solve_for!r} with the given fixed dimensions " + "(result <= 0). Adjust the fixed dimensions." + ) + rounded = math.ceil(raw / BUILD_INCREMENT_M) * BUILD_INCREMENT_M + final[solve_for] = rounded + area_unit = geo.area(final) + area_total = area_unit * units + return GeometryResult( + geometry=geo.key, + mode="solve", + units=units, + dims=final, + solved_key=solve_for, + solved_raw_m=raw, + gross_area_unit_m2=area_unit, + gross_area_total_m2=area_total, + required_gross_area_m2=required_gross_area_m2, + area_sufficient=True, # rounded up, so always covers the requirement + ) diff --git a/tests/test_geometry.py b/tests/test_geometry.py new file mode 100644 index 0000000..7cff194 --- /dev/null +++ b/tests/test_geometry.py @@ -0,0 +1,131 @@ +"""Tests for screen geometries (Figure 2, §3.7).""" + +import json +import math + +import pytest + +from fish_screen.cli import main +from fish_screen.geometry import BUILD_INCREMENT_M, GEOMETRIES, size_screen + +REFERENCE_DIMS = { + "disc": {"D": 0.6}, + "panel": {"W1": 0.5, "W2": 0.8}, + "box": {"L": 1.2, "W1": 0.5, "W2": 0.4}, + "cylinder": {"D": 0.3, "L": 1.0}, + "cone": {"r": 0.3, "L": 0.9}, + "halfbarrel": {"D": 0.4, "L": 1.1}, +} + + +def test_area_formulas_match_figure_2(): + assert math.isclose( + GEOMETRIES["disc"].area({"D": 0.6}), math.pi / 4 * 0.36 + ) + assert math.isclose(GEOMETRIES["panel"].area({"W1": 0.5, "W2": 0.8}), 0.4) + assert math.isclose( + GEOMETRIES["box"].area({"L": 1.2, "W1": 0.5, "W2": 0.4}), + 2 * 1.2 * 0.9, + ) + assert math.isclose( + GEOMETRIES["cylinder"].area({"D": 0.3, "L": 1.0}), math.pi * 0.3 + ) + assert math.isclose( + GEOMETRIES["cone"].area({"r": 0.3, "L": 0.9}), math.pi * 0.27 + ) + assert math.isclose( + GEOMETRIES["halfbarrel"].area({"D": 0.4, "L": 1.1}), + 0.5 * math.pi * 0.44, + ) + + +@pytest.mark.parametrize("key", sorted(GEOMETRIES)) +def test_solve_round_trips_every_dimension(key): + geo = GEOMETRIES[key] + dims = REFERENCE_DIMS[key] + area = geo.area(dims) + for dim_key in geo.dim_keys: + others = {k: v for k, v in dims.items() if k != dim_key} + solved = geo.solve(dim_key, area, others) + assert math.isclose(solved, dims[dim_key]), (key, dim_key) + + +def test_solved_dimension_rounds_up_to_build_increment(): + result = size_screen(1.0, "cylinder", {"D": 0.3}, solve_for="L") + exact = 1.0 / (math.pi * 0.3) + assert result.solved_raw_m == pytest.approx(exact) + assert result.dims["L"] >= exact + assert result.dims["L"] - exact < BUILD_INCREMENT_M + assert result.area_sufficient + # rounded value re-checks as sufficient + recheck = size_screen(1.0, "cylinder", dict(result.dims)) + assert recheck.area_sufficient + + +def test_units_split_the_required_area(): + one = size_screen(2.0, "cylinder", {"D": 0.3}, solve_for="L", units=1) + two = size_screen(2.0, "cylinder", {"D": 0.3}, solve_for="L", units=2) + assert two.dims["L"] == pytest.approx(one.dims["L"] / 2, abs=1e-3) + assert two.gross_area_total_m2 >= 2.0 + + +def test_check_mode_pass_and_fail(): + ok = size_screen(0.9, "cylinder", {"D": 0.3, "L": 1.0}) + small = size_screen(1.0, "cylinder", {"D": 0.3, "L": 1.0}) + assert ok.mode == "check" and ok.area_sufficient + assert math.isclose(small.gross_area_total_m2, math.pi * 0.3) + assert not small.area_sufficient + + +def test_impossible_solve_raises(): + # box W1 = A/(2L) - W2 goes negative for a huge fixed W2 + with pytest.raises(ValueError, match="Cannot solve"): + size_screen(0.1, "box", {"L": 1.0, "W2": 5.0}, solve_for="W1") + + +def test_missing_and_invalid_inputs_raise(): + with pytest.raises(ValueError, match="Missing dimension"): + size_screen(1.0, "cylinder", {"D": 0.3}) + with pytest.raises(ValueError, match="Unknown geometry"): + size_screen(1.0, "sphere", {"D": 0.3}) + with pytest.raises(ValueError, match="Cannot solve for"): + size_screen(1.0, "cylinder", {"D": 0.3}, solve_for="W1") + with pytest.raises(ValueError, match="units"): + size_screen(1.0, "cylinder", {"D": 0.3, "L": 1.0}, units=0) + + +def test_cli_solve_reports_dimension(capsys): + rc = main(["--flow", "0.05", "--geometry", "cylinder", + "--dim", "D=0.3", "--solve-for", "L"]) + out = capsys.readouterr().out + assert rc == 0 + assert "Solved L:" in out and "PASS" in out + + +def test_cli_check_fail(capsys): + rc = main(["--flow", "0.05", "--geometry", "panel", + "--dim", "W1=0.5", "--dim", "W2=0.5"]) + out = capsys.readouterr().out + assert rc == 0 + assert "FAIL" in out # 0.25 m^2 << required gross area + + +def test_cli_json_includes_geometry(capsys): + rc = main(["--flow", "0.05", "--geometry", "cylinder", + "--dim", "D=0.3", "--solve-for", "L", "--units", "2", + "--json"]) + assert rc == 0 + out = json.loads(capsys.readouterr().out) + geo = out["geometry"] + assert geo["mode"] == "solve" and geo["units"] == 2 + assert geo["area_sufficient"] is True + + +def test_cli_geometry_flag_errors(capsys): + assert main(["--flow", "0.05", "--dim", "D=0.3"]) == 2 + assert "require --geometry" in capsys.readouterr().out + assert main(["--flow", "0.05", "--geometry", "cylinder", + "--dim", "D=x", "--solve-for", "L"]) == 2 + assert "not a number" in capsys.readouterr().out + assert main(["--batch", "whatever.csv", "--geometry", "cylinder"]) == 2 + assert "not supported" in capsys.readouterr().out