physmap 0.2.0__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.
- physmap/__init__.py +61 -0
- physmap/_paths.py +69 -0
- physmap/applicability/__init__.py +0 -0
- physmap/applicability/fixtures.py +83 -0
- physmap/applicability/screen.py +99 -0
- physmap/baselines/__init__.py +0 -0
- physmap/benchmarks/__init__.py +0 -0
- physmap/benchmarks/benchmark_report.py +405 -0
- physmap/benchmarks/benchmark_v0_4.py +424 -0
- physmap/benchmarks/compare.py +149 -0
- physmap/benchmarks/registry.py +217 -0
- physmap/benchmarks/report.py +224 -0
- physmap/cli.py +301 -0
- physmap/closures/__init__.py +48 -0
- physmap/closures/data/__init__.py +7 -0
- physmap/closures/data/closure_index.json +2997 -0
- physmap/closures/formulas.py +213 -0
- physmap/closures/geometry_classes.py +109 -0
- physmap/closures/index.py +393 -0
- physmap/closures/registry.py +313 -0
- physmap/compat/__init__.py +0 -0
- physmap/core/__init__.py +0 -0
- physmap/core/mechanism.py +69 -0
- physmap/core/signals.py +50 -0
- physmap/corpus/__init__.py +12 -0
- physmap/corpus/calibration.py +543 -0
- physmap/corpus/data/__init__.py +12 -0
- physmap/corpus/data/corpus_seed.jsonl +15 -0
- physmap/corpus/data/evidence_claims_seed.jsonl +21 -0
- physmap/corpus/data/evidence_sources_seed.jsonl +8 -0
- physmap/corpus/data/premium_coverage.json +60 -0
- physmap/corpus/evidence.py +871 -0
- physmap/explain/__init__.py +0 -0
- physmap/explain/benchmark.py +101 -0
- physmap/explain/causal.py +82 -0
- physmap/guardrail/__init__.py +38 -0
- physmap/guardrail/aggregator_observability.py +187 -0
- physmap/guardrail/classify.py +147 -0
- physmap/guardrail/configs.py +120 -0
- physmap/guardrail/corpus_regimes.py +208 -0
- physmap/guardrail/detector_conformal.py +129 -0
- physmap/guardrail/detector_density.py +74 -0
- physmap/guardrail/enums.py +69 -0
- physmap/guardrail/graph.py +73 -0
- physmap/guardrail/guardrail.py +606 -0
- physmap/guardrail/io.py +201 -0
- physmap/guardrail/regime_observability.py +519 -0
- physmap/guardrail/render.py +159 -0
- physmap/guardrail/weighting_heuristic.py +216 -0
- physmap/infra/__init__.py +23 -0
- physmap/infra/blindspot_oracle.py +356 -0
- physmap/infra/corpus_runtime.py +275 -0
- physmap/integrations/__init__.py +0 -0
- physmap/materiality/__init__.py +0 -0
- physmap/materiality/estimator.py +239 -0
- physmap/materiality/independence.py +92 -0
- physmap/materiality/surrogate_fit.py +293 -0
- physmap/observability/__init__.py +0 -0
- physmap/pipeline/__init__.py +58 -0
- physmap/pipeline/aggregators.py +199 -0
- physmap/pipeline/assessment_v06.py +509 -0
- physmap/pipeline/core.py +442 -0
- physmap/pipeline/defeasible_aggregator.py +324 -0
- physmap/pipeline/detectors.py +309 -0
- physmap/pipeline/observability.py +430 -0
- physmap/pipeline/surrogate.py +251 -0
- physmap/pipeline/validity_signal.py +273 -0
- physmap/pipeline/vehicle_spec.py +287 -0
- physmap/release.py +81 -0
- physmap/stress_tests/__init__.py +9 -0
- physmap/stress_tests/lewis_reuse.py +517 -0
- physmap/substrate/__init__.py +28 -0
- physmap/substrate/corpus_real.py +206 -0
- physmap/substrate/engine.py +209 -0
- physmap/substrate/forrest.py +249 -0
- physmap/substrate/loaders.py +2176 -0
- physmap/substrate/naca_tn1451.py +379 -0
- physmap/substrate/naca_wpd_loader.py +187 -0
- physmap/substrate/stage1_ingest.py +187 -0
- physmap/substrate/vehicle_config.py +407 -0
- physmap-0.2.0.dist-info/METADATA +270 -0
- physmap-0.2.0.dist-info/RECORD +88 -0
- physmap-0.2.0.dist-info/WHEEL +5 -0
- physmap-0.2.0.dist-info/entry_points.txt +2 -0
- physmap-0.2.0.dist-info/licenses/LICENSE +21 -0
- physmap-0.2.0.dist-info/licenses/LICENSE-CORPUS +469 -0
- physmap-0.2.0.dist-info/licenses/NOTICE +77 -0
- physmap-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2176 @@
|
|
|
1
|
+
"""Data-source loader adapters — the LOADERS registry entries.
|
|
2
|
+
|
|
3
|
+
Each adapter takes a validated `VehicleConfig` plus the closure registry and
|
|
4
|
+
returns `(rows, reference, meta)` in the existing pipeline shape.
|
|
5
|
+
|
|
6
|
+
LOADER OWNERSHIP MIGRATION STATUS (per the Step-9 plan):
|
|
7
|
+
lance_smith_lfs — ENGINE-DRIVEN (Step 9a). Reuses CSV helpers from
|
|
8
|
+
`lance_smith_substrate.py` but computes all closure
|
|
9
|
+
predictions VIA THE REGISTRY. Byte-equal output to
|
|
10
|
+
the legacy `lance_smith_to_rows()`; the structural-
|
|
11
|
+
equality gate in test_substrate_engine pins this.
|
|
12
|
+
forrest_visual_estimates - placeholder (Step 9b).
|
|
13
|
+
mudhafar_wpd - placeholder (Step 9c, awaiting WPD CSVs).
|
|
14
|
+
naca_wpd - placeholder (Step 9d, locked-last per gate decision).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from physmap.closures import ClosureEntry
|
|
25
|
+
from physmap.substrate.corpus_real import SubstrateMeta
|
|
26
|
+
from physmap.substrate.stage1_ingest import Mechanism, Row, load_rows
|
|
27
|
+
from physmap.substrate.engine import register_loader
|
|
28
|
+
from physmap.substrate.vehicle_config import VehicleConfig
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Benchmark substrate CSVs live in the CHECKOUT, not in the installed package --
|
|
32
|
+
# they are deliberately outside the wheel. YAML paths look like
|
|
33
|
+
# "data/naca/wpd_fig10.csv" and resolve against the repository root.
|
|
34
|
+
#
|
|
35
|
+
# This raises rather than returning a wrong path. A silently mis-resolved CSV
|
|
36
|
+
# surfaces as an empty or short substrate, which the engine happily runs on and
|
|
37
|
+
# reports an outcome for.
|
|
38
|
+
def _repo_root() -> Path:
|
|
39
|
+
from physmap._paths import CheckoutRequired, repo_root
|
|
40
|
+
|
|
41
|
+
root = repo_root()
|
|
42
|
+
if root is None:
|
|
43
|
+
raise CheckoutRequired("the benchmark substrate data")
|
|
44
|
+
return root
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resolve(relative: str | None) -> Path | None:
|
|
48
|
+
if relative is None:
|
|
49
|
+
return None
|
|
50
|
+
p = Path(relative)
|
|
51
|
+
return p if p.is_absolute() else (_repo_root() / p)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── lance_smith_lfs (engine-driven, Step 9a) ────────────────────────────────
|
|
55
|
+
|
|
56
|
+
# The reason string in SubstrateMeta is preserved byte-equal to the legacy
|
|
57
|
+
# `lance_smith_to_rows()` so the structural-equality gate stays strict.
|
|
58
|
+
_LANCE_SMITH_REASON = (
|
|
59
|
+
"Independent experimental truth: SRQ-HeatFlux measured q\" at 3 "
|
|
60
|
+
"plate locations × 92 timesteps × {q\",B,S,U} = 276 measurements "
|
|
61
|
+
"per Lance & Smith ASME J. VVUQ 2016 (OSTI 1263650/1257801; USU "
|
|
62
|
+
"DigitalCommons engineering_datasets/2). Truth is measured q\" "
|
|
63
|
+
"with B/S/U uncertainty, NOT Eq-13 and NOT the theoretical Ri "
|
|
64
|
+
"grid (the two vehicle-3 disqualifiers, refused by the loader)."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def lance_smith_lfs(config: VehicleConfig,
|
|
69
|
+
registry: dict[str, ClosureEntry]) -> tuple[list[Row], dict, SubstrateMeta]:
|
|
70
|
+
"""Engine-driven Lance & Smith loader.
|
|
71
|
+
|
|
72
|
+
Reads raw measurements via the I/O helpers in `lance_smith_substrate.py`
|
|
73
|
+
(CSV parsers, time-base alignment, forbidden-source guard), then computes
|
|
74
|
+
all per-row closure predictions VIA THE CLOSURE REGISTRY:
|
|
75
|
+
|
|
76
|
+
Nu_forced := REGISTRY["blasius-pohlhausen-..."].fn(Re=Re_x, Pr=pr)
|
|
77
|
+
Nu_natural := REGISTRY["mcadams-vertical-..."].fn(Ra=Ra_x)
|
|
78
|
+
Nu_blend := REGISTRY["churchill-mixed-..."].fn(
|
|
79
|
+
Nu_forced=Nu_forced, Nu_natural=Nu_natural, n=n)
|
|
80
|
+
|
|
81
|
+
Physical constants (g, ν_air, k_air, Pr_air, Churchill blend exponent)
|
|
82
|
+
come from `config.data_source.options` — NOT from module-level constants
|
|
83
|
+
in `lance_smith_substrate.py`. This is the substantive Step-9a change:
|
|
84
|
+
the registry is the source of truth for closure formulas; the YAML is
|
|
85
|
+
the source of truth for vehicle-specific physical constants.
|
|
86
|
+
|
|
87
|
+
Byte-equality with the legacy loader is the gate. See
|
|
88
|
+
`tests/test_substrate_engine.py::test_lance_smith_engine_matches_direct_loader_call`.
|
|
89
|
+
"""
|
|
90
|
+
# I/O helpers stay in lance_smith_substrate for now (vehicle-specific
|
|
91
|
+
# CSV plumbing — moved out of scope for Step 9a). The closures DO move.
|
|
92
|
+
from physmap.substrate.lance_smith import (
|
|
93
|
+
BC_HEATED_WALL_NAME,
|
|
94
|
+
BC_INLET_TEMP_NAME,
|
|
95
|
+
BC_INLET_VEL_NAME,
|
|
96
|
+
SRQ_HEATFLUX_NAME,
|
|
97
|
+
_refuse_forbidden_sources,
|
|
98
|
+
read_bc_freestream_timeseries,
|
|
99
|
+
read_bc_mean_timeseries,
|
|
100
|
+
read_srq_heatflux,
|
|
101
|
+
read_wall_temp_local_at_X,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
data_dir_in = _resolve(config.data_source.dir)
|
|
105
|
+
if data_dir_in is None:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"VehicleConfig {config.vehicle_id!r}: data_source.dir is required "
|
|
108
|
+
f"for the lance_smith_lfs loader."
|
|
109
|
+
)
|
|
110
|
+
data_dir = data_dir_in.resolve()
|
|
111
|
+
_refuse_forbidden_sources(data_dir)
|
|
112
|
+
|
|
113
|
+
# ── physical constants from the YAML (data_source.options) ──────────────
|
|
114
|
+
opts = config.data_source.options
|
|
115
|
+
pr = float(opts.get("Pr_air", 0.71))
|
|
116
|
+
g_gravity = float(opts.get("g_gravity", 9.81))
|
|
117
|
+
nu_air = float(opts.get("nu_air_m2_s", 1.86e-5))
|
|
118
|
+
k_air = float(opts.get("k_air_w_mk", 0.0265))
|
|
119
|
+
churchill_n = float(opts.get("churchill_blend_n", 3.0))
|
|
120
|
+
time_subrange: tuple[float, float] | None = None
|
|
121
|
+
raw_subrange = opts.get("time_subrange_s")
|
|
122
|
+
if raw_subrange is not None:
|
|
123
|
+
time_subrange = (float(raw_subrange[0]), float(raw_subrange[1]))
|
|
124
|
+
|
|
125
|
+
# ── raw I/O ─────────────────────────────────────────────────────────────
|
|
126
|
+
srq = read_srq_heatflux(data_dir / SRQ_HEATFLUX_NAME)
|
|
127
|
+
t_seconds = srq["time_s"]
|
|
128
|
+
n_t = len(t_seconds)
|
|
129
|
+
X_m = srq["X_m"]
|
|
130
|
+
n_loc = len(X_m)
|
|
131
|
+
|
|
132
|
+
t_w, T_wall_local = read_wall_temp_local_at_X(
|
|
133
|
+
data_dir / BC_HEATED_WALL_NAME, X_m,
|
|
134
|
+
)
|
|
135
|
+
t_i, T_inlet_mean = read_bc_mean_timeseries(
|
|
136
|
+
data_dir / BC_INLET_TEMP_NAME, "T(K)",
|
|
137
|
+
)
|
|
138
|
+
t_u, u_inlet_freestream = read_bc_freestream_timeseries(
|
|
139
|
+
data_dir / BC_INLET_VEL_NAME, "u(m/s)", percentile=90.0,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Time-base sanity check (mirrors the legacy guard).
|
|
143
|
+
for label, t_arr in (("wall", t_w), ("inlet_T", t_i), ("inlet_u", t_u)):
|
|
144
|
+
if not np.allclose(t_arr, t_seconds, atol=1e-6):
|
|
145
|
+
raise RuntimeError(
|
|
146
|
+
f"time-base mismatch: {label} times {t_arr[:3]}... vs SRQ "
|
|
147
|
+
f"{t_seconds[:3]}... — re-check BC file headers"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# ΔT(X, t) is per-location-per-timestep — the heated wall has substantial
|
|
151
|
+
# X-variation (cool at leading edge, hot in developed region).
|
|
152
|
+
delta_T = T_wall_local - T_inlet_mean[np.newaxis, :]
|
|
153
|
+
T_film = 0.5 * (T_wall_local + T_inlet_mean[np.newaxis, :])
|
|
154
|
+
beta = 1.0 / T_film
|
|
155
|
+
|
|
156
|
+
# ── closure registry lookups (THE ENGINE-DRIVEN PIECE) ──────────────────
|
|
157
|
+
matched = registry[config.matched_closure_id] # Churchill blend
|
|
158
|
+
forced_id = "blasius-pohlhausen-flat-plate-forced-1921"
|
|
159
|
+
natural_id = "mcadams-vertical-plate-natural-1954"
|
|
160
|
+
forced = registry[forced_id]
|
|
161
|
+
natural = registry[natural_id]
|
|
162
|
+
|
|
163
|
+
# Pin the geometry-class linkage so a future registry edit that loses the
|
|
164
|
+
# right Pohlhausen/McAdams entries surfaces here, not as a silent
|
|
165
|
+
# numerical drift.
|
|
166
|
+
if forced_id not in config.reference_closure_ids:
|
|
167
|
+
raise RuntimeError(
|
|
168
|
+
f"Lance & Smith engine path requires {forced_id!r} in "
|
|
169
|
+
f"reference_closure_ids; got {config.reference_closure_ids}."
|
|
170
|
+
)
|
|
171
|
+
if natural_id not in config.reference_closure_ids:
|
|
172
|
+
raise RuntimeError(
|
|
173
|
+
f"Lance & Smith engine path requires {natural_id!r} in "
|
|
174
|
+
f"reference_closure_ids; got {config.reference_closure_ids}."
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# ── per-row build ───────────────────────────────────────────────────────
|
|
178
|
+
rows: list[Row] = []
|
|
179
|
+
for i_loc, x in enumerate(X_m):
|
|
180
|
+
x_f = float(x)
|
|
181
|
+
for i_t, t_s in enumerate(t_seconds):
|
|
182
|
+
t_s_f = float(t_s)
|
|
183
|
+
if time_subrange is not None:
|
|
184
|
+
t_lo, t_hi = time_subrange
|
|
185
|
+
if not (t_lo <= t_s_f <= t_hi):
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
u = float(u_inlet_freestream[i_t])
|
|
189
|
+
T_wall = float(T_wall_local[i_loc, i_t])
|
|
190
|
+
T_inlet = float(T_inlet_mean[i_t])
|
|
191
|
+
dT = float(delta_T[i_loc, i_t])
|
|
192
|
+
beta_i = float(beta[i_loc, i_t])
|
|
193
|
+
q_meas = float(srq["q"][i_loc, i_t])
|
|
194
|
+
u_q = float(srq["U"][i_loc, i_t])
|
|
195
|
+
|
|
196
|
+
# Local non-dimensional groups (positive ΔT for assisting/heated)
|
|
197
|
+
Re_x = max(u * x_f / nu_air, 1e-12)
|
|
198
|
+
Gr_x = (
|
|
199
|
+
g_gravity * beta_i * max(dT, 0.0) * (x_f ** 3) / (nu_air ** 2)
|
|
200
|
+
)
|
|
201
|
+
Ra_x = Gr_x * pr
|
|
202
|
+
Ri_x = Gr_x / (Re_x ** 2)
|
|
203
|
+
|
|
204
|
+
# ─── REGISTRY-DRIVEN CLOSURE EVALUATION ─────────────────────────
|
|
205
|
+
# The legacy code called nu_forced_pohlhausen_local, etc.,
|
|
206
|
+
# imported directly from lance_smith_substrate.py. Here we go
|
|
207
|
+
# through the registry — same math (closure formulas are
|
|
208
|
+
# byte-equal copies; pinned by test_closures_registry), but the
|
|
209
|
+
# registry is the source of truth.
|
|
210
|
+
Nu_F = float(forced.fn(Re=np.array([Re_x]), Pr=pr)[0])
|
|
211
|
+
Nu_N = float(natural.fn(Ra=np.array([Ra_x]))[0])
|
|
212
|
+
Nu_M_blend = float(
|
|
213
|
+
matched.fn(
|
|
214
|
+
Nu_forced=np.array([Nu_F]),
|
|
215
|
+
Nu_natural=np.array([Nu_N]),
|
|
216
|
+
n=churchill_n,
|
|
217
|
+
)[0]
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Measured Nu_x from measured q": Nu_x = q" · x / (k · ΔT)
|
|
221
|
+
if dT > 0 and x_f > 0:
|
|
222
|
+
nu_meas = q_meas * x_f / (k_air * dT)
|
|
223
|
+
nu_unc = u_q * x_f / (k_air * dT)
|
|
224
|
+
else:
|
|
225
|
+
nu_meas = float("nan")
|
|
226
|
+
nu_unc = float("nan")
|
|
227
|
+
|
|
228
|
+
# Mechanism calib_lo/hi come from the REGISTRY ranges; this
|
|
229
|
+
# replaces the BLASIUS_RE_LO/HI + MCADAMS_RA_LO/HI module
|
|
230
|
+
# constants in lance_smith_substrate.py.
|
|
231
|
+
if forced.re_range is None:
|
|
232
|
+
raise RuntimeError(
|
|
233
|
+
f"{forced_id!r} has no re_range; cannot construct "
|
|
234
|
+
f"forced-mechanism calibration bounds."
|
|
235
|
+
)
|
|
236
|
+
if natural.ra_range is None:
|
|
237
|
+
raise RuntimeError(
|
|
238
|
+
f"{natural_id!r} has no ra_range; cannot construct "
|
|
239
|
+
f"natural-mechanism calibration bounds."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
mechanisms = [
|
|
243
|
+
Mechanism(
|
|
244
|
+
name="forced",
|
|
245
|
+
closure_id=forced_id,
|
|
246
|
+
operating_value=Re_x,
|
|
247
|
+
calib_lo=forced.re_range[0],
|
|
248
|
+
calib_hi=forced.re_range[1],
|
|
249
|
+
contribution=Nu_F ** churchill_n,
|
|
250
|
+
),
|
|
251
|
+
Mechanism(
|
|
252
|
+
name="natural",
|
|
253
|
+
closure_id=natural_id,
|
|
254
|
+
operating_value=Ra_x,
|
|
255
|
+
calib_lo=natural.ra_range[0],
|
|
256
|
+
calib_hi=natural.ra_range[1],
|
|
257
|
+
contribution=Nu_N ** churchill_n,
|
|
258
|
+
),
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
rows.append(Row(
|
|
262
|
+
operating_point=(round(x_f, 4), round(t_s_f, 2)),
|
|
263
|
+
surrogate_prediction=Nu_M_blend,
|
|
264
|
+
cfd_truth=nu_meas,
|
|
265
|
+
truth_source="experimental",
|
|
266
|
+
cfd_uncertainty=nu_unc,
|
|
267
|
+
guardrail_signals={"ood": 0.0, "residual": 0.0, "variance": 0.0},
|
|
268
|
+
mechanisms=mechanisms,
|
|
269
|
+
meta={
|
|
270
|
+
"time_index": int(i_t),
|
|
271
|
+
"time_s": float(t_s),
|
|
272
|
+
"location_index": int(i_loc),
|
|
273
|
+
"X_m": float(x),
|
|
274
|
+
"u_inlet_m_per_s": float(u),
|
|
275
|
+
"T_wall_K": float(T_wall),
|
|
276
|
+
"T_inlet_K": float(T_inlet),
|
|
277
|
+
"delta_T_K": float(dT),
|
|
278
|
+
"Re_x": float(Re_x),
|
|
279
|
+
"Gr_x": float(Gr_x),
|
|
280
|
+
"Ra_x": float(Ra_x),
|
|
281
|
+
"Ri_x": float(Ri_x),
|
|
282
|
+
"Nu_F_local": float(Nu_F),
|
|
283
|
+
"Nu_N_local": float(Nu_N),
|
|
284
|
+
"Nu_blend_churchill": float(Nu_M_blend),
|
|
285
|
+
"q_meas_W_per_m2": float(q_meas),
|
|
286
|
+
"q_uncertainty_W_per_m2": float(u_q),
|
|
287
|
+
"Nu_meas": float(nu_meas),
|
|
288
|
+
"Nu_meas_uncertainty": float(nu_unc),
|
|
289
|
+
},
|
|
290
|
+
))
|
|
291
|
+
|
|
292
|
+
# Independence guard runs the per-row shape validation.
|
|
293
|
+
rows = load_rows(rows)
|
|
294
|
+
|
|
295
|
+
plate_length_m = float(
|
|
296
|
+
config.geometry.dims.get("plate_length_m", 1.926)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
reference = {"ood": [0.0], "residual": [0.0], "variance": [0.0]}
|
|
300
|
+
meta = SubstrateMeta(
|
|
301
|
+
name="lance-smith-vehicle3",
|
|
302
|
+
divergent_truth_substrate=True,
|
|
303
|
+
reason=_LANCE_SMITH_REASON,
|
|
304
|
+
norm_strategy="per_row_truth",
|
|
305
|
+
bound_for_pde=None,
|
|
306
|
+
magnitude_bridge_ok=True,
|
|
307
|
+
extra={
|
|
308
|
+
"data_dir": str(data_dir),
|
|
309
|
+
"n_timesteps": int(n_t),
|
|
310
|
+
"n_locations": int(n_loc),
|
|
311
|
+
"n_rows_pre_subrange": int(n_t * n_loc),
|
|
312
|
+
"n_rows_post_subrange": int(len(rows)),
|
|
313
|
+
"time_subrange_s": list(time_subrange) if time_subrange else None,
|
|
314
|
+
"plate_length_m": plate_length_m,
|
|
315
|
+
"X_positions_m": X_m.tolist(),
|
|
316
|
+
"time_range_s": [float(t_seconds[0]), float(t_seconds[-1])],
|
|
317
|
+
"fluid": "air at ~87 kPa, T_film ~305 K",
|
|
318
|
+
"air_properties": {
|
|
319
|
+
"nu_m2_per_s": nu_air,
|
|
320
|
+
"k_W_per_mK": k_air,
|
|
321
|
+
"Pr": pr,
|
|
322
|
+
"note": "ν pressure-corrected for ambient 87 kPa",
|
|
323
|
+
},
|
|
324
|
+
"closures": {
|
|
325
|
+
"forced": (
|
|
326
|
+
f"{forced_id} "
|
|
327
|
+
f"(Nu_x = 0.332·Re_x^(1/2)·Pr^(1/3))"
|
|
328
|
+
),
|
|
329
|
+
"natural": (
|
|
330
|
+
f"{natural_id} "
|
|
331
|
+
f"(Nu_x = 0.59·Ra_x^(1/4))"
|
|
332
|
+
),
|
|
333
|
+
"blend": f"Churchill assisting, n={churchill_n}",
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
)
|
|
337
|
+
return rows, reference, meta
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ── forrest_visual_estimates (engine-driven, Step 9b) ───────────────────────
|
|
341
|
+
|
|
342
|
+
# Identical to the legacy `forrest_substrate.SubstrateMeta.reason` so the
|
|
343
|
+
# structural-equality gate stays strict.
|
|
344
|
+
_FORREST_REASON = (
|
|
345
|
+
"Independent experimental truth: measured Nu via thermocouples + "
|
|
346
|
+
"RTDs + heat flux sensors on a high-aspect-ratio mini-channel "
|
|
347
|
+
"with asymmetric one-sided heating, per Forrest, Hu, Buongiorno, "
|
|
348
|
+
"McKrell (2014, SAND2014-18834J / OSTI 1295764). Nu uncertainty "
|
|
349
|
+
"at 95% CI ~±10%. Truth is measured q\" → measured Nu, NOT a "
|
|
350
|
+
"fitted correlation. Steady-state — no temporal autocorrelation "
|
|
351
|
+
"discount; each (Re, Pr) operating point is an independent draw."
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _load_forrest_visual_estimate_rows(csv_path: Path,
|
|
356
|
+
cell_assignment_fn,
|
|
357
|
+
) -> list[dict]:
|
|
358
|
+
"""Read the visual-estimate CSV into per-point dicts (raw measurements +
|
|
359
|
+
propagated total uncertainty). Comment lines beginning with '#' are
|
|
360
|
+
skipped; rows with non-positive Nu_meas are skipped (mirrors the
|
|
361
|
+
legacy `forrest_to_rows` filter).
|
|
362
|
+
|
|
363
|
+
Returns dicts in INSERTION ORDER (matches the CSV's row-id order) so
|
|
364
|
+
the structural-equality gate aligns row-for-row.
|
|
365
|
+
"""
|
|
366
|
+
import csv as _csv
|
|
367
|
+
import numpy as np
|
|
368
|
+
|
|
369
|
+
rows: list[dict] = []
|
|
370
|
+
with csv_path.open("r", newline="") as fh:
|
|
371
|
+
reader = _csv.DictReader(
|
|
372
|
+
(line for line in fh if not line.lstrip().startswith("#")),
|
|
373
|
+
)
|
|
374
|
+
for r in reader:
|
|
375
|
+
try:
|
|
376
|
+
Re = float(r["Re"])
|
|
377
|
+
Pr = float(r["Pr"])
|
|
378
|
+
Nu_meas = float(r["Nu_meas"])
|
|
379
|
+
except (TypeError, ValueError, KeyError):
|
|
380
|
+
continue
|
|
381
|
+
if Nu_meas <= 0:
|
|
382
|
+
continue
|
|
383
|
+
# Paper-reported uncertainty is FRACTIONAL (e.g. 0.10 = 10%);
|
|
384
|
+
# digitization uncertainty is ABSOLUTE Nu units. Both go into
|
|
385
|
+
# the ForrestRow shape the legacy expects.
|
|
386
|
+
nu_unc_paper_pct = float(r.get("Nu_unc_paper_pct", 0.0) or 0.0)
|
|
387
|
+
nu_unc_dig_abs_raw = r.get("Nu_unc_digitization_abs", "")
|
|
388
|
+
nu_unc_dig_abs = (
|
|
389
|
+
float(nu_unc_dig_abs_raw) if str(nu_unc_dig_abs_raw).strip() else 0.0
|
|
390
|
+
)
|
|
391
|
+
Nu_unc_reported = Nu_meas * nu_unc_paper_pct
|
|
392
|
+
# legacy propagation: sqrt(reported^2 + digitization^2)
|
|
393
|
+
if nu_unc_dig_abs > 0:
|
|
394
|
+
total_unc = float(
|
|
395
|
+
np.sqrt(Nu_unc_reported ** 2 + nu_unc_dig_abs ** 2)
|
|
396
|
+
)
|
|
397
|
+
else:
|
|
398
|
+
total_unc = Nu_unc_reported
|
|
399
|
+
rows.append({
|
|
400
|
+
"Re": Re,
|
|
401
|
+
"Pr": Pr,
|
|
402
|
+
"Nu_meas": Nu_meas,
|
|
403
|
+
"Nu_unc_reported": Nu_unc_reported,
|
|
404
|
+
"Nu_unc_total_propagated": total_unc,
|
|
405
|
+
"digitization_uncertainty": nu_unc_dig_abs if nu_unc_dig_abs > 0 else None,
|
|
406
|
+
"source": str(r.get("source") or "forrest"),
|
|
407
|
+
"figure_or_table": str(r.get("figure") or ""),
|
|
408
|
+
"T_bulk_K": None,
|
|
409
|
+
"T_wall_K": None,
|
|
410
|
+
"mass_flow_kg_s": None,
|
|
411
|
+
"cell": cell_assignment_fn(Re),
|
|
412
|
+
})
|
|
413
|
+
return rows
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _forrest_cell_assignment_from_config(config: VehicleConfig):
|
|
417
|
+
"""Return a function `Re -> cell_name` derived from the YAML's re_bands.
|
|
418
|
+
Mirrors `forrest_substrate.cell_assignment` semantics: the first band
|
|
419
|
+
whose `re_lt` exceeds Re wins; the last band is the catch-all."""
|
|
420
|
+
if config.cell_bands.type != "re_bands":
|
|
421
|
+
raise ValueError(
|
|
422
|
+
f"Forrest engine path expects cell_bands.type='re_bands'; "
|
|
423
|
+
f"got {config.cell_bands.type!r}."
|
|
424
|
+
)
|
|
425
|
+
bands = list(config.cell_bands.bands)
|
|
426
|
+
if not bands:
|
|
427
|
+
raise ValueError("Forrest cell_bands has no bands.")
|
|
428
|
+
|
|
429
|
+
def assign(re_val: float) -> str:
|
|
430
|
+
for band in bands:
|
|
431
|
+
re_lt = float(band["re_lt"])
|
|
432
|
+
if re_val < re_lt:
|
|
433
|
+
return str(band["name"])
|
|
434
|
+
return str(bands[-1]["name"])
|
|
435
|
+
return assign
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def forrest_visual_estimates(config: VehicleConfig,
|
|
439
|
+
registry: dict[str, ClosureEntry]) -> tuple[list[Row], dict, SubstrateMeta]:
|
|
440
|
+
"""Engine-driven Forrest loader (Step 9b).
|
|
441
|
+
|
|
442
|
+
Reads the visual-estimate CSV at `config.data_source.path` and emits
|
|
443
|
+
Row objects whose `surrogate_prediction` is the matched closure
|
|
444
|
+
(Modified Sparrow-Cur) computed VIA THE REGISTRY. Reference
|
|
445
|
+
closures (Gnielinski, Dittus-Boelter, Petukhov, Sieder-Tate) are
|
|
446
|
+
also computed via the registry and surfaced in meta + as Mechanism
|
|
447
|
+
entries.
|
|
448
|
+
|
|
449
|
+
The geometry-match invariant is exercised on its HAPPY path here:
|
|
450
|
+
Forrest's matched_closure_id is `modified-sparrow-cur-...` whose
|
|
451
|
+
registry `geometry_class` is `narrow_rect_channel_one_sided`, which
|
|
452
|
+
matches the vehicle YAML's `geometry.class`. No `expect_mismatch`
|
|
453
|
+
override needed.
|
|
454
|
+
|
|
455
|
+
Status caveat: today the only CSV available is the visual-estimate
|
|
456
|
+
triage file (~n=15, sub-critical + transition + boundary rows).
|
|
457
|
+
`cfd_truth` from this source is NOT bankable as a true verdict; the
|
|
458
|
+
`source` field is `visual-estimate-rendered-pdf-fig5` so downstream
|
|
459
|
+
callers can filter. The loader still RUNS so the substrate engine
|
|
460
|
+
is exercised end-to-end on Forrest before banked digitization lands.
|
|
461
|
+
"""
|
|
462
|
+
import csv as _csv
|
|
463
|
+
import numpy as np
|
|
464
|
+
|
|
465
|
+
if config.data_source.path is None:
|
|
466
|
+
raise ValueError(
|
|
467
|
+
f"VehicleConfig {config.vehicle_id!r}: data_source.path is "
|
|
468
|
+
f"required for the forrest_visual_estimates loader."
|
|
469
|
+
)
|
|
470
|
+
csv_path = _resolve(config.data_source.path)
|
|
471
|
+
if not csv_path.exists():
|
|
472
|
+
raise FileNotFoundError(
|
|
473
|
+
f"Forrest visual-estimate CSV not found: {csv_path}"
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# Registry lookups (matched + 4 references).
|
|
477
|
+
matched = registry[config.matched_closure_id]
|
|
478
|
+
forced_id = config.matched_closure_id # alias for readability below
|
|
479
|
+
refs: dict[str, ClosureEntry] = {
|
|
480
|
+
cid: registry[cid] for cid in config.reference_closure_ids
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
# Pin the legacy-expected reference set so a future YAML edit that
|
|
484
|
+
# drops one (or reorders) surfaces here, not as silent meta drift.
|
|
485
|
+
expected_refs = {
|
|
486
|
+
"gnielinski-1976",
|
|
487
|
+
"dittus-boelter-1930",
|
|
488
|
+
"sieder-tate-1936",
|
|
489
|
+
"petukhov-1970",
|
|
490
|
+
}
|
|
491
|
+
missing = expected_refs - set(refs)
|
|
492
|
+
if missing:
|
|
493
|
+
raise RuntimeError(
|
|
494
|
+
f"Forrest engine path expects reference_closure_ids superset "
|
|
495
|
+
f"of {sorted(expected_refs)}; missing: {sorted(missing)}."
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
assign_cell = _forrest_cell_assignment_from_config(config)
|
|
499
|
+
raw_rows = _load_forrest_visual_estimate_rows(csv_path, assign_cell)
|
|
500
|
+
|
|
501
|
+
# Build Row objects with predictions through the registry.
|
|
502
|
+
rows: list[Row] = []
|
|
503
|
+
for raw in raw_rows:
|
|
504
|
+
Re = raw["Re"]
|
|
505
|
+
Pr = raw["Pr"]
|
|
506
|
+
Re_arr = np.array([Re])
|
|
507
|
+
Pr_arr = np.array([Pr])
|
|
508
|
+
# MATCHED: Modified Sparrow-Cur. Geometry-matched primary closure.
|
|
509
|
+
Nu_sc = float(matched.fn(Re=Re_arr, Pr=Pr_arr)[0])
|
|
510
|
+
# References (geometry-mismatched circular-pipe closures, reported
|
|
511
|
+
# for Table-4 cross-comparison).
|
|
512
|
+
Nu_g = float(refs["gnielinski-1976"].fn(Re=Re_arr, Pr=Pr_arr)[0])
|
|
513
|
+
Nu_db = float(refs["dittus-boelter-1930"].fn(Re=Re_arr, Pr=Pr_arr)[0])
|
|
514
|
+
Nu_pk = float(refs["petukhov-1970"].fn(Re=Re_arr, Pr=Pr_arr)[0])
|
|
515
|
+
Nu_st = float(refs["sieder-tate-1936"].fn(Re=Re_arr, Pr=Pr_arr)[0])
|
|
516
|
+
|
|
517
|
+
# Mechanism calib_lo/hi come from the REGISTRY ranges; this
|
|
518
|
+
# replaces the SPARROW_CUR_RE_LO/HI + GNIELINSKI_RE_LO/HI +
|
|
519
|
+
# DITTUS_BOELTER_RE_LO/HI module constants in forrest_substrate.
|
|
520
|
+
if matched.re_range is None:
|
|
521
|
+
raise RuntimeError(
|
|
522
|
+
f"{config.matched_closure_id!r} has no re_range; cannot "
|
|
523
|
+
f"build matched mechanism."
|
|
524
|
+
)
|
|
525
|
+
for ref_id in ("gnielinski-1976", "dittus-boelter-1930"):
|
|
526
|
+
if refs[ref_id].re_range is None:
|
|
527
|
+
raise RuntimeError(
|
|
528
|
+
f"{ref_id!r} has no re_range; cannot build reference "
|
|
529
|
+
f"mechanism."
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
mechanisms = [
|
|
533
|
+
Mechanism(
|
|
534
|
+
name="forced_one_sided_narrow_rect_sparrow_cur_modified",
|
|
535
|
+
closure_id=config.matched_closure_id,
|
|
536
|
+
operating_value=Re,
|
|
537
|
+
calib_lo=matched.re_range[0],
|
|
538
|
+
calib_hi=matched.re_range[1],
|
|
539
|
+
contribution=Nu_sc,
|
|
540
|
+
),
|
|
541
|
+
Mechanism(
|
|
542
|
+
name="forced_internal_flow_gnielinski_reference",
|
|
543
|
+
closure_id="gnielinski-1976",
|
|
544
|
+
operating_value=Re,
|
|
545
|
+
calib_lo=refs["gnielinski-1976"].re_range[0],
|
|
546
|
+
calib_hi=refs["gnielinski-1976"].re_range[1],
|
|
547
|
+
contribution=Nu_g,
|
|
548
|
+
),
|
|
549
|
+
Mechanism(
|
|
550
|
+
name="forced_internal_flow_dittus_boelter_reference",
|
|
551
|
+
closure_id="dittus-boelter-1930",
|
|
552
|
+
operating_value=Re,
|
|
553
|
+
calib_lo=refs["dittus-boelter-1930"].re_range[0],
|
|
554
|
+
calib_hi=refs["dittus-boelter-1930"].re_range[1],
|
|
555
|
+
contribution=Nu_db,
|
|
556
|
+
),
|
|
557
|
+
]
|
|
558
|
+
|
|
559
|
+
rows.append(Row(
|
|
560
|
+
operating_point=(round(Re, 1), round(Pr, 4)),
|
|
561
|
+
surrogate_prediction=Nu_sc,
|
|
562
|
+
cfd_truth=raw["Nu_meas"],
|
|
563
|
+
truth_source="experimental",
|
|
564
|
+
cfd_uncertainty=raw["Nu_unc_total_propagated"],
|
|
565
|
+
guardrail_signals={"ood": 0.0, "residual": 0.0, "variance": 0.0},
|
|
566
|
+
mechanisms=mechanisms,
|
|
567
|
+
meta={
|
|
568
|
+
"Re": Re,
|
|
569
|
+
"Pr": Pr,
|
|
570
|
+
"Nu_meas": raw["Nu_meas"],
|
|
571
|
+
"Nu_unc_reported": raw["Nu_unc_reported"],
|
|
572
|
+
"Nu_unc_total_propagated": raw["Nu_unc_total_propagated"],
|
|
573
|
+
"Nu_pred_modified_sparrow_cur": Nu_sc,
|
|
574
|
+
"Nu_pred_gnielinski": Nu_g,
|
|
575
|
+
"Nu_pred_dittus_boelter": Nu_db,
|
|
576
|
+
"Nu_pred_petukhov": Nu_pk,
|
|
577
|
+
"Nu_pred_sieder_tate": Nu_st,
|
|
578
|
+
"cell": raw["cell"],
|
|
579
|
+
"T_bulk_K": raw["T_bulk_K"],
|
|
580
|
+
"T_wall_K": raw["T_wall_K"],
|
|
581
|
+
"mass_flow_kg_s": raw["mass_flow_kg_s"],
|
|
582
|
+
"source": raw["source"],
|
|
583
|
+
"figure_or_table": raw["figure_or_table"],
|
|
584
|
+
"digitization_uncertainty": raw["digitization_uncertainty"],
|
|
585
|
+
},
|
|
586
|
+
))
|
|
587
|
+
|
|
588
|
+
rows = load_rows(rows)
|
|
589
|
+
|
|
590
|
+
# Geometry / fluid context (from YAML).
|
|
591
|
+
dims = config.geometry.dims
|
|
592
|
+
alpha_star = float(dims.get("aspect_ratio_alpha_star", 0.0))
|
|
593
|
+
inverse = float(dims.get("aspect_ratio_inverse", 0.0))
|
|
594
|
+
gap_mm = float(dims.get("gap_mm", 0.0))
|
|
595
|
+
width_mm = float(dims.get("width_mm", 0.0))
|
|
596
|
+
length_mm = float(dims.get("length_mm", 0.0))
|
|
597
|
+
heated_length_mm = float(dims.get("heated_length_mm", 0.0))
|
|
598
|
+
heated_width_mm = float(dims.get("heated_width_mm", 0.0))
|
|
599
|
+
Dh_mm = float(dims.get("Dh_mm", 0.0))
|
|
600
|
+
|
|
601
|
+
geometry_text = (
|
|
602
|
+
f"high aspect ratio mini-channel, α*={alpha_star:.3f} "
|
|
603
|
+
f"(~{inverse:.0f}:1 width:gap), gap "
|
|
604
|
+
f"{gap_mm} mm, width {width_mm} mm, channel length "
|
|
605
|
+
f"{length_mm} mm, heated length {heated_length_mm} mm × "
|
|
606
|
+
f"width {heated_width_mm} mm, one-sided uniform-heat-flux "
|
|
607
|
+
f"asymmetric heating (other walls insulated/adiabatic)"
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
reference = {"ood": [0.0], "residual": [0.0], "variance": [0.0]}
|
|
611
|
+
meta = SubstrateMeta(
|
|
612
|
+
name="forrest-mini-channel",
|
|
613
|
+
divergent_truth_substrate=True,
|
|
614
|
+
reason=_FORREST_REASON,
|
|
615
|
+
norm_strategy="per_row_truth",
|
|
616
|
+
bound_for_pde=None,
|
|
617
|
+
magnitude_bridge_ok=True,
|
|
618
|
+
extra={
|
|
619
|
+
"geometry": geometry_text,
|
|
620
|
+
"hydraulic_diameter_mm": Dh_mm,
|
|
621
|
+
"fluid": config.fluid.name,
|
|
622
|
+
"Pr_range_paper_design": [1.77, 9.44],
|
|
623
|
+
"Re_range_paper_design": [2200, 93000],
|
|
624
|
+
"Pr_range_paper_table4": list(config.fluid.pr_range) if config.fluid.pr_range else [2.2, 5.4],
|
|
625
|
+
"Re_range_paper_table4_textbook_closures": [10000, 70000],
|
|
626
|
+
"Pr_range_2012_conference": [3.9, 4.0],
|
|
627
|
+
"Re_range_2012_conference": [5000, 32000],
|
|
628
|
+
"critical_Re": [3500, 4000],
|
|
629
|
+
"n_rows": len(rows),
|
|
630
|
+
"data_source_path": str(csv_path),
|
|
631
|
+
"closures": {
|
|
632
|
+
"primary_geometry_matched": (
|
|
633
|
+
f"{config.matched_closure_id} "
|
|
634
|
+
f"(corpus {matched.status}; ONLY corpus closure with "
|
|
635
|
+
"explicit one-sided-heating geometry dependence)"
|
|
636
|
+
),
|
|
637
|
+
"reference_circular_pipe": [
|
|
638
|
+
f"{cid} (corpus {refs[cid].status}; geometry-mismatched)"
|
|
639
|
+
for cid in config.reference_closure_ids
|
|
640
|
+
],
|
|
641
|
+
"matched_closure_rationale": (
|
|
642
|
+
"Sparrow-Cur-modified is the geometry-matched closure for "
|
|
643
|
+
"Forrest's one-sided heated narrow rectangular channel. "
|
|
644
|
+
"Forrest 2014 Table 4 reports MAE 6.1% — lowest of any "
|
|
645
|
+
"standard closure on this geometry. Testing the "
|
|
646
|
+
"differentiator on circular-pipe closures would "
|
|
647
|
+
"contaminate divergence with geometry mismatch (the Lance "
|
|
648
|
+
"& Smith confined-geometry trap)."
|
|
649
|
+
),
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
)
|
|
653
|
+
return rows, reference, meta
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# ── mudhafar_wpd (engine-driven scaffold, Step 9c) ──────────────────────────
|
|
657
|
+
|
|
658
|
+
# Mudhafar substrate-meta `reason` field. Kept in this module so the
|
|
659
|
+
# engine-driven path is self-contained; the meta text stays close to
|
|
660
|
+
# the loader logic.
|
|
661
|
+
_MUDHAFAR_REASON = (
|
|
662
|
+
"Independent experimental truth: measured Nu via thermocouples on "
|
|
663
|
+
"circular micro-tubes (50-950 μm inner diameter, smooth + rough "
|
|
664
|
+
"variants) for air and CO2, per Mudhafar, M. A. H. (2023), 'The "
|
|
665
|
+
"measurement of friction factors and heat transfer Nusselt numbers "
|
|
666
|
+
"for the flow of air and CO2 through micro tubes', Heat and Mass "
|
|
667
|
+
"Transfer 59:989-1004 (DOI 10.1007/s00231-022-03315-x). D3 positive-"
|
|
668
|
+
"control vehicle — INTENTIONAL geometry mismatch with the matched "
|
|
669
|
+
"closure (Modified Sparrow-Cur, narrow_rect_channel_one_sided) so "
|
|
670
|
+
"baseline + corpus signals are both expected to fire across the "
|
|
671
|
+
"small-d and rough cells."
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
# Expected WPD CSV schema, mirroring the Forrest visual-estimate shape but
|
|
676
|
+
# with two added geometry columns. Source figures: Figs 7-11 of Mudhafar
|
|
677
|
+
# 2023 (Heat and Mass Transfer 59:989-1004).
|
|
678
|
+
_MUDHAFAR_EXPECTED_CSV_COLUMNS = (
|
|
679
|
+
"row", "Re", "Pr", "Nu_meas",
|
|
680
|
+
"Nu_unc_paper_pct", "Nu_unc_digitization_abs",
|
|
681
|
+
"Dh_um", "rough",
|
|
682
|
+
"source", "figure", "note",
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _mudhafar_cell_assignment_from_config(config: VehicleConfig):
|
|
687
|
+
"""Build a `(Dh_um, rough) -> cell_name` mapper from the YAML's
|
|
688
|
+
dh_roughness_bands. The locked Mudhafar cell-assignment policy:
|
|
689
|
+
if rough → 'rough'
|
|
690
|
+
elif Dh_um < dh_um_max → 'small_d_smooth'
|
|
691
|
+
else → 'standard_smooth'
|
|
692
|
+
"""
|
|
693
|
+
if config.cell_bands.type != "dh_roughness_bands":
|
|
694
|
+
raise ValueError(
|
|
695
|
+
f"Mudhafar engine path expects cell_bands.type="
|
|
696
|
+
f"'dh_roughness_bands'; got {config.cell_bands.type!r}."
|
|
697
|
+
)
|
|
698
|
+
bands = list(config.cell_bands.bands)
|
|
699
|
+
if not bands:
|
|
700
|
+
raise ValueError("Mudhafar cell_bands has no bands.")
|
|
701
|
+
|
|
702
|
+
# Build lookup tables from the YAML so the assignment is data, not code.
|
|
703
|
+
rough_band: dict | None = None
|
|
704
|
+
small_d_band: dict | None = None
|
|
705
|
+
standard_band: dict | None = None
|
|
706
|
+
for band in bands:
|
|
707
|
+
if band.get("rough") is True:
|
|
708
|
+
rough_band = band
|
|
709
|
+
elif "dh_um_max" in band:
|
|
710
|
+
small_d_band = band
|
|
711
|
+
else:
|
|
712
|
+
standard_band = band
|
|
713
|
+
if rough_band is None or small_d_band is None or standard_band is None:
|
|
714
|
+
raise ValueError(
|
|
715
|
+
f"Mudhafar dh_roughness_bands must include one rough, one "
|
|
716
|
+
f"dh_um_max-bounded smooth, and one standard band; got "
|
|
717
|
+
f"{bands!r}."
|
|
718
|
+
)
|
|
719
|
+
dh_threshold = float(small_d_band["dh_um_max"])
|
|
720
|
+
|
|
721
|
+
def assign(dh_um: float, rough: bool) -> str:
|
|
722
|
+
if rough:
|
|
723
|
+
return str(rough_band["name"])
|
|
724
|
+
if dh_um < dh_threshold:
|
|
725
|
+
return str(small_d_band["name"])
|
|
726
|
+
return str(standard_band["name"])
|
|
727
|
+
return assign
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _load_mudhafar_wpd_rows(csv_path: Path,
|
|
731
|
+
cell_assignment_fn) -> list[dict]:
|
|
732
|
+
"""Read the Mudhafar WPD CSV into per-point dicts (raw measurements +
|
|
733
|
+
propagated total uncertainty). Mirrors the Forrest visual-estimate
|
|
734
|
+
reader; expected schema is documented at module scope.
|
|
735
|
+
|
|
736
|
+
Comment lines (starting with '#') are skipped; rows with non-positive
|
|
737
|
+
Nu_meas are skipped.
|
|
738
|
+
"""
|
|
739
|
+
import csv as _csv
|
|
740
|
+
import numpy as np
|
|
741
|
+
|
|
742
|
+
rows: list[dict] = []
|
|
743
|
+
with csv_path.open("r", newline="") as fh:
|
|
744
|
+
reader = _csv.DictReader(
|
|
745
|
+
(line for line in fh if not line.lstrip().startswith("#")),
|
|
746
|
+
)
|
|
747
|
+
for r in reader:
|
|
748
|
+
try:
|
|
749
|
+
Re = float(r["Re"])
|
|
750
|
+
Pr = float(r["Pr"])
|
|
751
|
+
Nu_meas = float(r["Nu_meas"])
|
|
752
|
+
Dh_um = float(r["Dh_um"])
|
|
753
|
+
except (TypeError, ValueError, KeyError):
|
|
754
|
+
continue
|
|
755
|
+
if Nu_meas <= 0:
|
|
756
|
+
continue
|
|
757
|
+
rough_str = str(r.get("rough", "")).strip().lower()
|
|
758
|
+
rough = rough_str in ("1", "true", "yes", "y")
|
|
759
|
+
nu_unc_paper_pct = float(r.get("Nu_unc_paper_pct", 0.0) or 0.0)
|
|
760
|
+
nu_unc_dig_raw = r.get("Nu_unc_digitization_abs", "")
|
|
761
|
+
nu_unc_dig_abs = (
|
|
762
|
+
float(nu_unc_dig_raw) if str(nu_unc_dig_raw).strip() else 0.0
|
|
763
|
+
)
|
|
764
|
+
Nu_unc_reported = Nu_meas * nu_unc_paper_pct
|
|
765
|
+
if nu_unc_dig_abs > 0:
|
|
766
|
+
total_unc = float(
|
|
767
|
+
np.sqrt(Nu_unc_reported ** 2 + nu_unc_dig_abs ** 2)
|
|
768
|
+
)
|
|
769
|
+
else:
|
|
770
|
+
total_unc = Nu_unc_reported
|
|
771
|
+
rows.append({
|
|
772
|
+
"Re": Re,
|
|
773
|
+
"Pr": Pr,
|
|
774
|
+
"Nu_meas": Nu_meas,
|
|
775
|
+
"Nu_unc_reported": Nu_unc_reported,
|
|
776
|
+
"Nu_unc_total_propagated": total_unc,
|
|
777
|
+
"digitization_uncertainty": nu_unc_dig_abs if nu_unc_dig_abs > 0 else None,
|
|
778
|
+
"Dh_um": Dh_um,
|
|
779
|
+
"rough": rough,
|
|
780
|
+
"source": str(r.get("source") or "mudhafar"),
|
|
781
|
+
"figure_or_table": str(r.get("figure") or ""),
|
|
782
|
+
"cell": cell_assignment_fn(Dh_um, rough),
|
|
783
|
+
})
|
|
784
|
+
return rows
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def mudhafar_wpd(config: VehicleConfig,
|
|
788
|
+
registry: dict[str, ClosureEntry]) -> tuple[list[Row], dict, SubstrateMeta]:
|
|
789
|
+
"""Engine-driven Mudhafar loader (Step 9c).
|
|
790
|
+
|
|
791
|
+
Reads a WPD-digitized CSV at `config.data_source.path` (when banked)
|
|
792
|
+
and emits Rows whose `surrogate_prediction` is the INTENTIONALLY
|
|
793
|
+
geometry-mismatched matched closure (Modified Sparrow-Cur applied to
|
|
794
|
+
circular micro-tubes) computed via the registry.
|
|
795
|
+
|
|
796
|
+
The geometry-match invariant is intentionally OVERRIDDEN by the
|
|
797
|
+
Mudhafar YAML's `expect_mismatch: true` + `mismatch_rationale`. The
|
|
798
|
+
substrate engine surfaces `mismatch_rationale` into `meta.extra` for
|
|
799
|
+
audit.
|
|
800
|
+
|
|
801
|
+
DATA STATE: the WPD CSVs from Figs 7-11 of Mudhafar 2023 are not yet
|
|
802
|
+
banked. Until they are, this loader raises NotImplementedError unless
|
|
803
|
+
`data_source.path` points at an existing CSV with the documented
|
|
804
|
+
schema. A synthetic-CSV test exercises the engine path end-to-end so
|
|
805
|
+
the wiring is known-good before real data lands.
|
|
806
|
+
|
|
807
|
+
Expected CSV columns (per `_MUDHAFAR_EXPECTED_CSV_COLUMNS`):
|
|
808
|
+
row, Re, Pr, Nu_meas, Nu_unc_paper_pct, Nu_unc_digitization_abs,
|
|
809
|
+
Dh_um, rough (truthy/falsy), source, figure, note
|
|
810
|
+
"""
|
|
811
|
+
import numpy as np
|
|
812
|
+
|
|
813
|
+
if config.data_source.path is None:
|
|
814
|
+
raise NotImplementedError(
|
|
815
|
+
"mudhafar_wpd loader: VehicleConfig.data_source.path is unset. "
|
|
816
|
+
"The Mudhafar WPD CSV from Figs 7-11 of Mudhafar 2023 must be "
|
|
817
|
+
"banked at `results/mudhafar_digitization/wpd_fig*.csv` (or "
|
|
818
|
+
"equivalent) and the YAML's `data_source.path` pointed at it. "
|
|
819
|
+
f"Expected columns: {_MUDHAFAR_EXPECTED_CSV_COLUMNS}."
|
|
820
|
+
)
|
|
821
|
+
csv_path = _resolve(config.data_source.path)
|
|
822
|
+
if not csv_path.exists():
|
|
823
|
+
raise NotImplementedError(
|
|
824
|
+
f"mudhafar_wpd loader: data_source.path resolves to {csv_path} "
|
|
825
|
+
f"which does not exist. The Mudhafar WPD CSV from Figs 7-11 of "
|
|
826
|
+
f"Mudhafar 2023 must be banked before this loader produces rows. "
|
|
827
|
+
f"Expected columns: {_MUDHAFAR_EXPECTED_CSV_COLUMNS}."
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
matched = registry[config.matched_closure_id]
|
|
831
|
+
# Mudhafar references are circular-pipe closures (Gnielinski, D-B,
|
|
832
|
+
# Petukhov). Pin the expected set so a YAML edit that drops one
|
|
833
|
+
# surfaces here.
|
|
834
|
+
refs: dict[str, ClosureEntry] = {
|
|
835
|
+
cid: registry[cid] for cid in config.reference_closure_ids
|
|
836
|
+
}
|
|
837
|
+
expected_refs = {"gnielinski-1976", "dittus-boelter-1930", "petukhov-1970"}
|
|
838
|
+
missing = expected_refs - set(refs)
|
|
839
|
+
if missing:
|
|
840
|
+
raise RuntimeError(
|
|
841
|
+
f"Mudhafar engine path expects reference_closure_ids superset "
|
|
842
|
+
f"of {sorted(expected_refs)}; missing: {sorted(missing)}."
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
assign_cell = _mudhafar_cell_assignment_from_config(config)
|
|
846
|
+
raw_rows = _load_mudhafar_wpd_rows(csv_path, assign_cell)
|
|
847
|
+
|
|
848
|
+
if matched.re_range is None:
|
|
849
|
+
raise RuntimeError(
|
|
850
|
+
f"{config.matched_closure_id!r} has no re_range; cannot build "
|
|
851
|
+
f"matched mechanism."
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
rows: list[Row] = []
|
|
855
|
+
for raw in raw_rows:
|
|
856
|
+
Re = raw["Re"]
|
|
857
|
+
Pr = raw["Pr"]
|
|
858
|
+
Re_arr = np.array([Re])
|
|
859
|
+
Pr_arr = np.array([Pr])
|
|
860
|
+
Nu_sc = float(matched.fn(Re=Re_arr, Pr=Pr_arr)[0])
|
|
861
|
+
Nu_g = float(refs["gnielinski-1976"].fn(Re=Re_arr, Pr=Pr_arr)[0])
|
|
862
|
+
Nu_db = float(refs["dittus-boelter-1930"].fn(Re=Re_arr, Pr=Pr_arr)[0])
|
|
863
|
+
Nu_pk = float(refs["petukhov-1970"].fn(Re=Re_arr, Pr=Pr_arr)[0])
|
|
864
|
+
|
|
865
|
+
mechanisms = [
|
|
866
|
+
Mechanism(
|
|
867
|
+
name="forced_circular_micro_tube_sparrow_cur_mismatched",
|
|
868
|
+
closure_id=config.matched_closure_id,
|
|
869
|
+
operating_value=Re,
|
|
870
|
+
calib_lo=matched.re_range[0],
|
|
871
|
+
calib_hi=matched.re_range[1],
|
|
872
|
+
contribution=Nu_sc,
|
|
873
|
+
),
|
|
874
|
+
Mechanism(
|
|
875
|
+
name="forced_internal_flow_gnielinski_reference",
|
|
876
|
+
closure_id="gnielinski-1976",
|
|
877
|
+
operating_value=Re,
|
|
878
|
+
calib_lo=refs["gnielinski-1976"].re_range[0],
|
|
879
|
+
calib_hi=refs["gnielinski-1976"].re_range[1],
|
|
880
|
+
contribution=Nu_g,
|
|
881
|
+
),
|
|
882
|
+
Mechanism(
|
|
883
|
+
name="forced_internal_flow_dittus_boelter_reference",
|
|
884
|
+
closure_id="dittus-boelter-1930",
|
|
885
|
+
operating_value=Re,
|
|
886
|
+
calib_lo=refs["dittus-boelter-1930"].re_range[0],
|
|
887
|
+
calib_hi=refs["dittus-boelter-1930"].re_range[1],
|
|
888
|
+
contribution=Nu_db,
|
|
889
|
+
),
|
|
890
|
+
]
|
|
891
|
+
|
|
892
|
+
rows.append(Row(
|
|
893
|
+
operating_point=(round(Re, 1), round(Pr, 4), round(raw["Dh_um"], 2)),
|
|
894
|
+
surrogate_prediction=Nu_sc,
|
|
895
|
+
cfd_truth=raw["Nu_meas"],
|
|
896
|
+
truth_source="experimental",
|
|
897
|
+
cfd_uncertainty=raw["Nu_unc_total_propagated"],
|
|
898
|
+
guardrail_signals={"ood": 0.0, "residual": 0.0, "variance": 0.0},
|
|
899
|
+
mechanisms=mechanisms,
|
|
900
|
+
meta={
|
|
901
|
+
"Re": Re,
|
|
902
|
+
"Pr": Pr,
|
|
903
|
+
"Dh_um": raw["Dh_um"],
|
|
904
|
+
"rough": raw["rough"],
|
|
905
|
+
"Nu_meas": raw["Nu_meas"],
|
|
906
|
+
"Nu_unc_reported": raw["Nu_unc_reported"],
|
|
907
|
+
"Nu_unc_total_propagated": raw["Nu_unc_total_propagated"],
|
|
908
|
+
"Nu_pred_modified_sparrow_cur": Nu_sc,
|
|
909
|
+
"Nu_pred_gnielinski": Nu_g,
|
|
910
|
+
"Nu_pred_dittus_boelter": Nu_db,
|
|
911
|
+
"Nu_pred_petukhov": Nu_pk,
|
|
912
|
+
"cell": raw["cell"],
|
|
913
|
+
"source": raw["source"],
|
|
914
|
+
"figure_or_table": raw["figure_or_table"],
|
|
915
|
+
"digitization_uncertainty": raw["digitization_uncertainty"],
|
|
916
|
+
},
|
|
917
|
+
))
|
|
918
|
+
|
|
919
|
+
rows = load_rows(rows)
|
|
920
|
+
|
|
921
|
+
reference = {"ood": [0.0], "residual": [0.0], "variance": [0.0]}
|
|
922
|
+
meta = SubstrateMeta(
|
|
923
|
+
name="mudhafar-micro-tubes",
|
|
924
|
+
divergent_truth_substrate=True,
|
|
925
|
+
reason=_MUDHAFAR_REASON,
|
|
926
|
+
norm_strategy="per_row_truth",
|
|
927
|
+
bound_for_pde=None,
|
|
928
|
+
magnitude_bridge_ok=True,
|
|
929
|
+
extra={
|
|
930
|
+
"geometry": (
|
|
931
|
+
f"circular micro-tubes; inner diameters "
|
|
932
|
+
f"{config.geometry.dims.get('smooth_diameters_um')} μm "
|
|
933
|
+
f"smooth + roughness levels "
|
|
934
|
+
f"{config.geometry.dims.get('rough_roughness_um')} μm Ra"
|
|
935
|
+
),
|
|
936
|
+
"fluid": config.fluid.name,
|
|
937
|
+
"Pr_range": list(config.fluid.pr_range) if config.fluid.pr_range else None,
|
|
938
|
+
"n_rows": len(rows),
|
|
939
|
+
"data_source_path": str(csv_path),
|
|
940
|
+
"closures": {
|
|
941
|
+
"primary_matched_INTENTIONAL_MISMATCH": (
|
|
942
|
+
f"{config.matched_closure_id} "
|
|
943
|
+
f"(corpus {matched.status}; INTENDED geometry mismatch — "
|
|
944
|
+
f"narrow_rect_channel_one_sided closure applied to "
|
|
945
|
+
f"circular_micro_tube_smooth geometry; positive-control "
|
|
946
|
+
f"design)"
|
|
947
|
+
),
|
|
948
|
+
"reference_circular_pipe": [
|
|
949
|
+
f"{cid} (corpus {refs[cid].status}; geometry-aligned to "
|
|
950
|
+
f"circular micro-tubes)"
|
|
951
|
+
for cid in config.reference_closure_ids
|
|
952
|
+
],
|
|
953
|
+
"positive_control_rationale": (
|
|
954
|
+
"Sparrow-Cur is the Forrest-matched closure. Applying it "
|
|
955
|
+
"to circular micro-tubes is the INTENDED experimental "
|
|
956
|
+
"mismatch. Baselines and corpus validity signal both "
|
|
957
|
+
"expected to fire across out-of-envelope cells "
|
|
958
|
+
"(small_d_smooth and rough)."
|
|
959
|
+
),
|
|
960
|
+
},
|
|
961
|
+
},
|
|
962
|
+
)
|
|
963
|
+
return rows, reference, meta
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
# ── naca_wpd (engine-driven, Step 9d) ───────────────────────────────────────
|
|
967
|
+
|
|
968
|
+
_NACA_REASON = (
|
|
969
|
+
"Independent experimental truth: measured Nu_x from heat-transfer "
|
|
970
|
+
"coefficients on a steam-jacketed circular tube (1.785\" ID), air "
|
|
971
|
+
"flow with Pr ≈ 0.71, per NACA TN-1451 (Boelter, Young, Iversen 1948). "
|
|
972
|
+
"Entrance-region heat-transfer coefficient varies strongly with x/D "
|
|
973
|
+
"below x/D ≈ 10; aggregate (Re, Pr) surrogates that omit x/D from "
|
|
974
|
+
"inputs cannot see this failure. The corpus's x/D ≥ 10 validity "
|
|
975
|
+
"boundary catches it — the differentiator on three figures (10, 15, "
|
|
976
|
+
"19) per docs/findings/PhysMAP_D3_NACA_EntranceRegion_Findings_v0_1.md."
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
# Two CSV schemas are accepted (both used by the NACA workflow):
|
|
980
|
+
# * WPD-banked (wpd_fig*.csv): uses `Nu_unc_digitization_abs` (absolute Nu).
|
|
981
|
+
# Routed through the strict `naca_wpd_loader.load_wpd_csv` which enforces
|
|
982
|
+
# source='wpd-csv' and the Fig-21 asymptote-not-reached exclusion.
|
|
983
|
+
# * Cross-validated visual (cross_validated_fig*.csv): uses
|
|
984
|
+
# `Nu_unc_digitization_pct` (percent). No strict source discipline; the
|
|
985
|
+
# visual-precision development runs use these.
|
|
986
|
+
_NACA_WPD_SCHEMA_DIG_COLUMN = "Nu_unc_digitization_abs"
|
|
987
|
+
_NACA_VISUAL_SCHEMA_DIG_COLUMN = "Nu_unc_digitization_pct"
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _naca_cell_assignment_from_config(config: VehicleConfig):
|
|
991
|
+
"""Return `x_over_D -> cell_name` from the YAML's x_over_d_bands.
|
|
992
|
+
Mirrors `naca_tn1451_substrate.cell_assignment`: first band whose
|
|
993
|
+
`x_over_d_lt` exceeds x/D wins."""
|
|
994
|
+
if config.cell_bands.type != "x_over_d_bands":
|
|
995
|
+
raise ValueError(
|
|
996
|
+
f"NACA engine path expects cell_bands.type='x_over_d_bands'; "
|
|
997
|
+
f"got {config.cell_bands.type!r}."
|
|
998
|
+
)
|
|
999
|
+
bands = list(config.cell_bands.bands)
|
|
1000
|
+
if not bands:
|
|
1001
|
+
raise ValueError("NACA cell_bands has no bands.")
|
|
1002
|
+
|
|
1003
|
+
def assign(x_over_D: float) -> str:
|
|
1004
|
+
for band in bands:
|
|
1005
|
+
if x_over_D < float(band["x_over_d_lt"]):
|
|
1006
|
+
return str(band["name"])
|
|
1007
|
+
return str(bands[-1]["name"])
|
|
1008
|
+
return assign
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _detect_naca_csv_schema(csv_path: Path) -> str:
|
|
1012
|
+
"""Inspect the CSV header to figure out which schema variant it is.
|
|
1013
|
+
Returns 'wpd' if the `Nu_unc_digitization_abs` column is present, else
|
|
1014
|
+
'visual' if `Nu_unc_digitization_pct` is present. Raises otherwise."""
|
|
1015
|
+
import csv as _csv
|
|
1016
|
+
with csv_path.open("r", newline="") as fh:
|
|
1017
|
+
reader = _csv.reader(
|
|
1018
|
+
line for line in fh if not line.lstrip().startswith("#")
|
|
1019
|
+
)
|
|
1020
|
+
header = next(reader, [])
|
|
1021
|
+
cols = set(header)
|
|
1022
|
+
if _NACA_WPD_SCHEMA_DIG_COLUMN in cols:
|
|
1023
|
+
return "wpd"
|
|
1024
|
+
if _NACA_VISUAL_SCHEMA_DIG_COLUMN in cols:
|
|
1025
|
+
return "visual"
|
|
1026
|
+
raise ValueError(
|
|
1027
|
+
f"NACA CSV {csv_path} has neither {_NACA_WPD_SCHEMA_DIG_COLUMN!r} nor "
|
|
1028
|
+
f"{_NACA_VISUAL_SCHEMA_DIG_COLUMN!r} in its header; cannot determine "
|
|
1029
|
+
f"which uncertainty convention to use. Header: {sorted(cols)}."
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _load_naca_raw_rows(csv_path: Path,
|
|
1034
|
+
schema: str,
|
|
1035
|
+
cell_assignment_fn) -> tuple[list[dict], list[dict]]:
|
|
1036
|
+
"""Read a NACA CSV into per-point raw-row dicts.
|
|
1037
|
+
|
|
1038
|
+
For schema='wpd': delegates to `naca_wpd_loader.load_wpd_csv` so the
|
|
1039
|
+
Fig-21 Re=26,100 asymptote-not-reached exclusion is enforced and
|
|
1040
|
+
source='wpd-csv' is required. Returns (verdict_rows, alignment_rows).
|
|
1041
|
+
|
|
1042
|
+
For schema='visual': hand-rolled CSV reader; the digitization
|
|
1043
|
+
uncertainty column is in percent, so it's converted to absolute Nu
|
|
1044
|
+
units before propagation. Returns (verdict_rows, []).
|
|
1045
|
+
"""
|
|
1046
|
+
import csv as _csv
|
|
1047
|
+
import numpy as np
|
|
1048
|
+
|
|
1049
|
+
verdict_rows: list[dict] = []
|
|
1050
|
+
alignment_rows: list[dict] = []
|
|
1051
|
+
|
|
1052
|
+
if schema == "wpd":
|
|
1053
|
+
from physmap.substrate.naca_wpd_loader import load_wpd_csv
|
|
1054
|
+
loaded = load_wpd_csv(csv_path)
|
|
1055
|
+
for naca_row, bucket in (
|
|
1056
|
+
[(r, "verdict") for r in loaded.rows_for_verdict]
|
|
1057
|
+
+ [(r, "alignment") for r in loaded.rows_alignment_only]
|
|
1058
|
+
):
|
|
1059
|
+
# NACARow → raw dict; load_wpd_csv has already combined paper +
|
|
1060
|
+
# digitization uncertainty into Nu_unc (absolute units).
|
|
1061
|
+
wpd_note = naca_row.__dict__.get("wpd_note", "")
|
|
1062
|
+
raw = {
|
|
1063
|
+
"Re": float(naca_row.Re),
|
|
1064
|
+
"Pr": float(naca_row.Pr),
|
|
1065
|
+
"x_over_D": float(naca_row.x_over_D),
|
|
1066
|
+
"Nu_meas": float(naca_row.Nu_meas),
|
|
1067
|
+
"Nu_unc_total_propagated": float(naca_row.Nu_unc),
|
|
1068
|
+
"digitization_uncertainty_abs": float(
|
|
1069
|
+
naca_row.digitization_uncertainty or 0.0
|
|
1070
|
+
),
|
|
1071
|
+
"source": str(naca_row.source),
|
|
1072
|
+
"figure": str(naca_row.figure),
|
|
1073
|
+
"entering_condition": str(naca_row.entering_condition),
|
|
1074
|
+
"note": wpd_note,
|
|
1075
|
+
"cell": cell_assignment_fn(float(naca_row.x_over_D)),
|
|
1076
|
+
"alignment_only": (bucket == "alignment"),
|
|
1077
|
+
}
|
|
1078
|
+
(alignment_rows if bucket == "alignment" else verdict_rows).append(raw)
|
|
1079
|
+
return verdict_rows, alignment_rows
|
|
1080
|
+
|
|
1081
|
+
# schema == "visual"
|
|
1082
|
+
with csv_path.open("r", newline="") as fh:
|
|
1083
|
+
reader = _csv.DictReader(
|
|
1084
|
+
(line for line in fh if not line.lstrip().startswith("#")),
|
|
1085
|
+
)
|
|
1086
|
+
for r in reader:
|
|
1087
|
+
try:
|
|
1088
|
+
Re = float(r["Re"])
|
|
1089
|
+
Pr = float(r["Pr"])
|
|
1090
|
+
x_over_D = float(r["x_over_D"])
|
|
1091
|
+
Nu_meas = float(r["Nu_meas"])
|
|
1092
|
+
except (TypeError, ValueError, KeyError):
|
|
1093
|
+
continue
|
|
1094
|
+
if Nu_meas <= 0:
|
|
1095
|
+
continue
|
|
1096
|
+
paper_pct = float(r.get("Nu_unc_paper_pct", 0.0) or 0.0)
|
|
1097
|
+
dig_pct_raw = r.get(_NACA_VISUAL_SCHEMA_DIG_COLUMN, "")
|
|
1098
|
+
dig_pct = float(dig_pct_raw) if str(dig_pct_raw).strip() else 0.0
|
|
1099
|
+
paper_abs = Nu_meas * paper_pct
|
|
1100
|
+
dig_abs = Nu_meas * dig_pct
|
|
1101
|
+
total_unc = float(np.sqrt(paper_abs ** 2 + dig_abs ** 2))
|
|
1102
|
+
verdict_rows.append({
|
|
1103
|
+
"Re": Re,
|
|
1104
|
+
"Pr": Pr,
|
|
1105
|
+
"x_over_D": x_over_D,
|
|
1106
|
+
"Nu_meas": Nu_meas,
|
|
1107
|
+
"Nu_unc_total_propagated": total_unc,
|
|
1108
|
+
"digitization_uncertainty_abs": dig_abs,
|
|
1109
|
+
"source": str(r.get("source") or "naca"),
|
|
1110
|
+
"figure": str(r.get("figure") or ""),
|
|
1111
|
+
"entering_condition": str(r.get("entering_condition") or ""),
|
|
1112
|
+
"note": str(r.get("note") or ""),
|
|
1113
|
+
"cell": cell_assignment_fn(x_over_D),
|
|
1114
|
+
"alignment_only": False,
|
|
1115
|
+
})
|
|
1116
|
+
return verdict_rows, []
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def naca_wpd(config: VehicleConfig,
|
|
1120
|
+
registry: dict[str, ClosureEntry]) -> tuple[list[Row], dict, SubstrateMeta]:
|
|
1121
|
+
"""Engine-driven NACA TN-1451 loader (Step 9d).
|
|
1122
|
+
|
|
1123
|
+
Reads the WPD-banked CSV (`wpd_fig*.csv`) at `config.data_source.path`
|
|
1124
|
+
and emits Rows whose `surrogate_prediction` is the INTENTIONALLY
|
|
1125
|
+
geometry-mismatched matched closure (Gnielinski applied to an
|
|
1126
|
+
entrance-region geometry) computed via the registry. Also supports
|
|
1127
|
+
the two-reader cross-validated visual CSVs (`cross_validated_fig*.csv`)
|
|
1128
|
+
when the YAML's `data_source.path` points at one — the loader
|
|
1129
|
+
detects the schema by column presence.
|
|
1130
|
+
|
|
1131
|
+
The geometry-match invariant is OVERRIDDEN by the YAML's
|
|
1132
|
+
`expect_mismatch: true` + `mismatch_rationale`. v0.3 framing per the
|
|
1133
|
+
findings doc: practitioners use Gnielinski as an aggregate
|
|
1134
|
+
heat-exchanger surrogate that omits x/D; PhysMAP's corpus signal
|
|
1135
|
+
catches the x/D < 10 invalidity.
|
|
1136
|
+
|
|
1137
|
+
Fig-21 Re=26,100 asymptote-not-reached exclusion is enforced for
|
|
1138
|
+
WPD-schema CSVs via `naca_wpd_loader.load_wpd_csv` (excluded rows
|
|
1139
|
+
appear in `meta.extra['alignment_only_rows']` for downstream
|
|
1140
|
+
inspection but are NOT in the returned `rows` list).
|
|
1141
|
+
|
|
1142
|
+
Expected WPD CSV columns: see
|
|
1143
|
+
`physmap/results/naca_digitization/WPD_DIGITIZATION_SPEC.md`.
|
|
1144
|
+
"""
|
|
1145
|
+
import numpy as np
|
|
1146
|
+
|
|
1147
|
+
if config.data_source.path is None:
|
|
1148
|
+
raise ValueError(
|
|
1149
|
+
f"VehicleConfig {config.vehicle_id!r}: data_source.path is "
|
|
1150
|
+
f"required for the naca_wpd loader."
|
|
1151
|
+
)
|
|
1152
|
+
csv_path = _resolve(config.data_source.path)
|
|
1153
|
+
if not csv_path.exists():
|
|
1154
|
+
raise FileNotFoundError(
|
|
1155
|
+
f"NACA CSV not found at {csv_path}. Bank a WPD CSV "
|
|
1156
|
+
f"(wpd_fig*.csv) or point the YAML at a cross-validated "
|
|
1157
|
+
f"visual CSV (cross_validated_fig*.csv)."
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
schema = _detect_naca_csv_schema(csv_path)
|
|
1161
|
+
|
|
1162
|
+
matched = registry[config.matched_closure_id] # gnielinski-1976
|
|
1163
|
+
refs: dict[str, ClosureEntry] = {
|
|
1164
|
+
cid: registry[cid] for cid in config.reference_closure_ids
|
|
1165
|
+
}
|
|
1166
|
+
expected_refs = {"dittus-boelter-1930", "sieder-tate-1936", "petukhov-1970"}
|
|
1167
|
+
missing = expected_refs - set(refs)
|
|
1168
|
+
if missing:
|
|
1169
|
+
raise RuntimeError(
|
|
1170
|
+
f"NACA engine path expects reference_closure_ids superset "
|
|
1171
|
+
f"of {sorted(expected_refs)}; missing: {sorted(missing)}."
|
|
1172
|
+
)
|
|
1173
|
+
|
|
1174
|
+
assign_cell = _naca_cell_assignment_from_config(config)
|
|
1175
|
+
verdict_raw, alignment_raw = _load_naca_raw_rows(
|
|
1176
|
+
csv_path, schema, assign_cell,
|
|
1177
|
+
)
|
|
1178
|
+
|
|
1179
|
+
if matched.re_range is None:
|
|
1180
|
+
raise RuntimeError(
|
|
1181
|
+
f"{config.matched_closure_id!r} has no re_range; cannot build "
|
|
1182
|
+
f"matched mechanism."
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
# Pin the geometry mismatch — the v0.3 framing depends on Gnielinski
|
|
1186
|
+
# (circular_pipe) applied to circular_pipe_entrance_region. If the
|
|
1187
|
+
# registry ever changes Gnielinski's geometry_class to match the
|
|
1188
|
+
# entrance-region one, the v0.3 framing is lost; surface that
|
|
1189
|
+
# mismatch loudly here.
|
|
1190
|
+
if matched.geometry_class == config.geometry.class_:
|
|
1191
|
+
raise RuntimeError(
|
|
1192
|
+
f"NACA engine path: Gnielinski's geometry_class is now "
|
|
1193
|
+
f"{matched.geometry_class!r} which MATCHES the vehicle's "
|
|
1194
|
+
f"{config.geometry.class_!r}. The v0.3 framing (Gnielinski "
|
|
1195
|
+
f"as an aggregate-heat-exchanger surrogate applied to entrance "
|
|
1196
|
+
f"regions, omitting x/D from inputs) requires this mismatch. "
|
|
1197
|
+
f"Audit the registry edit."
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
rows: list[Row] = []
|
|
1201
|
+
for raw in verdict_raw:
|
|
1202
|
+
Re = raw["Re"]
|
|
1203
|
+
Pr = raw["Pr"]
|
|
1204
|
+
Re_arr = np.array([Re])
|
|
1205
|
+
Pr_arr = np.array([Pr])
|
|
1206
|
+
Nu_g = float(matched.fn(Re=Re_arr, Pr=Pr_arr)[0])
|
|
1207
|
+
Nu_db = float(refs["dittus-boelter-1930"].fn(Re=Re_arr, Pr=Pr_arr)[0])
|
|
1208
|
+
Nu_st = float(refs["sieder-tate-1936"].fn(Re=Re_arr, Pr=Pr_arr)[0])
|
|
1209
|
+
Nu_pk = float(refs["petukhov-1970"].fn(Re=Re_arr, Pr=Pr_arr)[0])
|
|
1210
|
+
|
|
1211
|
+
mechanisms = [
|
|
1212
|
+
Mechanism(
|
|
1213
|
+
name="forced_circular_pipe_gnielinski_entrance_mismatched",
|
|
1214
|
+
closure_id=config.matched_closure_id,
|
|
1215
|
+
operating_value=Re,
|
|
1216
|
+
calib_lo=matched.re_range[0],
|
|
1217
|
+
calib_hi=matched.re_range[1],
|
|
1218
|
+
contribution=Nu_g,
|
|
1219
|
+
),
|
|
1220
|
+
Mechanism(
|
|
1221
|
+
name="forced_internal_flow_dittus_boelter_reference",
|
|
1222
|
+
closure_id="dittus-boelter-1930",
|
|
1223
|
+
operating_value=Re,
|
|
1224
|
+
calib_lo=refs["dittus-boelter-1930"].re_range[0],
|
|
1225
|
+
calib_hi=refs["dittus-boelter-1930"].re_range[1],
|
|
1226
|
+
contribution=Nu_db,
|
|
1227
|
+
),
|
|
1228
|
+
Mechanism(
|
|
1229
|
+
name="forced_internal_flow_sieder_tate_reference",
|
|
1230
|
+
closure_id="sieder-tate-1936",
|
|
1231
|
+
operating_value=Re,
|
|
1232
|
+
calib_lo=refs["sieder-tate-1936"].re_range[0],
|
|
1233
|
+
calib_hi=refs["sieder-tate-1936"].re_range[1],
|
|
1234
|
+
contribution=Nu_st,
|
|
1235
|
+
),
|
|
1236
|
+
Mechanism(
|
|
1237
|
+
name="forced_internal_flow_petukhov_reference",
|
|
1238
|
+
closure_id="petukhov-1970",
|
|
1239
|
+
operating_value=Re,
|
|
1240
|
+
calib_lo=refs["petukhov-1970"].re_range[0],
|
|
1241
|
+
calib_hi=refs["petukhov-1970"].re_range[1],
|
|
1242
|
+
contribution=Nu_pk,
|
|
1243
|
+
),
|
|
1244
|
+
]
|
|
1245
|
+
|
|
1246
|
+
rows.append(Row(
|
|
1247
|
+
operating_point=(round(Re, 1), round(raw["x_over_D"], 4)),
|
|
1248
|
+
surrogate_prediction=Nu_g,
|
|
1249
|
+
cfd_truth=raw["Nu_meas"],
|
|
1250
|
+
truth_source="experimental",
|
|
1251
|
+
cfd_uncertainty=raw["Nu_unc_total_propagated"],
|
|
1252
|
+
guardrail_signals={"ood": 0.0, "residual": 0.0, "variance": 0.0},
|
|
1253
|
+
mechanisms=mechanisms,
|
|
1254
|
+
meta={
|
|
1255
|
+
"Re": Re,
|
|
1256
|
+
"Pr": Pr,
|
|
1257
|
+
# x_over_D MUST be in meta — the validity-range detector
|
|
1258
|
+
# reads it to compute distance to Gnielinski's x/D ≥ 10
|
|
1259
|
+
# bound. The surrogate/baseline detectors deliberately
|
|
1260
|
+
# don't see it (the v0.3 omission framing).
|
|
1261
|
+
"x_over_D": raw["x_over_D"],
|
|
1262
|
+
"Nu_meas": raw["Nu_meas"],
|
|
1263
|
+
"Nu_unc_total_propagated": raw["Nu_unc_total_propagated"],
|
|
1264
|
+
"Nu_pred_gnielinski": Nu_g,
|
|
1265
|
+
"Nu_pred_dittus_boelter": Nu_db,
|
|
1266
|
+
"Nu_pred_sieder_tate": Nu_st,
|
|
1267
|
+
"Nu_pred_petukhov": Nu_pk,
|
|
1268
|
+
"cell": raw["cell"],
|
|
1269
|
+
"source": raw["source"],
|
|
1270
|
+
"figure_or_table": raw["figure"],
|
|
1271
|
+
"entering_condition": raw["entering_condition"],
|
|
1272
|
+
"note": raw["note"],
|
|
1273
|
+
"digitization_uncertainty": raw["digitization_uncertainty_abs"],
|
|
1274
|
+
},
|
|
1275
|
+
))
|
|
1276
|
+
|
|
1277
|
+
rows = load_rows(rows)
|
|
1278
|
+
|
|
1279
|
+
reference = {"ood": [0.0], "residual": [0.0], "variance": [0.0]}
|
|
1280
|
+
meta = SubstrateMeta(
|
|
1281
|
+
name="naca-tn1451-entrance-region",
|
|
1282
|
+
divergent_truth_substrate=True,
|
|
1283
|
+
reason=_NACA_REASON,
|
|
1284
|
+
norm_strategy="per_row_truth",
|
|
1285
|
+
bound_for_pde=None,
|
|
1286
|
+
magnitude_bridge_ok=True,
|
|
1287
|
+
extra={
|
|
1288
|
+
"geometry": (
|
|
1289
|
+
f"circular pipe ID {config.geometry.dims.get('tube_id_inch')}\" "
|
|
1290
|
+
f"({config.geometry.dims.get('tube_id_mm')} mm), "
|
|
1291
|
+
f"{config.geometry.dims.get('wall_condition')}, "
|
|
1292
|
+
f"{config.geometry.dims.get('surface_finish')}"
|
|
1293
|
+
),
|
|
1294
|
+
"fluid": config.fluid.name,
|
|
1295
|
+
"Pr_range": list(config.fluid.pr_range) if config.fluid.pr_range else None,
|
|
1296
|
+
"n_rows": len(rows),
|
|
1297
|
+
"n_alignment_only": len(alignment_raw),
|
|
1298
|
+
"alignment_only_rows": alignment_raw,
|
|
1299
|
+
"schema_detected": schema,
|
|
1300
|
+
"data_source_path": str(csv_path),
|
|
1301
|
+
"closures": {
|
|
1302
|
+
"primary_matched_INTENTIONAL_MISMATCH": (
|
|
1303
|
+
f"{config.matched_closure_id} (corpus {matched.status}; "
|
|
1304
|
+
f"circular_pipe closure applied to "
|
|
1305
|
+
f"circular_pipe_entrance_region geometry; aggregate-HE "
|
|
1306
|
+
f"surrogate practice — omits x/D from inputs)"
|
|
1307
|
+
),
|
|
1308
|
+
"reference_circular_pipe": [
|
|
1309
|
+
f"{cid} (corpus {refs[cid].status}; same aggregate-HE "
|
|
1310
|
+
f"family as the matched closure)"
|
|
1311
|
+
for cid in config.reference_closure_ids
|
|
1312
|
+
],
|
|
1313
|
+
"v03_framing_rationale": (
|
|
1314
|
+
"Per the locked v0.3 prereg + the findings doc, the "
|
|
1315
|
+
"matched closure for NACA is the bare circular-pipe "
|
|
1316
|
+
"Gnielinski applied as a practitioner aggregate-HE "
|
|
1317
|
+
"surrogate WITHOUT x/D in inputs. The mismatch with "
|
|
1318
|
+
"the entrance-region geometry is the failure mode the "
|
|
1319
|
+
"corpus catches; the surrogate + baseline detectors "
|
|
1320
|
+
"can't see it because the inputs (Re, Pr only) are "
|
|
1321
|
+
"in-distribution at every x/D."
|
|
1322
|
+
),
|
|
1323
|
+
},
|
|
1324
|
+
},
|
|
1325
|
+
)
|
|
1326
|
+
return rows, reference, meta
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
# ── Testi & Grassi 2006 (FC-72 horizontal tube) — middle-vehicle loader STUB ──
|
|
1330
|
+
#
|
|
1331
|
+
# The first buoyancy (mixed-convection) middle vehicle. Source: Testi & Grassi
|
|
1332
|
+
# (2006), "Mixed convection heat transfer of FC-72 in a horizontal tube",
|
|
1333
|
+
# J. Heat Transfer, DOI 10.1115/1.2345436. Local Nu at 5 cross-sections x 8
|
|
1334
|
+
# points, Re 3050-6800, Gr 1.3-5.0e8, FC-72.
|
|
1335
|
+
#
|
|
1336
|
+
# DATA STATE: the local-Nu figures are NOT yet digitized. Until banked, this
|
|
1337
|
+
# loader raises NotImplementedError with the expected schema. See
|
|
1338
|
+
# docs/PhysMAP_MiddleVehicle_Extraction_Protocol.md for the per-vehicle
|
|
1339
|
+
# extraction checklist (confirm LOCAL measured Nu; digitize with two-reader
|
|
1340
|
+
# cross-validation + per-point uncertainty; compute Ri/Gr per row; build the
|
|
1341
|
+
# forced-convection surrogate with the buoyancy variable OMITTED + the two
|
|
1342
|
+
# gates; pass the observability subsampling-stability gate; record the
|
|
1343
|
+
# entrance/calming condition for the NACA geometry-dependence link).
|
|
1344
|
+
|
|
1345
|
+
_TESTI_GRASSI_EXPECTED_CSV_COLUMNS = (
|
|
1346
|
+
"row", "Re", "Pr", "Ri", "Gr", "x_over_D", "cross_section",
|
|
1347
|
+
"Nu_meas", "Nu_unc_paper_pct", "Nu_unc_digitization_abs",
|
|
1348
|
+
"entering_condition", "source", "figure", "note",
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def testi_grassi_lfs(config: VehicleConfig,
|
|
1353
|
+
registry: dict[str, ClosureEntry]) -> tuple[list[Row], dict, SubstrateMeta]:
|
|
1354
|
+
"""Engine-driven Testi & Grassi 2006 loader (middle-vehicle STUB).
|
|
1355
|
+
|
|
1356
|
+
Emits Rows whose `surrogate_prediction` is the forced-convection matched
|
|
1357
|
+
closure (Gnielinski, circular pipe) computed via the registry, with the
|
|
1358
|
+
buoyancy variable (Richardson number) OMITTED from the surrogate inputs —
|
|
1359
|
+
the middle-axis failure mechanism. `meta` must carry `Ri` (computed per row
|
|
1360
|
+
from the measured Re + heat flux) so the observability score and the
|
|
1361
|
+
richardson_bands split can read it.
|
|
1362
|
+
|
|
1363
|
+
DATA STATE: not yet digitized — raises NotImplementedError until a CSV with
|
|
1364
|
+
the documented schema is banked at `config.data_source.path`.
|
|
1365
|
+
"""
|
|
1366
|
+
if config.data_source.path is None:
|
|
1367
|
+
raise NotImplementedError(
|
|
1368
|
+
"testi_grassi_lfs loader: VehicleConfig.data_source.path is unset. "
|
|
1369
|
+
"The Testi & Grassi 2006 local-Nu figures (DOI 10.1115/1.2345436) "
|
|
1370
|
+
"must be digitized and banked at "
|
|
1371
|
+
"`results/testi_grassi_digitization/local_nu.csv`. "
|
|
1372
|
+
f"Expected columns: {_TESTI_GRASSI_EXPECTED_CSV_COLUMNS}."
|
|
1373
|
+
)
|
|
1374
|
+
csv_path = _resolve(config.data_source.path)
|
|
1375
|
+
if not csv_path.exists():
|
|
1376
|
+
raise NotImplementedError(
|
|
1377
|
+
f"testi_grassi_lfs loader: data_source.path resolves to {csv_path} "
|
|
1378
|
+
f"which does not exist. Digitize the Testi & Grassi 2006 local-Nu "
|
|
1379
|
+
f"figures (LOCAL measured Nu, NOT overall coefficients) per "
|
|
1380
|
+
f"docs/PhysMAP_MiddleVehicle_Extraction_Protocol.md before this "
|
|
1381
|
+
f"loader produces rows. "
|
|
1382
|
+
f"Expected columns: {_TESTI_GRASSI_EXPECTED_CSV_COLUMNS}."
|
|
1383
|
+
)
|
|
1384
|
+
raise NotImplementedError(
|
|
1385
|
+
"testi_grassi_lfs loader: CSV banked but the row-construction body is "
|
|
1386
|
+
"not implemented. Implement per the extraction protocol: compute Ri/Gr "
|
|
1387
|
+
"per row, set surrogate_prediction from the forced-convection closure "
|
|
1388
|
+
"(buoyancy omitted), populate meta['Ri'] + entering_condition."
|
|
1389
|
+
)
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
# ── dirker_water_richardson_bands (Stage-3 MIDDLE-axis vehicle) ─────────────
|
|
1393
|
+
|
|
1394
|
+
_DIRKER_WATER_EXPECTED_CSV_COLUMNS = (
|
|
1395
|
+
"row", "case", "position", "phi_deg", "angle_span_deg",
|
|
1396
|
+
"local_heat_flux_Wm2", "power_W", "Re", "Pr", "Ri", "Ri_unc_pct",
|
|
1397
|
+
"Nu_meas", "Nu_unc_paper_pct", "Nu_unc_digitization_abs",
|
|
1398
|
+
"entering_condition", "source", "figure", "note",
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
_DIRKER_WATER_REASON = (
|
|
1402
|
+
"Independent experimental truth: length-averaged Nu measured on a smooth "
|
|
1403
|
+
"horizontal water tube (D=27.8 mm, Pr 6-7, laminar Re 650-2600) under "
|
|
1404
|
+
"circumferentially non-uniform heat flux, per Dirker, J., Meyer, J.P. & "
|
|
1405
|
+
"Reid, W.J. (2018), 'Experimental investigation of circumferentially "
|
|
1406
|
+
"non-uniform heat flux ... smooth horizontal tube with buoyancy driven "
|
|
1407
|
+
"secondary flow', Exp. Thermal & Fluid Sci. (ScienceDirect pii "
|
|
1408
|
+
"S0894177718302528). Stage-3 MIDDLE-axis vehicle: the surrogate is a "
|
|
1409
|
+
"buoyancy/position/flux-BLIND forced-convection fit on the low-Ri "
|
|
1410
|
+
"(forced-collapsed) rows; the failure variable Ri=Gr/Re^2 (digitized "
|
|
1411
|
+
"directly from the authors' per-point Figs 18/20) is OMITTED from the "
|
|
1412
|
+
"(log10_Re, Pr) surrogate inputs, so it is only PARTIALLY observable -> a "
|
|
1413
|
+
"middle observability score by construction. Multi-flux (6631 + 4421 W/m2) "
|
|
1414
|
+
"supplies Ri variation at FIXED Re so the failure is corpus-detectable but "
|
|
1415
|
+
"baseline-quiet in the interleaved band (clean_lift), unlike a single-flux "
|
|
1416
|
+
"set whose Ri ~ f(Re) is baseline-sufficient (near the Forrest pole)."
|
|
1417
|
+
)
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def _richardson_cell_assignment_from_config(config: VehicleConfig):
|
|
1421
|
+
"""Return a function `Ri -> cell_name` from the YAML's richardson_bands:
|
|
1422
|
+
the first band whose `ri_lt` exceeds Ri wins; the last band is the
|
|
1423
|
+
catch-all. Mirrors `_forrest_cell_assignment_from_config` but on the
|
|
1424
|
+
Richardson number instead of Re."""
|
|
1425
|
+
if config.cell_bands.type != "richardson_bands":
|
|
1426
|
+
raise ValueError(
|
|
1427
|
+
f"dirker_water path expects cell_bands.type='richardson_bands'; "
|
|
1428
|
+
f"got {config.cell_bands.type!r}."
|
|
1429
|
+
)
|
|
1430
|
+
bands = list(config.cell_bands.bands)
|
|
1431
|
+
if not bands:
|
|
1432
|
+
raise ValueError("richardson_bands has no bands.")
|
|
1433
|
+
|
|
1434
|
+
def assign(ri_val: float) -> str:
|
|
1435
|
+
for band in bands:
|
|
1436
|
+
if ri_val < float(band["ri_lt"]):
|
|
1437
|
+
return str(band["name"])
|
|
1438
|
+
return str(bands[-1]["name"])
|
|
1439
|
+
return assign
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def _load_dirker_water_rows(csv_path: Path) -> list[dict]:
|
|
1443
|
+
"""Read the digitized CSV into per-point dicts (raw measurements +
|
|
1444
|
+
propagated total uncertainty). '#' comment lines are skipped; rows with
|
|
1445
|
+
non-positive Nu_meas or Ri are skipped."""
|
|
1446
|
+
import csv as _csv
|
|
1447
|
+
|
|
1448
|
+
rows: list[dict] = []
|
|
1449
|
+
with csv_path.open("r", newline="") as fh:
|
|
1450
|
+
reader = _csv.DictReader(
|
|
1451
|
+
(line for line in fh if not line.lstrip().startswith("#")),
|
|
1452
|
+
)
|
|
1453
|
+
for r in reader:
|
|
1454
|
+
try:
|
|
1455
|
+
Re = float(r["Re"]); Pr = float(r["Pr"])
|
|
1456
|
+
Ri = float(r["Ri"]); Nu_meas = float(r["Nu_meas"])
|
|
1457
|
+
except (TypeError, ValueError, KeyError):
|
|
1458
|
+
continue
|
|
1459
|
+
if Nu_meas <= 0 or Ri <= 0:
|
|
1460
|
+
continue
|
|
1461
|
+
# Nu_unc_paper_pct is in PERCENT (e.g. 3.5); digitization is ABS Nu.
|
|
1462
|
+
nu_unc_paper_frac = float(r.get("Nu_unc_paper_pct", 0.0) or 0.0) / 100.0
|
|
1463
|
+
nu_unc_dig_abs = float(r.get("Nu_unc_digitization_abs", 0.0) or 0.0)
|
|
1464
|
+
Nu_unc_reported = Nu_meas * nu_unc_paper_frac
|
|
1465
|
+
total_unc = (
|
|
1466
|
+
float(np.sqrt(Nu_unc_reported ** 2 + nu_unc_dig_abs ** 2))
|
|
1467
|
+
if nu_unc_dig_abs > 0 else Nu_unc_reported
|
|
1468
|
+
)
|
|
1469
|
+
ri_unc_pct = float(r.get("Ri_unc_pct", 0.0) or 0.0)
|
|
1470
|
+
rows.append({
|
|
1471
|
+
"Re": Re, "Pr": Pr, "Ri": Ri, "Nu_meas": Nu_meas,
|
|
1472
|
+
"Ri_unc": Ri * ri_unc_pct / 100.0,
|
|
1473
|
+
"Nu_unc_reported": Nu_unc_reported,
|
|
1474
|
+
"Nu_unc_total_propagated": total_unc,
|
|
1475
|
+
"digitization_uncertainty": nu_unc_dig_abs if nu_unc_dig_abs > 0 else None,
|
|
1476
|
+
"case": str(r.get("case") or ""),
|
|
1477
|
+
"position": str(r.get("position") or ""),
|
|
1478
|
+
"phi_deg": float(r.get("phi_deg", 0.0) or 0.0),
|
|
1479
|
+
"local_heat_flux_Wm2": float(r.get("local_heat_flux_Wm2", 0.0) or 0.0),
|
|
1480
|
+
"entering_condition": str(r.get("entering_condition") or ""),
|
|
1481
|
+
"source": str(r.get("source") or "dirker-water"),
|
|
1482
|
+
"figure_or_table": str(r.get("figure") or ""),
|
|
1483
|
+
})
|
|
1484
|
+
return rows
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
def dirker_water_richardson_bands(config: VehicleConfig,
|
|
1488
|
+
registry: dict[str, ClosureEntry]) -> tuple[list[Row], dict, SubstrateMeta]:
|
|
1489
|
+
"""Engine-driven Dirker/Meyer/Reid (2018) MIDDLE-axis loader (Path A).
|
|
1490
|
+
|
|
1491
|
+
Reads the digitized 180-degree-span CSV and emits Rows whose
|
|
1492
|
+
`surrogate_prediction` is a buoyancy/position/flux-BLIND forced-convection
|
|
1493
|
+
fit on the LOW-Ri (forced-collapsed) rows. Gnielinski (the matched closure)
|
|
1494
|
+
is turbulent and yields negative Nu at laminar Re, so it CANNOT be the
|
|
1495
|
+
surrogate-of-record here; instead a degree-1 Nu-vs-log10(Re) least-squares
|
|
1496
|
+
trend is fit on the rows with Ri < the forced cutoff (richardson_bands
|
|
1497
|
+
band[0].ri_lt) and evaluated for EVERY row. Because position and heat-flux
|
|
1498
|
+
are not inputs, the fit collapses the configs at low Ri (accurate -> Gate 1)
|
|
1499
|
+
and fails where they spread at high Ri (measurably wrong -> Gate 2). Gnielinski
|
|
1500
|
+
stays the corpus/validity anchor (its richardson_number ceiling fires on the
|
|
1501
|
+
deploy rows); `meta['Ri']` is populated on every Row for the split, the
|
|
1502
|
+
observability score, and the validity detector.
|
|
1503
|
+
"""
|
|
1504
|
+
if config.data_source.path is None:
|
|
1505
|
+
raise ValueError(
|
|
1506
|
+
f"VehicleConfig {config.vehicle_id!r}: data_source.path is required "
|
|
1507
|
+
f"for the dirker_water_richardson_bands loader. Expected CSV columns: "
|
|
1508
|
+
f"{_DIRKER_WATER_EXPECTED_CSV_COLUMNS}."
|
|
1509
|
+
)
|
|
1510
|
+
csv_path = _resolve(config.data_source.path)
|
|
1511
|
+
if not csv_path.exists():
|
|
1512
|
+
raise FileNotFoundError(
|
|
1513
|
+
f"dirker_water loader: data_source.path resolves to {csv_path} "
|
|
1514
|
+
f"which does not exist."
|
|
1515
|
+
)
|
|
1516
|
+
|
|
1517
|
+
assign_cell = _richardson_cell_assignment_from_config(config)
|
|
1518
|
+
raw = _load_dirker_water_rows(csv_path)
|
|
1519
|
+
if not raw:
|
|
1520
|
+
raise RuntimeError(f"dirker_water loader: no usable rows in {csv_path}")
|
|
1521
|
+
|
|
1522
|
+
# Validate the matched closure is registered (corpus/validity anchor).
|
|
1523
|
+
matched = registry[config.matched_closure_id]
|
|
1524
|
+
|
|
1525
|
+
# ri_forced_cut = the low-Ri (forced/train) boundary = first band's ri_lt.
|
|
1526
|
+
ri_forced_cut = float(config.cell_bands.bands[0]["ri_lt"])
|
|
1527
|
+
|
|
1528
|
+
# PATH-A surrogate: fit the buoyancy-blind forced trend on the low-Ri rows.
|
|
1529
|
+
train = [d for d in raw if d["Ri"] < ri_forced_cut]
|
|
1530
|
+
if len(train) < 2:
|
|
1531
|
+
raise RuntimeError(
|
|
1532
|
+
f"dirker_water loader: need >= 2 low-Ri (Ri<{ri_forced_cut}) train "
|
|
1533
|
+
f"rows for the forced surrogate fit; got {len(train)}."
|
|
1534
|
+
)
|
|
1535
|
+
x_train = np.array([np.log10(d["Re"]) for d in train], dtype=float)
|
|
1536
|
+
y_train = np.array([d["Nu_meas"] for d in train], dtype=float)
|
|
1537
|
+
coeffs = np.polyfit(x_train, y_train, deg=1) # Nu ~ a*log10(Re) + b
|
|
1538
|
+
surrogate_calib_lo = float(min(d["Re"] for d in train))
|
|
1539
|
+
surrogate_calib_hi = float(max(d["Re"] for d in train))
|
|
1540
|
+
|
|
1541
|
+
rows: list[Row] = []
|
|
1542
|
+
for d in raw:
|
|
1543
|
+
Re = d["Re"]; Pr = d["Pr"]; Ri = d["Ri"]
|
|
1544
|
+
nu_surrogate = float(np.polyval(coeffs, np.log10(Re)))
|
|
1545
|
+
mech = Mechanism(
|
|
1546
|
+
name="forced_convection_surrogate_lowRi_fit",
|
|
1547
|
+
closure_id=config.matched_closure_id,
|
|
1548
|
+
operating_value=Re,
|
|
1549
|
+
calib_lo=surrogate_calib_lo,
|
|
1550
|
+
calib_hi=surrogate_calib_hi,
|
|
1551
|
+
contribution=nu_surrogate,
|
|
1552
|
+
)
|
|
1553
|
+
rows.append(Row(
|
|
1554
|
+
operating_point=(round(Re, 1), round(Pr, 4)),
|
|
1555
|
+
surrogate_prediction=nu_surrogate,
|
|
1556
|
+
cfd_truth=d["Nu_meas"],
|
|
1557
|
+
truth_source="experimental",
|
|
1558
|
+
cfd_uncertainty=d["Nu_unc_total_propagated"],
|
|
1559
|
+
guardrail_signals={"ood": 0.0, "residual": 0.0, "variance": 0.0},
|
|
1560
|
+
mechanisms=[mech],
|
|
1561
|
+
meta={
|
|
1562
|
+
"Re": Re, "Pr": Pr, "Ri": Ri,
|
|
1563
|
+
"Nu_meas": d["Nu_meas"],
|
|
1564
|
+
"Ri_unc": d["Ri_unc"],
|
|
1565
|
+
"Nu_unc_reported": d["Nu_unc_reported"],
|
|
1566
|
+
"Nu_unc_total_propagated": d["Nu_unc_total_propagated"],
|
|
1567
|
+
"Nu_pred_forced_surrogate": nu_surrogate,
|
|
1568
|
+
"cell": assign_cell(Ri),
|
|
1569
|
+
"case": d["case"], "position": d["position"], "phi_deg": d["phi_deg"],
|
|
1570
|
+
"local_heat_flux_Wm2": d["local_heat_flux_Wm2"],
|
|
1571
|
+
"entering_condition": d["entering_condition"],
|
|
1572
|
+
"source": d["source"], "figure_or_table": d["figure_or_table"],
|
|
1573
|
+
"digitization_uncertainty": d["digitization_uncertainty"],
|
|
1574
|
+
},
|
|
1575
|
+
))
|
|
1576
|
+
|
|
1577
|
+
rows = load_rows(rows)
|
|
1578
|
+
|
|
1579
|
+
n_train = sum(1 for d in raw if d["Ri"] < ri_forced_cut)
|
|
1580
|
+
dims = config.geometry.dims
|
|
1581
|
+
reference = {"ood": [0.0], "residual": [0.0], "variance": [0.0]}
|
|
1582
|
+
meta = SubstrateMeta(
|
|
1583
|
+
name="dirker-water-nonuniform-flux-180span",
|
|
1584
|
+
divergent_truth_substrate=True,
|
|
1585
|
+
reason=_DIRKER_WATER_REASON,
|
|
1586
|
+
norm_strategy="per_row_truth",
|
|
1587
|
+
bound_for_pde=None,
|
|
1588
|
+
magnitude_bridge_ok=True,
|
|
1589
|
+
extra={
|
|
1590
|
+
"geometry": (
|
|
1591
|
+
f"smooth horizontal circular tube, D={dims.get('tube_id_mm', 27.8)} mm, "
|
|
1592
|
+
f"L/D=72, 180-degree-span partial circumferential heating "
|
|
1593
|
+
f"(positions phi in {{0,90,135,180}} deg), laminar"
|
|
1594
|
+
),
|
|
1595
|
+
"fluid": config.fluid.name,
|
|
1596
|
+
"Pr_range": list(config.fluid.pr_range) if config.fluid.pr_range else [6.0, 7.0],
|
|
1597
|
+
"Re_range_laminar": [650, 2600],
|
|
1598
|
+
"heat_flux_levels_Wm2": [4421, 6631],
|
|
1599
|
+
"failure_variable": "Ri = Gr/Re^2 (digitized Figs 18/20; Okafor Eq 32)",
|
|
1600
|
+
"ri_forced_cut": ri_forced_cut,
|
|
1601
|
+
"n_rows": len(rows),
|
|
1602
|
+
"n_train_lowRi": n_train,
|
|
1603
|
+
"n_deploy_highRi": len(rows) - n_train,
|
|
1604
|
+
"surrogate": (
|
|
1605
|
+
f"PATH A: deg-1 Nu-vs-log10(Re) fit on the {n_train} low-Ri "
|
|
1606
|
+
f"(Ri<{ri_forced_cut}) rows; buoyancy/position/flux-blind; "
|
|
1607
|
+
f"coeffs(a,b)=({coeffs[0]:.4f},{coeffs[1]:.4f})"
|
|
1608
|
+
),
|
|
1609
|
+
"data_source_path": str(csv_path),
|
|
1610
|
+
"closures": {
|
|
1611
|
+
"corpus_validity_anchor": (
|
|
1612
|
+
f"{config.matched_closure_id} (corpus {matched.status}; "
|
|
1613
|
+
"circular-pipe geometry MATCH; carries the richardson_number "
|
|
1614
|
+
"ceiling that fires on deploy rows)"
|
|
1615
|
+
),
|
|
1616
|
+
},
|
|
1617
|
+
},
|
|
1618
|
+
)
|
|
1619
|
+
return rows, reference, meta
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
_VELAZQUEZ_REASON = (
|
|
1623
|
+
"Independent experimental truth: local heat-transfer coefficient of supercritical "
|
|
1624
|
+
"CO2 in a 0.88 mm horizontal microtube, measured by 20 axial thermocouples "
|
|
1625
|
+
"(Velazquez et al. 2026, ATE 285:129206; data EXACT from SI Appendix D, not "
|
|
1626
|
+
"digitized). The surrogate is a CONSTANT-PROPERTY (Re, Pr) closure (Gnielinski); the "
|
|
1627
|
+
"omitted failure driver is the wall/bulk viscosity ratio mu_w/mu_b, which blows up "
|
|
1628
|
+
"near the pseudo-critical point where constant-property correlations fail (Gnielinski "
|
|
1629
|
+
"MAPE ~245% across the set). Property-variation middle-vehicle candidate: mu_w/mu_b is "
|
|
1630
|
+
"absent from the surrogate inputs but partially correlated with Pr -> observability is "
|
|
1631
|
+
"MEASURED (middle vs near-pole is the open question). Buoyancy confound isolated by "
|
|
1632
|
+
"pressure (>=15 MPa clean, per paper Fig. 11). Properties precomputed via CoolProp "
|
|
1633
|
+
"(validated vs the paper's REFPROP); see results/velazquez_sco2_digitization/PROVENANCE.md."
|
|
1634
|
+
)
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
def velazquez_sco2_lfs(config: VehicleConfig,
|
|
1638
|
+
registry: dict[str, ClosureEntry]) -> tuple[list[Row], dict, SubstrateMeta]:
|
|
1639
|
+
"""Engine-driven Velazquez sCO2 loader (property-variation middle vehicle).
|
|
1640
|
+
|
|
1641
|
+
Reads the property-augmented exact-SI CSV and emits Rows whose
|
|
1642
|
+
`surrogate_prediction` is the constant-property matched closure
|
|
1643
|
+
(Gnielinski, circular pipe) computed via the registry. `meta` carries the
|
|
1644
|
+
failure driver `ratio_mu_w_b` (mu_w/mu_b) plus the split coordinates
|
|
1645
|
+
`p_MPa` and `abs_dT_pc`, so the property_variation_bands split + the
|
|
1646
|
+
observability score can read them.
|
|
1647
|
+
"""
|
|
1648
|
+
import csv as _csv
|
|
1649
|
+
|
|
1650
|
+
if config.data_source.path is None:
|
|
1651
|
+
raise ValueError(
|
|
1652
|
+
f"VehicleConfig {config.vehicle_id!r}: data_source.path is required "
|
|
1653
|
+
f"for the velazquez_sco2_lfs loader."
|
|
1654
|
+
)
|
|
1655
|
+
csv_path = _resolve(config.data_source.path)
|
|
1656
|
+
if not csv_path.exists():
|
|
1657
|
+
raise FileNotFoundError(
|
|
1658
|
+
f"Velazquez sCO2 properties CSV not found: {csv_path}. Run "
|
|
1659
|
+
f"results/velazquez_sco2_digitization/compute_properties.py (CoolProp, "
|
|
1660
|
+
f"offline) to generate it from the exact SI extraction."
|
|
1661
|
+
)
|
|
1662
|
+
|
|
1663
|
+
matched = registry[config.matched_closure_id] # gnielinski-constprop-sco2 (constant-property)
|
|
1664
|
+
if matched.re_range is None:
|
|
1665
|
+
raise RuntimeError(
|
|
1666
|
+
f"{config.matched_closure_id!r} has no re_range; cannot build mechanism."
|
|
1667
|
+
)
|
|
1668
|
+
db = registry.get("dittus-boelter-1930")
|
|
1669
|
+
|
|
1670
|
+
rows: list[Row] = []
|
|
1671
|
+
with open(csv_path, newline="") as fh:
|
|
1672
|
+
for raw in _csv.DictReader(fh):
|
|
1673
|
+
Re = float(raw["Re_b"])
|
|
1674
|
+
Pr = float(raw["Pr_b"])
|
|
1675
|
+
Nu_meas = float(raw["Nu_meas"])
|
|
1676
|
+
if not (Nu_meas > 0.0):
|
|
1677
|
+
continue
|
|
1678
|
+
alpha = float(raw["alpha_W_m2K"])
|
|
1679
|
+
unc_alpha = float(raw["unc_alpha_W_m2K"])
|
|
1680
|
+
Nu_unc = Nu_meas * (unc_alpha / alpha) if alpha > 0 else float("nan")
|
|
1681
|
+
Re_arr = np.array([Re])
|
|
1682
|
+
Pr_arr = np.array([Pr])
|
|
1683
|
+
Nu_pred = float(matched.fn(Re=Re_arr, Pr=Pr_arr)[0])
|
|
1684
|
+
Nu_db = float(db.fn(Re=Re_arr, Pr=Pr_arr)[0]) if db is not None else float("nan")
|
|
1685
|
+
|
|
1686
|
+
mechanisms = [
|
|
1687
|
+
Mechanism(
|
|
1688
|
+
name="constant_property_gnielinski_circular_pipe",
|
|
1689
|
+
closure_id=config.matched_closure_id,
|
|
1690
|
+
operating_value=Re,
|
|
1691
|
+
calib_lo=matched.re_range[0],
|
|
1692
|
+
calib_hi=matched.re_range[1],
|
|
1693
|
+
contribution=Nu_pred,
|
|
1694
|
+
),
|
|
1695
|
+
]
|
|
1696
|
+
rows.append(Row(
|
|
1697
|
+
operating_point=(round(Re, 1), round(Pr, 4)),
|
|
1698
|
+
surrogate_prediction=Nu_pred,
|
|
1699
|
+
cfd_truth=Nu_meas,
|
|
1700
|
+
truth_source="experimental",
|
|
1701
|
+
cfd_uncertainty=Nu_unc,
|
|
1702
|
+
guardrail_signals={"ood": 0.0, "residual": 0.0, "variance": 0.0},
|
|
1703
|
+
mechanisms=mechanisms,
|
|
1704
|
+
meta={
|
|
1705
|
+
"Re": Re,
|
|
1706
|
+
"Pr": Pr,
|
|
1707
|
+
# failure driver (omitted from surrogate inputs):
|
|
1708
|
+
"ratio_mu_w_b": float(raw["ratio_mu_w_b"]),
|
|
1709
|
+
# split coordinates:
|
|
1710
|
+
"p_MPa": float(raw["p_MPa"]),
|
|
1711
|
+
"abs_dT_pc": float(raw["abs_dT_pc_K"]),
|
|
1712
|
+
# context:
|
|
1713
|
+
"ratio_rho_w_b": float(raw["ratio_rho_w_b"]),
|
|
1714
|
+
"ratio_lam_w_b": float(raw["ratio_lam_w_b"]),
|
|
1715
|
+
"dT_b_to_pc": float(raw["dT_b_to_pc_K"]),
|
|
1716
|
+
"near_pc": raw["near_pc"] == "True",
|
|
1717
|
+
"Nu_meas": Nu_meas,
|
|
1718
|
+
"Nu_unc": Nu_unc,
|
|
1719
|
+
"Nu_pred_gnielinski": Nu_pred,
|
|
1720
|
+
"Nu_pred_dittus_boelter": Nu_db,
|
|
1721
|
+
"test": raw["test"],
|
|
1722
|
+
"station": int(raw["station"]),
|
|
1723
|
+
"T_b_C": float(raw["Tb_C"]),
|
|
1724
|
+
"T_wi_C": float(raw["Twi_C"]),
|
|
1725
|
+
"source": "velazquez-et-al-2026-SI-appendixD-exact",
|
|
1726
|
+
},
|
|
1727
|
+
))
|
|
1728
|
+
|
|
1729
|
+
rows = load_rows(rows)
|
|
1730
|
+
|
|
1731
|
+
reference = {"ood": [0.0], "residual": [0.0], "variance": [0.0]}
|
|
1732
|
+
meta = SubstrateMeta(
|
|
1733
|
+
name="velazquez-sco2-microtube",
|
|
1734
|
+
divergent_truth_substrate=True,
|
|
1735
|
+
reason=_VELAZQUEZ_REASON,
|
|
1736
|
+
norm_strategy="per_row_truth",
|
|
1737
|
+
bound_for_pde=None,
|
|
1738
|
+
magnitude_bridge_ok=True,
|
|
1739
|
+
extra={
|
|
1740
|
+
"geometry": "horizontal circular microtube, Di=0.88 mm, heated length 1600 mm, 316L",
|
|
1741
|
+
"fluid": config.fluid.name,
|
|
1742
|
+
"n_rows": len(rows),
|
|
1743
|
+
"data_source_path": str(csv_path),
|
|
1744
|
+
"failure_driver": "ratio_mu_w_b (wall/bulk viscosity ratio)",
|
|
1745
|
+
"buoyancy_isolation": "pressure >= 15 MPa (paper Fig. 11)",
|
|
1746
|
+
"constant_property_gnielinski_MAPE_pct": 245.0,
|
|
1747
|
+
"data_provenance": "exact SI Appendix D extraction; properties via CoolProp (validated vs REFPROP)",
|
|
1748
|
+
},
|
|
1749
|
+
)
|
|
1750
|
+
return rows, reference, meta
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
# ── casper_hypersonic_transition (aerospace PHYSMAP_WINS: freestream noise) ──
|
|
1754
|
+
|
|
1755
|
+
_CASPER_REASON = (
|
|
1756
|
+
"Independent experimental truth: digitized RMS wall pressure (p~/p_e) vs "
|
|
1757
|
+
"axial position on a sharp 7-deg cone in conventional-noisy (HWT-5, HWT-8) "
|
|
1758
|
+
"and flight-like-quiet (BAM6QT) hypersonic tunnels (Casper et al.; Casper "
|
|
1759
|
+
"PhD thesis, Purdue BAM6QT; G6 pre-registration v0.2). The surrogate is a GP "
|
|
1760
|
+
"transition model on (Mach, unit Reynolds, axial x) trained on the NOISY "
|
|
1761
|
+
"envelope (Mach 4.9-7.9, incl. HWT-8 so quiet M=6.0 is INTERIOR — the v0.2 "
|
|
1762
|
+
"Mach-confound fix). The omitted driver is the tunnel freestream disturbance "
|
|
1763
|
+
"(RMS Pitot %), baseline-invisible. On the QUIET deploy (~0.05%) transition "
|
|
1764
|
+
"is delayed and the noisy-trained surrogate over-predicts rms; the corpus "
|
|
1765
|
+
"freestream-noise validity bound (Pate-Stainback, >= ~0.5%) fires there while "
|
|
1766
|
+
"the steelman baseline stays silent (quiet deploy is interior in inputs)."
|
|
1767
|
+
)
|
|
1768
|
+
|
|
1769
|
+
# Freestream-noise facility table (mirrors g6_differentiator_v2.py): RMS Pitot %
|
|
1770
|
+
# vs unit Reynolds (millions) per facility; Quiet (BAM6QT laminar nozzle) is the
|
|
1771
|
+
# flight-like ~0.05%. Source: Casper freestream-noise digitization / thesis Fig 4.30.
|
|
1772
|
+
_CASPER_FREESTREAM_FN: dict[str, dict[float, float]] = {
|
|
1773
|
+
"HWT-5": {7.0: 1.8, 9.3: 1.5, 12.0: 1.35, 15.0: 1.2, 20.0: 1.0},
|
|
1774
|
+
"BAM6QT": {5.0: 3.3, 7.0: 2.9, 9.3: 2.549, 11.0: 2.2},
|
|
1775
|
+
"HWT-8": {9.3: 3.736, 12.0: 3.05, 15.0: 2.65},
|
|
1776
|
+
}
|
|
1777
|
+
_CASPER_QUIET_PCT = 0.05
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
def _casper_facility(mach: float) -> str:
|
|
1781
|
+
return "HWT-5" if mach < 5.3 else ("BAM6QT" if mach < 6.5 else "HWT-8")
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def _casper_freestream_pct(flow: str, mach: float, re_per_m_e6: float) -> float:
|
|
1785
|
+
"""RMS-Pitot % for a row: Quiet -> ~0.05%; Noisy -> Re-interpolated facility value."""
|
|
1786
|
+
if flow == "Quiet":
|
|
1787
|
+
return _CASPER_QUIET_PCT
|
|
1788
|
+
table = _CASPER_FREESTREAM_FN[_casper_facility(mach)]
|
|
1789
|
+
xs = sorted(table)
|
|
1790
|
+
return float(np.interp(re_per_m_e6, xs, [table[x] for x in xs]))
|
|
1791
|
+
|
|
1792
|
+
|
|
1793
|
+
def casper_hypersonic_transition(config: VehicleConfig,
|
|
1794
|
+
registry: dict[str, ClosureEntry]) -> tuple[list[Row], dict, SubstrateMeta]:
|
|
1795
|
+
"""Engine-driven Casper loader (aerospace PHYSMAP_WINS: freestream-noise transition).
|
|
1796
|
+
|
|
1797
|
+
Reads the digitized RMS-pressure-vs-x CSV (unit p_rms/p_e), keeping the
|
|
1798
|
+
noisy/quiet source-figures named in data_source.options (defaults reproduce
|
|
1799
|
+
G6 v0.2: noisy Fig9a+Fig13a + HWT-8 thesis Fig4.16; quiet Fig13a). Each row is
|
|
1800
|
+
tagged with its tunnel freestream-noise % (the omitted driver). The surrogate
|
|
1801
|
+
is a GP on (Mach, unit Reynolds, axial x) fit on the NOISY rows (matching the
|
|
1802
|
+
G6 kernel), so it over-predicts on the quiet deploy. The matched closure
|
|
1803
|
+
(Pate-Stainback freestream-noise bound) is the corpus/validity anchor only.
|
|
1804
|
+
"""
|
|
1805
|
+
from sklearn.gaussian_process import GaussianProcessRegressor
|
|
1806
|
+
from sklearn.gaussian_process.kernels import RBF, WhiteKernel, ConstantKernel
|
|
1807
|
+
import csv as _csv
|
|
1808
|
+
|
|
1809
|
+
opts = config.data_source.options
|
|
1810
|
+
rms_unit = str(opts.get("rms_unit", "p_rms/p_e"))
|
|
1811
|
+
noisy_figs = set(opts.get("noisy_figures", ["Fig9a", "Fig13a"]))
|
|
1812
|
+
quiet_figs = set(opts.get("quiet_figures", ["Fig13a"]))
|
|
1813
|
+
|
|
1814
|
+
if config.data_source.path is None:
|
|
1815
|
+
raise ValueError(
|
|
1816
|
+
f"VehicleConfig {config.vehicle_id!r}: data_source.path is required "
|
|
1817
|
+
f"for the casper_hypersonic_transition loader."
|
|
1818
|
+
)
|
|
1819
|
+
main_path = _resolve(config.data_source.path)
|
|
1820
|
+
if not main_path.exists():
|
|
1821
|
+
raise FileNotFoundError(f"Casper rms-vs-x CSV not found: {main_path}")
|
|
1822
|
+
|
|
1823
|
+
def _keep(path: Path, flow_to_figs: dict[str, set]) -> list[dict]:
|
|
1824
|
+
out: list[dict] = []
|
|
1825
|
+
with open(path, newline="") as fh:
|
|
1826
|
+
for r in _csv.DictReader(fh):
|
|
1827
|
+
if r.get("unit") != rms_unit:
|
|
1828
|
+
continue
|
|
1829
|
+
flow = r.get("flow")
|
|
1830
|
+
if flow not in flow_to_figs: # this flow not requested from this file
|
|
1831
|
+
continue
|
|
1832
|
+
figs = flow_to_figs[flow]
|
|
1833
|
+
if figs and r.get("source_figure") not in figs: # empty set = all figures
|
|
1834
|
+
continue
|
|
1835
|
+
mach = float(r["M"]); re_e6 = float(r["Re_per_m_e6"])
|
|
1836
|
+
x_m = float(r["x_m"]); val = float(r["value"])
|
|
1837
|
+
paper_pct = float(r.get("unc_paper_pct", 0.0) or 0.0)
|
|
1838
|
+
dig_abs = float(r.get("unc_digitization", 0.0) or 0.0)
|
|
1839
|
+
total_unc = float(np.sqrt((val * paper_pct / 100.0) ** 2 + dig_abs ** 2))
|
|
1840
|
+
out.append(dict(
|
|
1841
|
+
flow=flow, M=mach, re=re_e6, x=x_m, truth=val,
|
|
1842
|
+
fs=_casper_freestream_pct(flow, mach, re_e6),
|
|
1843
|
+
src_fig=r.get("source_figure", ""), unc=total_unc,
|
|
1844
|
+
))
|
|
1845
|
+
return out
|
|
1846
|
+
|
|
1847
|
+
raw = _keep(main_path, {"Noisy": noisy_figs, "Quiet": quiet_figs})
|
|
1848
|
+
hwt8_rel = opts.get("hwt8_path")
|
|
1849
|
+
if hwt8_rel:
|
|
1850
|
+
hwt8_path = _resolve(hwt8_rel)
|
|
1851
|
+
if hwt8_path.exists():
|
|
1852
|
+
raw += _keep(hwt8_path, {"Noisy": set()}) # HWT-8: noisy, all figures
|
|
1853
|
+
if not raw:
|
|
1854
|
+
raise RuntimeError(f"casper loader: no usable {rms_unit!r} rows in {main_path}")
|
|
1855
|
+
|
|
1856
|
+
noisy = [d for d in raw if d["flow"] == "Noisy"]
|
|
1857
|
+
if len(noisy) < 3:
|
|
1858
|
+
raise RuntimeError(f"casper loader: need >= 3 noisy train rows, got {len(noisy)}")
|
|
1859
|
+
|
|
1860
|
+
# GP surrogate on (Mach, unit Reynolds, axial x), fit on NOISY (G6 v0.2 kernel).
|
|
1861
|
+
Xtr = np.array([[d["M"], d["re"], d["x"]] for d in noisy], dtype=float)
|
|
1862
|
+
ytr = np.array([d["truth"] for d in noisy], dtype=float)
|
|
1863
|
+
mu, sd = Xtr.mean(0), Xtr.std(0)
|
|
1864
|
+
sd = np.where(sd > 0, sd, 1.0)
|
|
1865
|
+
gp = GaussianProcessRegressor(
|
|
1866
|
+
kernel=ConstantKernel(0.001, (1e-6, 1.0)) * RBF([1.0, 1.0, 1.0], (0.1, 10.0))
|
|
1867
|
+
+ WhiteKernel(1e-5, (1e-8, 1e-2)),
|
|
1868
|
+
normalize_y=True, n_restarts_optimizer=4, random_state=0,
|
|
1869
|
+
).fit((Xtr - mu) / sd, ytr)
|
|
1870
|
+
|
|
1871
|
+
matched = registry[config.matched_closure_id] # corpus/validity anchor (Pate bound)
|
|
1872
|
+
fs_lo, fs_hi = matched.bound_range # freestream-noise validated band (corpus mirror)
|
|
1873
|
+
|
|
1874
|
+
rows: list[Row] = []
|
|
1875
|
+
for d in raw:
|
|
1876
|
+
xz = (np.array([[d["M"], d["re"], d["x"]]], dtype=float) - mu) / sd
|
|
1877
|
+
pred = float(gp.predict(xz)[0])
|
|
1878
|
+
mech = Mechanism(
|
|
1879
|
+
name="casper_freestream_noise_validity_anchor",
|
|
1880
|
+
closure_id=config.matched_closure_id,
|
|
1881
|
+
operating_value=d["fs"], calib_lo=fs_lo, calib_hi=fs_hi,
|
|
1882
|
+
contribution=pred,
|
|
1883
|
+
)
|
|
1884
|
+
rows.append(Row(
|
|
1885
|
+
operating_point=(round(d["M"], 2), round(d["re"], 3), round(d["x"], 4),
|
|
1886
|
+
d["flow"], d["src_fig"]),
|
|
1887
|
+
surrogate_prediction=pred,
|
|
1888
|
+
cfd_truth=d["truth"],
|
|
1889
|
+
truth_source="experimental",
|
|
1890
|
+
cfd_uncertainty=d["unc"],
|
|
1891
|
+
guardrail_signals={"ood": 0.0, "residual": 0.0, "variance": 0.0},
|
|
1892
|
+
mechanisms=[mech],
|
|
1893
|
+
meta={
|
|
1894
|
+
"M": d["M"], "Re_per_m_e6": d["re"], "x_m": d["x"],
|
|
1895
|
+
"freestream_noise_pct": d["fs"],
|
|
1896
|
+
"flow": d["flow"], "source_figure": d["src_fig"],
|
|
1897
|
+
"rms_meas": d["truth"], "rms_pred_gp": pred, "rms_unc": d["unc"],
|
|
1898
|
+
"cell": "quiet_deploy" if d["fs"] < fs_lo else "noisy_train",
|
|
1899
|
+
},
|
|
1900
|
+
))
|
|
1901
|
+
|
|
1902
|
+
rows = load_rows(rows)
|
|
1903
|
+
n_quiet = sum(1 for d in raw if d["flow"] == "Quiet")
|
|
1904
|
+
reference = {"ood": [0.0], "residual": [0.0], "variance": [0.0]}
|
|
1905
|
+
meta = SubstrateMeta(
|
|
1906
|
+
name="casper-hypersonic-transition-quiet-vs-noisy",
|
|
1907
|
+
divergent_truth_substrate=True,
|
|
1908
|
+
reason=_CASPER_REASON,
|
|
1909
|
+
norm_strategy="per_row_truth",
|
|
1910
|
+
bound_for_pde=None,
|
|
1911
|
+
magnitude_bridge_ok=True,
|
|
1912
|
+
extra={
|
|
1913
|
+
"domain": "aerospace / hypersonic boundary-layer transition",
|
|
1914
|
+
"fluid": config.fluid.name,
|
|
1915
|
+
"n_rows": len(rows), "n_noisy_train": len(noisy), "n_quiet_deploy": n_quiet,
|
|
1916
|
+
"training_M_range": [float(Xtr[:, 0].min()), float(Xtr[:, 0].max())],
|
|
1917
|
+
"failure_variable": "freestream_noise_pct (RMS Pitot %, omitted from surrogate inputs)",
|
|
1918
|
+
"data_source_path": str(main_path),
|
|
1919
|
+
"closures": {
|
|
1920
|
+
"corpus_validity_anchor": (
|
|
1921
|
+
f"{config.matched_closure_id} (corpus {matched.status}; "
|
|
1922
|
+
"freestream-noise bound >= ~0.5% fires on the quiet deploy)"
|
|
1923
|
+
),
|
|
1924
|
+
},
|
|
1925
|
+
},
|
|
1926
|
+
)
|
|
1927
|
+
return rows, reference, meta
|
|
1928
|
+
|
|
1929
|
+
|
|
1930
|
+
# ── marineau_hypersonic_transition (aerospace NEGATIVE CONTROL: bluntness) ──
|
|
1931
|
+
|
|
1932
|
+
_MARINEAU_REASON = (
|
|
1933
|
+
"Independent experimental truth: transition parameters on blunt cones at "
|
|
1934
|
+
"Mach ~10, transcribed from Marineau et al. (2014, SAND2014-4326C) Table 3 "
|
|
1935
|
+
"'Transition Parameters at 0-deg AoA'. The surrogate predicts transition "
|
|
1936
|
+
"momentum-thickness Reynolds Re_theta,ST from (unit Reynolds, nose radius); "
|
|
1937
|
+
"the failure driver is the entropy-layer/shock-interaction ratio S_T/X_SW "
|
|
1938
|
+
"(< 0.1 -> 2nd mode absent, e^N regime breaks). NEGATIVE CONTROL: a citable "
|
|
1939
|
+
"validity bound exists (S_T/X_SW >= 0.1) and the corpus fires on the large-"
|
|
1940
|
+
"bluntness deploy, BUT nose radius IS a surrogate input, so the steelman "
|
|
1941
|
+
"baseline ALSO fires (deploy is exterior in Rn) -> PhysMAP correctly declines "
|
|
1942
|
+
"a clean differentiator."
|
|
1943
|
+
)
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
def marineau_hypersonic_transition(config: VehicleConfig,
|
|
1947
|
+
registry: dict[str, ClosureEntry]) -> tuple[list[Row], dict, SubstrateMeta]:
|
|
1948
|
+
"""Engine-driven Marineau loader (aerospace NEGATIVE CONTROL: bluntness/entropy).
|
|
1949
|
+
|
|
1950
|
+
Reads the Table-3 CSV (run, Rn_mm, Re_per_m, ..., ST_Xsw, RethetaST, ...).
|
|
1951
|
+
Split: benign/train where S_T/X_SW >= the band cutoff (e^N/2nd-mode regime),
|
|
1952
|
+
deploy where S_T/X_SW < cutoff (entropy-layer dominated). The surrogate is a
|
|
1953
|
+
GP on (unit Reynolds, nose radius) -> Re_theta,ST fit on benign. The matched
|
|
1954
|
+
closure (Marineau entropy-layer/shock bound) is the corpus/validity anchor.
|
|
1955
|
+
"""
|
|
1956
|
+
from sklearn.gaussian_process import GaussianProcessRegressor
|
|
1957
|
+
from sklearn.gaussian_process.kernels import RBF, WhiteKernel, ConstantKernel
|
|
1958
|
+
import csv as _csv
|
|
1959
|
+
|
|
1960
|
+
if config.data_source.path is None:
|
|
1961
|
+
raise ValueError(
|
|
1962
|
+
f"VehicleConfig {config.vehicle_id!r}: data_source.path is required "
|
|
1963
|
+
f"for the marineau_hypersonic_transition loader."
|
|
1964
|
+
)
|
|
1965
|
+
csv_path = _resolve(config.data_source.path)
|
|
1966
|
+
if not csv_path.exists():
|
|
1967
|
+
raise FileNotFoundError(f"Marineau Table-3 CSV not found: {csv_path}")
|
|
1968
|
+
|
|
1969
|
+
st_cut = float(config.cell_bands.bands[0].get("st_xsw_lt", 0.1))
|
|
1970
|
+
|
|
1971
|
+
raw: list[dict] = []
|
|
1972
|
+
with open(csv_path, newline="") as fh:
|
|
1973
|
+
for r in _csv.DictReader(fh):
|
|
1974
|
+
try:
|
|
1975
|
+
raw.append(dict(
|
|
1976
|
+
run=int(float(r["run"])),
|
|
1977
|
+
Re_per_m=float(r["Re_per_m"]),
|
|
1978
|
+
Rn_mm=float(r["Rn_mm"]),
|
|
1979
|
+
st_xsw=float(r["ST_Xsw"]),
|
|
1980
|
+
truth=float(r["RethetaST"]),
|
|
1981
|
+
))
|
|
1982
|
+
except (KeyError, ValueError):
|
|
1983
|
+
continue
|
|
1984
|
+
if not raw:
|
|
1985
|
+
raise RuntimeError(f"marineau loader: no usable rows in {csv_path}")
|
|
1986
|
+
|
|
1987
|
+
benign = [d for d in raw if d["st_xsw"] >= st_cut]
|
|
1988
|
+
if len(benign) < 3:
|
|
1989
|
+
raise RuntimeError(f"marineau loader: need >= 3 benign train rows, got {len(benign)}")
|
|
1990
|
+
|
|
1991
|
+
Xtr = np.array([[d["Re_per_m"], d["Rn_mm"]] for d in benign], dtype=float)
|
|
1992
|
+
ytr = np.array([d["truth"] for d in benign], dtype=float)
|
|
1993
|
+
mu, sd = Xtr.mean(0), Xtr.std(0)
|
|
1994
|
+
sd = np.where(sd > 0, sd, 1.0)
|
|
1995
|
+
gp = GaussianProcessRegressor(
|
|
1996
|
+
kernel=ConstantKernel(1.0, (1e-3, 1e3)) * RBF([1.0, 1.0], (1e-2, 1e2))
|
|
1997
|
+
+ WhiteKernel(1.0, (1e-5, 1e2)),
|
|
1998
|
+
normalize_y=True, n_restarts_optimizer=4, random_state=0,
|
|
1999
|
+
).fit((Xtr - mu) / sd, ytr)
|
|
2000
|
+
|
|
2001
|
+
matched = registry[config.matched_closure_id]
|
|
2002
|
+
st_lo, st_hi = matched.bound_range # S_T/X_SW validated band (corpus mirror)
|
|
2003
|
+
|
|
2004
|
+
rows: list[Row] = []
|
|
2005
|
+
for d in raw:
|
|
2006
|
+
xz = (np.array([[d["Re_per_m"], d["Rn_mm"]]], dtype=float) - mu) / sd
|
|
2007
|
+
pred = float(gp.predict(xz)[0])
|
|
2008
|
+
mech = Mechanism(
|
|
2009
|
+
name="marineau_entropy_layer_shock_validity_anchor",
|
|
2010
|
+
closure_id=config.matched_closure_id,
|
|
2011
|
+
operating_value=d["st_xsw"], calib_lo=st_lo, calib_hi=st_hi,
|
|
2012
|
+
contribution=pred,
|
|
2013
|
+
)
|
|
2014
|
+
rows.append(Row(
|
|
2015
|
+
operating_point=(d["run"], round(d["Rn_mm"], 3)),
|
|
2016
|
+
surrogate_prediction=pred,
|
|
2017
|
+
cfd_truth=d["truth"],
|
|
2018
|
+
truth_source="experimental",
|
|
2019
|
+
cfd_uncertainty=float("nan"),
|
|
2020
|
+
guardrail_signals={"ood": 0.0, "residual": 0.0, "variance": 0.0},
|
|
2021
|
+
mechanisms=[mech],
|
|
2022
|
+
meta={
|
|
2023
|
+
"Re_per_m": d["Re_per_m"], "Rn_mm": d["Rn_mm"],
|
|
2024
|
+
"st_xsw_ratio": d["st_xsw"],
|
|
2025
|
+
"RethetaST_meas": d["truth"], "RethetaST_pred_gp": pred,
|
|
2026
|
+
"run": d["run"],
|
|
2027
|
+
"cell": "failure_2ndmode_absent" if d["st_xsw"] < st_cut else "benign_eN_valid",
|
|
2028
|
+
},
|
|
2029
|
+
))
|
|
2030
|
+
|
|
2031
|
+
rows = load_rows(rows)
|
|
2032
|
+
n_deploy = sum(1 for d in raw if d["st_xsw"] < st_cut)
|
|
2033
|
+
reference = {"ood": [0.0], "residual": [0.0], "variance": [0.0]}
|
|
2034
|
+
meta = SubstrateMeta(
|
|
2035
|
+
name="marineau-hypersonic-transition-bluntness",
|
|
2036
|
+
divergent_truth_substrate=True,
|
|
2037
|
+
reason=_MARINEAU_REASON,
|
|
2038
|
+
norm_strategy="per_row_truth",
|
|
2039
|
+
bound_for_pde=None,
|
|
2040
|
+
magnitude_bridge_ok=True,
|
|
2041
|
+
extra={
|
|
2042
|
+
"domain": "aerospace / hypersonic boundary-layer transition",
|
|
2043
|
+
"fluid": config.fluid.name,
|
|
2044
|
+
"n_rows": len(rows), "n_benign_train": len(benign), "n_deploy": n_deploy,
|
|
2045
|
+
"benign_Rn_mm_range": [float(Xtr[:, 1].min()), float(Xtr[:, 1].max())],
|
|
2046
|
+
"failure_variable": "st_xsw_ratio (S_T/X_SW; recoverable from Rn -> baseline-visible)",
|
|
2047
|
+
"data_source_path": str(csv_path),
|
|
2048
|
+
"closures": {
|
|
2049
|
+
"corpus_validity_anchor": (
|
|
2050
|
+
f"{config.matched_closure_id} (corpus {matched.status}; "
|
|
2051
|
+
"S_T/X_SW >= 0.1 bound fires on the large-bluntness deploy)"
|
|
2052
|
+
),
|
|
2053
|
+
},
|
|
2054
|
+
},
|
|
2055
|
+
)
|
|
2056
|
+
return rows, reference, meta
|
|
2057
|
+
|
|
2058
|
+
|
|
2059
|
+
_JIN_REASON = (
|
|
2060
|
+
"Jin et al. 2023 sCO2 vertical-tube buoyancy: measured local Nu (truth) vs the direction-OMITTING "
|
|
2061
|
+
"constant-property Dittus-Boelter surrogate. Failure driver = Liu buoyancy parameter Bu (omitted); the "
|
|
2062
|
+
"matched closure dittus-boelter-buoyancy-sco2 carries the corpus Bu<=1.3e-5 bound. Digitization-tier "
|
|
2063
|
+
"(Fig-17 cell human-confirmed, q/G families reader-2-verified); see results/jin_sco2_buoyancy/DATA_STATUS.md."
|
|
2064
|
+
)
|
|
2065
|
+
|
|
2066
|
+
|
|
2067
|
+
def jin_sco2_buoyancy_lfs(config: VehicleConfig,
|
|
2068
|
+
registry: dict[str, ClosureEntry]) -> tuple[list[Row], dict, SubstrateMeta]:
|
|
2069
|
+
"""Engine-driven Jin sCO2 vertical-tube buoyancy loader (buoyancy middle vehicle).
|
|
2070
|
+
|
|
2071
|
+
Reads the offline-precomputed benchmark substrate CSV (make_benchmark_substrate.py: CoolProp,
|
|
2072
|
+
one-off) and emits Rows whose `surrogate_prediction` is the constant-property, direction-OMITTING
|
|
2073
|
+
Dittus-Boelter Nu(Re,Pr) (matched closure dittus-boelter-buoyancy-sco2) and whose `cfd_truth` is the
|
|
2074
|
+
measured local Nu. `meta` carries the failure driver `Bu` (Liu buoyancy parameter) + the `split_role`
|
|
2075
|
+
benign/deploy label, so the buoyancy_parameter_bands split + the observability score can read them.
|
|
2076
|
+
"""
|
|
2077
|
+
import csv as _csv
|
|
2078
|
+
|
|
2079
|
+
if config.data_source.path is None:
|
|
2080
|
+
raise ValueError(
|
|
2081
|
+
f"VehicleConfig {config.vehicle_id!r}: data_source.path is required for jin_sco2_buoyancy_lfs."
|
|
2082
|
+
)
|
|
2083
|
+
csv_path = _resolve(config.data_source.path)
|
|
2084
|
+
if not csv_path.exists():
|
|
2085
|
+
raise FileNotFoundError(
|
|
2086
|
+
f"Jin sCO2 benchmark substrate CSV not found: {csv_path}. Run "
|
|
2087
|
+
f"results/jin_sco2_buoyancy/make_benchmark_substrate.py (CoolProp, offline) to generate it."
|
|
2088
|
+
)
|
|
2089
|
+
|
|
2090
|
+
matched = registry[config.matched_closure_id] # dittus-boelter-buoyancy-sco2 (constant-property D-B)
|
|
2091
|
+
if matched.re_range is None:
|
|
2092
|
+
raise RuntimeError(f"{config.matched_closure_id!r} has no re_range; cannot build mechanism.")
|
|
2093
|
+
|
|
2094
|
+
rows: list[Row] = []
|
|
2095
|
+
with open(csv_path, newline="") as fh:
|
|
2096
|
+
for raw in _csv.DictReader(fh):
|
|
2097
|
+
Re = float(raw["Re_b"]); Pr = float(raw["Pr_b"])
|
|
2098
|
+
Nu_meas = float(raw["Nu_meas"])
|
|
2099
|
+
if not (Nu_meas > 0.0):
|
|
2100
|
+
continue
|
|
2101
|
+
Nu_unc = float(raw["Nu_unc"])
|
|
2102
|
+
Nu_pred = float(matched.fn(Re=np.array([Re]), Pr=np.array([Pr]))[0])
|
|
2103
|
+
mechanisms = [
|
|
2104
|
+
Mechanism(
|
|
2105
|
+
name="constant_property_dittus_boelter_direction_omitted",
|
|
2106
|
+
closure_id=config.matched_closure_id,
|
|
2107
|
+
operating_value=Re,
|
|
2108
|
+
calib_lo=matched.re_range[0],
|
|
2109
|
+
calib_hi=matched.re_range[1],
|
|
2110
|
+
contribution=Nu_pred,
|
|
2111
|
+
),
|
|
2112
|
+
]
|
|
2113
|
+
rows.append(Row(
|
|
2114
|
+
operating_point=(round(Re, 1), round(Pr, 4)),
|
|
2115
|
+
surrogate_prediction=Nu_pred,
|
|
2116
|
+
cfd_truth=Nu_meas,
|
|
2117
|
+
truth_source="experimental",
|
|
2118
|
+
cfd_uncertainty=Nu_unc,
|
|
2119
|
+
guardrail_signals={"ood": 0.0, "residual": 0.0, "variance": 0.0},
|
|
2120
|
+
mechanisms=mechanisms,
|
|
2121
|
+
meta={
|
|
2122
|
+
"Re": Re,
|
|
2123
|
+
"Pr": Pr,
|
|
2124
|
+
# failure driver (omitted from the surrogate inputs):
|
|
2125
|
+
"Bu": float(raw["Bu"]),
|
|
2126
|
+
# split coordinate (physical benign/deploy regime label):
|
|
2127
|
+
"split_role": raw["split_role"],
|
|
2128
|
+
# context:
|
|
2129
|
+
"Bo_star": float(raw["Bo_star"]),
|
|
2130
|
+
"flow_direction": raw["flow_direction"],
|
|
2131
|
+
"H_b_kJ_kg": float(raw["H_b_kJ_kg"]),
|
|
2132
|
+
"G_kg_m2s": float(raw["G_kg_m2s"]),
|
|
2133
|
+
"q_kW_m2": float(raw["q_kW_m2"]),
|
|
2134
|
+
"P_MPa": float(raw["P_MPa"]),
|
|
2135
|
+
"T_b_C": float(raw["T_b_C"]),
|
|
2136
|
+
"T_wi_C": float(raw["T_wi_C"]),
|
|
2137
|
+
"Nu_meas": Nu_meas,
|
|
2138
|
+
"Nu_unc": Nu_unc,
|
|
2139
|
+
"h_meas_kW_m2K": float(raw["h_meas_kW_m2K"]),
|
|
2140
|
+
"source": raw["source"],
|
|
2141
|
+
},
|
|
2142
|
+
))
|
|
2143
|
+
|
|
2144
|
+
rows = load_rows(rows)
|
|
2145
|
+
reference = {"ood": [0.0], "residual": [0.0], "variance": [0.0]}
|
|
2146
|
+
meta = SubstrateMeta(
|
|
2147
|
+
name="jin-sco2-vertical-tube-buoyancy",
|
|
2148
|
+
divergent_truth_substrate=True,
|
|
2149
|
+
reason=_JIN_REASON,
|
|
2150
|
+
norm_strategy="per_row_truth",
|
|
2151
|
+
bound_for_pde=None,
|
|
2152
|
+
magnitude_bridge_ok=True,
|
|
2153
|
+
extra={
|
|
2154
|
+
"geometry": "vertical circular tube, Di=7.74 mm, heated length 1050 mm (L/d=135), 316L",
|
|
2155
|
+
"fluid": config.fluid.name,
|
|
2156
|
+
"n_rows": len(rows),
|
|
2157
|
+
"data_source_path": str(csv_path),
|
|
2158
|
+
"failure_driver": "Bu (Liu buoyancy parameter, wall-aware)",
|
|
2159
|
+
"buoyancy_bound": "dittus-boelter-buoyancy-sco2: Bu <= 1.3e-5 (a-priori, banked jin-2023-correct Claim)",
|
|
2160
|
+
"data_tier": "digitization (Fig-17 cell human-confirmed; q/G families reader-2-verified)",
|
|
2161
|
+
},
|
|
2162
|
+
)
|
|
2163
|
+
return rows, reference, meta
|
|
2164
|
+
|
|
2165
|
+
|
|
2166
|
+
# Register all loaders at import time.
|
|
2167
|
+
register_loader("lance_smith_lfs", lance_smith_lfs)
|
|
2168
|
+
register_loader("forrest_visual_estimates", forrest_visual_estimates)
|
|
2169
|
+
register_loader("mudhafar_wpd", mudhafar_wpd)
|
|
2170
|
+
register_loader("naca_wpd", naca_wpd)
|
|
2171
|
+
register_loader("testi_grassi_lfs", testi_grassi_lfs)
|
|
2172
|
+
register_loader("dirker_water_richardson_bands", dirker_water_richardson_bands)
|
|
2173
|
+
register_loader("velazquez_sco2_lfs", velazquez_sco2_lfs)
|
|
2174
|
+
register_loader("jin_sco2_buoyancy_lfs", jin_sco2_buoyancy_lfs)
|
|
2175
|
+
register_loader("casper_hypersonic_transition", casper_hypersonic_transition)
|
|
2176
|
+
register_loader("marineau_hypersonic_transition", marineau_hypersonic_transition)
|