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,379 @@
|
|
|
1
|
+
"""PhysMAP D3 — NACA TN-1451 entrance-region vehicle substrate.
|
|
2
|
+
|
|
3
|
+
Source paper: Boelter, L.M.K., Young, G., Iversen, H.W. (1948).
|
|
4
|
+
"An Investigation of Aircraft Heaters XXVII — Distribution of Heat-Transfer
|
|
5
|
+
Rate in the Entrance Section of a Circular Tube." NACA Technical Note 1451,
|
|
6
|
+
July 1948. UC Berkeley. NTRS ID 19930082084. Public domain.
|
|
7
|
+
|
|
8
|
+
What this substrate is for (one sentence): a steady-state internal-flow
|
|
9
|
+
heat-transfer vehicle where MEASUREMENTS span both fully-developed
|
|
10
|
+
(x/D > 10) and developing (x/D < 5) regions at the SAME (Re, Pr), providing
|
|
11
|
+
the orthogonal-axis decoupling Forrest could not provide — surrogate inputs
|
|
12
|
+
on aggregate (Re, Pr) coords look in-distribution to baselines at any test
|
|
13
|
+
x/D, while the closure-validity literature knows x/D < 10 is outside the
|
|
14
|
+
fully-developed regime where Gnielinski / Dittus-Boelter apply.
|
|
15
|
+
|
|
16
|
+
D3 role (per locked design v0.2 + entrance-region recon v0.1 + v0.2 steelman):
|
|
17
|
+
The differentiator test substrate. Replaces the locked v0.2 Forrest+Mudhafar
|
|
18
|
+
scenario (which failed the on-axis decoupling). Per the v0.2 steelman, the
|
|
19
|
+
test cell is:
|
|
20
|
+
- Train: fully-developed measurements (x/D > 10), surrogate inputs =
|
|
21
|
+
(log10_Re, Pr) only. Per the v0.2 steelman, x/D is OMITTED from
|
|
22
|
+
surrogate inputs (the aggregate HE use-case).
|
|
23
|
+
- Test_A: developing-region measurements (x/D < 5) at the SAME (Re, Pr)
|
|
24
|
+
conditions as training. Surrogate inputs identical to training; closure
|
|
25
|
+
is invalid because flow isn't developed; truth is the measured Nu
|
|
26
|
+
which is ~1.5-2x the closure prediction (Hausen-factor regime).
|
|
27
|
+
- Test_B: held-out fully-developed measurements. Surrogate inputs in
|
|
28
|
+
training distribution; closure works to within ~5% MAE.
|
|
29
|
+
|
|
30
|
+
Expected D3 signals:
|
|
31
|
+
- Baselines (distance, GP variance) on (log10_Re, Pr) inputs: cannot fire
|
|
32
|
+
on test_A vs test_B because the inputs are LITERALLY identical (same
|
|
33
|
+
Re, same Pr, same geometry). |Cohen d| approximately 0 by construction.
|
|
34
|
+
- Validity-range-distance signal: fires on test_A (x/D < 10 is outside
|
|
35
|
+
Gnielinski's new validated range [10, infinity]); quiet on test_B
|
|
36
|
+
(x/D > 10 is inside). Large positive Cohen d.
|
|
37
|
+
|
|
38
|
+
Steelman framing (v0.2 ENSEMBLE, NOT structural-blindness):
|
|
39
|
+
Not "no competent practitioner includes x/D." Rather: across the ensemble
|
|
40
|
+
of competent practitioners building aggregate HE surrogates, a non-trivial
|
|
41
|
+
fraction omit x/D, omit the Hausen correction, or use bare Gnielinski
|
|
42
|
+
beyond L/D > 10. PhysMAP's corpus adds REAL LIFT to those practitioners'
|
|
43
|
+
workflows by catching the literature-derived validity boundary their
|
|
44
|
+
surrogate inputs can't represent. The result claim is "adds lift," NOT
|
|
45
|
+
"is the unique mechanism" — practitioners who already apply Hausen don't
|
|
46
|
+
need the corpus, but the ensemble of omitters is the value-add target.
|
|
47
|
+
|
|
48
|
+
Data-acquisition status:
|
|
49
|
+
- NACA TN-1451 PDF in hand at /tmp/naca_tn1451.pdf (downloaded from NTRS).
|
|
50
|
+
- Per-x/D Nu measurements are in Figures 10-25 of the report (16 entering-
|
|
51
|
+
air conditions x multiple x/D positions). Apply same triage discipline
|
|
52
|
+
as Forrest Fig 5: visual-estimate rows tagged separately from any
|
|
53
|
+
future WPD-digitized rows.
|
|
54
|
+
|
|
55
|
+
Per-paper facts (from paper text):
|
|
56
|
+
- 0.93-inch ID circular tube (smooth wall).
|
|
57
|
+
- 16 entering-air conditions tested.
|
|
58
|
+
- x/D positions reported include 1.03, 4.41, 5.25, 15.40, 16.50 and others.
|
|
59
|
+
- Air only (Pr ≈ 0.71 at typical conditions).
|
|
60
|
+
- Quantified uncertainty: ±5% total experimental error; ±3% reproducibility.
|
|
61
|
+
- Paper finding: "experimental values appreciably higher than equations
|
|
62
|
+
derived from over-all data taken on long pipes" — the entrance-region
|
|
63
|
+
divergence, central to the paper.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
from __future__ import annotations
|
|
67
|
+
|
|
68
|
+
from dataclasses import dataclass
|
|
69
|
+
from pathlib import Path
|
|
70
|
+
from typing import Literal
|
|
71
|
+
|
|
72
|
+
import numpy as np
|
|
73
|
+
|
|
74
|
+
from physmap.substrate.stage1_ingest import Mechanism, Row, load_rows
|
|
75
|
+
from physmap.substrate.corpus_real import SubstrateMeta
|
|
76
|
+
from physmap.closures import REGISTRY
|
|
77
|
+
from physmap.closures.formulas import (
|
|
78
|
+
dittus_boelter_nu,
|
|
79
|
+
gnielinski_nu,
|
|
80
|
+
petukhov_nu,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Calibration ranges sourced from the closure registry (Cleanup 5 part 2).
|
|
85
|
+
GNIELINSKI_RE_LO, GNIELINSKI_RE_HI = REGISTRY["gnielinski-1976"].re_range
|
|
86
|
+
DITTUS_BOELTER_RE_LO, DITTUS_BOELTER_RE_HI = REGISTRY["dittus-boelter-1930"].re_range
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── geometry + fluid (per NACA TN-1451 paper) ───────────────────────────────
|
|
90
|
+
# Tube ID from paper line 233: "highly polished seamless steel tube 32 inches
|
|
91
|
+
# long having a 2-inch outside diameter (1.785 in. I.D.)". Earlier prereg
|
|
92
|
+
# draft had 0.93 in — that was wrong (confused with Mudhafar). Corrected here.
|
|
93
|
+
TUBE_ID_INCH = 1.785
|
|
94
|
+
TUBE_ID_MM = TUBE_ID_INCH * 25.4 # 45.34 mm
|
|
95
|
+
TUBE_ID_FT = TUBE_ID_INCH / 12.0 # 0.14875 ft (used to convert fc → Nu)
|
|
96
|
+
FLUID = "air"
|
|
97
|
+
PR_AIR_NOMINAL = 0.71 # air at typical heater operating temps
|
|
98
|
+
ALPHA_STAR_CIRCULAR = 1.0
|
|
99
|
+
HEATING_PATTERN = "all_walls_steam_jacketed" # uniform circumferential
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── cell definitions (per Hausen factor + textbook L/D > 10 rule) ────────────
|
|
103
|
+
# Cells defined by x/D position. The orthogonal-axis differentiator depends
|
|
104
|
+
# on this cell structure:
|
|
105
|
+
# - developing: x/D < 5 (Hausen factor > ~1.34, divergence > 30%)
|
|
106
|
+
# - shoulder: 5 ≤ x/D < 10 (Hausen factor 1.22-1.34, divergence 20-30%)
|
|
107
|
+
# - fully_developed: x/D ≥ 10 (Hausen factor < 1.22, divergence < 20%
|
|
108
|
+
# and approaching textbook MAE of ~5-10%)
|
|
109
|
+
|
|
110
|
+
Cell = Literal["developing", "shoulder", "fully_developed"]
|
|
111
|
+
|
|
112
|
+
DEVELOPING_X_OVER_D_HI = 5.0
|
|
113
|
+
SHOULDER_X_OVER_D_HI = 10.0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cell_assignment(x_over_D: float) -> Cell:
|
|
117
|
+
"""Per-row cell label by x/D band."""
|
|
118
|
+
if x_over_D < DEVELOPING_X_OVER_D_HI:
|
|
119
|
+
return "developing"
|
|
120
|
+
if x_over_D < SHOULDER_X_OVER_D_HI:
|
|
121
|
+
return "shoulder"
|
|
122
|
+
return "fully_developed"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Hausen entrance-correction factor (textbook approximation):
|
|
126
|
+
# F(x/D) = 1 + (D/x)^(2/3)
|
|
127
|
+
# At x/D = 1: F = 2.0; x/D = 2: F = 1.63; x/D = 5: F = 1.34; x/D = 10: F = 1.22
|
|
128
|
+
def hausen_factor(x_over_D: np.ndarray | float) -> np.ndarray:
|
|
129
|
+
"""Hausen entrance-correction multiplier for fully-developed Nu."""
|
|
130
|
+
x_over_D = np.asarray(x_over_D, dtype=float)
|
|
131
|
+
return 1.0 + (1.0 / np.maximum(x_over_D, 1e-3)) ** (2.0 / 3.0)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── schema ───────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class NACARow:
|
|
138
|
+
"""One measured local-Nu point on the NACA TN-1451 substrate.
|
|
139
|
+
|
|
140
|
+
Re, Pr, Nu_meas, x_over_D, Nu_unc are load-bearing.
|
|
141
|
+
"""
|
|
142
|
+
Re: float
|
|
143
|
+
Pr: float
|
|
144
|
+
Nu_meas: float
|
|
145
|
+
x_over_D: float
|
|
146
|
+
Nu_unc: float = None # absolute Nu units; if None, use 5% of Nu_meas
|
|
147
|
+
# Provenance
|
|
148
|
+
entering_condition: str = "bellmouth" # e.g. "bellmouth", "long_calming", "sharp_edge_orifice"
|
|
149
|
+
figure: str = "" # which Fig (10-25) the point came from
|
|
150
|
+
source: str = "naca_tn1451"
|
|
151
|
+
digitization_uncertainty: float | None = None
|
|
152
|
+
|
|
153
|
+
def __post_init__(self):
|
|
154
|
+
if self.Nu_unc is None:
|
|
155
|
+
self.Nu_unc = 0.05 * self.Nu_meas # paper-reported ±5% default
|
|
156
|
+
|
|
157
|
+
def cell(self) -> Cell:
|
|
158
|
+
return cell_assignment(self.x_over_D)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ── loader ───────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
def naca_to_rows(data_table: list[NACARow]) -> tuple[list[Row], dict, SubstrateMeta]:
|
|
164
|
+
"""Convert NACA measurement points into Stage-1 Rows.
|
|
165
|
+
|
|
166
|
+
Each Row carries:
|
|
167
|
+
operating_point = (Re, Pr) - the SURROGATE inputs per v0.2 steelman
|
|
168
|
+
surrogate_prediction = Gnielinski Nu (the closure the practitioner uses)
|
|
169
|
+
cfd_truth = measured Nu (the actual measurement)
|
|
170
|
+
meta = full physical context INCLUDING x_over_D, so detectors that
|
|
171
|
+
use the physical context (validity signal) can access x/D
|
|
172
|
+
while detectors that use surrogate inputs (baselines) don't
|
|
173
|
+
extract x_over_D.
|
|
174
|
+
"""
|
|
175
|
+
rows: list[Row] = []
|
|
176
|
+
for r in data_table:
|
|
177
|
+
Re = float(r.Re)
|
|
178
|
+
Pr = float(r.Pr)
|
|
179
|
+
Nu_meas = float(r.Nu_meas)
|
|
180
|
+
Nu_unc = float(r.Nu_unc)
|
|
181
|
+
x_over_D = float(r.x_over_D)
|
|
182
|
+
if Nu_meas <= 0:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
Nu_g = float(gnielinski_nu(Re=np.array([Re]), Pr=np.array([Pr]))[0])
|
|
186
|
+
Nu_db = float(dittus_boelter_nu(Re=np.array([Re]), Pr=np.array([Pr]))[0])
|
|
187
|
+
Nu_pk = float(petukhov_nu(Re=np.array([Re]), Pr=np.array([Pr]))[0])
|
|
188
|
+
|
|
189
|
+
if r.digitization_uncertainty is not None and r.digitization_uncertainty > 0:
|
|
190
|
+
total_unc = float(np.sqrt(Nu_unc ** 2 + r.digitization_uncertainty ** 2))
|
|
191
|
+
else:
|
|
192
|
+
total_unc = Nu_unc
|
|
193
|
+
|
|
194
|
+
# The surrogate is Gnielinski (the standard industrial choice for HE).
|
|
195
|
+
# Per the v0.2 steelman: this closure is applied with surrogate
|
|
196
|
+
# inputs = (Re, Pr) only, omitting x/D. The corpus now encodes
|
|
197
|
+
# x_over_D > 10 as Gnielinski's validated range.
|
|
198
|
+
mechanisms = [
|
|
199
|
+
Mechanism(
|
|
200
|
+
name="forced_internal_pipe_gnielinski_aggregate_use",
|
|
201
|
+
closure_id="gnielinski-1976",
|
|
202
|
+
operating_value=Re,
|
|
203
|
+
calib_lo=GNIELINSKI_RE_LO,
|
|
204
|
+
calib_hi=GNIELINSKI_RE_HI,
|
|
205
|
+
contribution=Nu_g,
|
|
206
|
+
),
|
|
207
|
+
Mechanism(
|
|
208
|
+
name="forced_internal_pipe_dittus_boelter_reference",
|
|
209
|
+
closure_id="dittus-boelter-1930",
|
|
210
|
+
operating_value=Re,
|
|
211
|
+
calib_lo=DITTUS_BOELTER_RE_LO,
|
|
212
|
+
calib_hi=DITTUS_BOELTER_RE_HI,
|
|
213
|
+
contribution=Nu_db,
|
|
214
|
+
),
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
rows.append(Row(
|
|
218
|
+
operating_point=(round(Re, 1), round(Pr, 4)),
|
|
219
|
+
surrogate_prediction=Nu_g, # Gnielinski (the practitioner's surrogate)
|
|
220
|
+
cfd_truth=Nu_meas,
|
|
221
|
+
truth_source="experimental",
|
|
222
|
+
cfd_uncertainty=total_unc,
|
|
223
|
+
guardrail_signals={"ood": 0.0, "residual": 0.0, "variance": 0.0},
|
|
224
|
+
mechanisms=mechanisms,
|
|
225
|
+
meta={
|
|
226
|
+
"Re": Re, "Pr": Pr,
|
|
227
|
+
"Nu_meas": Nu_meas, "Nu_unc_reported": Nu_unc,
|
|
228
|
+
"Nu_unc_total_propagated": total_unc,
|
|
229
|
+
"Nu_pred_gnielinski": Nu_g,
|
|
230
|
+
"Nu_pred_dittus_boelter": Nu_db,
|
|
231
|
+
"Nu_pred_petukhov": Nu_pk,
|
|
232
|
+
"x_over_D": x_over_D, # PHYSICAL CONTEXT (validity uses this)
|
|
233
|
+
"x_over_D_log10": float(np.log10(x_over_D)),
|
|
234
|
+
"hausen_factor_expected": float(hausen_factor(x_over_D)),
|
|
235
|
+
"cell": r.cell(),
|
|
236
|
+
"Dh_mm": TUBE_ID_MM,
|
|
237
|
+
"alpha_star": ALPHA_STAR_CIRCULAR,
|
|
238
|
+
"heating_pattern_indicator": 1.0,
|
|
239
|
+
"roughness_relative": 0.0,
|
|
240
|
+
"fluid": FLUID,
|
|
241
|
+
"entering_condition": r.entering_condition,
|
|
242
|
+
"figure": r.figure,
|
|
243
|
+
"source": r.source,
|
|
244
|
+
"digitization_uncertainty": r.digitization_uncertainty,
|
|
245
|
+
},
|
|
246
|
+
))
|
|
247
|
+
|
|
248
|
+
rows = load_rows(rows)
|
|
249
|
+
|
|
250
|
+
reference = {"ood": [0.0], "residual": [0.0], "variance": [0.0]}
|
|
251
|
+
meta = SubstrateMeta(
|
|
252
|
+
name="naca-tn1451-entrance-region",
|
|
253
|
+
divergent_truth_substrate=True,
|
|
254
|
+
reason=(
|
|
255
|
+
"Independent experimental truth: measured local Nu via "
|
|
256
|
+
"steam-condensation calorimetry per section in a 0.93-in ID "
|
|
257
|
+
"circular tube. Per NACA TN-1451 (Boelter, Young, Iversen 1948; "
|
|
258
|
+
"NTRS ID 19930082084; public domain). 16 entering-air conditions "
|
|
259
|
+
"tested across multiple x/D positions; the per-x/D variation IS "
|
|
260
|
+
"the central paper finding. Steady-state — no temporal "
|
|
261
|
+
"autocorrelation. Per D3 v0.2 steelman, this substrate provides "
|
|
262
|
+
"the orthogonal-axis decoupling: x/D failure axis is NOT in the "
|
|
263
|
+
"aggregate-HE surrogate's inputs (Re, Pr), so baselines see "
|
|
264
|
+
"test_A (developing) and test_B (fully-developed) as identical "
|
|
265
|
+
"inputs, while the corpus literature knows x/D < 10 is outside "
|
|
266
|
+
"Gnielinski's validated range."
|
|
267
|
+
),
|
|
268
|
+
norm_strategy="per_row_truth",
|
|
269
|
+
bound_for_pde=None,
|
|
270
|
+
magnitude_bridge_ok=True,
|
|
271
|
+
extra={
|
|
272
|
+
"geometry": (
|
|
273
|
+
f"Circular tube ID = {TUBE_ID_INCH} inch = {TUBE_ID_MM:.2f} mm, "
|
|
274
|
+
f"smooth wall, steam-jacketed (uniform circumferential heating), "
|
|
275
|
+
f"variable length (~30 inches total); per-section local Nu "
|
|
276
|
+
f"measured via condensation rate per individual jacket section."
|
|
277
|
+
),
|
|
278
|
+
"fluid": FLUID,
|
|
279
|
+
"Pr_nominal": PR_AIR_NOMINAL,
|
|
280
|
+
"Re_range_paper": [1000, 100000], # approximate; 16 conditions
|
|
281
|
+
"x_over_D_range_paper": [1.03, ~30], # explicit positions in text
|
|
282
|
+
"cell_bands": {
|
|
283
|
+
"developing": {
|
|
284
|
+
"range": f"x/D < {DEVELOPING_X_OVER_D_HI}",
|
|
285
|
+
"role": "D3 differentiator test cell — closure invalid (Hausen factor > 1.34)",
|
|
286
|
+
"expected_validity_signal": "FIRES (x/D below corpus validated range [10, inf])",
|
|
287
|
+
"expected_baseline_signal": (
|
|
288
|
+
"QUIET if surrogate inputs exclude x/D (per v0.2 steelman). "
|
|
289
|
+
"Test_A and Test_B have identical surrogate inputs by construction."
|
|
290
|
+
),
|
|
291
|
+
},
|
|
292
|
+
"shoulder": {
|
|
293
|
+
"range": f"{DEVELOPING_X_OVER_D_HI} <= x/D < {SHOULDER_X_OVER_D_HI}",
|
|
294
|
+
"role": "transition shoulder — borderline (Hausen factor 1.22-1.34)",
|
|
295
|
+
"use": "excluded from primary differentiator test; reserved for sensitivity check",
|
|
296
|
+
},
|
|
297
|
+
"fully_developed": {
|
|
298
|
+
"range": f"x/D >= {SHOULDER_X_OVER_D_HI}",
|
|
299
|
+
"role": "training set + Test_B control",
|
|
300
|
+
"expected_validity_signal": "quiet (x/D in corpus validated range)",
|
|
301
|
+
"expected_baseline_signal": "quiet (training distribution)",
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
"n_entering_conditions": 16,
|
|
305
|
+
"uncertainty_quantified": {
|
|
306
|
+
"max_total_error_pct": 5.0,
|
|
307
|
+
"reproducibility_pct": 3.0,
|
|
308
|
+
"source": "NACA TN-1451 paper text (line 329 of pdftotext extract)",
|
|
309
|
+
},
|
|
310
|
+
"d3_role": (
|
|
311
|
+
"DIFFERENTIATOR vehicle. Replaces the locked v0.2 Forrest+Mudhafar "
|
|
312
|
+
"scenario after the Forrest on-axis structural finding. Provides "
|
|
313
|
+
"the orthogonal-axis decoupling needed to genuinely test the "
|
|
314
|
+
"literature-validity signal against quiet baselines."
|
|
315
|
+
),
|
|
316
|
+
"data_source_note": (
|
|
317
|
+
"Per-row Nu measurements pending digitization of NACA Figs 10-25. "
|
|
318
|
+
"Apply Forrest-style triage discipline: visual-estimate rows "
|
|
319
|
+
"tagged 'visual-estimate-rendered-pdf-naca' separated from any "
|
|
320
|
+
"future WPD-digitized rows."
|
|
321
|
+
),
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
return rows, reference, meta
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ── data-acquisition path stubs (NotImplementedError until digitization) ─────
|
|
328
|
+
|
|
329
|
+
def from_wpd_csv(path: Path) -> list[NACARow]:
|
|
330
|
+
raise NotImplementedError(
|
|
331
|
+
"NACA Figs 10-25 WPD digitization pending. Recommended sequence: "
|
|
332
|
+
"Fig 10 (bellmouth, full Re range) first as a starting target; then "
|
|
333
|
+
"Figs 11-25 for the other entering conditions. Export CSV with "
|
|
334
|
+
"(Re, x_over_D, Nu_meas) columns; entering_condition encoded in filename."
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def from_visual_estimate_csv(path: Path) -> list[NACARow]:
|
|
339
|
+
raise NotImplementedError("Visual-estimate digitization not yet performed.")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def from_synthetic_shape(n_re_conditions: int = 16,
|
|
343
|
+
x_over_D_positions: tuple = (1, 2, 3, 5, 10, 15, 20, 30, 50),
|
|
344
|
+
re_min: float = 3000.0,
|
|
345
|
+
re_max: float = 100000.0,
|
|
346
|
+
seed: int = 20260605) -> list[NACARow]:
|
|
347
|
+
"""SYNTHETIC data matching NACA TN-1451's experimental shape.
|
|
348
|
+
|
|
349
|
+
Used for the synthetic-shape verification of the differentiator design
|
|
350
|
+
BEFORE digitization lands. The synthesis uses:
|
|
351
|
+
- n_re_conditions Re values sampled log-uniformly in [re_min, re_max].
|
|
352
|
+
Default [3000, 100000] keeps Re strictly inside Gnielinski's
|
|
353
|
+
validated Re lower bound (3000) so the synthetic test isolates the
|
|
354
|
+
x/D axis as the cause of any corpus firing — any benign FA on a
|
|
355
|
+
synthetic point would necessarily be a true positive on Re axis
|
|
356
|
+
otherwise (Gnielinski's validated range Re=[3000, 5e6]).
|
|
357
|
+
- x/D positions covering both developing and fully-developed.
|
|
358
|
+
- Nu_truth = Gnielinski(Re, Pr) * Hausen_factor(x/D) + noise
|
|
359
|
+
- 5% measurement noise per the paper.
|
|
360
|
+
|
|
361
|
+
Honest about being synthetic: marks source='synthetic-naca-shape' so
|
|
362
|
+
these rows are clearly distinguished from any future real-data rows.
|
|
363
|
+
"""
|
|
364
|
+
rng = np.random.default_rng(seed)
|
|
365
|
+
Re_values = np.logspace(np.log10(re_min), np.log10(re_max), n_re_conditions)
|
|
366
|
+
rows = []
|
|
367
|
+
for Re in Re_values:
|
|
368
|
+
# Synthesize Nu using Gnielinski + Hausen factor + noise
|
|
369
|
+
Nu_gnielinski = float(gnielinski_nu(np.array([Re]), np.array([PR_AIR_NOMINAL]))[0])
|
|
370
|
+
for xd in x_over_D_positions:
|
|
371
|
+
F = float(hausen_factor(xd))
|
|
372
|
+
Nu_truth = Nu_gnielinski * F * (1.0 + rng.normal(0, 0.05))
|
|
373
|
+
rows.append(NACARow(
|
|
374
|
+
Re=float(Re), Pr=PR_AIR_NOMINAL, Nu_meas=float(Nu_truth),
|
|
375
|
+
x_over_D=float(xd),
|
|
376
|
+
entering_condition="synthetic_bellmouth",
|
|
377
|
+
source="synthetic-naca-shape",
|
|
378
|
+
))
|
|
379
|
+
return rows
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""PhysMAP D3 — NACA TN-1451 WPD CSV loader.
|
|
2
|
+
|
|
3
|
+
Loads WebPlotDigitizer-produced CSVs in the schema locked at
|
|
4
|
+
physmap/results/naca_digitization/WPD_DIGITIZATION_SPEC.md.
|
|
5
|
+
|
|
6
|
+
Per prereg v0.3 non-negotiable #5: source='wpd-csv' is the BANKED tag;
|
|
7
|
+
visual-estimate-rendered-pdf-naca rows are NOT banked.
|
|
8
|
+
|
|
9
|
+
Per user 2026-06-05 directive on Fig 21:
|
|
10
|
+
The Re=26,100 curve in Fig 21 (right-angle bend) is still descending at
|
|
11
|
+
x/D=17 (hasn't reached asymptote within plotted range). Those rows are:
|
|
12
|
+
- EXCLUDED from both verdict cells (not in subcrit, not in benign)
|
|
13
|
+
- TAGGED 'asymptote_not_reached' in meta
|
|
14
|
+
- RETAINED as recorded observation for the alignment analysis
|
|
15
|
+
This loader enforces the exclusion automatically when figure='Fig21' AND
|
|
16
|
+
Re=26100 AND x_over_D>=10.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import csv
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Sequence
|
|
25
|
+
|
|
26
|
+
from physmap.substrate.naca_tn1451 import NACARow
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
REQUIRED_COLUMNS = (
|
|
30
|
+
"row", "Re", "Pr", "x_over_D", "fc_Btu_hr_ft2_F", "fc_unc_wpd",
|
|
31
|
+
"Nu_meas", "Nu_unc_paper_pct", "Nu_unc_digitization_abs",
|
|
32
|
+
"source", "figure", "entering_condition", "note",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Fig 21 Re=26,100 asymptote_not_reached rule (locked per user 2026-06-05)
|
|
36
|
+
FIG21_EXCLUDED_FIGURE = "Fig21"
|
|
37
|
+
FIG21_EXCLUDED_RE = 26100.0
|
|
38
|
+
FIG21_EXCLUDED_X_OVER_D_MIN = 10.0
|
|
39
|
+
ASYMPTOTE_NOT_REACHED_TAG = "asymptote_not_reached"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class LoadedWPDData:
|
|
44
|
+
"""Output of load_wpd_csv: rows usable in the verdict + the alignment-only
|
|
45
|
+
rows + per-figure metadata."""
|
|
46
|
+
rows_for_verdict: list[NACARow]
|
|
47
|
+
rows_alignment_only: list[NACARow] # asymptote_not_reached etc.
|
|
48
|
+
figure_metadata: dict[str, dict]
|
|
49
|
+
n_total_loaded: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _validate_header(header: Sequence[str], path: Path) -> None:
|
|
53
|
+
missing = [c for c in REQUIRED_COLUMNS if c not in header]
|
|
54
|
+
if missing:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"WPD CSV {path} missing required columns: {missing}. "
|
|
57
|
+
f"See physmap/results/naca_digitization/WPD_DIGITIZATION_SPEC.md "
|
|
58
|
+
f"for the canonical schema."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_fig21_re26100_asymptote(figure: str, Re: float, x_over_D: float) -> bool:
|
|
63
|
+
return (
|
|
64
|
+
figure == FIG21_EXCLUDED_FIGURE
|
|
65
|
+
and abs(Re - FIG21_EXCLUDED_RE) < 50.0 # tolerance for digitization noise on Re
|
|
66
|
+
and x_over_D >= FIG21_EXCLUDED_X_OVER_D_MIN
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_wpd_csv(path: Path) -> LoadedWPDData:
|
|
71
|
+
"""Load a single WPD CSV and produce LoadedWPDData with the Fig 21
|
|
72
|
+
exclusion rule applied."""
|
|
73
|
+
path = Path(path)
|
|
74
|
+
rows_for_verdict: list[NACARow] = []
|
|
75
|
+
rows_alignment_only: list[NACARow] = []
|
|
76
|
+
|
|
77
|
+
with open(path) as f:
|
|
78
|
+
# Strip lines starting with '#' (comment headers WPD/user may add)
|
|
79
|
+
reader = csv.DictReader(
|
|
80
|
+
(line for line in f if not line.lstrip().startswith("#"))
|
|
81
|
+
)
|
|
82
|
+
_validate_header(reader.fieldnames or [], path)
|
|
83
|
+
for r in reader:
|
|
84
|
+
source = r["source"].strip()
|
|
85
|
+
if source != "wpd-csv":
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"WPD CSV {path} row {r['row']} has source='{source}'; "
|
|
88
|
+
f"only 'wpd-csv' is banked. Use a separate file with "
|
|
89
|
+
f"source='visual-estimate-...' if needed."
|
|
90
|
+
)
|
|
91
|
+
figure = r["figure"].strip()
|
|
92
|
+
Re = float(r["Re"])
|
|
93
|
+
Pr = float(r["Pr"])
|
|
94
|
+
x_over_D = float(r["x_over_D"])
|
|
95
|
+
Nu_meas = float(r["Nu_meas"])
|
|
96
|
+
Nu_unc_dig = float(r["Nu_unc_digitization_abs"])
|
|
97
|
+
# Paper unc = 3% per prereg (line-text). Stored as fraction.
|
|
98
|
+
Nu_unc_paper_pct_frac = float(r["Nu_unc_paper_pct"])
|
|
99
|
+
paper_unc_abs = Nu_meas * Nu_unc_paper_pct_frac
|
|
100
|
+
# Combined unc: quadrature of paper (absolute) + digitization (absolute)
|
|
101
|
+
total_unc = float((paper_unc_abs ** 2 + Nu_unc_dig ** 2) ** 0.5)
|
|
102
|
+
|
|
103
|
+
# Build NACARow
|
|
104
|
+
note = r.get("note", "").strip()
|
|
105
|
+
if _is_fig21_re26100_asymptote(figure, Re, x_over_D):
|
|
106
|
+
note = (
|
|
107
|
+
f"{note}; {ASYMPTOTE_NOT_REACHED_TAG}: Fig 21 Re=26,100 "
|
|
108
|
+
f"curve still descending at x/D=17; excluded from verdict "
|
|
109
|
+
f"cells; retained for alignment analysis."
|
|
110
|
+
).strip("; ")
|
|
111
|
+
|
|
112
|
+
nrow = NACARow(
|
|
113
|
+
Re=Re,
|
|
114
|
+
Pr=Pr,
|
|
115
|
+
Nu_meas=Nu_meas,
|
|
116
|
+
x_over_D=x_over_D,
|
|
117
|
+
Nu_unc=total_unc,
|
|
118
|
+
entering_condition=r["entering_condition"].strip(),
|
|
119
|
+
figure=figure,
|
|
120
|
+
source=source,
|
|
121
|
+
digitization_uncertainty=Nu_unc_dig,
|
|
122
|
+
)
|
|
123
|
+
# Attach the note as an attribute (NACARow doesn't have a note field
|
|
124
|
+
# by default; mutate __dict__ for forward-compatibility with the
|
|
125
|
+
# alignment / verdict reporting):
|
|
126
|
+
nrow.__dict__["wpd_note"] = note
|
|
127
|
+
|
|
128
|
+
if _is_fig21_re26100_asymptote(figure, Re, x_over_D):
|
|
129
|
+
rows_alignment_only.append(nrow)
|
|
130
|
+
else:
|
|
131
|
+
rows_for_verdict.append(nrow)
|
|
132
|
+
|
|
133
|
+
fig_meta = {}
|
|
134
|
+
n_loaded = len(rows_for_verdict) + len(rows_alignment_only)
|
|
135
|
+
for r in rows_for_verdict + rows_alignment_only:
|
|
136
|
+
figure_id = r.figure
|
|
137
|
+
if figure_id not in fig_meta:
|
|
138
|
+
fig_meta[figure_id] = {
|
|
139
|
+
"n_total": 0, "n_verdict": 0, "n_alignment_only": 0,
|
|
140
|
+
"Re_values": set(), "entering_condition": r.entering_condition,
|
|
141
|
+
}
|
|
142
|
+
fig_meta[figure_id]["n_total"] += 1
|
|
143
|
+
fig_meta[figure_id]["Re_values"].add(round(r.Re, 0))
|
|
144
|
+
for r in rows_for_verdict:
|
|
145
|
+
fig_meta[r.figure]["n_verdict"] += 1
|
|
146
|
+
for r in rows_alignment_only:
|
|
147
|
+
fig_meta[r.figure]["n_alignment_only"] += 1
|
|
148
|
+
for fid in fig_meta:
|
|
149
|
+
fig_meta[fid]["Re_values"] = sorted(fig_meta[fid]["Re_values"])
|
|
150
|
+
|
|
151
|
+
return LoadedWPDData(
|
|
152
|
+
rows_for_verdict=rows_for_verdict,
|
|
153
|
+
rows_alignment_only=rows_alignment_only,
|
|
154
|
+
figure_metadata=fig_meta,
|
|
155
|
+
n_total_loaded=n_loaded,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def load_wpd_directory(dir_path: Path, glob_pattern: str = "wpd_fig*.csv"
|
|
160
|
+
) -> LoadedWPDData:
|
|
161
|
+
"""Load all WPD CSVs matching the pattern in a directory, merge into one
|
|
162
|
+
LoadedWPDData. Per-figure metadata accumulates across files."""
|
|
163
|
+
dir_path = Path(dir_path)
|
|
164
|
+
files = sorted(dir_path.glob(glob_pattern))
|
|
165
|
+
if not files:
|
|
166
|
+
raise FileNotFoundError(
|
|
167
|
+
f"No WPD CSVs found in {dir_path} matching '{glob_pattern}'. "
|
|
168
|
+
f"Expected files like wpd_fig10.csv, wpd_fig15.csv, etc."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
all_verdict: list[NACARow] = []
|
|
172
|
+
all_alignment: list[NACARow] = []
|
|
173
|
+
merged_meta: dict[str, dict] = {}
|
|
174
|
+
n_total = 0
|
|
175
|
+
for f in files:
|
|
176
|
+
result = load_wpd_csv(f)
|
|
177
|
+
all_verdict.extend(result.rows_for_verdict)
|
|
178
|
+
all_alignment.extend(result.rows_alignment_only)
|
|
179
|
+
n_total += result.n_total_loaded
|
|
180
|
+
merged_meta.update(result.figure_metadata)
|
|
181
|
+
|
|
182
|
+
return LoadedWPDData(
|
|
183
|
+
rows_for_verdict=all_verdict,
|
|
184
|
+
rows_alignment_only=all_alignment,
|
|
185
|
+
figure_metadata=merged_meta,
|
|
186
|
+
n_total_loaded=n_total,
|
|
187
|
+
)
|