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.
- slabx/__init__.py +0 -0
- slabx/coefficients.py +445 -0
- slabx/core/__init__.py +0 -0
- slabx/core/height_closure.py +237 -0
- slabx/core/plume.py +1286 -0
- slabx/core/puff.py +459 -0
- slabx/core/source.py +617 -0
- slabx/core/trajectory.py +450 -0
- slabx/core/vertical_jet.py +326 -0
- slabx/io/__init__.py +0 -0
- slabx/post/__init__.py +0 -0
- slabx/post/concentration.py +395 -0
- slabx/scope.py +145 -0
- slabx/submodels/__init__.py +0 -0
- slabx/submodels/added_mass.py +78 -0
- slabx/submodels/atmosphere.py +389 -0
- slabx/submodels/entrainment.py +520 -0
- slabx/submodels/ground.py +112 -0
- slabx/submodels/rainout.py +230 -0
- slabx/thermo/__init__.py +0 -0
- slabx/thermo/base.py +340 -0
- slabx/thermo/coolprop.py +581 -0
- slabx/thermo/equilibrium.py +334 -0
- slabx/thermo/mixture.py +176 -0
- slabx/validation/__init__.py +0 -0
- slabx/validation/_data_access.py +106 -0
- slabx/validation/data/provenance/README.md +146 -0
- slabx/validation/data/provenance/publication_status.csv +28 -0
- slabx/validation/data/provenance/source_manifest.csv +11 -0
- slabx/validation/data/provenance/variable_mapping.csv +28 -0
- slabx/validation/data/smedis_inventory.csv +29 -0
- slabx/validation/field_trials.py +469 -0
- slabx/validation/lng_pools.py +194 -0
- slabx/validation/metrics.py +345 -0
- slabx/validation/smedis.py +302 -0
- slabx/validation/thorney_island.py +186 -0
- slabx/validation/wind_tunnel.py +153 -0
- slabx-1.0.3.dist-info/METADATA +200 -0
- slabx-1.0.3.dist-info/RECORD +42 -0
- slabx-1.0.3.dist-info/WHEEL +5 -0
- slabx-1.0.3.dist-info/licenses/LICENSE +25 -0
- 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
|
+
}
|