diff --git a/src/meapy/heat_transfer.py b/src/meapy/heat_transfer.py index 5f0147a..c6c4312 100644 --- a/src/meapy/heat_transfer.py +++ b/src/meapy/heat_transfer.py @@ -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 diff --git a/src/meapy/lambda_handler.py b/src/meapy/lambda_handler.py index 087823f..e35cef0 100644 --- a/src/meapy/lambda_handler.py +++ b/src/meapy/lambda_handler.py @@ -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}) diff --git a/src/meapy/pump.py b/src/meapy/pump.py index d9174a7..b517aa7 100644 --- a/src/meapy/pump.py +++ b/src/meapy/pump.py @@ -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 @@ -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) @@ -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) diff --git a/src/meapy/utils.py b/src/meapy/utils.py index 03cf0c8..9e4e9c4 100644 --- a/src/meapy/utils.py +++ b/src/meapy/utils.py @@ -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,