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,206 @@
|
|
|
1
|
+
"""PhysMAP Corpus Rung 2 — real-truth corpus-vs-causal test on Row-shaped data.
|
|
2
|
+
|
|
3
|
+
Data-source-agnostic: three adapters feed the same machinery — `pipe` (the vehicle-3
|
|
4
|
+
NB Ri sweep at conv_step3_final.json), `pump` (FDA pump fHb measurement; idles at
|
|
5
|
+
EMPIRICAL-INCOMPLETE while harvest grows + magnitude bridge lands), `step0_fixture`
|
|
6
|
+
(the algebraic stand-in for the plumbing smoke-test). Leave-one-out at the row level;
|
|
7
|
+
rung-1's verdict functions are imported.
|
|
8
|
+
|
|
9
|
+
The HEADLINE is G-corpus-beats-causal, but its interpretation is gated by
|
|
10
|
+
G-divergence-exists, which itself has BOTH a numeric criterion AND a substrate
|
|
11
|
+
criterion. If the substrate cannot carry a closure-reality divergence claim (Eq13 is a
|
|
12
|
+
correlation, not measurement; algebraic truth is closure-derivable by construction),
|
|
13
|
+
the headline is NOT-INFORMATIVE — neither pass nor fail. The corpus value test
|
|
14
|
+
requires a divergent-truth substrate, which today only the (future) bridged pump is.
|
|
15
|
+
|
|
16
|
+
Run: python -m physmap.substrate.corpus_real [--dataset pipe|pump|step0_fixture]
|
|
17
|
+
Torch-free.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import csv
|
|
24
|
+
import inspect
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
from physmap.infra.blindspot_oracle import EMAX, SMAX, THETA_MAT
|
|
33
|
+
from physmap.infra.corpus_runtime import _bootstrap_ci
|
|
34
|
+
from physmap.infra.corpus_runtime import Entry, corpus_verdict, pde_residual_flag
|
|
35
|
+
from physmap.substrate.stage1_ingest import (
|
|
36
|
+
TRUTH_FIXTURE,
|
|
37
|
+
TRUTH_INDEPENDENT,
|
|
38
|
+
Mechanism,
|
|
39
|
+
Row,
|
|
40
|
+
cfd_rows,
|
|
41
|
+
step0_fixture,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
OUT_DIR = Path(__file__).parent.parent / "results" / "physmap_corpus_real"
|
|
45
|
+
|
|
46
|
+
# Defaults
|
|
47
|
+
K_DEFAULT = 5
|
|
48
|
+
THETA_ERR = 0.05
|
|
49
|
+
TOL = 0.05
|
|
50
|
+
GUARDRAIL_PCT = 95
|
|
51
|
+
SCALE_STEP0 = SMAX + EMAX
|
|
52
|
+
|
|
53
|
+
# Pipe knobs
|
|
54
|
+
RI_CAL_LO, RI_CAL_HI = 0.1, 10.0
|
|
55
|
+
DEFAULT_PIPE_PATH = os.path.expanduser(
|
|
56
|
+
os.environ.get("PHYSMAP_PIPE_DATA", "~/physmap-wall/convection/conv_step3_final.json"))
|
|
57
|
+
DEFAULT_PUMP_PATH = os.path.expanduser(
|
|
58
|
+
os.environ.get("PHYSMAP_PUMP_DATA", "~/physmap-wall/pump/pump_rows_step2.json"))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── substrate metadata ───────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class SubstrateMeta:
|
|
65
|
+
"""Per-substrate declaration of the properties that gate the headline's
|
|
66
|
+
interpretation. `divergent_truth_substrate` is the critical flag — if False, the
|
|
67
|
+
substrate cannot test the corpus's value (closure-vs-correlation is not closure-
|
|
68
|
+
vs-reality), and G-corpus-beats-causal must report NOT-INFORMATIVE."""
|
|
69
|
+
name: str
|
|
70
|
+
divergent_truth_substrate: bool
|
|
71
|
+
reason: str
|
|
72
|
+
norm_strategy: str # how to normalize closure/truth to comparable units
|
|
73
|
+
bound_for_pde: float | None # None = PDE arm inert (no fixed ceiling)
|
|
74
|
+
magnitude_bridge_ok: bool
|
|
75
|
+
extra: dict = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── physics coords (kNN index) ───────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
def physics_coords_row(row: Row, scaling: str = "log") -> np.ndarray:
|
|
81
|
+
"""Per-row coord vector for kNN indexing. Mechanism-agnostic: uses each mechanism's
|
|
82
|
+
operating_value. Log scaling is the default — operating values typically span
|
|
83
|
+
orders of magnitude and Euclidean kNN behaves better in log space."""
|
|
84
|
+
vals = np.array([m.operating_value for m in row.mechanisms], float)
|
|
85
|
+
if scaling == "log":
|
|
86
|
+
return np.log(np.maximum(vals, 1e-12))
|
|
87
|
+
if scaling == "raw":
|
|
88
|
+
return vals
|
|
89
|
+
if scaling == "calib_range":
|
|
90
|
+
out = []
|
|
91
|
+
for m in row.mechanisms:
|
|
92
|
+
rng = m.calib_hi - m.calib_lo
|
|
93
|
+
out.append((m.operating_value - m.calib_lo) / max(rng, 1e-12))
|
|
94
|
+
return np.array(out, float)
|
|
95
|
+
raise ValueError(f"unknown scaling: {scaling}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── normalization (closure vs truth on a comparable scale) ───────────────────
|
|
99
|
+
|
|
100
|
+
def _norm_for(row: Row, meta: SubstrateMeta) -> float:
|
|
101
|
+
"""Per-row normalizer so |closure - truth|/norm is unitless and comparable to TOL."""
|
|
102
|
+
if meta.norm_strategy == "per_row_truth":
|
|
103
|
+
return float(row.cfd_truth)
|
|
104
|
+
if meta.norm_strategy == "global_scale":
|
|
105
|
+
return float(SCALE_STEP0)
|
|
106
|
+
if meta.norm_strategy == "per_row_ref_const":
|
|
107
|
+
return float(row.meta.get("RIH2_C5_normalization_reference", 1.0))
|
|
108
|
+
raise ValueError(f"unknown norm_strategy: {meta.norm_strategy}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def normalize_row(row: Row, meta: SubstrateMeta) -> tuple[float, float, float, float]:
|
|
112
|
+
"""Return (closure_norm, truth_norm, error_norm_signed, norm)."""
|
|
113
|
+
norm = _norm_for(row, meta)
|
|
114
|
+
if meta.norm_strategy == "per_row_truth":
|
|
115
|
+
# closure/truth_norm both reported on the per-row truth scale
|
|
116
|
+
closure_norm = float(row.surrogate_prediction) / norm
|
|
117
|
+
truth_norm = 1.0
|
|
118
|
+
else:
|
|
119
|
+
closure_norm = float(row.surrogate_prediction) / norm
|
|
120
|
+
truth_norm = float(row.cfd_truth) / norm
|
|
121
|
+
err = truth_norm - closure_norm
|
|
122
|
+
return closure_norm, truth_norm, err, norm
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── per-row verdicts (the four real-arm verdicts; pde may be inert) ──────────
|
|
126
|
+
|
|
127
|
+
def naive_verdict_row(row: Row) -> bool:
|
|
128
|
+
"""Untrustworthy iff any mechanism is out of [calib_lo, calib_hi]."""
|
|
129
|
+
return any(not m.in_calibration() for m in row.mechanisms)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def causal_verdict_row(row: Row, theta_mat: float = THETA_MAT) -> bool:
|
|
133
|
+
"""Untrustworthy iff any material mechanism (contribution fraction ≥ theta_mat) is
|
|
134
|
+
out of calibration. Mirrors rung 1's causal verdict on the schema's mechanisms."""
|
|
135
|
+
mats = row.materialities()
|
|
136
|
+
for i, m in enumerate(row.mechanisms):
|
|
137
|
+
if (not m.in_calibration()) and (float(mats[i]) >= theta_mat):
|
|
138
|
+
return True
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def guardrails_verdict_row(row: Row, reference: dict, pct: float = GUARDRAIL_PCT) -> bool:
|
|
143
|
+
"""Untrustworthy iff any guardrail signal exceeds the pct-th percentile of the
|
|
144
|
+
precomputed reference distribution. Reference signals all-zero (placeholder substrate)
|
|
145
|
+
→ guardrail arm is inert (never flags)."""
|
|
146
|
+
for k in ("ood", "residual", "variance"):
|
|
147
|
+
ref = np.asarray(reference.get(k, [0.0]), float)
|
|
148
|
+
thr = float(np.percentile(ref, pct)) if len(ref) else 0.0
|
|
149
|
+
sig = float(row.guardrail_signals.get(k, 0.0))
|
|
150
|
+
if sig > thr:
|
|
151
|
+
return True
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def pde_residual_verdict(closure_norm: float, bound: float | None) -> bool | None:
|
|
156
|
+
"""Bound check on the NORMALIZED closure. Returns None if the arm is inert
|
|
157
|
+
(no fixed ceiling for this substrate)."""
|
|
158
|
+
if bound is None:
|
|
159
|
+
return None
|
|
160
|
+
return bool(pde_residual_flag(np.array([closure_norm]), bound=bound)[0])
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ── corpus build + LOO ───────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def row_to_entry(row: Row, meta: SubstrateMeta, scaling: str = "log") -> Entry:
|
|
166
|
+
"""Build a rung-1 Entry from a Row, normalized + with materiality fractions."""
|
|
167
|
+
closure_norm, truth_norm, err_norm, _ = normalize_row(row, meta)
|
|
168
|
+
mats = row.materialities()
|
|
169
|
+
s_frac = float(mats[0]) if len(mats) >= 1 else 0.0
|
|
170
|
+
e_frac = float(mats[1]) if len(mats) >= 2 else 0.0
|
|
171
|
+
return Entry(
|
|
172
|
+
coords=physics_coords_row(row, scaling=scaling),
|
|
173
|
+
closure=closure_norm,
|
|
174
|
+
truth=truth_norm,
|
|
175
|
+
error=err_norm, # signed; corpus_verdict takes |error|
|
|
176
|
+
qoi_contrib_s=s_frac,
|
|
177
|
+
qoi_contrib_e=e_frac,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def divergence_exists(rows: list[Row], meta: SubstrateMeta, tol: float,
|
|
182
|
+
theta_mat: float) -> dict:
|
|
183
|
+
"""Per-row check: is |closure - truth| > tol AND does causal NOT flag it?
|
|
184
|
+
Records the divergence-bearing condition ops explicitly."""
|
|
185
|
+
divergent = []
|
|
186
|
+
for r in rows:
|
|
187
|
+
cl_n, tr_n, _, _ = normalize_row(r, meta)
|
|
188
|
+
if abs(cl_n - tr_n) > tol and not causal_verdict_row(r, theta_mat):
|
|
189
|
+
divergent.append({"op": list(r.operating_point),
|
|
190
|
+
"closure_norm": round(cl_n, 4),
|
|
191
|
+
"truth_norm": round(tr_n, 4),
|
|
192
|
+
"abs_err_norm": round(abs(cl_n - tr_n), 4)})
|
|
193
|
+
return {"n_divergent": len(divergent), "divergent_ops": divergent,
|
|
194
|
+
"numeric_criterion_passed": len(divergent) >= 1}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# The leave-one-out EXPERIMENT path -- loo_run, metrics_loo, gates, plotting,
|
|
199
|
+
# CSV writing, the dataset loaders and the __main__ runner -- is not ported.
|
|
200
|
+
#
|
|
201
|
+
# None of it is on the benchmark path: outside this module only SubstrateMeta is
|
|
202
|
+
# imported. It also computed per-arm recall and false-alarm rates, which a build
|
|
203
|
+
# that reports no performance metrics must not carry -- the public-surface audit
|
|
204
|
+
# flagged exactly those two lines, and deleting the dead code is the honest fix
|
|
205
|
+
# rather than adding an exemption to the guard.
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Generic config-driven substrate engine.
|
|
2
|
+
|
|
3
|
+
Per the v0.2 architecture refactor (Part 1): one `build_substrate(config, ...)`
|
|
4
|
+
function replaces the per-vehicle hand-coded substrate loaders. Vehicle-specific
|
|
5
|
+
data (geometry, closures, cell bands, raw-data loader) lives in YAML configs
|
|
6
|
+
under `physmap/vehicles/`; the engine reads the config, enforces the
|
|
7
|
+
geometry-match invariant, dispatches to a registered loader adapter, and
|
|
8
|
+
annotates the returned SubstrateMeta with engine-level provenance.
|
|
9
|
+
|
|
10
|
+
THE GEOMETRY-MATCH INVARIANT (the Sparrow-Cur / Lance & Smith lesson encoded
|
|
11
|
+
as code):
|
|
12
|
+
|
|
13
|
+
assert matched_closure.geometry_class == config.geometry.class_, REFUSE
|
|
14
|
+
|
|
15
|
+
The check has a single escape hatch: `expect_mismatch=True` paired with a
|
|
16
|
+
non-empty `mismatch_rationale` in the VehicleConfig. This is the explicit
|
|
17
|
+
override used by positive-control vehicles (Mudhafar) whose mismatch is the
|
|
18
|
+
experiment. The override surfaces in the returned meta's `extra` dict so
|
|
19
|
+
downstream auditors can see why the invariant was bypassed.
|
|
20
|
+
|
|
21
|
+
Phase-1 staging: loader adapters in `substrate_loaders.py` are THIN wrappers
|
|
22
|
+
around the existing hand-coded substrate loaders (`lance_smith_to_rows()`,
|
|
23
|
+
etc.). The engine's job in Step 4 is the invariant + dispatch + meta
|
|
24
|
+
annotation, not row-level computation. In Step 9 (per-vehicle migration),
|
|
25
|
+
each loader is decomposed into a raw-data reader; the engine then computes
|
|
26
|
+
matched + reference predictions via the closure registry, and the
|
|
27
|
+
structural-equality gate becomes non-trivial.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import dataclasses
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Callable
|
|
35
|
+
|
|
36
|
+
from physmap.closures import REGISTRY, ClosureEntry
|
|
37
|
+
from physmap.substrate.corpus_real import SubstrateMeta
|
|
38
|
+
from physmap.substrate.stage1_ingest import Row
|
|
39
|
+
from physmap.substrate.vehicle_config import (
|
|
40
|
+
VehicleConfig,
|
|
41
|
+
VehicleConfigError,
|
|
42
|
+
load_vehicle_config,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── exceptions ──────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
class GeometryMismatchError(ValueError):
|
|
49
|
+
"""The matched closure's geometry_class does not equal the vehicle's
|
|
50
|
+
geometry class, and `expect_mismatch=True` was not set. Refused at
|
|
51
|
+
build time — the substrate engine guards itself against the trap that
|
|
52
|
+
bit Lance & Smith and almost bit Forrest."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Loader signature: takes the validated VehicleConfig and the closure registry,
|
|
56
|
+
# returns (rows, reference, meta) compatible with the existing pipeline.
|
|
57
|
+
LoaderFn = Callable[[VehicleConfig, dict[str, ClosureEntry]],
|
|
58
|
+
tuple[list[Row], dict, SubstrateMeta]]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── LOADERS registry ────────────────────────────────────────────────────────
|
|
62
|
+
# Populated by `substrate_loaders` at import time. Kept as a plain dict so
|
|
63
|
+
# tests can monkey-patch entries for the byte-equality gate.
|
|
64
|
+
|
|
65
|
+
LOADERS: dict[str, LoaderFn] = {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def register_loader(name: str, fn: LoaderFn) -> None:
|
|
69
|
+
"""Register a data-source loader adapter. Raises if the name is taken
|
|
70
|
+
(silent override would mask refactor bugs)."""
|
|
71
|
+
if name in LOADERS:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"LOADERS already has entry {name!r}; refusing silent override. "
|
|
74
|
+
f"If you intend to swap implementations, delete the entry first."
|
|
75
|
+
)
|
|
76
|
+
LOADERS[name] = fn
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── the engine ──────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
def build_substrate(
|
|
82
|
+
config: VehicleConfig | str | Path,
|
|
83
|
+
*,
|
|
84
|
+
registry: dict[str, ClosureEntry] | None = None,
|
|
85
|
+
corpus_path: str | Path | None = None,
|
|
86
|
+
) -> tuple[list[Row], dict, SubstrateMeta]:
|
|
87
|
+
"""Build a substrate from a VehicleConfig.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
config: a `VehicleConfig` instance, or a path/string to a YAML file
|
|
91
|
+
(auto-loaded via `vehicle_config.load_vehicle_config`).
|
|
92
|
+
registry: closure registry; defaults to the global `REGISTRY`. Tests
|
|
93
|
+
inject a custom registry to exercise edge cases.
|
|
94
|
+
corpus_path: optional calibration-corpus path; accepted for forward
|
|
95
|
+
compatibility but unused in Phase 1 (the closure registry caches
|
|
96
|
+
ranges; the ClosureValidityDetector reads corpus.jsonl directly).
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
(rows, reference, meta) — the same tuple shape every existing
|
|
100
|
+
substrate loader returns. `meta.extra` is augmented with engine-level
|
|
101
|
+
provenance keys: `substrate_engine_version`, `vehicle_id`,
|
|
102
|
+
`geometry_class`, `matched_closure_id`, `matched_closure_geometry_class`,
|
|
103
|
+
`matched_closure_status`, `expect_mismatch`, `mismatch_rationale` (only
|
|
104
|
+
if expect_mismatch is set), `reference_closure_ids`, `cell_bands_type`.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
GeometryMismatchError: matched-closure geometry != vehicle geometry
|
|
108
|
+
and expect_mismatch is not set.
|
|
109
|
+
ValueError: expect_mismatch is set but geometries actually agree
|
|
110
|
+
(dead override; either fix the YAML or drop the flag).
|
|
111
|
+
KeyError: unknown loader name in `data_source.loader`.
|
|
112
|
+
"""
|
|
113
|
+
# Step 1 — coerce path to VehicleConfig
|
|
114
|
+
if isinstance(config, (str, Path)):
|
|
115
|
+
config = load_vehicle_config(config)
|
|
116
|
+
elif not isinstance(config, VehicleConfig):
|
|
117
|
+
raise TypeError(
|
|
118
|
+
f"build_substrate(config): expected VehicleConfig or path, got "
|
|
119
|
+
f"{type(config).__name__}."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
reg = registry if registry is not None else REGISTRY
|
|
123
|
+
|
|
124
|
+
# Step 2 — registry lookup (KeyError already produced by VehicleConfig
|
|
125
|
+
# validation, but defensively re-check here so test-injected registries
|
|
126
|
+
# surface the error from the engine, not from dataclass construction).
|
|
127
|
+
if config.matched_closure_id not in reg:
|
|
128
|
+
raise KeyError(
|
|
129
|
+
f"matched_closure_id={config.matched_closure_id!r} not in the "
|
|
130
|
+
f"provided registry. Known: {sorted(reg.keys())}."
|
|
131
|
+
)
|
|
132
|
+
matched: ClosureEntry = reg[config.matched_closure_id]
|
|
133
|
+
|
|
134
|
+
# Step 3 — geometry-match invariant (THE Sparrow-Cur lesson)
|
|
135
|
+
geom_match = (matched.geometry_class == config.geometry.class_)
|
|
136
|
+
|
|
137
|
+
if not geom_match and not config.expect_mismatch:
|
|
138
|
+
raise GeometryMismatchError(
|
|
139
|
+
f"VehicleConfig {config.vehicle_id!r}: matched_closure_id "
|
|
140
|
+
f"{config.matched_closure_id!r} has geometry_class="
|
|
141
|
+
f"{matched.geometry_class!r} but vehicle geometry.class="
|
|
142
|
+
f"{config.geometry.class_!r}. This is the Sparrow-Cur / "
|
|
143
|
+
f"Lance & Smith trap; refused at build time. If this mismatch is "
|
|
144
|
+
f"INTENTIONAL (positive control), set `expect_mismatch: true` "
|
|
145
|
+
f"and supply a non-empty `mismatch_rationale` in the YAML."
|
|
146
|
+
)
|
|
147
|
+
if geom_match and config.expect_mismatch:
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"VehicleConfig {config.vehicle_id!r}: expect_mismatch=true but "
|
|
150
|
+
f"geometries actually match (both are "
|
|
151
|
+
f"{matched.geometry_class!r}). Either remove expect_mismatch / "
|
|
152
|
+
f"mismatch_rationale (override is dead), or correct the geometry "
|
|
153
|
+
f"class. Refusing to silently treat a matching configuration as "
|
|
154
|
+
f"a positive control."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Step 4 — verify all reference closures resolve in the registry
|
|
158
|
+
# (VehicleConfig already checks against the GLOBAL REGISTRY; if a custom
|
|
159
|
+
# registry is injected, re-check.)
|
|
160
|
+
if registry is not None:
|
|
161
|
+
unknown_refs = [c for c in config.reference_closure_ids if c not in reg]
|
|
162
|
+
if unknown_refs:
|
|
163
|
+
raise KeyError(
|
|
164
|
+
f"reference_closure_ids unknown to provided registry: "
|
|
165
|
+
f"{unknown_refs}. Known: {sorted(reg.keys())}."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Step 5 — loader dispatch
|
|
169
|
+
loader_name = config.data_source.loader
|
|
170
|
+
if loader_name not in LOADERS:
|
|
171
|
+
raise KeyError(
|
|
172
|
+
f"Unknown data-source loader {loader_name!r}. Registered: "
|
|
173
|
+
f"{sorted(LOADERS.keys())}. To add a new loader, call "
|
|
174
|
+
f"substrate_engine.register_loader(...) at import time."
|
|
175
|
+
)
|
|
176
|
+
loader = LOADERS[loader_name]
|
|
177
|
+
|
|
178
|
+
rows, reference, meta = loader(config, reg)
|
|
179
|
+
|
|
180
|
+
# Step 6 — annotate meta with engine-level provenance
|
|
181
|
+
extra = dict(meta.extra)
|
|
182
|
+
extra.update({
|
|
183
|
+
"substrate_engine_version": "v0.2-phase1",
|
|
184
|
+
"vehicle_id": config.vehicle_id,
|
|
185
|
+
"domain": config.domain,
|
|
186
|
+
"geometry_class": config.geometry.class_,
|
|
187
|
+
"geometry_dims": dict(config.geometry.dims),
|
|
188
|
+
"matched_closure_id": config.matched_closure_id,
|
|
189
|
+
"matched_closure_geometry_class": matched.geometry_class,
|
|
190
|
+
"matched_closure_status": matched.status,
|
|
191
|
+
"reference_closure_ids": list(config.reference_closure_ids),
|
|
192
|
+
"cell_bands_type": config.cell_bands.type,
|
|
193
|
+
"expect_mismatch": bool(config.expect_mismatch),
|
|
194
|
+
})
|
|
195
|
+
if config.expect_mismatch:
|
|
196
|
+
extra["mismatch_rationale"] = config.mismatch_rationale
|
|
197
|
+
|
|
198
|
+
annotated = dataclasses.replace(meta, extra=extra)
|
|
199
|
+
return rows, reference, annotated
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# Bootstrap: register the loader adapters at engine-import time. Importing
|
|
203
|
+
# substrate_loaders also imports the existing legacy substrate modules (which
|
|
204
|
+
# is fine — we want them available during the Phase-1 wrapper period).
|
|
205
|
+
def _register_default_loaders() -> None:
|
|
206
|
+
from physmap.substrate import loaders as substrate_loaders # noqa: F401 (side-effect import)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
_register_default_loaders()
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""PhysMAP Forrest mini-channel vehicle — D3 differentiator substrate.
|
|
2
|
+
|
|
3
|
+
Build the substrate for the D3 differentiator experiment. Source paper:
|
|
4
|
+
Forrest, Hu, Buongiorno, McKrell (2014), "Convective Heat Transfer in a High
|
|
5
|
+
Aspect Ratio Mini-Channel Heated on One Side," J. Heat Transfer 138(2):021704,
|
|
6
|
+
DOI: 10.1115/1.4031646 / OSTI 1295764 / SAND2014-18834J. Geometry confirmed
|
|
7
|
+
against the 2012 RERTR predecessor paper (Forrest, Buongiorno, McKrell, Hu).
|
|
8
|
+
|
|
9
|
+
What this substrate is for (one sentence): a steady-state internal-flow
|
|
10
|
+
heat-transfer vehicle with three cells defined by Re band — BENIGN
|
|
11
|
+
(10k≤Re≤70k, geometry-MATCHED Modified Sparrow-Cur MAE 6.1%, paper Table 4),
|
|
12
|
+
TRANSITION (4k≤Re<10k, textbook turbulent closures not validated; paper's
|
|
13
|
+
semi-analytic Eq. 24 covers it at MAE 4.6%), and SUB_CRITICAL_LAMINAR
|
|
14
|
+
(Re<4k, critical Re between 3500-4000 per Eq. 18; turbulent closures
|
|
15
|
+
overpredict by ~40% at Pr=5.4 per paper body p.7) — suitable for testing
|
|
16
|
+
whether PhysMAP's corpus-error signal catches divergence that
|
|
17
|
+
input-distribution novelty detectors miss or weakly signal.
|
|
18
|
+
|
|
19
|
+
Geometry-matched primary closure (resolves the Lance & Smith trap):
|
|
20
|
+
The primary closure tested on this substrate is Modified Sparrow-Cur
|
|
21
|
+
(Forrest 2014 Eq. 7, corpus entry
|
|
22
|
+
`modified-sparrow-cur-asym-narrow-rect-channel-2014`), which IS designed
|
|
23
|
+
for one-sided heated narrow rectangular channels. Circular-pipe closures
|
|
24
|
+
(Gnielinski, Dittus-Boelter, Petukhov, Sieder-Tate) are computed for
|
|
25
|
+
cross-comparison against Forrest's Table 4 MAEs but are NOT the primary
|
|
26
|
+
test target. Testing the differentiator on the geometry-mismatched
|
|
27
|
+
circular-pipe closures would contaminate the result with geometry
|
|
28
|
+
mismatch — exactly the trap that produced the Lance & Smith
|
|
29
|
+
confined-geometry finding. By using the matched closure, the
|
|
30
|
+
divergence at Re<4k and the transition is cleanly attributable to
|
|
31
|
+
closure validity-edge failure (Re below the closure's validated lower
|
|
32
|
+
bound 10000), not to geometry incompatibility.
|
|
33
|
+
|
|
34
|
+
Build discipline (mirrors D1 / Lance & Smith):
|
|
35
|
+
- Truth column = measured Nu ONLY. Refuse fitted-correlation truth
|
|
36
|
+
(Eq. 19 and Eq. 24 are the AUTHORS' own fits; cannot serve as truth —
|
|
37
|
+
independence guard).
|
|
38
|
+
- Steady-state → no temporal-autocorrelation discount on effective n.
|
|
39
|
+
Each (Re, Pr) operating point is a genuinely independent sample.
|
|
40
|
+
- Inspect before blessing (D1 done-gate discipline).
|
|
41
|
+
|
|
42
|
+
Two corrections wired in from the build directive:
|
|
43
|
+
|
|
44
|
+
Correction 1 — baseline-invisibility is the MEASURED OUTCOME of D3,
|
|
45
|
+
NOT passed by reading the paper. D3 measures the SIGNAL-GAP between
|
|
46
|
+
both baseline detectors (distance-to-training AND prediction-variance/
|
|
47
|
+
ensemble) and the corpus signal at the transition cell. Both detectors
|
|
48
|
+
must be quiet simultaneously at the transition for the truly-invisible
|
|
49
|
+
bar; gap-against-threshold is the weaker bar. Which bar counts as a win
|
|
50
|
+
is pre-registered BEFORE the experiment runs.
|
|
51
|
+
|
|
52
|
+
Correction 2 — Mudhafar (canonical smooth pipe, separate substrate) is
|
|
53
|
+
the baseline positive control. Built deliberately, not as an
|
|
54
|
+
afterthought. Mudhafar's divergent cells (small d, rough surface) are
|
|
55
|
+
baseline-VISIBLE by design. Same detectors that fire on Mudhafar's
|
|
56
|
+
divergence should stay quiet/weak on Forrest's transition — the
|
|
57
|
+
contrast that makes the differentiator result interpretable.
|
|
58
|
+
|
|
59
|
+
Data-acquisition status:
|
|
60
|
+
- OSTI 1295764 / 2014 paper read in full; confirms NO supplementary data
|
|
61
|
+
tables. Table 2 (geometry) + Table 4 (cell-level MAE summary) are the
|
|
62
|
+
only tables. Per-point Nu values are in Figures 5, 6, 7, 9.
|
|
63
|
+
- Hybrid path: cell-level analysis from Table 4 + Eq. 24 model for the
|
|
64
|
+
benign cell (no digitization); figure digitization of Fig. 5 for the
|
|
65
|
+
transition+sub-critical cells where the differentiator signal lives.
|
|
66
|
+
Author-data request as long-tail clean path.
|
|
67
|
+
|
|
68
|
+
The raw-data ingest function (forrest_to_rows) is parameterized by a
|
|
69
|
+
caller-supplied data table; the three ingest stubs below
|
|
70
|
+
(from_thesis_table / from_digitization / from_author_csv) raise
|
|
71
|
+
NotImplementedError until the data-acquisition decision lands.
|
|
72
|
+
|
|
73
|
+
Entry (post-data-acquisition): python -m physmap.forrest_inspect
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
from __future__ import annotations
|
|
77
|
+
|
|
78
|
+
from typing import Literal
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Cleanup 5 part 2: the Forrest legacy closure functions, calibration-range
|
|
82
|
+
# constants, ForrestRow dataclass, forrest_to_rows loader, and the three
|
|
83
|
+
# data-acquisition stubs have all been retired. Their canonical homes:
|
|
84
|
+
# - closures/formulas.py (Pohlhausen, McAdams, Churchill,
|
|
85
|
+
# Sparrow-Cur, Gnielinski, Dittus-Boelter,
|
|
86
|
+
# Petukhov, Sieder-Tate formulas)
|
|
87
|
+
# - closures/registry.py (re_range, pr_range, geometry_class,
|
|
88
|
+
# status)
|
|
89
|
+
# - vehicles/forrest.yaml (geometry + data_source)
|
|
90
|
+
# - substrate_loaders.py (engine-driven row build)
|
|
91
|
+
#
|
|
92
|
+
# This module retains ONLY:
|
|
93
|
+
# - geometry constants (CHANNEL_*) documenting the rig dimensions
|
|
94
|
+
# - the Cell Literal type + cell_assignment helper (still imported by
|
|
95
|
+
# forrest_resolvability)
|
|
96
|
+
# - the Re-band thresholds + Forrest Table-4 reference MAE dicts
|
|
97
|
+
# (descriptive constants used by reports + analysis scripts)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── geometry + fluid properties ──────────────────────────────────────────────
|
|
101
|
+
# From the Forrest et al. predecessor paper (2012 RERTR conference paper),
|
|
102
|
+
# Table 1, confirmed in SAND2014-18834J / OSTI 1295764:
|
|
103
|
+
# - High-aspect-ratio rectangular channel, α* = gap/width = 0.035 (≈29:1
|
|
104
|
+
# width:gap convention).
|
|
105
|
+
# - Hydraulic diameter Dh = 3.78 mm (Table 1, explicit). The 2*gap = 3.92 mm
|
|
106
|
+
# value is the parallel-plates asymptote; the actual finite-width
|
|
107
|
+
# rectangular-channel Dh = 4·A_flow / P_w gives 3.78. The paper uses Dh
|
|
108
|
+
# as the characteristic length in its Re and Nu definitions.
|
|
109
|
+
# - One-sided heating (asymmetric); the heated wall is one of the long
|
|
110
|
+
# faces (51 mm × 305 mm heated). The Nu is the channel-averaged for the
|
|
111
|
+
# heated wall.
|
|
112
|
+
# - Working fluid: water. Pr range 2.2 – 5.4 (corresponds to bulk
|
|
113
|
+
# temperatures of roughly 60-95°C; lower Pr = higher temperature).
|
|
114
|
+
|
|
115
|
+
# Geometry per Forrest, Buongiorno, McKrell, Hu (2012 RERTR conference paper),
|
|
116
|
+
# Table 1 — the predecessor publication to the 2014 J. Heat Transfer paper.
|
|
117
|
+
# Same experimental rig; the 2012 paper's Table 1 gives the dimensions
|
|
118
|
+
# explicitly. (The 2014 paper extends the dataset to more Pr values and
|
|
119
|
+
# higher Re, but uses the same rig.)
|
|
120
|
+
CHANNEL_GAP_MM = 1.96
|
|
121
|
+
CHANNEL_WIDTH_MM = 56.0
|
|
122
|
+
CHANNEL_LENGTH_MM = 483.0
|
|
123
|
+
CHANNEL_HEATED_LENGTH_MM = 305.0
|
|
124
|
+
CHANNEL_HEATED_WIDTH_MM = 51.0
|
|
125
|
+
CHANNEL_ASPECT_RATIO_ALPHA_STAR = CHANNEL_GAP_MM / CHANNEL_WIDTH_MM # = 0.035, per Table 1
|
|
126
|
+
CHANNEL_ASPECT_RATIO_INVERSE = CHANNEL_WIDTH_MM / CHANNEL_GAP_MM # = 28.6, the
|
|
127
|
+
# "high aspect ratio"
|
|
128
|
+
# convention (~29:1)
|
|
129
|
+
CHANNEL_HYDRAULIC_DIAMETER_MM = 3.79 # Forrest 2014 Table 2 explicit value
|
|
130
|
+
# (2012 RERTR paper Table 1 had 3.78;
|
|
131
|
+
# 2014 paper supersedes with 3.79).
|
|
132
|
+
# NOT 2*gap=3.92: the 2*gap limit is the
|
|
133
|
+
# parallel-plates asymptote; the actual
|
|
134
|
+
# rectangular-channel Dh = 4·A_flow/P_w
|
|
135
|
+
# accounts for finite width.
|
|
136
|
+
|
|
137
|
+
# Closure-bound calib ranges per the Track C corpus entries.
|
|
138
|
+
#
|
|
139
|
+
# PRIMARY (geometry-matched) closure for this substrate:
|
|
140
|
+
# Modified Sparrow-Cur (Forrest 2014 Eq. 7) — Nu = 0.036·Re^0.76·Pr^(1/3).
|
|
141
|
+
# Corpus entry: modified-sparrow-cur-asym-narrow-rect-channel-2014.
|
|
142
|
+
# This is the ONLY corpus closure with explicit one-sided-heating geometry
|
|
143
|
+
# dependence and is the right closure for Forrest's narrow rectangular
|
|
144
|
+
# mini-channel. Its validity range is what should drive the
|
|
145
|
+
# benign/transition/laminar cell-validity gates.
|
|
146
|
+
#
|
|
147
|
+
# COMPARISON (geometry-mismatched, circular-pipe) closures:
|
|
148
|
+
# Gnielinski, Dittus-Boelter, Petukhov, Sieder-Tate. These are computed
|
|
149
|
+
# alongside Sparrow-Cur for cross-comparison against Forrest 2014 Table 4
|
|
150
|
+
# MAEs, but the closure-validity test runs on Sparrow-Cur (the matched
|
|
151
|
+
# closure). Testing the differentiator on Gnielinski would contaminate
|
|
152
|
+
# the result with geometry mismatch (the same trap that produced the
|
|
153
|
+
# Lance & Smith confined-geometry finding), so the matched closure is
|
|
154
|
+
# the primary; circular-pipe closures are reported for reference only.
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ── cell definitions ─────────────────────────────────────────────────────────
|
|
159
|
+
# Cell-band thresholds match Forrest 2014 paper's EXPLICIT characterizations
|
|
160
|
+
# (revised after reading the full paper; the prior recon-memory had four
|
|
161
|
+
# cells with wrong boundaries).
|
|
162
|
+
#
|
|
163
|
+
# - Re < 4,000: SUB_CRITICAL / LAMINAR. Critical Re between 3,500
|
|
164
|
+
# and 4,000 (Eq. 18). At Re < 4,000 the flow is
|
|
165
|
+
# likely still laminar. Paper body, p.7: the
|
|
166
|
+
# Gnielinski correlation for Pr = 5.4 (top curve in
|
|
167
|
+
# Fig. 6) overpredicts the Nusselt number by about
|
|
168
|
+
# 40%, indicating the closure is being applied
|
|
169
|
+
# below its validity. Modified Sparrow-Cur (turbulent
|
|
170
|
+
# form, Re^0.76) would similarly over-predict here.
|
|
171
|
+
#
|
|
172
|
+
# - 4,000 ≤ Re < 10,000: TRANSITION. Flow becomes fully turbulent at
|
|
173
|
+
# Re ≈ 7,000 per the friction-factor data (paper
|
|
174
|
+
# p.5). Textbook turbulent closures are not
|
|
175
|
+
# validated here — Forrest's Table 4 MAEs are
|
|
176
|
+
# reported only for Re ≥ 10,000. The paper's
|
|
177
|
+
# semi-analytic Eq. (24) covers 4k–70k at MAE
|
|
178
|
+
# 4.6%.
|
|
179
|
+
#
|
|
180
|
+
# - 10,000 ≤ Re ≤ 70,000: BENIGN (turbulent). Textbook closures applicable
|
|
181
|
+
# here with MAE 6.1-15.2% (Table 4). NO sub-cell
|
|
182
|
+
# structure within this range — Gnielinski's
|
|
183
|
+
# 8.4% MAE is reported uniformly across this
|
|
184
|
+
# interval, not split by sub-range.
|
|
185
|
+
#
|
|
186
|
+
# This is THREE cells, not four. The prior `divergent_high_re` (Re > 35k)
|
|
187
|
+
# was a misreading of Eq. (19)'s 10k–35k validity (the authors' OWN power
|
|
188
|
+
# law fit, limited to the range where a simple power law is adequate);
|
|
189
|
+
# it is NOT a textbook-closure divergence cell.
|
|
190
|
+
|
|
191
|
+
Cell = Literal[
|
|
192
|
+
"benign_turbulent", # do-no-harm cell: closures perform per Table 4
|
|
193
|
+
"transition_excluded_indeterminate", # excluded: closure-divergence signal within measurement floor
|
|
194
|
+
"sub_critical_laminar", # divergent test cell: ~50-80% closure over-prediction
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
BENIGN_RE_LO = 10000.0 # paper Table 4 lower bound for all textbook closures
|
|
198
|
+
BENIGN_RE_HI = 70000.0 # paper Table 4 upper bound for all textbook closures
|
|
199
|
+
TRANSITION_RE_LO = 4000.0 # paper p.7 / Fig. 5 region — EXCLUDED, not benign
|
|
200
|
+
TRANSITION_RE_HI = 10000.0
|
|
201
|
+
SUB_CRITICAL_RE_HI = 4000.0 # paper Eq. 18: Re_crit between 3500-4000
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cell_assignment(Re: float) -> Cell:
|
|
205
|
+
"""Per-row cell label by Re band. Bands READ FROM the Forrest 2014 paper
|
|
206
|
+
(Table 4 + Eq. 18 + p.7 cell-by-cell discussion), NOT derived from the
|
|
207
|
+
data (which would be a leak).
|
|
208
|
+
|
|
209
|
+
Cell roles in the D3 differentiator (per the build directive):
|
|
210
|
+
- `sub_critical_laminar`: the ONLY divergent test cell. Visible n=2
|
|
211
|
+
in Fig 5 at Re ~ 3800, Pr=5.4. Both Sparrow-Cur and Gnielinski
|
|
212
|
+
over-predict by ~50-80% (resolvable: |gap|/unc > 3).
|
|
213
|
+
- `transition_excluded_indeterminate`: excluded from the test cell
|
|
214
|
+
set. Resolvability triage on visual estimates from Fig 5 (n=12)
|
|
215
|
+
shows median |gap|/unc ~ 0.4-0.5 — within the measurement-
|
|
216
|
+
uncertainty floor. Paper text calls the transition "conservative
|
|
217
|
+
estimate" territory, which is a safety statement not a validity
|
|
218
|
+
statement, so the cell is indeterminate (NOT benign-relabeled).
|
|
219
|
+
- `benign_turbulent`: do-no-harm cell. Closures perform per Table 4
|
|
220
|
+
(Sparrow-Cur MAE 6.1%, Gnielinski 8.4%, etc.). The corpus signal
|
|
221
|
+
is expected to be quiet here.
|
|
222
|
+
"""
|
|
223
|
+
if Re < SUB_CRITICAL_RE_HI:
|
|
224
|
+
return "sub_critical_laminar"
|
|
225
|
+
if Re < TRANSITION_RE_HI:
|
|
226
|
+
return "transition_excluded_indeterminate"
|
|
227
|
+
return "benign_turbulent" # 10000 ≤ Re; covers full validated turbulent range
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# Paper Table 4 reference MAE numbers (2.2 ≤ Pr ≤ 5.4, Re per cell)
|
|
231
|
+
# Used for cell-level comparison against corpus signal predictions.
|
|
232
|
+
# Recorded here as ground-truth references; the live closure-vs-truth
|
|
233
|
+
# comparison still runs row-by-row on measured Nu when data is in hand.
|
|
234
|
+
TABLE_4_MAES_BENIGN = {
|
|
235
|
+
"modified-sparrow-cur-asym-narrow-rect-channel-2014": 0.061, # 6.1% — GEOMETRY-MATCHED, lowest
|
|
236
|
+
"dittus-boelter-1930": 0.064, # 6.4%
|
|
237
|
+
"modified-colburn": 0.073, # 7.3% (not in corpus)
|
|
238
|
+
"gnielinski-1976": 0.084, # 8.4%
|
|
239
|
+
"petukhov-1970": 0.114, # 11.4% (not currently in corpus)
|
|
240
|
+
"sieder-tate-1936": 0.152, # 15.2%
|
|
241
|
+
"barrow-one-sided": 0.204, # 20.4% (worst — not in corpus)
|
|
242
|
+
}
|
|
243
|
+
TABLE_4_FORREST_FITS = {
|
|
244
|
+
"Empirical fit Eq.(19) [10000-35000]": 0.038, # 3.8%
|
|
245
|
+
"Semi-analytic Eq.(24) [4000-70000]": 0.046, # 4.6%
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
|