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.
Files changed (88) hide show
  1. physmap/__init__.py +61 -0
  2. physmap/_paths.py +69 -0
  3. physmap/applicability/__init__.py +0 -0
  4. physmap/applicability/fixtures.py +83 -0
  5. physmap/applicability/screen.py +99 -0
  6. physmap/baselines/__init__.py +0 -0
  7. physmap/benchmarks/__init__.py +0 -0
  8. physmap/benchmarks/benchmark_report.py +405 -0
  9. physmap/benchmarks/benchmark_v0_4.py +424 -0
  10. physmap/benchmarks/compare.py +149 -0
  11. physmap/benchmarks/registry.py +217 -0
  12. physmap/benchmarks/report.py +224 -0
  13. physmap/cli.py +301 -0
  14. physmap/closures/__init__.py +48 -0
  15. physmap/closures/data/__init__.py +7 -0
  16. physmap/closures/data/closure_index.json +2997 -0
  17. physmap/closures/formulas.py +213 -0
  18. physmap/closures/geometry_classes.py +109 -0
  19. physmap/closures/index.py +393 -0
  20. physmap/closures/registry.py +313 -0
  21. physmap/compat/__init__.py +0 -0
  22. physmap/core/__init__.py +0 -0
  23. physmap/core/mechanism.py +69 -0
  24. physmap/core/signals.py +50 -0
  25. physmap/corpus/__init__.py +12 -0
  26. physmap/corpus/calibration.py +543 -0
  27. physmap/corpus/data/__init__.py +12 -0
  28. physmap/corpus/data/corpus_seed.jsonl +15 -0
  29. physmap/corpus/data/evidence_claims_seed.jsonl +21 -0
  30. physmap/corpus/data/evidence_sources_seed.jsonl +8 -0
  31. physmap/corpus/data/premium_coverage.json +60 -0
  32. physmap/corpus/evidence.py +871 -0
  33. physmap/explain/__init__.py +0 -0
  34. physmap/explain/benchmark.py +101 -0
  35. physmap/explain/causal.py +82 -0
  36. physmap/guardrail/__init__.py +38 -0
  37. physmap/guardrail/aggregator_observability.py +187 -0
  38. physmap/guardrail/classify.py +147 -0
  39. physmap/guardrail/configs.py +120 -0
  40. physmap/guardrail/corpus_regimes.py +208 -0
  41. physmap/guardrail/detector_conformal.py +129 -0
  42. physmap/guardrail/detector_density.py +74 -0
  43. physmap/guardrail/enums.py +69 -0
  44. physmap/guardrail/graph.py +73 -0
  45. physmap/guardrail/guardrail.py +606 -0
  46. physmap/guardrail/io.py +201 -0
  47. physmap/guardrail/regime_observability.py +519 -0
  48. physmap/guardrail/render.py +159 -0
  49. physmap/guardrail/weighting_heuristic.py +216 -0
  50. physmap/infra/__init__.py +23 -0
  51. physmap/infra/blindspot_oracle.py +356 -0
  52. physmap/infra/corpus_runtime.py +275 -0
  53. physmap/integrations/__init__.py +0 -0
  54. physmap/materiality/__init__.py +0 -0
  55. physmap/materiality/estimator.py +239 -0
  56. physmap/materiality/independence.py +92 -0
  57. physmap/materiality/surrogate_fit.py +293 -0
  58. physmap/observability/__init__.py +0 -0
  59. physmap/pipeline/__init__.py +58 -0
  60. physmap/pipeline/aggregators.py +199 -0
  61. physmap/pipeline/assessment_v06.py +509 -0
  62. physmap/pipeline/core.py +442 -0
  63. physmap/pipeline/defeasible_aggregator.py +324 -0
  64. physmap/pipeline/detectors.py +309 -0
  65. physmap/pipeline/observability.py +430 -0
  66. physmap/pipeline/surrogate.py +251 -0
  67. physmap/pipeline/validity_signal.py +273 -0
  68. physmap/pipeline/vehicle_spec.py +287 -0
  69. physmap/release.py +81 -0
  70. physmap/stress_tests/__init__.py +9 -0
  71. physmap/stress_tests/lewis_reuse.py +517 -0
  72. physmap/substrate/__init__.py +28 -0
  73. physmap/substrate/corpus_real.py +206 -0
  74. physmap/substrate/engine.py +209 -0
  75. physmap/substrate/forrest.py +249 -0
  76. physmap/substrate/loaders.py +2176 -0
  77. physmap/substrate/naca_tn1451.py +379 -0
  78. physmap/substrate/naca_wpd_loader.py +187 -0
  79. physmap/substrate/stage1_ingest.py +187 -0
  80. physmap/substrate/vehicle_config.py +407 -0
  81. physmap-0.2.0.dist-info/METADATA +270 -0
  82. physmap-0.2.0.dist-info/RECORD +88 -0
  83. physmap-0.2.0.dist-info/WHEEL +5 -0
  84. physmap-0.2.0.dist-info/entry_points.txt +2 -0
  85. physmap-0.2.0.dist-info/licenses/LICENSE +21 -0
  86. physmap-0.2.0.dist-info/licenses/LICENSE-CORPUS +469 -0
  87. physmap-0.2.0.dist-info/licenses/NOTICE +77 -0
  88. 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
+ )