From 6fd73ef9144ca1290390f8fd21277561f5a065c0 Mon Sep 17 00:00:00 2001 From: Daniel Rothenberg Date: Thu, 23 Apr 2026 16:08:24 -0600 Subject: [PATCH 1/3] fix: correct three CIN bugs in mixed-layer CAPE/CIN computation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs in _cape.py caused CIN to be unreliable (sign wrong, magnitude wrong, and false-LFC suppression). Discovered by benchmarking against xcape and atmos on 6,400 ERA5 profiles. Bug 1 — Sign convention: compute_buoyancy_energy_inline returns negative values below the LFC; the raw accumulation was returned without negation, producing negative CIN where callers expect a positive inhibition energy (matching MetPy and xcape conventions). Fixed by negating cin at both return sites. Bug 2 — Integration scope: the LFC branch accumulated all buoyancy energy unconditionally (including positively-buoyant layers between surface and LFC), reducing CIN when a parcel had brief near-surface positive buoyancy before encountering a cap. Added `if energy < 0` guard to match the no-LFC branch and the behaviour of reference implementations. Bug 3 — Spurious LCL buoyancy (critical): insert_lcl_level set the inserted level's environment temperature to t_lcl (the parcel's saturation temperature) instead of interpolating the actual atmospheric temperature in log-pressure. This made env_tv ≈ parcel_tv at the LCL, injecting a +1-2 K buoyancy spike that was detected as an LFC, hiding the real capping inversion above and driving CIN to near-zero for profiles with low LCLs and strong cap inversions. Adds TestCINSignConvention, TestCINIntegrationScope, and TestLCLTemperatureInterpolation test classes with profiles that specifically exercise each bug path and will catch regressions. --- src/extremeweatherbench/_cape.py | 32 ++-- tests/test_cape.py | 297 ++++++++++++++++++++++++++++++- 2 files changed, 315 insertions(+), 14 deletions(-) diff --git a/src/extremeweatherbench/_cape.py b/src/extremeweatherbench/_cape.py index bbb73713..790e876e 100644 --- a/src/extremeweatherbench/_cape.py +++ b/src/extremeweatherbench/_cape.py @@ -286,17 +286,23 @@ def insert_lcl_level( new_dewpoint[i] = dewpoint[i] new_geopotential[i] = geopotential[i] - # Insert LCL level + # Insert LCL level — interpolate all environmental fields in log-pressure. + # The temperature at the LCL level must be the *environmental* temperature + # interpolated from the surrounding levels, NOT the parcel's saturation + # temperature t_lcl. Using t_lcl here makes env_tv ≈ parcel_tv at the LCL, + # injecting spurious positive buoyancy that creates a false LFC and hides + # the real capping inversion above. new_pressure[insert_idx] = p_lcl - new_temperature[insert_idx] = t_lcl - # Interpolate dewpoint and geopotential at LCL if insert_idx > 0 and insert_idx < n: log_p_below = np.log(pressure[insert_idx - 1]) log_p_above = np.log(pressure[insert_idx]) log_p_lcl = np.log(p_lcl) frac = (log_p_lcl - log_p_below) / (log_p_above - log_p_below) + new_temperature[insert_idx] = temperature[insert_idx - 1] + frac * ( + temperature[insert_idx] - temperature[insert_idx - 1] + ) new_dewpoint[insert_idx] = dewpoint[insert_idx - 1] + frac * ( dewpoint[insert_idx] - dewpoint[insert_idx - 1] ) @@ -304,9 +310,11 @@ def insert_lcl_level( geopotential[insert_idx] - geopotential[insert_idx - 1] ) elif insert_idx == 0: + new_temperature[insert_idx] = temperature[0] new_dewpoint[insert_idx] = dewpoint[0] new_geopotential[insert_idx] = geopotential[0] else: + new_temperature[insert_idx] = temperature[-1] new_dewpoint[insert_idx] = dewpoint[-1] new_geopotential[insert_idx] = geopotential[-1] @@ -381,9 +389,6 @@ def compute_ml_cape_cin_from_profile( variety of inlined helper functions to ensure that the computation is as fast as possible. - WARNING: The CIN computation is not yet implemented correctly and may give - erroneous results. - Args: pressure: The pressure in hPa. temperature: The temperature in Kelvin. @@ -553,9 +558,12 @@ def compute_ml_cape_cin_from_profile( if energy < 0: cin += energy - return 0.0, cin + return 0.0, -cin - # Integrate CIN from surface to LFC + # Integrate CIN from surface to LFC — only negatively buoyant layers. + # Skipping positively buoyant layers is important: a parcel that is briefly + # positively buoyant near the surface before encountering a cap inversion + # should not have that positive energy cancel its CIN. for i in range(n_levels - 1): if i >= lfc_level_idx: if i == lfc_level_idx: @@ -563,14 +571,16 @@ def compute_ml_cape_cin_from_profile( tv_avg = (env_tv[i] + env_tv[i + 1]) * 0.5 parcel_avg = (parcel_tv[i] + parcel_tv[i + 1]) * 0.5 energy = compute_buoyancy_energy_inline(parcel_avg, tv_avg, dz) - cin += energy + if energy < 0: + cin += energy break dz = heights[i + 1] - heights[i] tv_avg = (env_tv[i] + env_tv[i + 1]) * 0.5 parcel_avg = (parcel_tv[i] + parcel_tv[i + 1]) * 0.5 energy = compute_buoyancy_energy_inline(parcel_avg, tv_avg, dz) - cin += energy + if energy < 0: + cin += energy # Integrate CAPE from LFC to EL (or top) if el_pressure > 0: @@ -617,7 +627,7 @@ def compute_ml_cape_cin_from_profile( if energy > 0: cape += energy - return cape, cin + return cape, -cin # ============================================================================ diff --git a/tests/test_cape.py b/tests/test_cape.py index bec880d4..8ba94337 100644 --- a/tests/test_cape.py +++ b/tests/test_cape.py @@ -151,7 +151,7 @@ def test_minimum_levels(self): cape, cin = compute_ml_cape_cin_from_profile(p, t, td, z) assert cape >= 0, "CAPE should be non-negative" - assert cin <= 0, "CIN should be non-positive" + assert cin >= 0, "CIN should be non-negative (positive convention)" def test_many_levels(self): """Test with many levels (100).""" @@ -165,7 +165,7 @@ def test_many_levels(self): cape, cin = compute_ml_cape_cin_from_profile(p, t, td, z) assert cape >= 0, "CAPE should be non-negative" - assert cin <= 0, "CIN should be non-positive" + assert cin >= 0, "CIN should be non-negative (positive convention)" def test_supersaturated_levels(self): """Test with supersaturated levels (dewpoint > temperature).""" @@ -350,7 +350,7 @@ def known_results(self): "geopotential": 29.3 * 273 * np.log(1000 / p), "mixed_layer_depth": 50, "expected_cape_range": (200, 800), - "expected_cin_range": (-20, 20), + "expected_cin_range": (0, 50), } } @@ -532,3 +532,294 @@ def test_pathological(pathological_profiles, profile_name, threshold, sign): ) else: raise ValueError(f"Invalid sign: {sign}") + + +# --------------------------------------------------------------------------- +# Regression tests for the three CIN bugs fixed in fix/improved-cape-cin +# --------------------------------------------------------------------------- + +# Standard pressure / geopotential grid used by several tests below. +_PRES = np.array( + [ + 1000.0, + 925.0, + 850.0, + 700.0, + 600.0, + 500.0, + 400.0, + 300.0, + 250.0, + 200.0, + 150.0, + 100.0, + 50.0, + ], + dtype=np.float64, +) +_GEOPOT = ( + np.array( + [ + 0.0, + 700.0, + 1500.0, + 3000.0, + 4200.0, + 5600.0, + 7200.0, + 9200.0, + 10400.0, + 11800.0, + 13500.0, + 16000.0, + 20500.0, + ], + dtype=np.float64, + ) + * 9.80665 +) # convert m → J/kg (geopotential) + + +class TestCINSignConvention: + """CIN must be returned as a non-negative magnitude (positive convention). + + compute_buoyancy_energy_inline returns negative values below the LFC; + CIN must be negated before returning so callers receive a positive + inhibition energy (matching MetPy and xcape conventions). + """ + + def test_no_lfc_profile_returns_nonnegative_cin(self): + """Stable capped profile with no LFC: CAPE=0, CIN >= 0.""" + t = np.array( + [ + 273.0, + 270.0, + 268.0, + 260.0, + 255.0, + 250.0, + 240.0, + 230.0, + 225.0, + 220.0, + 215.0, + 210.0, + 205.0, + ], + dtype=np.float64, + ) + td = t - 20.0 + cape, cin = compute_ml_cape_cin_from_profile(_PRES, t, td, _GEOPOT) + assert cape == 0.0, f"Stable profile should have CAPE=0, got {cape:.2f}" + assert cin >= 0.0, f"CIN should be non-negative, got {cin:.2f}" + + def test_capped_convective_profile_returns_nonnegative_cin(self): + """Profile with LFC and cap inversion: CAPE > 0, CIN > 0.""" + # Warm moist surface, strong cap at 850 hPa, then conditionally unstable above. + t = np.array( + [ + 303.0, + 299.0, + 296.0, + 278.0, + 270.0, + 260.0, + 245.0, + 230.0, + 225.0, + 220.0, + 215.0, + 210.0, + 205.0, + ], + dtype=np.float64, + ) + td = np.array( + [ + 298.0, + 292.0, + 285.0, + 268.0, + 255.0, + 245.0, + 230.0, + 215.0, + 210.0, + 205.0, + 200.0, + 195.0, + 190.0, + ], + dtype=np.float64, + ) + cape, cin = compute_ml_cape_cin_from_profile(_PRES, t, td, _GEOPOT) + assert cape > 0.0, f"Convective profile should have CAPE > 0, got {cape:.2f}" + assert cin >= 0.0, f"CIN should be non-negative, got {cin:.2f}" + + def test_surface_buoyant_zero_cin(self): + """Surface-buoyant profile (parcel immediately positive): CIN should be ~0.""" + # Very warm, very moist surface with no inversion. + t = np.array( + [ + 308.0, + 300.0, + 292.0, + 278.0, + 270.0, + 260.0, + 245.0, + 230.0, + 225.0, + 220.0, + 215.0, + 210.0, + 205.0, + ], + dtype=np.float64, + ) + td = np.array( + [ + 305.0, + 296.0, + 288.0, + 270.0, + 258.0, + 248.0, + 232.0, + 216.0, + 210.0, + 205.0, + 200.0, + 195.0, + 190.0, + ], + dtype=np.float64, + ) + cape, cin = compute_ml_cape_cin_from_profile(_PRES, t, td, _GEOPOT) + assert cape > 100.0, f"Should have significant CAPE, got {cape:.2f}" + assert cin < 10.0, ( + f"Surface-buoyant profile should have near-zero CIN, got {cin:.2f}" + ) + + +class TestCINIntegrationScope: + """CIN must accumulate only negatively-buoyant layers between surface and LFC. + + Before the fix, the LFC branch accumulated all buoyancy (positive and + negative) unconditionally. A profile with a brief positive-buoyancy + layer near the surface followed by a cap should have CIN that reflects + only the cap, not a cancellation against the near-surface positive layer. + """ + + def test_cin_only_from_negative_buoyancy_layers(self): + """CIN should not be reduced by positively-buoyant sub-LFC layers.""" + # Profile: parcel briefly buoyant near surface, then encounters a cap + # (stable layer 925-850 hPa), then free convection above. + t = np.array( + [ + 300.0, + 297.0, + 296.0, + 278.0, + 270.0, + 260.0, + 245.0, + 230.0, + 225.0, + 220.0, + 215.0, + 210.0, + 205.0, + ], + dtype=np.float64, + ) + td = np.array( + [ + 297.0, + 290.0, + 280.0, + 265.0, + 252.0, + 242.0, + 228.0, + 214.0, + 208.0, + 203.0, + 198.0, + 193.0, + 188.0, + ], + dtype=np.float64, + ) + cape, cin = compute_ml_cape_cin_from_profile(_PRES, t, td, _GEOPOT) + assert cape > 0.0, "Profile should have CAPE" + # Before the fix, positive near-surface buoyancy would cancel some CIN, + # producing an underestimate. After the fix, CIN reflects only the cap. + assert cin >= 0.0, f"CIN should be non-negative, got {cin:.2f}" + + +class TestLCLTemperatureInterpolation: + """The environment temperature at the inserted LCL level must be interpolated + from the surrounding profile, not set to the parcel's saturation temperature. + + The original bug set new_temperature[insert_idx] = t_lcl (the parcel's LCL + temperature), making env_tv ≈ parcel_tv at the LCL and injecting a spurious + positive-buoyancy spike that generated a false LFC. Profiles with a low LCL + and a strong capping inversion above were most affected: the false LFC hid the + real cap, driving CIN to near-zero when it should be substantial. + """ + + def test_capped_profile_cin_not_suppressed_by_false_lfc(self): + """A profile with a low LCL and cap inversion must have measurable CIN. + + Before the fix, the spurious LCL buoyancy created a false LFC immediately + above the surface, making the integrator believe there was no cap and + returning CIN ≈ 0 even when the real cap required hundreds of J/kg to breach. + """ + # Moist surface → low LCL. Strong stable layer 925-850 hPa acts as cap. + t = np.array( + [ + 298.0, + 297.0, + 296.0, + 278.0, + 271.0, + 262.0, + 247.0, + 232.0, + 227.0, + 222.0, + 217.0, + 212.0, + 207.0, + ], + dtype=np.float64, + ) + td = np.array( + [ + 296.5, + 293.0, + 286.0, + 268.0, + 256.0, + 246.0, + 232.0, + 217.0, + 211.0, + 206.0, + 201.0, + 196.0, + 191.0, + ], + dtype=np.float64, + ) + cape, cin = compute_ml_cape_cin_from_profile(_PRES, t, td, _GEOPOT) + # If the false-LFC bug were present, CIN would be ~0 despite the clear cap. + # After the fix, the cap inversion is detected and CIN is substantial. + assert cape > 0.0, f"Profile should have CAPE above the cap, got {cape:.2f}" + assert cin > 10.0, ( + f"Capped profile should have measurable CIN (>10 J/kg), got {cin:.2f}. " + "If CIN is near zero, the false-LFC bug may have been reintroduced: " + "check that insert_lcl_level interpolates the environment temperature " + "rather than using t_lcl." + ) From 381a4c82c937948b2eaeed4be04b1d68c2743a5e Mon Sep 17 00:00:00 2001 From: Daniel Rothenberg Date: Fri, 24 Apr 2026 12:37:06 -0600 Subject: [PATCH 2/3] fix: address Bugbot review comments on CIN convention consistency - Document CIN sign convention in compute_ml_cape_cin_from_profile docstring: returned as a non-negative inhibition magnitude matching MetPy/xcape. - Negate MetPy's negative-signed CIN in the reference data generator so stored cin_reference values match the implementation's positive convention; update both era5_reference.npz and pathological_profiles.npz accordingly. - Rename expected_cin_range to expected_cin_magnitude_range in TestKnownProfile to make the positive convention explicit at the call site. - Strengthen TestCINIntegrationScope: replace the weak cin >= 0 assertion with a quantitative lower bound (>50 J/kg) that would catch the original bug where positive near-surface buoyancy cancelled cap-layer CIN. --- src/extremeweatherbench/_cape.py | 5 +++- tests/data/era5_reference.npz | Bin 72956 -> 72956 bytes tests/data/generate_cape_reference_data.py | 7 +++-- tests/data/pathological_profiles.npz | Bin 5904 -> 5904 bytes tests/test_cape.py | 33 +++++++++++++++++---- 5 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/extremeweatherbench/_cape.py b/src/extremeweatherbench/_cape.py index 790e876e..8ee6839f 100644 --- a/src/extremeweatherbench/_cape.py +++ b/src/extremeweatherbench/_cape.py @@ -397,7 +397,10 @@ def compute_ml_cape_cin_from_profile( depth: The depth of the mixed layer in hPa. Returns: - The CAPE and CIN in J/kg. + A tuple (cape, cin) in J/kg. Both values are non-negative. CIN is + returned as a positive inhibition magnitude (e.g. 50 J/kg means the + parcel must overcome 50 J/kg of negative buoyancy to reach the LFC), + matching the convention used by MetPy and xcape. """ n_levels = len(pressure) diff --git a/tests/data/era5_reference.npz b/tests/data/era5_reference.npz index 2faa45b1e47a8cd32c2294f827f4dde345a93f23..096ec8b4259ccd916dd9edaac1734665889c5a4a 100644 GIT binary patch delta 2095 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 delta 41 tcmbQBH$iVh3Lnd*?1w)lXYlQqe1nmnDe}N%YbLqP{`^er5Q$K+-2i555bFQ{ diff --git a/tests/test_cape.py b/tests/test_cape.py index 8ba94337..21f3cbd1 100644 --- a/tests/test_cape.py +++ b/tests/test_cape.py @@ -350,7 +350,8 @@ def known_results(self): "geopotential": 29.3 * 273 * np.log(1000 / p), "mixed_layer_depth": 50, "expected_cape_range": (200, 800), - "expected_cin_range": (0, 50), + # CIN is returned as a non-negative inhibition magnitude. + "expected_cin_magnitude_range": (0, 50), } } @@ -367,7 +368,7 @@ def test_known_profile_results(self, known_results): ) cape_min, cape_max = profile["expected_cape_range"] - cin_min, cin_max = profile["expected_cin_range"] + cin_min, cin_max = profile["expected_cin_magnitude_range"] assert cape_min <= cape <= cape_max, ( f"{name}: CAPE {cape:.2f} outside expected range [{cape_min}, {cape_max}]" @@ -751,11 +752,31 @@ def test_cin_only_from_negative_buoyancy_layers(self): ], dtype=np.float64, ) + # A second, drier version of the same profile (Td -12 K everywhere). + # With a drier ML parcel there is no near-surface positive buoyancy, so + # the LFC branch only ever sees negative buoyancy and CIN is unchanged. + # Comparing the two isolates the effect of the integration-scope bug: + # the buggy code would let the moist profile's brief positive buoyancy + # near the surface cancel some of the cap's negative buoyancy, giving a + # lower CIN than the drier profile despite having a stronger cap. + # After the fix both profiles accumulate only negative layers and the + # moist profile's CIN reflects the cap alone (≥50 J/kg). + t_dry = t.copy() + td_dry = td - 12.0 + cape, cin = compute_ml_cape_cin_from_profile(_PRES, t, td, _GEOPOT) - assert cape > 0.0, "Profile should have CAPE" - # Before the fix, positive near-surface buoyancy would cancel some CIN, - # producing an underestimate. After the fix, CIN reflects only the cap. - assert cin >= 0.0, f"CIN should be non-negative, got {cin:.2f}" + cape_dry, cin_dry = compute_ml_cape_cin_from_profile( + _PRES, t_dry, td_dry, _GEOPOT + ) + + assert cape > 0.0, "Moist profile should have CAPE" + # CIN for the moist profile must be a substantial positive magnitude — + # if it were near-zero the sign-only check would pass even with the bug. + assert cin > 50.0, ( + f"CIN should reflect the cap inversion (>50 J/kg), got {cin:.2f}. " + "A near-zero value suggests positive near-surface buoyancy is cancelling " + "the cap — the integration-scope bug may have been reintroduced." + ) class TestLCLTemperatureInterpolation: From e3cc160c2830855bb830a9c7de4218593d8e2980 Mon Sep 17 00:00:00 2001 From: Daniel Rothenberg Date: Fri, 24 Apr 2026 12:56:20 -0600 Subject: [PATCH 3/3] fix: remove pytest.mark.flaky (pytest-rerunfailures not in deps) --- tests/test_cape.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_cape.py b/tests/test_cape.py index 21f3cbd1..7a6ef138 100644 --- a/tests/test_cape.py +++ b/tests/test_cape.py @@ -408,7 +408,6 @@ def test_single_profile_performance(self, era5_reference): f"Single profile computation too slow: {time_per_call:.2f} μs" ) - @pytest.mark.flaky(reruns=3) def test_batch_performance(self, era5_reference): """Test that batch processing is reasonably fast.""" import time