From 6b8ddfb774eaf96aef4d84f6364e40f1f90a5a99 Mon Sep 17 00:00:00 2001 From: Defne Nihal Ertugrul Date: Thu, 9 Apr 2026 20:55:39 +0300 Subject: [PATCH 1/5] fix(control): guard tune_cohen_coon against zero dead time Cohen-Coon formulas contain 1/theta, so calling with theta=0 silently produces inf/NaN gains. Match the existing tune_ziegler_nichols guard and raise ValueError with a units-aware message directing users to lambda tuning (which handles theta~0 correctly). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cooltower/control.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/cooltower/control.py b/src/cooltower/control.py index 6917239..b13ad91 100644 --- a/src/cooltower/control.py +++ b/src/cooltower/control.py @@ -335,7 +335,15 @@ def tune_cohen_coon(model: FOPDTModel) -> PIParameters: Returns: Tuned :class:`PIParameters`. + + Raises: + ValueError: If dead time θ ≈ 0 (formula is singular). """ + if model.theta < 1e-6: + raise ValueError( + "Cohen–Coon tuning requires θ > 0 s (formula contains 1/θ). " + f"Got θ = {model.theta} s — use lambda tuning for near-zero dead time." + ) r = model.theta / model.tau_p K_c = (model.tau_p / (model.K_p * model.theta)) * (0.9 + r / 12.0) tau_I = model.theta * (30.0 + 3.0 * r) / (9.0 + 20.0 * r) From 2bc3ac659bd445384f545371f4158533aac1604a Mon Sep 17 00:00:00 2001 From: Defne Nihal Ertugrul Date: Thu, 9 Apr 2026 20:55:55 +0300 Subject: [PATCH 2/5] fix(control): support negative-step responses in _interpolate_response_time The two_point FOPDT identification passes target deltas of 0.283*delta_y and 0.632*delta_y. When the step magnitude is negative (delta_y < 0) the targets are negative, but the previous crossing check assumed y[i]-y0 <= target <= y[i+1]-y0 which only holds for monotonically rising responses. Falling responses therefore raised "Response does not reach the target level" even when they clearly did. Detect the crossing by bracketing between min/max of the two samples so both directions work, and guard against zero denominators in the linear interpolation step. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cooltower/control.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/cooltower/control.py b/src/cooltower/control.py index b13ad91..94457bc 100644 --- a/src/cooltower/control.py +++ b/src/cooltower/control.py @@ -210,10 +210,21 @@ def _interpolate_response_time( y0: float, target_delta: float, ) -> float: - """Find the time at which (y − y0) first reaches *target_delta* by linear interpolation.""" + """Find the time at which (y − y0) first reaches *target_delta* by linear interpolation. + + Works for both rising (positive step) and falling (negative step) + responses: the crossing is detected whenever *target_delta* lies + between two consecutive (y[i] − y0) samples, regardless of sign. + """ for i in range(len(y) - 1): - if (y[i] - y0) <= target_delta <= (y[i + 1] - y0): - frac = (target_delta - (y[i] - y0)) / max(y[i + 1] - y[i], 1e-12) + d0 = y[i] - y0 + d1 = y[i + 1] - y0 + lo, hi = (d0, d1) if d0 <= d1 else (d1, d0) + if lo <= target_delta <= hi: + denom = d1 - d0 + if abs(denom) < 1e-12: + return t[i] + frac = (target_delta - d0) / denom return t[i] + frac * (t[i + 1] - t[i]) raise ValueError( f"Response does not reach the target level (Δy = {target_delta:.4f}) " From f9680cdd5a1fcb508c444f432187b7fb007b09b9 Mon Sep 17 00:00:00 2001 From: Defne Nihal Ertugrul Date: Thu, 9 Apr 2026 20:56:06 +0300 Subject: [PATCH 3/5] fix(control): validate post-step sample count in identify_fopdt tangent method With fewer than 2 post-step samples the tangent branch built an empty `slopes` list and then called `max(range(0), key=...)`, which raises a bare ValueError from builtins with no context about what the caller did wrong. Check length up front and raise a targeted ValueError explaining the minimum sample count and suggesting the two_point fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cooltower/control.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/cooltower/control.py b/src/cooltower/control.py index 94457bc..86d9f8c 100644 --- a/src/cooltower/control.py +++ b/src/cooltower/control.py @@ -173,6 +173,12 @@ def identify_fopdt( tau_p = 1.5 * (t63 - t28) theta = t63 - tau_p - (post_step_t[0] - step_time) elif method == "tangent": + if len(post_step_t) < 2: + raise ValueError( + "tangent method requires at least 2 post-step samples to " + f"estimate a slope, got {len(post_step_t)}. Extend the " + "step test or use method='two_point'." + ) # Find inflection point (max slope in post-step data) slopes = [ (post_step_y[i + 1] - post_step_y[i]) / max(post_step_t[i + 1] - post_step_t[i], 1e-9) From cd977b0a5daed0ad42da857324860bcf81556320 Mon Sep 17 00:00:00 2001 From: Defne Nihal Ertugrul Date: Thu, 9 Apr 2026 20:56:26 +0300 Subject: [PATCH 4/5] fix(control): remove spurious one-sample dead time in closed_loop_response delay_steps was clamped with max(1, int(theta/dt)), which forced at least one sample of transport delay even when the FOPDT model has theta == 0. That injected a fake dt-sized lag into pure first-order simulations and biased closed-loop performance indices. Round to the nearest sample and allow delay_steps == 0 so a zero-dead-time model is simulated faithfully. Also validate dt > 0 up front to avoid a confusing ZeroDivisionError deeper in the loop. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cooltower/control.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/cooltower/control.py b/src/cooltower/control.py index 86d9f8c..1cb39b2 100644 --- a/src/cooltower/control.py +++ b/src/cooltower/control.py @@ -435,11 +435,18 @@ def closed_loop_response( Returns: Tuple ``(time, output, control_signal)``. """ + if dt <= 0: + raise ValueError(f"dt must be positive [s], got {dt}.") + n = int(t_end / dt) + 1 time_vec = [i * dt for i in range(n)] - # Store delayed outputs for dead-time approximation (integer delay) - delay_steps = max(1, int(model.theta / dt)) + # Store delayed outputs for dead-time approximation (integer delay). + # Round (rather than floor) to the nearest sample and allow zero + # delay when the process has no dead time — the previous max(1, ...) + # floor injected a spurious one-sample lag into pure first-order + # models, biasing the simulated response. + delay_steps = max(0, int(round(model.theta / dt))) u_history: list[float] = [0.0] * (delay_steps + n) y = 0.0 From ae5e4803f728805d46a05ea93c6a4c745a4a12ee Mon Sep 17 00:00:00 2001 From: Defne Nihal Ertugrul Date: Thu, 9 Apr 2026 20:56:45 +0300 Subject: [PATCH 5/5] fix(psychrometrics): validate omega feasibility in wet_bulb_temperature Previously, passing a humidity ratio above saturation at T_db or a negative omega would send the Newton iteration outside the valid psychrometric region and eventually bubble up as a generic "did not converge within 50 iterations" RuntimeError, which is hard to diagnose for end users. Check omega >= 0 and omega <= omega_sat(T_db, P) up front and raise ValueError with all three offending quantities (with units) so the caller can see exactly which input is inconsistent. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cooltower/psychrometrics.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/cooltower/psychrometrics.py b/src/cooltower/psychrometrics.py index 47e2a34..ee55871 100644 --- a/src/cooltower/psychrometrics.py +++ b/src/cooltower/psychrometrics.py @@ -317,6 +317,22 @@ def wet_bulb_temperature( """ _validate_temperature(T_db, "T_db") _validate_pressure(P) + if omega < 0.0: + raise ValueError( + f"Humidity ratio cannot be negative, got ω = {omega} kg/kg." + ) + + # Feasibility: ω must not exceed saturation at T_db, otherwise the + # Newton iteration will wander outside the valid psychrometric region + # and eventually raise a generic non-convergence error. + omega_sat = humidity_ratio_from_rh(T_db, 1.0, P) + if omega > omega_sat * (1.0 + 1e-6): + raise ValueError( + f"Humidity ratio ω = {omega:.6f} kg/kg exceeds saturation " + f"ω_sat = {omega_sat:.6f} kg/kg at T_db = {T_db} °C, " + f"P = {P:.0f} Pa. The state is supersaturated; no wet-bulb " + "temperature exists." + ) T_wb = T_db # initial guess A = 6.6e-4