cle15 package

Submodules

cle15.bench_cle15 module

Benchmark and accuracy comparison:

cle15.py (pure-Python reference) cle15n.py (numba-accelerated) cle15m.py (octave/matlab – run last, limited samples, very slow)

Usage:

python -m w22.bench_cle15

cle15.bench_precision module

Accuracy vs. cost trade-off for the numba CLE15 solver.

Sweeps each resolution / iteration knob independently while holding the others at their current default values. Reports timing (ms/call) and accuracy (% rmax error relative to the pure-Python reference) for a filtered benchmark grid (Vmax 20-90 m/s, r0 200-2000 km, f in {3, 5, 7} x 1e-5 s^-1), with one degenerate region pre-filtered:

  • Ro = Vmax/(f*r0) < 0.2: low-Rossby rotation-dominated regime (currently Vmax=20 m/s, r0 in {1600, 2000} km, f=7e-5 s^-1)

Cases with inner Rossby number Ro_in = Vmax/(f*rmax) > 2000 (currently only Vmax=90 m/s, r0=200 km, f=3e-5 s^-1) return NaN from both solvers; NaN-vs-NaN is counted as correct agreement, not a failure.

Usage:

python -m cle15.bench_precision          # print tables only
python -m cle15.bench_precision --plot   # also save figures

The three knobs investigated are:

  1. num_pts_er11 – ER11 points inside the bisection kernel (default 5 000)

  2. nx_intersect – intersection-check grid points (default 4 000)

  3. max_iter – bisection iterations (default 50)

Note: Nr_e04 (E04 Euler grid points) is not swept because the E04 function clamps Nr at int(1/drfracr0) = 1 000 for normal r0 values (10 000 for r0 < 200 km or r0 > 2 500 km), so varying it above that cap has no effect on either timing or accuracy.

class cle15.bench_precision.SweepResult(name, values, ms_per_call=<factory>, mean_err=<factory>, max_err=<factory>, n_fail=<factory>)

Bases: object

max_err: List[float]
mean_err: List[float]
ms_per_call: List[float]
n_fail: List[int]
name: str
values: List
cle15.bench_precision.main()
Return type:

None

cle15.bench_precision.plot_results(sweeps, ref_rmax, rmaxes_fast, rmaxes_default)

Produce and save three figures that show where precision matters most.

Parameters:
  • sweeps (list) – List of three SweepResult objects (num_pts_er11, nx_intersect, max_iter) as returned by the three _sweep calls.

  • ref_rmax (ndarray) – Array of reference rmax values (km) for all 75 cases.

  • rmaxes_fast (ndarray) – Numba rmax estimates at the fast preset for each case.

  • rmaxes_default (ndarray) – Numba rmax estimates at the default preset for each case.

Return type:

None

cle15.cle15 module

Python translation of MATLAB code for calculating the Chavas et al. (2015) tropical cyclone wind profile, merging Emanuel & Rotunno (2011) inner profile and Emanuel (2004) outer profile, using r0 as input.

Translated by Gemini-2.5-pro (experimental) on 2025-05-05.

Based on MATLAB scripts: - ER11E04_nondim_r0input.m - E04_outerwind_r0input_nondim_MM0.m - ER11_radprof.m - ER11_radprof_raw.m - curveintersect.m (simplified implementation) - radprof_eyeadj.m - CLE15_plot_r0input.m (for example usage)

References

  • Chavas, D. R., Lin, N., & Emanuel, K. (2015). A model for tropical cyclone wind speed and rainfall profiles with physical interpretations. Journal of the Atmospheric Sciences, 72(9), 3403-3428.

  • Emanuel, K., & Rotunno, R. (2011). Self-Stratification of Tropical Cyclone Outflow. Part I: Implications for Storm Structure. Journal of the Atmospheric Sciences, 68(10), 2236-2249.

  • Emanuel, K. (2004). Tropical Cyclone Energetics and Structure. In Atmospheric Turbulence and Mesoscale Meteorology (pp. 165-191). Cambridge University Press.

cle15.cle15.chavas_et_al_2015_profile(Vmax, r0, fcor, Cdvary, C_d, w_cool, CkCdvary, CkCd_input, eye_adj, alpha_eye)

Calculates the Chavas et al. (2015) merged tropical cyclone wind profile.

Merges the Emanuel (2004) outer profile and Emanuel & Rotunno (2011) inner profile by finding a tangent point in M/M0 vs r/r0 space.

Parameters:
  • Vmax (float) – Target maximum wind speed (m/s).

  • r0 (float) – Outer radius where V=0 (m).

  • fcor (float) – Coriolis parameter (s^-1).

  • Cdvary (int) – 0: Outer region Cd = C_d; 1: Outer region Cd = f(V).

  • C_d (float) – Surface drag coefficient in outer region (if Cdvary=0).

  • w_cool (float) – Radiative-subsidence rate (m/s).

  • CkCdvary (int) – 0: Inner region Ck/Cd = CkCd_input; 1: Inner region Ck/Cd = f(Vmax).

  • CkCd_input (float) – Ratio Ck/Cd in inner region (if CkCdvary=0).

  • eye_adj (int) – 0: Use raw ER11 profile in eye; 1: Apply adjustment.

  • alpha_eye (float) – Exponent for eye adjustment (if eye_adj=1).

Returns:

(rr, VV, rmax, rmerge, Vmerge, rrfracr0, MMfracM0, rmaxr0,

MmM0, rmerger0, MmergeM0)

  • rr (np.ndarray): Radius vector (m).

  • VV (np.ndarray): Wind speed vector (m/s).

  • rmax (float): Radius of maximum wind (m).

  • rmerge (float): Radius of merge point (m).

  • Vmerge (float): Wind speed at merge point (m/s).

  • rrfracr0 (np.ndarray): Non-dim radius vector r/r0.

  • MMfracM0 (np.ndarray): Non-dim angular momentum M/M0.

  • rmaxr0 (float): Non-dim rmax/r0.

  • MmM0 (float): Non-dim M/M0 at rmax.

  • rmerger0 (float): Non-dim rmerge/r0.

  • MmergeM0 (float): Non-dim M/M0 at rmerge.

Returns NaNs for values if calculation fails.

Return type:

tuple

cle15.cle15.process_inputs(inputs)

Process the input parameters for the CLE15 model.

Parameters:

inputs (dict) – Input parameters.

Returns:

Processed input parameters.

Return type:

dict

cle15.cle15.profile_from_stats(vmax, fcor, r0, p0, rho0=1.225, pressure_assumption='isothermal')

Run the CLE15 model with given parameters and return the wind profile. This function is a wrapper around the _run_cle15_octave function.

Parameters:
  • vmax (float) – Maximum wind speed [m/s].

  • fcor (float) – Coriolis parameter [s-1].

  • r0 (float) – Radius of the storm [m].

  • p0 (float) – Background pressure [hPa].

  • rho0 (float, optional) – Density of air [kg/m^3]. Defaults to RHO_AIR_DEFAULT.

  • pressure_assumption (str, optional) – Assumption for pressure calculation. Defaults to “isothermal”.

Returns:

Dictionary containing the wind profile and pressure profile. The dictionary contains the following keys:

  • ”rr”: Radius vector [m].

  • ”VV”: Wind speed vector [m/s].

  • ”rmax”: Radius of maximum wind [m].

  • ”rmerge”: Radius of merge point [m].

  • ”Vmerge”: Wind speed at merge point [m/s].

  • ”p”: Pressure profile [hPa].

Return type:

dict

cle15.cle15.run_cle15(plot=False, inputs=None, rho0=1.225, pressure_assumption='isothermal')

Run the CLE15 model.

Parameters:
  • plot (bool, optional) – Plot the output. Defaults to False.

  • inputs (Optional[Dict[str, any]], optional) – Input parameters. Defaults to None.

Returns:

pm [Pa], rmax [m], pc [Pa]

Return type:

Tuple[float, float, float]

cle15.cle15m module

CLE15 model for matlab/octave version.

cle15.cle15m.delete_tmp()

Delete temporary folder.

cle15.cle15m.get_unique_folder()

Create a unique folder for this run. The folder is created in the TMPS_PATH directory. The folder name is based on the hostname, process ID, and a UUID. This ensures that the folder name is unique and does not collide with other folders created by other processes or runs.

Returns:

The path to the unique folder.

Return type:

str

cle15.cle15m.process_inputs(inputs)

Process the input parameters for the CLE15 model.

Parameters:

inputs (dict) – Input parameters.

Returns:

Processed input parameters.

Return type:

dict

cle15.cle15m.profile_from_stats(vmax, fcor, r0, p0, pressure_assumption='isothermal')

Run the CLE15 model with given parameters and return the wind profile. This function is a wrapper around the _run_cle15_octave function.

Parameters:
  • vmax (float) – Maximum wind speed [m/s].

  • fcor (float) – Coriolis parameter [s-1].

  • r0 (float) – Radius of the storm [m].

  • p0 (float) – Background pressure [hPa].

Returns:

Dictionary containing the wind profile and pressure profile.

Return type:

dict

cle15.cle15m.run_cle15(plot=False, inputs=None, rho0=1.225, pressure_assumption='isopycnal')

Run the CLE15 model.

Parameters:
  • plot (bool, optional) – Plot the output. Defaults to False.

  • inputs (Optional[Dict[str, any]], optional) – Input parameters. Defaults to None.

Returns:

pm [Pa], rmax [m], pc [Pa]

Return type:

Tuple[float, float, float]

cle15.cle15n module

Numba-accelerated version of the Chavas et al. (2015) TC wind profile.

This module is a drop-in replacement for w22.cle15 that uses numba JIT compilation to speed up the three main hot-spots:

  1. The E04 Euler integration loop (~200 000 iterations, formerly pure-Python)

  2. The ER11 profile formula evaluation (vectorised, now inside numba)

  3. The outer bisection over rmaxr0 (~50 iterations × ER11 cost) including the curve-intersection check, all executed inside a single @njit kernel so there is no Python overhead between iterations.

The public API is identical to w22.cle15:

References

  • Chavas et al. (2015) JAS 72, 3403-3428.

  • Emanuel & Rotunno (2011) JAS 68, 2236-2249.

  • Emanuel (2004) in Atmospheric Turbulence and Mesoscale Meteorology.

class cle15.cle15n.SolverConfig(Nr_e04=200000, num_pts_er11=5000, nx_intersect=4000, max_iter=50)

Bases: object

Resolution / iteration knobs for the numba CLE15 solver.

All four parameters control accuracy vs. run-time. The table below summarises the sweep results from bench_precision.py (75-case benchmark, rmax error relative to the pure-Python reference):

Preset

ms/call

mean err%

max err%

fails

"fast" "default" "precise"

~0.8 ~6-7

~13

~0.82 ~0.27 ~0.13

~3.0 ~1.2 ~1.0

0/75 0/75 0/75

Knob summary (from individual sweeps, all others held at default):

Nr_e04 — E04 Euler grid points

Accuracy is insensitive to Nr_e04 across the full range tested (1 000 – 500 000): mean rmax error stays at 0.265 % throughout. Cost is also flat (~6 ms). The default 200 000 can safely be reduced to 10 000 with no accuracy penalty.

num_pts_er11 — ER11 profile points inside the bisection kernel

The dominant cost and accuracy driver. Halving from 5 000 to 2 000 saves ~2× time (6 → 2.7 ms) with only a small accuracy cost (0.27 % → 0.40 % mean). Going to 500 points gives ~6× speedup at the cost of ~0.82 % mean error (max ~3 %).

nx_intersect — intersection check grid points

Very weak effect on accuracy beyond 200 points. Cost is also nearly flat (6.0 – 6.8 ms). Reducing from 4 000 to 500 is free.

max_iter — bisection iterations

Below 15 iterations the solver degrades sharply (44 % mean error at 5 iterations). At 15 iterations (~5 ms) accuracy is already within 0.30 % mean. Going above 20 gives negligible improvement.

Parameters:
  • Nr_e04 (int) – Number of points in the E04 Euler integration grid.

  • num_pts_er11 (int) – Number of ER11 profile points evaluated inside each bisection step.

  • nx_intersect (int) – Number of points in the common grid used for the curve-intersection check inside the bisection kernel.

  • max_iter (int) – Maximum number of bisection iterations.

Nr_e04: int = 200000
classmethod default()

Current default settings; rmax mean error ~0.27 %, max ~1.2 %.

Return type:

SolverConfig

classmethod fast()

~7.5× faster than default; rmax mean error ~0.82 %, max ~3 %.

Recommended for large ensemble runs where speed matters more than sub-percent accuracy in rmax.

Return type:

SolverConfig

max_iter: int = 50
num_pts_er11: int = 5000
nx_intersect: int = 4000
classmethod precise()

~2× slower than default; rmax mean error ~0.13 %, max ~1.0 %.

Useful for reference comparisons or validating the fast preset.

Return type:

SolverConfig

cle15.cle15n.chavas_et_al_2015_profile(Vmax, r0, fcor, Cdvary, C_d, w_cool, CkCdvary, CkCd_input, eye_adj, alpha_eye, solver=None)

Chavas et al. (2015) merged TC wind profile — numba-accelerated.

API is identical to w22.cle15.chavas_et_al_2015_profile(), with one additional optional argument.

Parameters:

solver (SolverConfig, optional) – Resolution / iteration settings. Defaults to SolverConfig() (the “default” preset). Pass SolverConfig.fast() for ~7.5× faster evaluation at ~0.8 % rmax accuracy, or SolverConfig.precise() for ~2× slower but ~0.13 % accuracy.

Return type:

Tuple[ndarray[Any, dtype[TypeVar(_ScalarType_co, bound= generic, covariant=True)]], ndarray[Any, dtype[TypeVar(_ScalarType_co, bound= generic, covariant=True)]], float, float, float, ndarray[Any, dtype[TypeVar(_ScalarType_co, bound= generic, covariant=True)]], ndarray[Any, dtype[TypeVar(_ScalarType_co, bound= generic, covariant=True)]], float, float, float, float]

Examples

>>> from cle15.cle15n import chavas_et_al_2015_profile, SolverConfig
>>> res = chavas_et_al_2015_profile(
...     50.0, 800e3, 5e-5, 0, 1.5e-3, 2e-3, 0, 0.9, 0, 0.5,
...     solver=SolverConfig.fast(),
... )
>>> rmax_km = res[2] / 1e3          # rmax in km
>>> 5 < rmax_km < 300               # sanity: physically plausible rmax
True
cle15.cle15n.process_inputs(inputs)

Process inputs — identical to cle15.process_inputs.

Return type:

dict

cle15.cle15n.profile_from_stats(vmax, fcor, r0, p0, rho0=1.225, pressure_assumption='isothermal', solver=None)

Same as cle15.profile_from_stats but uses the numba-accelerated kernel.

Parameters:

solver (SolverConfig, optional) – Resolution settings. Defaults to SolverConfig() (default preset).

Return type:

dict

cle15.cle15n.run_cle15(plot=False, inputs=None, rho0=1.225, pressure_assumption='isothermal', solver=None)

Run the numba-accelerated CLE15 model — same API as cle15.run_cle15.

Parameters:

solver (SolverConfig, optional) – Resolution settings. Defaults to SolverConfig() (default preset).

Return type:

Tuple[float, float, float]

cle15.cle15n.warmup()

Trigger numba JIT compilation by running one cheap profile.

Call this explicitly before benchmarking so spin-up cost is not counted against the timed runs.

cle15.constants module

Constants for the cle15 package.

Re-exports all symbols from w22.constants so that the moved cle15 modules can use from .constants import ... without change.

SRC_PATH is overridden to point at this package’s own directory (i.e. cle15/) so that cle15.cle15m can locate mcle/ via os.path.join(SRC_PATH, 'mcle', ...).

cle15.test_cle15 module

Test suite for the CLE15 tropical cyclone wind profile implementations.

Covers: - cle15.cle15 (pure-Python reference) - cle15.cle15n (numba-accelerated drop-in replacement)

Tests are grouped as:

  1. TestChavasProfile – unit tests for chavas_et_al_2015_profile

  2. TestProcessInputs – unit tests for process_inputs

  3. TestRunCle15 – smoke tests for run_cle15

  4. TestProfileFromStats – smoke tests for profile_from_stats

  5. TestNbConsistency – numerical agreement between the two implementations

  6. TestEdgeCases – degenerate / boundary inputs

  7. TestSolverConfig – SolverConfig dataclass and presets

  8. TestNbRobustness – known failure modes in the numba solver

    (these tests are expected to FAIL until the kernel-level convergence is fixed; they are marked xfail so the suite stays green while the regressions are tracked)

  9. TestNanHandling – NaN propagation and failure-mode behaviour,

    derived from the MATLAB reference implementation

  10. TestMatlabRegression – rmax cross-validated against Octave

    (ER11E04_nondim_r0input.m, same Python-default parameters: Cdvary=0, CkCd=0.9)

Run with:

pytest cle15/test_cle15.py -v

or as part of the full suite:

pytest
class cle15.test_cle15.TestChavasProfile

Bases: object

Unit tests for chavas_et_al_2015_profile in both implementations.

test_eye_adjustment_changes_profile(mod)

Enabling eye adjustment (eye_adj=1) should change the inner wind profile.

test_negative_fcor_same_as_positive(mod)

The Southern Hemisphere sign convention (negative fcor) should work.

test_profile_arrays_shape_and_finite(mod, Vmax, r0, fcor)

rr and VV must be 1-D arrays of equal length with mostly finite values.

test_radius_vector_monotone(mod, Vmax, r0, fcor)

rr must be strictly monotonically increasing.

test_returns_finite_values(mod, Vmax, r0, fcor)

All scalar outputs must be finite for valid inputs.

test_rmax_within_r0(mod, Vmax, r0, fcor)

rmax must be strictly less than r0.

test_rmaxr0_nondim_consistent(mod, Vmax, r0, fcor)

rmaxr0 must equal rmax / r0 to within 1 %.

test_rmerge_between_rmax_and_r0(mod, Vmax, r0, fcor)

rmerge must sit between rmax and r0.

test_vmax_in_profile(mod, Vmax, r0, fcor)

Peak wind speed in the output profile must be within 2 % of target Vmax.

test_wind_non_negative(mod, Vmax, r0, fcor)

All finite wind speeds in the output profile must be >= 0.

class cle15.test_cle15.TestEdgeCases

Bases: object

Degenerate and boundary inputs.

test_cdvary_mode(mod)

Cdvary=1 (wind-speed-dependent Cd) should produce a valid profile.

test_ckcdvary_mode(mod)

CkCdvary=1 (Vmax-dependent Ck/Cd) should produce a valid profile.

test_high_latitude_fcor(mod)

High Coriolis (fcor=1e-4, ~45°) should still converge.

test_profile_from_stats_returns_valid(mod)

profile_from_stats must not raise for a standard set of inputs.

test_very_large_storm(mod)

Large r0 (1500 km) should converge.

test_very_small_storm(mod)

Small r0 (200 km) should still converge without raising.

class cle15.test_cle15.TestMatlabRegression

Bases: object

Cross-validate rmax against MATLAB/Octave reference (ER11E04_nondim_r0input.m).

test_rmax_matches_octave(Vmax, r0, fcor, oct_rmax_km, tol_pct, label)

Python rmax must agree with Octave reference to within tol_pct %.

For the degenerate case (Vmax=90, r0=200km, f=3e-5) the inner Rossby number Ro_in > 2000 and the solver now returns NaN rather than an unreliable value, so we assert NaN instead of a proximity check.

class cle15.test_cle15.TestNanHandling

Bases: object

NaN propagation and graceful-failure tests derived from the MATLAB reference implementation (ER11E04_nondim_r0input.m, ER11_radprof.m).

MATLAB behaviour under failure conditions

ER11_radprof.m (outer convergence loop, max 20 iterations):

If the nested (rmax, Vmax) loop does not converge within 20 outer iterations the function returns V_ER11 = NaN(size(rr_ER11)) and r_out = NaN. The caller (ER11E04_nondim_r0input.m) detects isnan(max(VV_ER11)) and treats the bisection step as “rmax too small” — nudging the bisection upward.

ER11E04_nondim_r0input.m (soln_converged outer loop):

If the bisection converges without ever finding an intersection (~exist('rmerger0', 'var')), soln_converged stays 0 and CkCd is incremented by 0.1. The MATLAB code does not cap CkCd internally (beyond the 1.9 cap applied at entry), so in principle it could loop indefinitely; the Python translation caps at CkCd ≥ 3.0 and returns NaN arrays.

run_cle15 / profile_from_stats (Python only):

cle15n.run_cle15 adds explicit NaN guards absent from the MATLAB code (which simply crashes or returns NaN arrays that propagate silently into the pressure integral). The pure-Python cle15.run_cle15 zeroes out NaN winds before integration, which can yield a finite-but-wrong pressure rather than NaN. These tests document and pin that asymmetry.

Test sub-groups

  1. ER11_radprof non-convergence → NaN arrays returned

  2. CkCd upward-nudge fallback (MATLAB soln_converged loop)

  3. run_cle15 NaN propagation

  4. profile_from_stats NaN propagation

  5. chavas_et_al_2015_profile NaN output structure

  6. Pressure-assumption asymmetry between run_cle15 and profile_from_stats

test_ckcd_at_cap_19_does_not_raise(mod)

MATLAB caps CkCd at 1.9 with a warning but does not error. CkCdvary=1 with Vmax=5 m/s gives CkCd ≈ 0.62 (fine); with Vmax=200 m/s the quadratic gives > 1.9, which both MATLAB and Python must cap and continue.

test_ckcd_nudge_fallback_produces_finite_result(mod)

MATLAB increments CkCd by 0.1 when the bisection converges without ever finding an intersection (ER11 below E04 for all rmaxr0 in [0.001, 0.75]). This can happen at very low CkCd. Both implementations must return a finite rmax rather than NaN.

CkCd = 0.3 (below the typical lower bound of 0.5 that MATLAB commented out) with a mid-size storm reliably triggers the fallback.

test_ckcd_nudge_fallback_rmax_physical(mod)

After CkCd nudge the resulting rmax must still be < r0.

test_cle15n_run_cle15_nan_guard_high_nan_fraction()

cle15n.run_cle15 must return (NaN, NaN, NaN) when more than 10 % of the wind profile is NaN (indicating a failed solve), matching the intent of MATLAB’s isnan(max(VV_ER11)) check on the bisection step.

test_cle15n_run_cle15_nan_guard_rmax_nan()

cle15n.run_cle15 must return (NaN, NaN, NaN) when the solver returns NaN for rmax. We verify this by monkeypatching chavas_et_al_2015_profile inside cle15n to return an all-NaN result, simulating the solver-failure path that MATLAB would produce (V_ER11 = NaN(…) from ER11_radprof.m with no soln_converged).

test_cle15n_run_cle15_nan_guard_rmerge_equals_r0()

cle15n.run_cle15 must return (NaN, NaN, NaN) when rmerge == r0 (degenerate merge — no interior tangent found). MATLAB would return a meaningless profile (VV based on a bad rmerge); Python cle15n explicitly guards against this.

test_er11_radprof_nan_propagates_to_profile()

When ER11 returns NaN for a bisection step the MATLAB bisection treats it as ‘rmaxr0 too small’ and nudges upward; it does NOT propagate NaN straight to the output. The final profile must therefore still be finite for the default (Vmax=50, r0=800 km) case even though individual bisection steps may silently fail.

test_er11_radprof_nan_when_ckcd_zero()

CkCd = 0 makes the ER11 formula degenerate (exponent 1/(2-0)=0.5 is fine, but combined with extremely high Rossby number the nested loop fails within 20 iterations and must return NaN arrays — matching MATLAB ER11_radprof.m line: V_ER11 = NaN(size(rr_ER11)).

test_pressure_assumption_asymmetry_is_consistent_within_each_function()

run_cle15 defaults to ‘isopycnal’; profile_from_stats defaults to ‘isothermal’. The two assumptions give different pressures for the same inputs. This test verifies the asymmetry is real and stable — i.e. the two functions do NOT accidentally agree, and that each function returns the same answer when called twice (determinism).

test_pressure_assumption_consistent_when_matched()

When both functions are called with the same pressure assumption (isothermal, passed explicitly) their central pressures must agree to within a few hPa for the same (Vmax, r0, fcor) inputs.

A tight (< 1 hPa) agreement is not achievable because the two functions integrate pressure along different radial grids: run_cle15 uses the merged CLE15 profile on a coarse grid tuned for the bisection solver, while profile_from_stats uses a finer user-specified grid. The resulting ~2.7 hPa offset is systematic (independent of pressure assumption) and is documented here as the expected tolerance. If either function changes its pressure integration this test will detect regressions.

test_profile_from_stats_finite_standard_case(mod)

MATLAB example (Vmax=50, r0=900 km, fcor=5e-5) must produce a finite pressure profile in profile_from_stats for both implementations.

test_profile_nan_structure_on_impossible_vmax(mod)

Vmax=0 m/s is unphysical (no storm). Both implementations must return a NaN result tuple (with a warning) rather than crashing.

MATLAB reference behaviour: when Vmax=0 the ER11 rmax-r0 solve produces empty roots, so MATLAB would error without a graceful return.

Fix: chavas_et_al_2015_profile now guards Vmax<=0 at entry and returns the NaN sentinel with a warning in both modules.

test_profile_nan_structure_on_zero_fcor(mod)

fcor=0 (equator) makes M0=0 and the ER11 power-law ratio singular. Both implementations must return a NaN result tuple (with a warning) rather than crashing.

MATLAB reference behaviour: syms solve returns an empty root set for this degenerate case, effectively erroring without graceful output.

Fix: chavas_et_al_2015_profile now guards fcor==0 at entry (after fcor = abs(fcor)) and returns the NaN sentinel with a warning in both modules, avoiding the ZeroDivisionError that previously occurred deep inside the ER11 power-law computation.

test_profile_nan_structure_on_zero_r0(mod)

r0=0 is unphysical (no outer boundary). Both implementations must return a NaN result tuple (with a warning) rather than crashing.

MATLAB reference behaviour: M0 = 0.5*fcor*r0^2 = 0, the symbolic solve returns no valid roots, so MATLAB would error here.

Fix: chavas_et_al_2015_profile now guards r0<=0 at entry and returns the NaN sentinel with a warning in both modules.

test_profile_nan_structure_on_zero_wcool(mod)

w_cool = 0 makes the E04 gamma parameter infinite (Cd*f*r0/0). MATLAB would produce NaN in the E04 integration; Python must handle this gracefully (either NaN output or a warning, not a crash).

test_run_cle15_finite_for_standard_inputs(mod)

MATLAB’s CLE15_plot_r0input.m uses Vmax=50, r0=900 km, fcor=5e-5. Both implementations must return finite (pm, rmax, pc).

test_run_cle15_py_nan_guard_missing_but_documented()

Document the known asymmetry: cle15.run_cle15 does NOT have an explicit rmax-NaN guard, while cle15n.run_cle15 does.

When chavas_et_al_2015_profile returns a valid profile for the standard MATLAB example inputs, run_cle15 must return finite values in both cases (the guard difference only matters under solver failure). This test pins the current behaviour: both return finite results for valid inputs, so the missing guard is not yet observable here.

class cle15.test_cle15.TestNbConsistency

Bases: object

Verify that cle15n produces results numerically consistent with cle15.

Thresholds are deliberately generous (matching observed benchmark spread):
  • rmax: within 2 % relative

  • rmerge: within 3 % relative

  • Vmerge: within 0.5 m/s absolute

  • Profile RMS: within 0.02 m/s

test_profile_rms(Vmax, r0, fcor)

RMS difference of wind profiles on a common grid must be < 0.05 m/s.

The 0.05 m/s threshold reflects the residual difference from the simplified 10-iteration ER11 loop inside the compiled numba bisection kernel; all other algorithm choices are identical between the two implementations.

test_rmax_agreement(Vmax, r0, fcor)
test_rmerge_agreement(Vmax, r0, fcor)
test_run_cle15_consistent()

run_cle15 pm and pc must agree within 50 Pa between implementations.

test_vmerge_agreement(Vmax, r0, fcor)
class cle15.test_cle15.TestNbRobustness

Bases: object

Regression tests for known numba solver failure modes (now fixed).

Root cause (fixed)

The compiled kernel _bisect_rmaxr0_nb clipped the ER11 profile at r <= r0 before the curve-intersection check. Since the E04 outer profile also terminates at r/r0 = 1.0, both curves shared an endpoint at the boundary. When the ER11 M/M0 exceeded 1.0 in the outer region (possible for large rmaxr0 guesses) and then dropped back to 1.0 at the boundary, a spurious sign-change crossing was detected there — locking the bisection to an unphysical small-rmax solution.

Fix: the ER11 grid is now clipped to r < r0 × (1 - 1e-6) (strictly less than r0), so the boundary point is never included and the spurious crossing cannot occur. The bisection then correctly finds the physical interior tangent point.

These tests document the previously failing (Vmax, r0, fcor) combinations and verify they now produce physically correct results.

test_nb_failure_rate_in_bisection_range()

Verify zero failure rate of the numba solver over the ps.py bisection range.

Sweeps r0 ∈ [1.9, 2.4] Mm at Vmax=49.5 m/s, fcor at 25°N (the same range used by w22/ps.py for a typical tropical storm input). Previously ~19 % of r0 values returned NaN due to the spurious boundary-crossing bug. The fix (excluding the r=r0 grid endpoint) reduces this to 0 %.

test_nb_profile_rmerge_not_degenerate(Vmax, r0, fcor)

numba chavas_et_al_2015_profile must not return rmerge == r0.

test_nb_run_cle15_agrees_with_python(Vmax, r0, fcor)

numba run_cle15 pm must agree with pure-Python to within 100 Pa.

test_nb_run_cle15_finite(Vmax, r0, fcor)

numba run_cle15 must return finite pm for all failure-case inputs.

class cle15.test_cle15.TestProcessInputs

Bases: object

Unit tests for process_inputs in both implementations.

test_defaults_populated(mod)

Calling process_inputs({}) should populate all required keys.

test_p0_range_check(mod)

p0 outside [900, 1100] hPa should raise AssertionError.

test_unknown_key_ignored(mod)

Unknown keys in the input dict should not raise an error.

test_user_override_respected(mod)

A user-supplied value should override the default.

class cle15.test_cle15.TestProfileFromStats

Bases: object

Smoke tests for profile_from_stats in both implementations.

test_arrays_same_length(mod)

rr, VV, and p must all have the same length.

test_pressure_in_hpa(mod)

Pressure array p should be in hPa (i.e., between ~850 and 1050).

test_returns_required_keys(mod)

Output dict must contain rr, VV, rmax, rmerge, Vmerge, p.

class cle15.test_cle15.TestRunCle15

Bases: object

Smoke tests for the run_cle15 convenience wrapper.

test_custom_vmax_changes_output(mod)

A stronger storm (higher Vmax) should produce a lower central pressure.

test_pm_between_pc_and_background(mod)

pm (at rmax) should be between pc (centre) and background pressure.

test_pressure_ordering(mod)

Central pressure pc must be less than ambient (background) pressure.

test_returns_three_floats(mod)

run_cle15 must return a 3-tuple of finite floats (pm, rmax, pc).

class cle15.test_cle15.TestSolverConfig

Bases: object

Tests for the SolverConfig dataclass and named presets in cle15n.

test_custom_config()

A manually constructed SolverConfig should work without error.

test_default_fields()

SolverConfig() should expose the documented default values.

test_fast_is_faster_than_default()

SolverConfig.fast() should be measurably quicker than default (at least 2×).

test_fast_preset_fields()

SolverConfig.fast() should have smaller values than default.

test_fast_rmax_within_5pct_of_default(Vmax, r0, fcor)

Fast preset rmax must be within 5 % of the default preset rmax.

test_precise_preset_fields()

SolverConfig.precise() should have >= default values.

test_preset_produces_finite_rmax(preset, label)

All three presets must converge for a standard input.

test_solver_kwarg_passthrough_profile_from_stats()

profile_from_stats should accept and honour the solver kwarg.

test_solver_kwarg_passthrough_run_cle15()

run_cle15 should accept and honour the solver kwarg.

cle15.test_cle15.warmup_numba()

cle15.utils module

Utility re-exports for the cle15 package.

Re-exports w22.utils.pressure_from_wind() so that the moved cle15 modules can use from .utils import pressure_from_wind without change.

Module contents

cle15 — Chavas, Lin & Emanuel (2015) tropical cyclone wind profile.

Three implementations are provided:

All three expose the same public API: run_cle15, profile_from_stats, process_inputs.