From acb5faa5e6d72f2defdee5d7eebe0aebf15b9b9b Mon Sep 17 00:00:00 2001 From: Defne Nihal Ertugrul Date: Thu, 9 Apr 2026 20:54:34 +0300 Subject: [PATCH 1/5] fix(lambda): return 400 on ValueError from analyse_exchanger analyse_exchanger raises ValueError for caller-correctable inputs (non-positive flows, LMTD temperature crossover, unknown flow_direction). Previously these propagated to the broad Exception handler and produced 500 responses, obscuring client errors in logs and metrics. Treat them as 400 Bad Request alongside the existing TypeError case. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/meapy/lambda_handler.py | 4 ++++ 1 file changed, 4 insertions(+) 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}) From 5e6418955d669063e8e3fa081c0620e20686cf3c Mon Sep 17 00:00:00 2001 From: Defne Nihal Ertugrul Date: Thu, 9 Apr 2026 20:54:52 +0300 Subject: [PATCH 2/5] fix(pump): reject zero-variance pump speeds in regression fits scipy.stats.linregress silently returns NaN slope/intercept/r when the x-vector has zero variance, producing a fitted model whose predict() and invert() emit NaN and whose ExponentialLevelModel.__post_init__ raises an opaque r_squared validation error. Detect the degenerate input up front and raise a clear ValueError pointing at the real cause. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/meapy/pump.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/meapy/pump.py b/src/meapy/pump.py index d9174a7..8bb6898 100644 --- a/src/meapy/pump.py +++ b/src/meapy/pump.py @@ -246,6 +246,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 +289,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) From 32427d616313484ec978de0eff5aca06f3eb0305 Mon Sep 17 00:00:00 2001 From: Defne Nihal Ertugrul Date: Thu, 9 Apr 2026 20:55:04 +0300 Subject: [PATCH 3/5] fix(utils): reject non-finite values in summarise_array A single NaN in the input poisons mean/std/min/max, returning NaN statistics without any warning and silently masking upstream data- quality problems (e.g. transmitter dropouts). Fail loudly with a message that tells the caller how many bad samples were found so they can decide whether to clean or mask the data. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/meapy/utils.py | 6 ++++++ 1 file changed, 6 insertions(+) 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, From 9365de7f314e3749cfbb308b8ded748a3542311f Mon Sep 17 00:00:00 2001 From: Defne Nihal Ertugrul Date: Thu, 9 Apr 2026 20:55:19 +0300 Subject: [PATCH 4/5] fix(heat_transfer): validate nonzero driving force in analyse_exchanger When the MEA and utility inlets are equal, the existing hot/cold selection arbitrarily tags MEA as the hot stream and the call eventually fails deep inside effectiveness() with "inlet temperatures are equal; effectiveness is undefined". Detect the condition at the entry point so the error message names the actual offending inputs. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/meapy/heat_transfer.py | 9 +++++++++ 1 file changed, 9 insertions(+) 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 From f6b209c83b80c23f74aef16994577a243f154760 Mon Sep 17 00:00:00 2001 From: Defne Nihal Ertugrul Date: Thu, 9 Apr 2026 20:55:45 +0300 Subject: [PATCH 5/5] fix(pump): guard ExponentialLevelModel.invert against k == 0 If a caller constructs or fits a flat exponential level model (k = 0), invert() would raise ZeroDivisionError from the math.log(...)/self.k expression. That leaks an implementation detail and, when invoked from safe_pump_speed(), aborts the commissioning analysis with a traceback that doesn't explain the root cause. Detect the degenerate slope and raise a ValueError describing why inversion is ill-posed. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/meapy/pump.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/meapy/pump.py b/src/meapy/pump.py index 8bb6898..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