Skip to content
Open
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
9 changes: 9 additions & 0 deletions src/meapy/heat_transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,15 @@ def analyse_exchanger(
"""
if flow_direction not in {"counter", "co"}:
raise ValueError(f"flow_direction must be 'counter' or 'co', got {flow_direction!r}.")
if math.isclose(t_mea_in_c, t_utility_in_c, abs_tol=1e-6):
# Without a driving force there is no hot/cold assignment, LMTD is
# undefined, and effectiveness divides by zero. Fail up front with a
# message that names the real cause rather than letting a downstream
# helper raise something cryptic.
raise ValueError(
f"t_mea_in_c ({t_mea_in_c}) and t_utility_in_c ({t_utility_in_c}) "
"are equal; no driving force, exchanger analysis is undefined."
)

mea_kg_s = mea_flow_kg_h * _CONV
util_kg_s = utility_flow_kg_h * _CONV
Expand Down
4 changes: 4 additions & 0 deletions src/meapy/lambda_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ def _analyse(body: JsonDict) -> JsonDict:
result = heat_transfer.analyse_exchanger(**body)
except TypeError as exc:
return _response(400, {"error": f"bad payload: {exc}"})
except ValueError as exc:
# Physical/domain validation errors (e.g. non-positive flows, LMTD
# crossover) are caller-fixable inputs, not server faults.
return _response(400, {"error": f"invalid inputs: {exc}"})
return _response(200, {"result": result})


Expand Down
19 changes: 19 additions & 0 deletions src/meapy/pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@ def invert(self, target_level_pct: float) -> float:
"""
if target_level_pct <= 0:
raise ValueError(f"target_level_pct must be positive, got {target_level_pct!r}.")
if math.isclose(self.k, 0.0, abs_tol=1e-12):
# A flat fit (k = 0) means level is independent of speed; no
# finite speed solves L₀·exp(0) = target unless target == L₀,
# in which case every speed does. Either way, inversion is
# ill-posed — surface a clear error rather than dividing by zero.
raise ValueError(
"Cannot invert an ExponentialLevelModel with k ≈ 0: the fitted "
"level is independent of pump speed."
)
return math.log(target_level_pct / self.l0) / self.k


Expand Down Expand Up @@ -246,6 +255,11 @@ def fit_exponential_level_model(
f"All MEA level values must be strictly positive for log-linearisation. "
f"Found {(lev <= 0).sum()} non-positive value(s)."
)
if np.ptp(ps) == 0:
raise ValueError(
"pump_speeds_pct has zero variance; cannot fit an exponential model "
"(slope would be undefined)."
)

ln_lev = np.log(lev)
slope, intercept, r, _, _ = linregress(ps, ln_lev)
Expand Down Expand Up @@ -284,6 +298,11 @@ def fit_linear_flowrate_model(
)
if len(ps) < 2:
raise ValueError("At least 2 data points are required to fit a linear model.")
if np.ptp(ps) == 0:
raise ValueError(
"pump_speeds_pct has zero variance; cannot fit a linear model "
"(slope would be undefined)."
)

slope, intercept, r, _, _ = linregress(ps, fl)
r_sq = float(r**2)
Expand Down
6 changes: 6 additions & 0 deletions src/meapy/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,12 @@ def summarise_array(arr: npt.ArrayLike) -> dict[str, float]:
a = np.asarray(arr, dtype=float).ravel()
if a.size == 0:
raise ValueError("Cannot summarise an empty array.")
if not np.all(np.isfinite(a)):
n_bad = int(np.sum(~np.isfinite(a)))
raise ValueError(
f"Input array contains {n_bad} non-finite value(s) (NaN/inf); "
"summary statistics would be poisoned. Clean or mask the data first."
)
return {
"mean": float(np.mean(a)),
"std": float(np.std(a, ddof=1)) if a.size > 1 else 0.0,
Expand Down
Loading