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,213 @@
1
+ """Pure Nu(...) closure formulas, extracted from substrate files.
2
+
3
+ Each function takes keyword-only inputs (so the substrate engine can pass an
4
+ input bag and each closure picks what it needs) and returns Nu as a numpy
5
+ array. Formulas are byte-faithful copies of the originals — DO NOT alter
6
+ the math; the byte-for-byte substrate-migration gate compares numerical
7
+ output exactly.
8
+
9
+ Inputs that may appear in the kwarg bag (not every closure uses every one):
10
+ Re Reynolds number (turbulent internal flow; or Re_x for flat plate)
11
+ Pr Prandtl number
12
+ Ra Rayleigh number (natural convection)
13
+ Nu_forced pre-computed forced-convection Nu (for Churchill blend)
14
+ Nu_natural pre-computed natural-convection Nu (for Churchill blend)
15
+ mu_ratio μ_bulk / μ_wall (Sieder-Tate viscosity correction)
16
+ heating bool — Dittus-Boelter n=0.4 (heating) vs 0.3 (cooling)
17
+ n Churchill blend exponent
18
+
19
+ Each closure declares the kwargs it uses in `registry.ClosureEntry.required_inputs`;
20
+ the substrate engine dispatches on that to populate only what each formula needs.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import numpy as np
26
+
27
+
28
+ # ── internal-flow closures (Re, Pr) ─────────────────────────────────────────
29
+
30
+ def modified_sparrow_cur_nu(*, Re: np.ndarray, Pr: np.ndarray) -> np.ndarray:
31
+ """Modified Sparrow-Cur (Forrest 2014 Eq. 7): Nu = 0.036·Re^0.76·Pr^(1/3).
32
+
33
+ Geometry-matched primary closure for one-sided heated narrow rectangular
34
+ mini-channels with high aspect ratio (alpha* < 0.1). Validated bounds:
35
+ Re in [10000, 70000], Pr in [2.2, 5.4]. Corpus entry:
36
+ modified-sparrow-cur-asym-narrow-rect-channel-2014 (status: extrapolated).
37
+
38
+ Original Sparrow & Cur (1982): Nu = 0.0464·Re^0.76 at fixed Pr=2.5,
39
+ isothermal wall, alpha*=0.0556, Re 10000-45000. Forrest 2014 modification:
40
+ Chilton-Colburn analogy gives explicit Pr^(1/3); coefficient adjusted
41
+ 0.0464 -> 0.036 consistent with Pr^(1/3) at Pr=2.5; wall BC extended
42
+ from isothermal to uniform heat flux; Re extended empirically to 70000.
43
+ """
44
+ Re = np.asarray(Re, dtype=float)
45
+ Pr = np.asarray(Pr, dtype=float)
46
+ return 0.036 * Re ** 0.76 * Pr ** (1.0 / 3.0)
47
+
48
+
49
+ def gnielinski_nu(*, Re: np.ndarray, Pr: np.ndarray) -> np.ndarray:
50
+ """Gnielinski (1976): Nu_D = (f/8)(Re-1000)Pr / (1 + 12.7*sqrt(f/8)*(Pr^(2/3)-1))
51
+ where f = (0.790*ln(Re) - 1.64)^(-2) is Petukhov's friction factor.
52
+
53
+ Geometry: circular pipe. Validated Re in [3000, 5e6], Pr in [0.5, 2000]
54
+ per the corpus calibration entry gnielinski-1976.
55
+ """
56
+ Re = np.asarray(Re, dtype=float)
57
+ Pr = np.asarray(Pr, dtype=float)
58
+ f = (0.790 * np.log(np.maximum(Re, 1.0)) - 1.64) ** (-2.0)
59
+ num = (f / 8.0) * (Re - 1000.0) * Pr
60
+ den = 1.0 + 12.7 * np.sqrt(f / 8.0) * (Pr ** (2.0 / 3.0) - 1.0)
61
+ return num / den
62
+
63
+
64
+ def dittus_boelter_nu(*, Re: np.ndarray, Pr: np.ndarray,
65
+ heating: bool = True) -> np.ndarray:
66
+ """Dittus-Boelter (McAdams form, 1930): Nu_D = 0.023 Re^0.8 Pr^n.
67
+
68
+ n = 0.4 for heating (fluid heated by wall), n = 0.3 for cooling.
69
+ Geometry: circular pipe. Validated Re in [10000, 1.2e6], Pr in [0.7, 160]
70
+ per the corpus calibration entry dittus-boelter-1930.
71
+ """
72
+ Re = np.asarray(Re, dtype=float)
73
+ Pr = np.asarray(Pr, dtype=float)
74
+ n = 0.4 if heating else 0.3
75
+ return 0.023 * Re ** 0.8 * Pr ** n
76
+
77
+
78
+ def petukhov_nu(*, Re: np.ndarray, Pr: np.ndarray) -> np.ndarray:
79
+ """Petukhov (1970): Nu = (f/8)*Re*Pr / [1.07 + 12.7*sqrt(f/8)*(Pr^(2/3)-1)]
80
+ where f = (1.82*log10(Re) - 1.64)^(-2).
81
+
82
+ Geometry: circular pipe. NOT IN the calibration corpus (corpus has
83
+ Gnielinski/D-B/Sieder-Tate as the canonical turbulent-circular-pipe
84
+ triple); kept in the registry as reference-only for Forrest's
85
+ Table-4 cross-comparison.
86
+ """
87
+ Re = np.asarray(Re, dtype=float)
88
+ Pr = np.asarray(Pr, dtype=float)
89
+ f = (1.82 * np.log10(np.maximum(Re, 1.0)) - 1.64) ** (-2.0)
90
+ num = (f / 8.0) * Re * Pr
91
+ den = 1.07 + 12.7 * np.sqrt(f / 8.0) * (Pr ** (2.0 / 3.0) - 1.0)
92
+ return num / den
93
+
94
+
95
+ def sieder_tate_nu(*, Re: np.ndarray, Pr: np.ndarray,
96
+ mu_ratio: np.ndarray | float = 1.0) -> np.ndarray:
97
+ """Sieder-Tate (1936): Nu_D = 0.027 Re^0.8 Pr^(1/3) (mu_b/mu_w)^0.14.
98
+
99
+ mu_ratio defaults to 1.0 (no viscosity correction); the substrate
100
+ engine may supply a row-level value if available. Geometry: circular pipe.
101
+ """
102
+ Re = np.asarray(Re, dtype=float)
103
+ Pr = np.asarray(Pr, dtype=float)
104
+ return 0.027 * Re ** 0.8 * Pr ** (1.0 / 3.0) * mu_ratio ** 0.14
105
+
106
+
107
+ # ── external flat-plate / vertical-plate closures ───────────────────────────
108
+
109
+ def pohlhausen_forced_local_nu(*, Re: np.ndarray, Pr: np.ndarray) -> np.ndarray:
110
+ """Pohlhausen local laminar forced flat-plate: Nu_x = 0.332 Re_x^(1/2) Pr^(1/3).
111
+
112
+ LOCAL form (at position x); the length-averaged form is 0.664 Re_L^(1/2) Pr^(1/3).
113
+ The Lance & Smith substrate computes LOCAL Nu at each measurement position
114
+ for like-with-like comparison against the measured local q". Geometry:
115
+ flat_plate_external_forced. Validated Re in [0, 5e5], Pr in [0.6, 50].
116
+ Corpus entry: blasius-pohlhausen-flat-plate-forced-1921.
117
+
118
+ The kwarg is named `Re` (not `Re_x`) for uniform-bag dispatch; the substrate
119
+ engine populates Re with the local Re_x value at each row.
120
+ """
121
+ Re = np.asarray(Re, dtype=float)
122
+ Pr = np.asarray(Pr, dtype=float)
123
+ return 0.332 * np.sqrt(np.maximum(Re, 0.0)) * Pr ** (1.0 / 3.0)
124
+
125
+
126
+ def mcadams_natural_local_nu(*, Ra: np.ndarray) -> np.ndarray:
127
+ """McAdams local laminar natural vertical-plate: Nu_x = 0.59 Ra_x^(1/4).
128
+
129
+ Validated Ra in [1e4, 1e9], Pr in [0.6, 7] (Pr enters via Ra=Gr*Pr).
130
+ Geometry: vertical_plate_external_natural. Corpus entry:
131
+ mcadams-vertical-plate-natural-1954.
132
+ """
133
+ Ra = np.asarray(Ra, dtype=float)
134
+ return 0.59 * np.maximum(Ra, 0.0) ** 0.25
135
+
136
+
137
+ # ── composite / blender closures ────────────────────────────────────────────
138
+
139
+ def churchill_mixed_nu(*, Nu_forced: np.ndarray, Nu_natural: np.ndarray,
140
+ n: float = 3.0) -> np.ndarray:
141
+ """Churchill mixed-convection blend (assisting flow): Nu_M^n = Nu_F^n + Nu_N^n.
142
+
143
+ n=3 for assisting flow (standard Churchill exponent). Geometry:
144
+ flat_plate_external_mixed. NOT IN the calibration corpus today; kept in
145
+ the registry as the Lance & Smith matched-prediction surrogate.
146
+ """
147
+ Nu_F = np.asarray(Nu_forced, float)
148
+ Nu_N = np.asarray(Nu_natural, float)
149
+ return (Nu_F ** n + Nu_N ** n) ** (1.0 / n)
150
+
151
+
152
+ def aung_worku_mixed_nu(*, Re: np.ndarray, Pr: np.ndarray) -> np.ndarray:
153
+ """Aung & Worku (1986) mixed forced-natural convection — registered as a
154
+ CORPUS-VALIDITY ANCHOR ONLY, not as an executable predictor.
155
+
156
+ This closure exists in the registry so the substrate engine's geometry-match
157
+ invariant and matched-closure lookup resolve for the Stage-3 MIDDLE vehicle
158
+ (Dirker/Meyer/Reid water tube). main's regime framework maps
159
+ MIXED_CONVECTION_HORIZONTAL_TUBE -> this closure; it carries the
160
+ `richardson_number` validity bound (Ri in [0.1, 10]) that the vehicle's
161
+ closure-validity detector reads directly from the corpus. The vehicle's
162
+ surrogate-of-record is a data-driven GP fit (loader
163
+ `dirker_water_richardson_bands`), NOT this correlation, so the Nu formula is
164
+ deliberately unimplemented (the original is a vertical-channel Eq-13 family
165
+ form; porting it to the horizontal-tube geometry is out of scope and unused).
166
+ Raises loudly if ever dispatched as a predictor.
167
+ """
168
+ raise NotImplementedError(
169
+ "aung-worku-mixed-convection-1986 is a corpus-validity anchor "
170
+ "(richardson_number bound) only; it has no executable Nu predictor. The "
171
+ "dirker_water Stage-3 vehicle uses a data-driven GP surrogate, not this "
172
+ "correlation."
173
+ )
174
+
175
+
176
+ def pate_freestream_noise_bound(**kwargs) -> np.ndarray:
177
+ """Pate-Stainback freestream-disturbance transition boundary — CORPUS-
178
+ VALIDITY ANCHOR ONLY, not an executable predictor.
179
+
180
+ Registered so the substrate engine's geometry-match invariant + matched-
181
+ closure lookup resolve for the aerospace Casper vehicle (quiet-vs-noisy
182
+ hypersonic transition). It carries the freestream_noise_rms_pitot_pct
183
+ validity bound (conventional tunnels validated for RMS Pitot >= ~0.5%);
184
+ the QUIET deploy points (~0.05%) fall below it and the closure-validity
185
+ detector fires. Casper's surrogate-of-record is a GP transition model
186
+ (loader `casper_hypersonic_transition`), so no Nu/rms formula is needed.
187
+ Raises loudly if ever dispatched as a predictor.
188
+ """
189
+ raise NotImplementedError(
190
+ "pate-stainback-freestream-noise-hypersonic-1980 is a corpus-validity "
191
+ "anchor (freestream_noise_rms_pitot_pct bound) only; it has no executable "
192
+ "predictor. The Casper vehicle uses a GP surrogate."
193
+ )
194
+
195
+
196
+ def marineau_entropy_shock_bound(**kwargs) -> np.ndarray:
197
+ """Marineau entropy-layer / shock-interaction transition boundary — CORPUS-
198
+ VALIDITY ANCHOR ONLY, not an executable predictor.
199
+
200
+ Registered so the geometry-match invariant + matched-closure lookup resolve
201
+ for the aerospace Marineau vehicle (bluntness negative control). It carries
202
+ the entropy_layer_shock_ratio (S_T/X_SW) validity bound (>= 0.1 for the
203
+ e^N / 2nd-mode regime); the large-bluntness deploy points fall below it and
204
+ the closure-validity detector fires — but the steelman baseline ALSO fires
205
+ (nose radius is a surrogate input), so PhysMAP correctly declines a clean
206
+ differentiator. Marineau's surrogate-of-record is a data-driven fit (loader
207
+ `marineau_hypersonic_transition`). Raises loudly if dispatched as a predictor.
208
+ """
209
+ raise NotImplementedError(
210
+ "marineau-entropy-layer-shock-interaction-2014 is a corpus-validity "
211
+ "anchor (entropy_layer_shock_ratio bound) only; it has no executable "
212
+ "predictor. The Marineau vehicle uses a data-driven surrogate."
213
+ )
@@ -0,0 +1,109 @@
1
+ """Controlled vocabulary for closure / vehicle geometry classes.
2
+
3
+ The geometry-match invariant in `substrate_engine.build_substrate(...)` is an
4
+ exact-string comparison between `ClosureEntry.geometry_class` and
5
+ `VehicleConfig.geometry.class_`. Both sides MUST draw from this vocabulary.
6
+ String constants (not Enum) so YAML serialization is trivial.
7
+
8
+ A geometry class describes WHAT KIND of flow/heat-transfer configuration a
9
+ closure was derived for OR a vehicle realizes. Mismatch between the matched
10
+ closure's geometry class and the vehicle's geometry class is exactly the
11
+ Sparrow-Cur / Lance & Smith trap that motivates the invariant.
12
+
13
+ NOTE: geometry is INTENTIONALLY absent from `corpus.jsonl` (gate 6 of the
14
+ calibration corpus schema forbids geometry in `physics_coordinates`).
15
+ The registry is the place where geometry meets the executable formula.
16
+ """
17
+
18
+ from typing import Literal
19
+
20
+
21
+ # Forrest mini-channel — Modified Sparrow-Cur was derived for this exact
22
+ # configuration (one-sided heated, high aspect ratio rectangular).
23
+ NARROW_RECT_CHANNEL_ONE_SIDED = "narrow_rect_channel_one_sided"
24
+
25
+ # Generic circular pipe — Gnielinski, Dittus-Boelter, Petukhov, Sieder-Tate
26
+ # are circular-pipe correlations. Used as REFERENCE closures on other
27
+ # geometries (Forrest, Lance & Smith) for Table-4 cross-comparison.
28
+ CIRCULAR_PIPE = "circular_pipe"
29
+
30
+ # Mudhafar smooth micro-tubes (50-950 μm inner diameter). Geometrically a
31
+ # sub-class of circular_pipe, but kept distinct because micro-scale physics
32
+ # may diverge from macro-scale circular-pipe closures.
33
+ CIRCULAR_MICRO_TUBE_SMOOTH = "circular_micro_tube_smooth"
34
+
35
+ # Mudhafar rough micro-tubes (Ra 5.3-60 μm). Roughness puts the flow outside
36
+ # all standard smooth-pipe correlations.
37
+ CIRCULAR_PIPE_ROUGH = "circular_pipe_rough"
38
+
39
+ # Lance & Smith / Pohlhausen — external flat-plate laminar forced.
40
+ FLAT_PLATE_EXTERNAL_FORCED = "flat_plate_external_forced"
41
+
42
+ # Lance & Smith / McAdams — vertical-plate laminar natural convection.
43
+ VERTICAL_PLATE_EXTERNAL_NATURAL = "vertical_plate_external_natural"
44
+
45
+ # Lance & Smith / Churchill — mixed-convection assisting flow on flat plate.
46
+ # This is the matched geometry for the L&S substrate.
47
+ FLAT_PLATE_EXTERNAL_MIXED = "flat_plate_external_mixed"
48
+
49
+ # NACA TN-1451 — circular pipe with entrance-region effects (x/D matters).
50
+ # Per v0.2 steelman, NACA's matched closure is Gnielinski (circular_pipe),
51
+ # applied to this geometry as the documented practitioner habit (aggregate
52
+ # HE surrogates omit x/D). The Hausen multiplicative correction lives in
53
+ # the substrate as a justification signal; it's not a closure family.
54
+ CIRCULAR_PIPE_ENTRANCE_REGION = "circular_pipe_entrance_region"
55
+
56
+ # Casper hypersonic sharp cone — boundary-layer transition driven by tunnel
57
+ # freestream disturbance (Pate-Stainback noise validity boundary). Aerospace
58
+ # domain; matched closure carries the freestream-noise validity bound.
59
+ HYPERSONIC_SHARP_CONE = "hypersonic_sharp_cone"
60
+
61
+ # Marineau hypersonic blunt cone — transition driven by entropy-layer / shock
62
+ # interaction (nose bluntness). Aerospace domain; matched closure carries the
63
+ # entropy-layer/shock-interaction (S_T/X_SW) validity bound.
64
+ HYPERSONIC_BLUNT_CONE = "hypersonic_blunt_cone"
65
+
66
+
67
+ GeometryClass = Literal[
68
+ "narrow_rect_channel_one_sided",
69
+ "circular_pipe",
70
+ "circular_micro_tube_smooth",
71
+ "circular_pipe_rough",
72
+ "flat_plate_external_forced",
73
+ "vertical_plate_external_natural",
74
+ "flat_plate_external_mixed",
75
+ "circular_pipe_entrance_region",
76
+ "hypersonic_sharp_cone",
77
+ "hypersonic_blunt_cone",
78
+ ]
79
+
80
+
81
+ ALL_GEOMETRY_CLASSES: frozenset[str] = frozenset({
82
+ NARROW_RECT_CHANNEL_ONE_SIDED,
83
+ CIRCULAR_PIPE,
84
+ CIRCULAR_MICRO_TUBE_SMOOTH,
85
+ CIRCULAR_PIPE_ROUGH,
86
+ FLAT_PLATE_EXTERNAL_FORCED,
87
+ VERTICAL_PLATE_EXTERNAL_NATURAL,
88
+ FLAT_PLATE_EXTERNAL_MIXED,
89
+ CIRCULAR_PIPE_ENTRANCE_REGION,
90
+ HYPERSONIC_SHARP_CONE,
91
+ HYPERSONIC_BLUNT_CONE,
92
+ })
93
+
94
+
95
+ def assert_known_geometry(geometry_class: str) -> None:
96
+ """Raise ValueError if the string is not in the controlled vocabulary.
97
+
98
+ Both ClosureEntry construction and VehicleConfig loading call this to fail
99
+ fast on typos (cheap defense against the silent-string-mismatch class of
100
+ bug the invariant is designed to catch).
101
+ """
102
+ if geometry_class not in ALL_GEOMETRY_CLASSES:
103
+ raise ValueError(
104
+ f"Unknown geometry_class {geometry_class!r}. "
105
+ f"Must be one of: {sorted(ALL_GEOMETRY_CLASSES)}. "
106
+ f"Add it to physmap/closures/geometry_classes.py if it's a new "
107
+ f"configuration; do NOT silently accept unknown strings — the "
108
+ f"geometry-match invariant compares strings exactly."
109
+ )