Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,11 @@ fish-screen --flow 0.05 --geometry cylinder --dim D=0.3 --solve-for L # size a

Batch CSV columns: `name`, `flow_m3s` *or* `flow_cfs`, `water_type`,
`sweeping_velocity_mps`, `sensitive_species`, `proposed_opening_mm`,
`open_area_ratio`, `blockage_allowance` (blank cells take the CLI defaults;
add `--json` for a machine-readable array). Row errors are reported per intake
without stopping the rest.
`open_area_ratio`, `blockage_allowance`, plus optional geometry columns —
`geometry`, `dim_D`/`dim_L`/`dim_W1`/`dim_W2`/`dim_r` (metres), `solve_for`,
`units` (blank cells take the CLI defaults; add `--json` for a
machine-readable array). Row errors are reported per intake without stopping
the rest.

## Worked examples

Expand Down
4 changes: 3 additions & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ as a scriptable/batch backend. If kept, the original backlog applies:
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)
- ✅ Geometry columns in batch CSV mode (2026-08-23: `geometry`, `dim_*`,
`solve_for`, `units` per row; solve or check per intake, geometry
column in the table and object in JSON, row errors isolated)
- ✅ 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`,
Expand Down
63 changes: 62 additions & 1 deletion src/fish_screen/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
- ``proposed_opening_mm`` — optional opening-size check
- ``open_area_ratio``, ``blockage_allowance`` — optional overrides

Geometry columns (optional; require ``geometry``):

- ``geometry`` — disc, panel, box, cylinder, cone, or halfbarrel
- ``dim_D``, ``dim_L``, ``dim_W1``, ``dim_W2``, ``dim_r`` — dimensions in
metres (only the shape's own keys are needed)
- ``solve_for`` — dimension key to solve; blank checks fixed dimensions
- ``units`` — identical screen units sharing the flow (default 1)

Blank cells take the same defaults as the CLI flags.
"""

Expand All @@ -20,8 +28,18 @@
from dataclasses import dataclass

from .calculator import ScreenSpec, calculate_screen_spec
from .geometry import GeometryResult, size_screen
from .units import cfs_to_m3s

#: CSV column -> geometry dimension key.
GEOMETRY_DIM_COLUMNS = {
"dim_D": "D",
"dim_L": "L",
"dim_W1": "W1",
"dim_W2": "W2",
"dim_r": "r",
}

KNOWN_COLUMNS = frozenset(
{
"name",
Expand All @@ -33,6 +51,10 @@
"proposed_opening_mm",
"open_area_ratio",
"blockage_allowance",
"geometry",
"solve_for",
"units",
*GEOMETRY_DIM_COLUMNS,
}
)

Expand All @@ -48,6 +70,7 @@ class BatchResult:
imperial: bool # row supplied flow_cfs
spec: ScreenSpec | None
error: str | None
geo: GeometryResult | None = None


def _get(row: dict[str, str], key: str) -> str | None:
Expand Down Expand Up @@ -99,7 +122,45 @@ def _row_result(name: str, row: dict[str, str]) -> BatchResult:
sensitive_species=_parse_bool(row, "sensitive_species"),
**kwargs, # type: ignore[arg-type]
)
return BatchResult(name=name, imperial=imperial, spec=spec, error=None)
geo = _row_geometry(row, spec)
return BatchResult(
name=name, imperial=imperial, spec=spec, error=None, geo=geo
)


def _row_geometry(
row: dict[str, str], spec: ScreenSpec
) -> GeometryResult | None:
geometry = _get(row, "geometry")
geo_columns = ("solve_for", "units", *GEOMETRY_DIM_COLUMNS)
if geometry is None:
used = [c for c in geo_columns if _get(row, c) is not None]
if used:
raise ValueError(
f"column(s) {', '.join(used)} require a geometry"
)
return None
dims: dict[str, float] = {}
for column, key in GEOMETRY_DIM_COLUMNS.items():
value = _parse_float(row, column)
if value is not None:
dims[key] = value
units = 1
units_raw = _get(row, "units")
if units_raw is not None:
try:
units = int(units_raw)
except ValueError:
raise ValueError(
f"column 'units': {units_raw!r} is not an integer"
) from None
return size_screen(
required_gross_area_m2=spec.gross_area_m2,
geometry=geometry,
dims=dims,
solve_for=_get(row, "solve_for"),
units=units,
)


def run_batch(path: str) -> list[BatchResult]:
Expand Down
29 changes: 26 additions & 3 deletions src/fish_screen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,15 +198,23 @@ def _print_batch(results: list[BatchResult], as_json: bool) -> int:
for r in results:
if r.spec is None:
rows.append({"name": r.name, "error": r.error})
else:
rows.append({"name": r.name, **_spec_as_dict(r.spec, r.imperial)})
continue
row: dict[str, object] = {
"name": r.name, **_spec_as_dict(r.spec, r.imperial)
}
if r.geo is not None:
row["geometry"] = dataclasses.asdict(r.geo)
rows.append(row)
print(json.dumps(rows, indent=2))
return 2 if failures else 0
any_geo = any(r.geo is not None for r in results)
name_width = max(len(r.name) for r in results)
header = (
f"{'intake':<{name_width}} {'v_design':>8} {'A_eff m^2':>9} "
f"{'A_gross m^2':>11} opening"
)
if any_geo:
header += " geometry"
print(header)
print("-" * len(header))
for r in results:
Expand All @@ -219,11 +227,26 @@ def _print_batch(results: list[BatchResult], as_json: bool) -> int:
else:
verdict = "PASS" if s.opening_compliant else "FAIL"
opening = f"{s.proposed_opening_mm:.2f} mm {verdict}"
geo_txt = ""
if r.geo is not None:
g = r.geo
geo_verdict = "PASS" if g.area_sufficient else "FAIL"
solved = (
f" {g.solved_key}={g.dims[g.solved_key]:.3f}m"
if g.solved_key is not None else ""
)
n_units = f" ×{g.units}" if g.units > 1 else ""
geo_txt = (
f" {g.geometry}{n_units}{solved} "
f"{g.gross_area_total_m2:.3f} m^2 {geo_verdict}"
)
if any_geo:
opening = f"{opening:<12}"
low_oar = "" if s.meets_min_open_area else " (OAR < 50% min)"
print(
f"{r.name:<{name_width}} {s.design_approach_velocity_mps:>8.3f} "
f"{s.effective_area_m2:>9.3f} {s.gross_area_m2:>11.3f} "
f"{opening}{low_oar}"
f"{opening}{geo_txt}{low_oar}"
)
if failures:
print(f"\n{len(failures)} of {len(results)} row(s) failed.")
Expand Down
50 changes: 50 additions & 0 deletions tests/test_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,56 @@ def test_cli_batch_failure_exit_code(tmp_path, capsys):
assert "ERROR" in capsys.readouterr().out


GEO_CSV = """\
name,flow_m3s,geometry,dim_D,dim_L,dim_W1,dim_W2,solve_for,units
solve-cyl,0.05,cylinder,0.3,,,,L,2
check-panel,0.05,panel,,,0.5,0.5,,
no-geo,0.05,,,,,,,
"""


def test_batch_geometry_solve_and_check(tmp_path):
results = run_batch(write_csv(tmp_path, GEO_CSV))
solve, check, plain = results
assert solve.error is None
assert solve.geo.mode == "solve" and solve.geo.units == 2
assert solve.geo.solved_key == "L" and solve.geo.area_sufficient
assert check.geo.mode == "check"
assert not check.geo.area_sufficient # 0.25 m^2 << required gross
assert plain.geo is None


def test_batch_geometry_row_errors_are_isolated(tmp_path):
path = write_csv(tmp_path, """\
name,flow_m3s,geometry,dim_D,solve_for,units
bad-shape,0.05,sphere,0.3,,
bad-units,0.05,cylinder,0.3,L,two
orphan-dim,0.05,,0.3,,
good,0.05,cylinder,0.3,L,
""")
results = run_batch(path)
assert "Unknown geometry" in results[0].error
assert "not an integer" in results[1].error
assert "require a geometry" in results[2].error
assert results[3].error is None and results[3].geo is not None


def test_cli_batch_geometry_table_and_json(tmp_path, capsys):
path = write_csv(tmp_path, GEO_CSV)
rc = main(["--batch", path])
out = capsys.readouterr().out
assert rc == 0
assert "geometry" in out # header column
assert "cylinder ×2 L=" in out
assert "panel" in out and "FAIL" in out
rc = main(["--batch", path, "--json"])
assert rc == 0
rows = json.loads(capsys.readouterr().out)
assert rows[0]["geometry"]["solved_key"] == "L"
assert rows[1]["geometry"]["area_sufficient"] is False
assert "geometry" not in rows[2]


def test_cli_batch_missing_file(capsys):
rc = main(["--batch", "/nonexistent/intakes.csv"])
assert rc == 2
Expand Down
Loading