slabx 1.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. slabx/__init__.py +0 -0
  2. slabx/coefficients.py +445 -0
  3. slabx/core/__init__.py +0 -0
  4. slabx/core/height_closure.py +237 -0
  5. slabx/core/plume.py +1286 -0
  6. slabx/core/puff.py +459 -0
  7. slabx/core/source.py +617 -0
  8. slabx/core/trajectory.py +450 -0
  9. slabx/core/vertical_jet.py +326 -0
  10. slabx/io/__init__.py +0 -0
  11. slabx/post/__init__.py +0 -0
  12. slabx/post/concentration.py +395 -0
  13. slabx/scope.py +145 -0
  14. slabx/submodels/__init__.py +0 -0
  15. slabx/submodels/added_mass.py +78 -0
  16. slabx/submodels/atmosphere.py +389 -0
  17. slabx/submodels/entrainment.py +520 -0
  18. slabx/submodels/ground.py +112 -0
  19. slabx/submodels/rainout.py +230 -0
  20. slabx/thermo/__init__.py +0 -0
  21. slabx/thermo/base.py +340 -0
  22. slabx/thermo/coolprop.py +581 -0
  23. slabx/thermo/equilibrium.py +334 -0
  24. slabx/thermo/mixture.py +176 -0
  25. slabx/validation/__init__.py +0 -0
  26. slabx/validation/_data_access.py +106 -0
  27. slabx/validation/data/provenance/README.md +146 -0
  28. slabx/validation/data/provenance/publication_status.csv +28 -0
  29. slabx/validation/data/provenance/source_manifest.csv +11 -0
  30. slabx/validation/data/provenance/variable_mapping.csv +28 -0
  31. slabx/validation/data/smedis_inventory.csv +29 -0
  32. slabx/validation/field_trials.py +469 -0
  33. slabx/validation/lng_pools.py +194 -0
  34. slabx/validation/metrics.py +345 -0
  35. slabx/validation/smedis.py +302 -0
  36. slabx/validation/thorney_island.py +186 -0
  37. slabx/validation/wind_tunnel.py +153 -0
  38. slabx-1.0.3.dist-info/METADATA +200 -0
  39. slabx-1.0.3.dist-info/RECORD +42 -0
  40. slabx-1.0.3.dist-info/WHEEL +5 -0
  41. slabx-1.0.3.dist-info/licenses/LICENSE +25 -0
  42. slabx-1.0.3.dist-info/top_level.txt +1 -0
slabx/__init__.py ADDED
File without changes
slabx/coefficients.py ADDED
@@ -0,0 +1,445 @@
1
+ """
2
+ Physical constants and externalized empirical coefficients.
3
+
4
+ Everything here that Ermak (1990) hard-coded is exposed as a field of a
5
+ frozen dataclass so that it can be (a) swapped in ablation studies,
6
+ (b) perturbed in uncertainty quantification, and (c) re-fitted against
7
+ validation data. No numeric literal from the original model should ever
8
+ appear inline in the physics modules.
9
+
10
+ Provenance is recorded per field. `EQ` refers to equation numbers in
11
+ UCRL-MA-105607 (Ermak 1990); `TNO` to CPR 14E chapter 4 (Yellow Book).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, replace, asdict, field
17
+ from typing import Any
18
+
19
+
20
+ # ===========================================================================
21
+ # Physical constants — not tunable
22
+ # ===========================================================================
23
+ @dataclass(frozen=True)
24
+ class Physical:
25
+ """Universal constants and fixed fluid properties (SLAB.FOR L325-406)."""
26
+
27
+ R_GAS: float = 8.31431 # J/(mol*K) gas constant
28
+ GRAVITY: float = 9.80665 # m/s^2
29
+ VON_KARMAN: float = 0.41 # - EQ 32
30
+ P_ATM: float = 101325.0 # Pa SLAB assumes 1 atm throughout
31
+
32
+ # dry air
33
+ MW_AIR: float = 0.02896 # kg/mol
34
+ CP_AIR: float = 1005.87 # J/(kg*K)
35
+
36
+ # water (built into SLAB; not user input)
37
+ MW_WATER: float = 0.01802 # kg/mol
38
+ RHO_WATER_LIQ: float = 1000.0 # kg/m^3
39
+ CP_WATER_VAP: float = 1846.0 # J/(kg*K)
40
+ CP_WATER_LIQ: float = 4178.0 # J/(kg*K)
41
+ DH_WATER: float = 2441000.0 # J/kg heat of vaporisation
42
+ SAT_A_WATER: float = 15.08 # - EQ 43a constants for water
43
+ SAT_B_WATER: float = 5514.0 # K
44
+
45
+
46
+ PHYS = Physical()
47
+
48
+
49
+ # ===========================================================================
50
+ # Empirical coefficients — tunable
51
+ # ===========================================================================
52
+ @dataclass(frozen=True)
53
+ class Coefficients:
54
+ """
55
+ Empirically fitted constants of the SLAB submodels.
56
+
57
+ Defaults reproduce Ermak (1990) exactly. Named variants live in
58
+ `PRESETS`. Use `.perturb(**kw)` or `.variant(name)` to obtain
59
+ modified sets; instances are frozen so a Trajectory can safely record
60
+ the exact set that produced it.
61
+ """
62
+
63
+ # -- entrainment -------------------------------------------------------
64
+ a_entrain: float = 1.5
65
+ """Vertical entrainment constant `a`, EQ 35a. Zeman (1982)."""
66
+
67
+ h_entrain_ref: float = 4.0
68
+
69
+ #: Vertical entrainment closure. ``"slab"`` is EQ 35a as Ermak wrote it,
70
+ #: with the fixed reference height above. ``"robins"`` and ``"nielsen"``
71
+ #: replace the ``urf/uah`` factor with a published Richardson-number form
72
+ #: that carries no absolute length; see `submodels/entrainment.py`.
73
+ #: Variants only -- the default reproduces the original.
74
+ entrainment_closure: str = "slab"
75
+
76
+ #: The other absolute length in the entrainment closures: EQ 36a
77
+ #: references its ambient-stability correction to the wind at a fixed
78
+ #: 2 m. Externalised for the Froude-similarity test.
79
+ h_horiz_ref: float = 2.0
80
+
81
+ #: Added-mass coefficient for a cloud rising broadside, ``C_A`` in
82
+ #: ``k = C_A (rho_a/rho)(B/h)``. The default is the potential-flow value
83
+ #: for an oblate spheroid, 2/pi, which is not fitted. Externalised so
84
+ #: that the Hall & Walker comparison can vary it; see
85
+ #: `docs/PREREG_added_mass.md`.
86
+ c_added_mass: float = 0.6366197723675814
87
+
88
+ #: Weight on the *ambient* term of the in-cloud Monin-Obukhov length,
89
+ #: EQ 35d. The default 1.0 is what SLAB does; setting it to zero leaves
90
+ #: only the cloud's own stratification, which is the controlled
91
+ #: intervention that turns the observed stability correlation into a
92
+ #: causal statement. See `docs/PREREG_stability.md`.
93
+ w_ambient_stratification: float = 1.0
94
+
95
+ #: Robins et al. (2001), Atmos. Environ. 35, 2243-2252:
96
+ #: ``W_E / u* = c_robins / (1 + b_robins Ri*)`` for Ri* < 15, measured
97
+ #: directly in a wind tunnel over a rough surface.
98
+ c_robins: float = 0.65
99
+ b_robins: float = 0.2
100
+
101
+ #: Nielsen (1998), Risoe-R-1030(EN):
102
+ #: ``u_e / e = c_nielsen / (a_nielsen + Ri)`` with
103
+ #: ``e = (u*^3 + 0.1 w*^3)^(1/3)``, constructed to meet all four
104
+ #: canonical-flow limits at once.
105
+ #:
106
+ #: The numerator is fixed by Nielsen's own passive limit rather than
107
+ #: taken on trust: at Ri = 0 in neutral air ``e = u*``, so ``u_e/u*``
108
+ #: must equal ``lambda_1 = 0.75``, giving ``c = 0.75 * 3.3 = 2.5``.
109
+ #: The form was first transcribed here with 0.25, which is a factor ten
110
+ #: below its own stated limit; the inconsistency is what found it.
111
+ c_nielsen: float = 2.5
112
+ a_nielsen: float = 3.3
113
+ """
114
+ Reference height in the vertical entrainment, EQ 35a [m].
115
+
116
+ ``W_e`` is scaled by ``U_r / U_a(h_top)`` with ``U_r`` the dimensionless
117
+ wind profile at this height. It is an *absolute* length in an otherwise
118
+ similarity-based closure, and it is the reason the model is not
119
+ Froude-similar: scaling a wind-tunnel release up by a factor 1000 in
120
+ length, with velocity and rate scaled to match, changes the predicted
121
+ concentration by 2.4 rather than leaving it fixed.
122
+
123
+ That matters because it means wind-tunnel and field results cannot be
124
+ transferred through the model, which is why the MEP requires wind-tunnel
125
+ trials to be run at tunnel scale rather than at their equivalent full
126
+ scale. Exposed here so the dependence can be measured; the default is
127
+ Ermak's hard-coded value.
128
+ """
129
+
130
+ weber_critical: float = 12.0
131
+ """
132
+ Critical Weber number for aerodynamic droplet break-up (rainout).
133
+
134
+ Sets the largest stable droplet, ``d = We sigma / (rho_g u^2)``, and
135
+ through it the settling velocity. Values of 10-20 appear in the
136
+ break-up literature depending on how break-up is defined; 12 is the usual
137
+ choice for the largest stable droplet.
138
+
139
+ It lives here rather than as a module constant because a module-level
140
+ default argument binds at import and cannot be swept — which silently
141
+ made an entire sensitivity study return the same answer five times.
142
+ """
143
+
144
+ rainout_efficiency: float = 1.0
145
+ """
146
+ Scaling on the rainout removal rate, ``v_t / (U h)``.
147
+
148
+ The droplet size comes from a measured break-up criterion and the
149
+ settling velocity from a drag balance, so neither is free. The removal
150
+ rate is the one modelling choice: it assumes a droplet has to fall the
151
+ full cloud depth and that the liquid is distributed uniformly through it,
152
+ both of which overstate how quickly droplets reach the ground.
153
+
154
+ Calibrated against the SMEDIS equivalent sources for Desert Tortoise —
155
+ the distance at which no liquid remains, 51 m for DT1 and 48 m for DT2 —
156
+ which is a droplet-lifetime measurement and carries no dispersion
157
+ statistics. Sweeping it, DT1 reaches its target at about 0.13 and DT2 at
158
+ about 0.45 — a factor three apart, which for a first calibration on two
159
+ trials at the resolution of the output grid is reasonable agreement, and
160
+ far tighter than the factor 200 that the surface roughness needed. Both
161
+ say the unscaled closure removes droplets several times too fast.
162
+
163
+ The default leaves the closure unscaled so that the calibration is
164
+ opt-in; `PRESETS["rainout_smedis"]` carries 0.25.
165
+ """
166
+
167
+ alpha_front: float = 0.0
168
+ """
169
+ Gravity-front entrainment coefficient (EQ 36a extension, not in Ermak).
170
+
171
+ SLAB's horizontal entrainment has two contributions — ambient turbulence
172
+ and jet shear — and no term driven by the gravity-spreading front itself.
173
+ Every other integral model of the same generation has one: DEGADIS takes
174
+ it from van Ulden (1979, 1983), HEGADAS folds it into its cross-wind
175
+ spreading equation, and DRIFT reports its "edge" entrainment dominating
176
+ over top entrainment in F2 conditions.
177
+
178
+ The front velocity V_g is 5-30 times SLAB's whole horizontal entrainment
179
+ velocity in stable, low-wind conditions, so even a small coefficient
180
+ changes the answer. The default is **zero** — Ermak's model exactly —
181
+ because the slab-averaged value is not established in the literature and
182
+ has to be calibrated; `PRESETS["frontal"]` carries a nominal value for
183
+ sensitivity work.
184
+ """
185
+
186
+ a2_horiz: float = 0.0004
187
+ """Horizontal entrainment damping `a2`, EQ 36a."""
188
+
189
+ c_10: float = 0.086
190
+ """Reference friction-velocity ratio `C10` in S(La), EQ 36a (JS `cf00`)."""
191
+
192
+ L_a_ref: float = 10.0
193
+ """Reference Monin-Obukhov length `La` [m], EQ 36a."""
194
+
195
+ p_meander: float = 0.2
196
+ """Exponent `p` in the averaging-time function F_theta, EQ 36a."""
197
+
198
+ tau_min: float = 10.0
199
+ """Minimum averaging time `tau` [s], EQ 36a / EQ 49b."""
200
+
201
+ t_ref_avg: float = 900.0
202
+ """Normalisation averaging time `t0` [s] (15 min), EQ 36a."""
203
+
204
+ c_shear_x: float = 0.6
205
+ """Down-wind shear coefficient, EQ 36b / TNO 4.128."""
206
+
207
+ c_shear_y: float = 0.209
208
+ """Cross-wind shear coefficient (= a*k), TNO 4.127."""
209
+
210
+ c3_stability: float = 34.1
211
+ """Constant `C3` [m] in the stability function fs(L), TNO 4.130."""
212
+
213
+ # -- friction velocity / fluxes ---------------------------------------
214
+ c_drag_top: float = 0.02
215
+ """
216
+ Top-of-cloud drag coefficient `Cf`, EQ 35e / TNO 4.132-4.133.
217
+
218
+ TNO's write-up implies 0.0195; Ermak's code uses 0.02
219
+ (SLAB.FOR L381). The default follows the code.
220
+ TNO 4.127 independently confirms 0.0195: its cross-wind shear constant
221
+ 0.209 equals sqrt(0.0195)*1.5 to four figures, whereas sqrt(0.02)*1.5
222
+ gives 0.2121. We therefore treat 0.02 as a rounding defect in the
223
+ reference implementation, reproducible via `Coefficients.LEGACY_JS`.
224
+ """
225
+
226
+ c_mu_strat: float = 0.025
227
+ """
228
+ Stratification coefficient in the in-cloud Monin-Obukhov length, EQ 35d.
229
+
230
+ Together with `phi_stable` it fixes the *shape* of the entrainment
231
+ damping. In the two shear-driven limits SLAB gives
232
+
233
+ u_e/u_* -> lambda_1 as Ri -> 0
234
+ u_e Ri / u_* -> lambda_2 = lambda_1 / (phi_stable c_mu_strat)
235
+
236
+ so the ratio ``lambda_2/lambda_1 = 1/(phi_stable c_mu_strat)`` is a pure
237
+ shape number: it is independent of the box-height convention and of how
238
+ the turbulence velocity scale is defined, because both limits are
239
+ normalised the same way. That makes it directly comparable with
240
+ laboratory measurements.
241
+
242
+ Nielsen & Jensen (Risoe) collect the canonical values
243
+ ``lambda_1 = 0.75`` (Sutton 1953, analytic) and ``lambda_2 = 2.5``
244
+ (Kato & Phillips 1969, annular tank), giving ``lambda_2/lambda_1 = 3.33``.
245
+ With ``phi_stable = 5`` that requires ``c_mu_strat = 0.060``; Ermak's
246
+ value is 0.025, so SLAB's stratification damping is a factor 2.4 too
247
+ weak and it over-entrains strongly stratified clouds.
248
+
249
+ Field data contradicts the laboratory value
250
+ -------------------------------------------
251
+ Sweeping the damping strength ``D = phi_stable * c_mu_strat`` against the
252
+ Burro measurements gives, at the standard roughness:
253
+
254
+ D lambda_2/lambda_1 MG VG FAC2 NMSE
255
+ 0.031 32.0 0.820 1.248 0.83 0.279 passes
256
+ 0.0625 16.0 0.710 1.376 0.75 0.461
257
+ 0.125 8.0 0.606 1.616 0.67 0.750 Ermak
258
+ 0.300 3.33 0.535 1.978 0.50 0.982 laboratory
259
+
260
+ Monotone: the value the canonical flows require is the *worst* on every
261
+ measure, and the field prefers four times less damping than Ermak already
262
+ uses. The between-trial scatter tells the same story with a genuine
263
+ interior minimum — the standard deviation of log MG across the four
264
+ trials falls from 0.416 at the laboratory value to 0.241 at D = 0.0625,
265
+ so this is not simply a bias knob.
266
+
267
+ The reading is that this coefficient is standing in for mixing that field
268
+ releases experience and laboratory gravity currents do not — ambient
269
+ turbulence over a range of scales that a tank or an annular flume cannot
270
+ reproduce. Ermak's 0.125 is already well below the laboratory value, so
271
+ the calibration was evidently against field data too; the field simply
272
+ wants to go further.
273
+
274
+ This is why `PRESETS["canonical"]` fails its pre-registered prediction:
275
+ it moves *towards* the laboratory value. The preset is kept because the
276
+ laboratory limits are a real constraint and the contradiction between
277
+ them and the field is the finding, not an error to be tuned away.
278
+
279
+ The same coefficient also controls the *convective* branch, through the
280
+ same damping function but with the thermal velocity scale in place of
281
+ the friction velocity. Setting it from the shear limits alone moves the
282
+ convective knee from 2.5 to 1.0 against a reference of 0.72 — an
283
+ independent check, since the convective data were not used to choose it.
284
+
285
+ The default stays at Ermak's value — `PRESETS["canonical"]` carries the
286
+ laboratory-consistent one. Note that none of the four reference values
287
+ comes from a dispersion trial, so using field data to test the change is
288
+ not circular.
289
+ """
290
+ """Stratification damping `C_mu` in the in-cloud MO length, EQ 35d."""
291
+
292
+ c_thermal: float = 0.14
293
+ """
294
+ Convective-turbulence coefficient `C_t` in EQ 35e.
295
+
296
+ It also fixes the relation between SLAB's thermal velocity and the
297
+ standard convective velocity scale. With ``phi = rho V_H cp dT`` the
298
+ definition ``w_*^3 = g h phi/(rho cp T)`` reduces to
299
+ ``w_*^3 = g h V_H dT / T``, so EQ 35e's
300
+ ``U_t^3 = C_t g dT V_H h / T`` gives ``U_t = C_t^(1/3) w_* = 0.519 w_*``.
301
+ Any comparison of SLAB's convective entrainment with measured values has
302
+ to apply that factor, or it overstates the entrainment by 1.93.
303
+ """
304
+ """Thermal convection constant `Ct`, EQ 35e."""
305
+
306
+ c_drag_lofted: float = 0.039
307
+ """Drag coefficient for lofted plumes, TNO 4.134-4.135."""
308
+
309
+ # -- gravity spreading -------------------------------------------------
310
+ alpha_g: float = 0.25
311
+ """Gravity-flow pressure coefficient `alpha_g` in EQ 4 (JS `alfg`).
312
+ Set to 0 for lofted clouds."""
313
+
314
+ alpha_gv: float = 0.75
315
+ """Gravity-flow coefficient in the cross-wind momentum EQ 5a (JS `alfgv`)."""
316
+
317
+ c_source_shear: float = 0.05
318
+ """
319
+ Source-jet shear contribution to the in-cloud friction velocity,
320
+ U_ss^2 = c * W_s * Ua (JS `cws`). EQ 35e as scanned reads 0.5; the
321
+ reference implementation uses 0.05 and is taken as authoritative.
322
+ """
323
+
324
+ x_relax_source: float = 1.0
325
+ """Scale of the exponential relaxation of U_bs^2 away from the source
326
+ region (JS `xstr`); set per-source, not a free constant."""
327
+
328
+ # -- plume rise --------------------------------------------------------
329
+ hmp_rise: float = 1.32
330
+ """Dense-jet max plume rise prefactor, EQ 45a. Hoot-Meroney-Peterka (1973)."""
331
+
332
+ hmp_conc: float = 1.69
333
+ """Dense-jet peak concentration prefactor, EQ 45c."""
334
+
335
+ hmp_conc_exp: float = 1.85
336
+ """Dense-jet peak concentration exponent, EQ 45c."""
337
+
338
+ hmp_xpr: float = 0.435
339
+ """Down-wind location of max rise, EQ 45b."""
340
+
341
+ briggs_beta0: float = 0.4
342
+ """Briggs entrainment `beta = beta0 + beta1*Ua/Ws`, EQ 46b."""
343
+
344
+ briggs_beta1: float = 1.2
345
+ """See above, EQ 46b."""
346
+
347
+ briggs_buoyant: float = 1.2
348
+ """Buoyant-jet plume rise prefactor, EQ 46c."""
349
+
350
+ aspect_ratio: float = 0.6
351
+ """Assumed cloud height / width after plume rise, EQ 45e-45f."""
352
+
353
+ # -- atmosphere --------------------------------------------------------
354
+ mix_height_ref: float = 130.0
355
+ """Mixing-layer height prefactor [m]: H = 130 * 2^(7-s), section 2.5.1."""
356
+
357
+ phi_stable: float = 5.0
358
+ """Coefficient in the stable MO function Phi_m = 1 + 5 z/L, EQ 33a."""
359
+
360
+ phi_unstable: float = 16.0
361
+ """Coefficient in the unstable MO function, EQ 33b."""
362
+
363
+ z_transition: float = 2.71828182845904523536
364
+ """Near-ground matching height in units of z0 (= e), EQ 34c."""
365
+
366
+ # -- numerics (not physics, but must be recorded) ----------------------
367
+ newton_tol: float = 1.0e-3
368
+ """Convergence tolerance of the thermodynamic Newton loop."""
369
+
370
+ newton_max_iter: int = 15
371
+ """Iteration cap of the thermodynamic Newton loop."""
372
+
373
+ eval_tol: float = 1.0e-3
374
+ """Convergence tolerance of the outer U-h-Uab fixed-point loop."""
375
+
376
+ eval_max_iter: int = 11
377
+ """Iteration cap of the outer fixed-point loop."""
378
+
379
+ # -- provenance --------------------------------------------------------
380
+ name: str = "ermak90"
381
+ """Identifier recorded in the Trajectory metadata."""
382
+
383
+ # ------------------------------------------------------------------
384
+ def perturb(self, **kw: Any) -> "Coefficients":
385
+ """Return a copy with selected fields replaced (for UQ / ablation)."""
386
+ unknown = set(kw) - set(asdict(self))
387
+ if unknown:
388
+ raise KeyError(f"unknown coefficient(s): {sorted(unknown)}")
389
+ kw.setdefault("name", f"{self.name}+mod")
390
+ return replace(self, **kw)
391
+
392
+ def scaled(self, factor: float, *fields: str) -> "Coefficients":
393
+ """Return a copy with `fields` multiplied by `factor` (sensitivity runs)."""
394
+ d = asdict(self)
395
+ return self.perturb(
396
+ **{f: d[f] * factor for f in fields},
397
+ name=f"{self.name}*{factor:g}({','.join(fields)})",
398
+ )
399
+
400
+ def as_dict(self) -> dict:
401
+ return asdict(self)
402
+
403
+
404
+ COEFFS = Coefficients()
405
+
406
+ #: Named coefficient sets used by the ablation study.
407
+ PRESETS: dict[str, Coefficients] = {
408
+ # Manual / TNO values. Default.
409
+ "ermak90": COEFFS,
410
+ # The value TNO's Yellow Book implies for the same coefficient. Its
411
+ # cross-wind shear constant is 0.209, and 0.209 = sqrt(0.0195) * 1.5 to
412
+ # four figures, against sqrt(0.02) * 1.5 = 0.2121.
413
+ #
414
+ # This was originally taken for a rounding defect in the code and the
415
+ # default was set to 0.0195. Checking against Ermak's Fortran
416
+ # (SLAB.FOR line 381) shows the code has always used 0.02,
417
+ # so the discrepancy is between the code and the TNO documentation of it,
418
+ # not an error in either. The default now follows the code; this preset
419
+ # keeps the documented value available.
420
+ "tno": COEFFS.perturb(c_drag_top=0.0195, name="tno"),
421
+ # Nominal gravity-front entrainment, for sensitivity work only. The
422
+ # value is a placeholder chosen to make the new term comparable to the
423
+ # ambient-turbulence term at mid-range, NOT a calibrated result.
424
+ "frontal": COEFFS.perturb(alpha_front=0.05, name="frontal"),
425
+ # Stratification damping set by the canonical shear-flow limits rather
426
+ # than by Ermak's choice; see `c_mu_strat`. Determined without any
427
+ # dispersion data — and refuted by it.
428
+ "canonical": COEFFS.perturb(c_mu_strat=0.060, name="canonical"),
429
+ # The other end of the same axis: the damping strength at which the
430
+ # between-trial scatter on Burro is smallest, D = 0.0625, half Ermak's.
431
+ # Fitted to dispersion data and labelled as such.
432
+ "field_damping": COEFFS.perturb(c_mu_strat=0.0125, name="field_damping"),
433
+ # Rainout removal rate scaled to the SMEDIS droplet-lifetime target for
434
+ # Desert Tortoise. Calibrated against a droplet measurement, not against
435
+ # dispersion statistics, so field concentrations remain a test of it.
436
+ "rainout_smedis": COEFFS.perturb(rainout_efficiency=0.25,
437
+ name="rainout_smedis"),
438
+ }
439
+
440
+
441
+ def preset(name: str) -> Coefficients:
442
+ try:
443
+ return PRESETS[name]
444
+ except KeyError:
445
+ raise KeyError(f"unknown preset {name!r}; have {sorted(PRESETS)}") from None
slabx/core/__init__.py ADDED
File without changes
@@ -0,0 +1,237 @@
1
+ """
2
+ Does the shallow-layer height closure cause the bias?
3
+ =====================================================
4
+
5
+ SMEDIS evaluated four classes of dense-gas model against the same field data
6
+ with the same arc-wise statistic and found, on the trials with no complex
7
+ effects, that integral models were unbiased (ln(MG) = -0.01, FAC2 = 0.74)
8
+ while shallow-layer models over-predicted by a factor 1.6 (ln(MG) = -0.49,
9
+ FAC2 = 0.65). `slabx` reproduces the shallow-layer number to two figures
10
+ against the Burro trials.
11
+
12
+ That is a correlation between a model *class* and a bias. It is not yet a
13
+ cause. The obvious candidate is the one structural thing that distinguishes
14
+ the two classes: an integral model carries cloud height as a dependent
15
+ variable of its own, while a shallow-layer model recovers it algebraically
16
+ from mass conservation,
17
+
18
+ h = R / (rho U B) EQ 2b
19
+
20
+ so any error in the width propagates directly into the height with the
21
+ opposite sign, and the cross-section — which is what sets the concentration
22
+ — absorbs both.
23
+
24
+ This module tests that by integrating the height from the vertical
25
+ entrainment and recovering the *width* from mass conservation instead —
26
+ swapping which of the two is the dependent variable, and changing nothing
27
+ else.
28
+
29
+ A correction made while designing the test
30
+ -------------------------------------------
31
+ The first version of this prediction assumed that integrating the height
32
+ would change the dilution. It does not. Mass conservation fixes the
33
+ cross-section,
34
+
35
+ B h = R / (rho U)
36
+
37
+ whichever of B and h is algebraic, so the *product* is identical either way
38
+ and the cross-wind-averaged concentration cannot move. What does move is the
39
+ centreline value, because the profile carries the width and the shape
40
+ parameter separately,
41
+
42
+ C(x,0,z) ~ B (h/sigma) Cbar / b, sigma = h/sqrt3 for a grounded cloud
43
+
44
+ so ``h/sigma`` is constant and ``C ~ B/b``. Integrating the height gives a
45
+ cloud 1.3 to 2.3 times taller on these trials, hence a width smaller by the
46
+ same factor, hence a lower centreline concentration — with ``b`` unchanged,
47
+ since it is driven by gravity spreading and not by entrainment.
48
+
49
+ The test is therefore about the *partition* between width and height, not
50
+ about the total dilution. That is a sharper hypothesis than the one it
51
+ replaces: it predicts a specific ratio, not just a direction.
52
+
53
+ The prediction, registered before the comparison
54
+ ------------------------------------------------
55
+ If the height closure is the cause:
56
+
57
+ P1 the over-prediction largely disappears: MG moves from 0.61 towards
58
+ the integral-model value of 0.99, i.e. into 0.85-1.15
59
+ P2 FAC2 rises from 0.67 towards the integral-model 0.74
60
+ P3 the effect is *largest* where the width error is largest, which the
61
+ diagnosis puts in the neutral, windy trials (BU03, BU07, BU09) and
62
+ not in the stable one (BU08), where the model is already unbiased
63
+ P4 the reproduction of the *reference* degrades, because this is a
64
+ deliberate change to the formulation rather than a defect fix — if
65
+ it did not, the change would be doing nothing
66
+
67
+ If instead the bias comes from somewhere else — the entrainment closure, the
68
+ source term, the profile shapes — then P1 and P2 fail while P4 still holds,
69
+ and the shallow-layer explanation is wrong.
70
+
71
+ P3 is the discriminating one. A change that improves every trial by a
72
+ similar amount is behaving like a bias correction, not like a mechanism.
73
+
74
+ What is deliberately *not* changed
75
+ ----------------------------------
76
+ Entrainment, thermodynamics, the source terms, the profile functions and the
77
+ concentration post-processing are untouched, so anything that moves is
78
+ attributable to the height closure. The width equation EQ 7 is also
79
+ untouched; only the route from mass to height changes.
80
+
81
+ Outcome: the hypothesis is falsified
82
+ ------------------------------------
83
+ Implemented as `height_closure="constrained"` in `plume.integrate_plume`,
84
+ and run on the four Burro trials against the same observations:
85
+
86
+ P1 MG 0.606 -> 0.546 target 0.85-1.15 FAILED
87
+ P1b Cbar changed by 13.3 % target < 1 % FAILED
88
+ P2 FAC2 0.67 -> 0.67 target >= 0.72 FAILED
89
+ P3 effect 5.8 % in the neutral trials, 21.0 % in the
90
+ stable one — the wrong way round FAILED
91
+
92
+ The implementation is sound: mass conservation holds to 1e-3 in both
93
+ closures, ``m = q/(2R)`` to 1e-5, and the height does come out 1.1-1.3 times
94
+ larger with the width smaller to match. The bias simply gets *worse*.
95
+
96
+ Where the reasoning was wrong
97
+ ------------------------------
98
+ P1b was supposed to hold by construction and did not, which is the tell. The
99
+ argument was that mass conservation fixes ``B h``, so the partition cannot
100
+ change the dilution. But EQ 2a entrains through
101
+
102
+ dR/dx = rho_a sqrt3 (V_e h + W_e B) + source
103
+
104
+ which depends on h and B *separately*, not on their product. A taller,
105
+ narrower cloud presents more side area and less top area, and for these
106
+ trials the trade is unfavourable: the cloud entrains less overall, so it
107
+ stays more concentrated. The cross-section is therefore not conserved
108
+ between closures, and the premise of the test was wrong.
109
+
110
+ What this does and does not settle
111
+ -----------------------------------
112
+ It rules out the simplest version of the shallow-layer explanation: the bias
113
+ is not caused merely by which of the two lengths is the dependent variable.
114
+ Something about the shallow-layer formulation may still be responsible — the
115
+ flat-slab geometry, the profile shapes, the single vertical scale — but the
116
+ algebraic height recovery on its own is not it.
117
+
118
+ It does not rescue SLAB either. The correlation SMEDIS reports between model
119
+ class and bias stands; only this particular mechanism for it is eliminated.
120
+
121
+ The registered predictions are kept above exactly as written. Rewriting them
122
+ after the fact to match what happened would destroy the only thing that makes
123
+ the negative result worth anything.
124
+
125
+ A second candidate, also eliminated
126
+ ------------------------------------
127
+ If the height closure is not responsible, the next structural suspect is the
128
+ concentration profile: SLAB reports a flat-topped cross-wind shape with
129
+ Gaussian edges, and a rigid ``sigma_z = h/sqrt3``, where an integral model
130
+ would carry the vertical scale independently.
131
+
132
+ That can be tested without changing anything, by asking whether SLAB's own
133
+ reported centreline concentration agrees with what its own spreads imply for
134
+ a plain Gaussian plume,
135
+
136
+ C(x,0,0) = q / (pi u sigma_y sigma_z rho)
137
+
138
+ On the Burro trials:
139
+
140
+ BU03, BU07, BU09 (neutral, windy) C_slab / C_gauss = 0.76 - 1.03
141
+ BU08 (stable, dense) C_slab / C_gauss = 0.36 - 0.51
142
+
143
+ In the neutral trials SLAB *is* a consistent Gaussian plume built on its own
144
+ sigmas, so neither the profile shape nor the normalisation can be responsible
145
+ for the over-prediction there — it is entirely in the spreads. Only in the
146
+ stable, dense trial does the flat-topped profile do real work, and that is
147
+ the one trial the model already gets right.
148
+
149
+ Three candidates have now been eliminated: the height closure, the profile
150
+ shape, and the normalisation. What remains is the dispersion rates
151
+ themselves, which is where the per-trial roughness analysis in
152
+ `validation/field_trials.py` also points — the required roughness spans a
153
+ factor 200 ordered by stability class, so the deficiency varies with
154
+ stability rather than being a constant offset.
155
+
156
+ What twelve points can no longer separate
157
+ ------------------------------------------
158
+ Distinguishing which stability-dependent term is short — the damping
159
+ function, the mixing height, the ambient-stability correction S(La), or the
160
+ friction-velocity mapping — needs several trials per stability class. Burro
161
+ gives one class C, two class D and one class E. The remaining candidates
162
+ cannot be told apart on this data, and adding coefficients until the
163
+ statistics improve is exactly the failure mode the pre-registration is there
164
+ to prevent.
165
+ """
166
+
167
+ from __future__ import annotations
168
+
169
+ from dataclasses import dataclass
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class Registered:
174
+ """A prediction fixed before the comparison that tests it."""
175
+
176
+ name: str
177
+ statement: str
178
+ passes_if: str
179
+
180
+
181
+ #: Registered before any run of the integrated-height variant.
182
+ PREDICTIONS = (
183
+ Registered(
184
+ "P1", "the geometric mean bias moves from the shallow-layer value "
185
+ "towards the integral-model value",
186
+ "0.85 <= MG <= 1.15 (from 0.61)",
187
+ ),
188
+ Registered(
189
+ "P1b", "the cross-wind-averaged concentration does NOT move, because "
190
+ "mass conservation fixes B h regardless of which is integrated",
191
+ "median |dCbar| < 1 % — if this fails the change is not what it claims",
192
+ ),
193
+ Registered(
194
+ "P2", "the fraction within a factor of two rises towards the "
195
+ "integral-model value",
196
+ "FAC2 >= 0.72 (from 0.67)",
197
+ ),
198
+ Registered(
199
+ "P3", "the effect concentrates in the trials where the width error "
200
+ "is largest, not uniformly",
201
+ "|dMG| in BU03/BU07/BU09 exceeds |dMG| in BU08 by at least 2x",
202
+ ),
203
+ Registered(
204
+ "P4", "agreement with the reference degrades, since the formulation "
205
+ "has deliberately changed",
206
+ "median |A-C| on cloud height exceeds 5 % (from 0.19 %)",
207
+ ),
208
+ )
209
+
210
+
211
+ #: The candidate closures, and what separates them.
212
+ #:
213
+ #: EQ 2a already carries the entrained mass through ``rho_a sqrt3 (V_e h +
214
+ #: W_e B)``. A height equation that simply integrates the vertical
215
+ #: entrainment velocity would count the same air twice — once as mass, once
216
+ #: as volume — so the closure has to be stated carefully.
217
+ HEIGHT_CLOSURES = {
218
+ "algebraic": (
219
+ "h = R / (rho U B). Ermak's, and the definition of a shallow-layer "
220
+ "model. Mass is conserved exactly; height carries whatever error "
221
+ "the width has, inverted."
222
+ ),
223
+ "entrainment": (
224
+ "dh/dx = sqrt3 W_e / U, with the density stretch applied to h as "
225
+ "well as B. Height then follows the turbulence directly, and mass "
226
+ "conservation has to be restored by letting the *density* or the "
227
+ "concentration absorb the residual — which is what an integral model "
228
+ "does. This is the variant the prediction above is about."
229
+ ),
230
+ "constrained": (
231
+ "Integrate h from the vertical entrainment, then recover B from "
232
+ "``B = R/(rho U h)``. Mass stays exact and the roles of the two "
233
+ "lengths are swapped. This is the variant the predictions above "
234
+ "describe: it is minimal, it leaves every other closure alone, and "
235
+ "the only thing it can change is the width-to-height partition."
236
+ ),
237
+ }