gea-program 0.1.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 (163) hide show
  1. gea/BENCH_TEST_PROTOCOL.md +97 -0
  2. gea/IMPORT_RECORD.md +61 -0
  3. gea/__init__.py +160 -0
  4. gea/__main__.py +661 -0
  5. gea/acceptance_tests.py +1315 -0
  6. gea/accuracy_statement.py +181 -0
  7. gea/alarm_engine.py +307 -0
  8. gea/bench.py +144 -0
  9. gea/blind_harness.py +108 -0
  10. gea/case_study.py +229 -0
  11. gea/catalog/acex_lomonosov_age_depth_model.provenance.json +23 -0
  12. gea/catalog/acex_lomonosov_age_depth_model.txt +28 -0
  13. gea/catalog/agassiz77_canada_temperature.csv +68 -0
  14. gea/catalog/agassiz77_canada_temperature.provenance.json +17 -0
  15. gea/catalog/barbados_110_consolidation.provenance.json +24 -0
  16. gea/catalog/barbados_110_consolidation.txt +91 -0
  17. gea/catalog/bengal_u1452_grain_size.provenance.json +20 -0
  18. gea/catalog/bengal_u1452_grain_size.txt +252 -0
  19. gea/catalog/blake_164_methane_isotopes.provenance.json +22 -0
  20. gea/catalog/blake_164_methane_isotopes.txt +68 -0
  21. gea/catalog/chicxulub_m0077a_pwave_velocity.provenance.json +23 -0
  22. gea/catalog/chicxulub_m0077a_pwave_velocity.txt +735 -0
  23. gea/catalog/collingwood_1_28_ks_complete.las +128 -0
  24. gea/catalog/collingwood_1_28_ks_complete.provenance.json +17 -0
  25. gea/catalog/costa_rica_odp_friction_envelope.provenance.json +24 -0
  26. gea/catalog/costa_rica_odp_friction_envelope.txt +57 -0
  27. gea/catalog/dead_sea_5017_debrite_xrf_ms.provenance.json +21 -0
  28. gea/catalog/dead_sea_5017_debrite_xrf_ms.txt +94 -0
  29. gea/catalog/dsdp_504b_physical_properties.provenance.json +24 -0
  30. gea/catalog/dsdp_504b_physical_properties.txt +82 -0
  31. gea/catalog/dsdp_504b_sound_velocity.provenance.json +21 -0
  32. gea/catalog/dsdp_504b_sound_velocity.txt +81 -0
  33. gea/catalog/elgygytgyn_5011_turbidites.provenance.json +20 -0
  34. gea/catalog/elgygytgyn_5011_turbidites.txt +193 -0
  35. gea/catalog/epica_domec_co2_800kyr.provenance.json +21 -0
  36. gea/catalog/epica_domec_co2_800kyr.txt +265 -0
  37. gea/catalog/fram_909_organic_petrography.provenance.json +22 -0
  38. gea/catalog/fram_909_organic_petrography.txt +40 -0
  39. gea/catalog/gbr_325_coral_uth_ages.provenance.json +23 -0
  40. gea/catalog/gbr_325_coral_uth_ages.txt +71 -0
  41. gea/catalog/gisp2_greenland_temperature.csv +599 -0
  42. gea/catalog/gisp2_greenland_temperature.provenance.json +17 -0
  43. gea/catalog/gom_308_t2p_insitu.provenance.json +21 -0
  44. gea/catalog/gom_308_t2p_insitu.txt +40 -0
  45. gea/catalog/guaymas_385_dom_d13c.provenance.json +21 -0
  46. gea/catalog/guaymas_385_dom_d13c.txt +103 -0
  47. gea/catalog/hikurangi_u1520_friction_insitu.provenance.json +26 -0
  48. gea/catalog/hikurangi_u1520_friction_insitu.txt +74 -0
  49. gea/catalog/hydrate_ridge_204_ncr_hydrate.provenance.json +22 -0
  50. gea/catalog/hydrate_ridge_204_ncr_hydrate.txt +60 -0
  51. gea/catalog/iodp_u1324_pore_pressure.provenance.json +22 -0
  52. gea/catalog/iodp_u1324_pore_pressure.txt +42 -0
  53. gea/catalog/jfast_c0019_slow_slip_events.provenance.json +28 -0
  54. gea/catalog/jfast_c0019_slow_slip_events.txt +35 -0
  55. gea/catalog/kennetcook_2_p129_excerpt.las +139 -0
  56. gea/catalog/kennetcook_2_p129_excerpt.provenance.json +17 -0
  57. gea/catalog/ktb_hb_bhgm_density.dat +227 -0
  58. gea/catalog/ktb_hb_bhgm_density.provenance.json +22 -0
  59. gea/catalog/ktb_hb_complog_6020_excerpt.provenance.json +20 -0
  60. gea/catalog/ktb_hb_complog_6020_excerpt.txt +72 -0
  61. gea/catalog/ktb_hb_hlog246_temperature.dat +1636 -0
  62. gea/catalog/ktb_hb_hlog246_temperature.provenance.json +22 -0
  63. gea/catalog/ktb_hb_rockmech_compress.dat +33 -0
  64. gea/catalog/ktb_hb_rockmech_compress.provenance.json +22 -0
  65. gea/catalog/ktb_hb_tvd_0_9080_excerpt.dat +2817 -0
  66. gea/catalog/ktb_hb_tvd_0_9080_excerpt.provenance.json +20 -0
  67. gea/catalog/ktb_vb_rockmech_compress.dat +125 -0
  68. gea/catalog/ktb_vb_rockmech_compress.provenance.json +22 -0
  69. gea/catalog/ktb_vb_vlog251_temperature.dat +1127 -0
  70. gea/catalog/ktb_vb_vlog251_temperature.provenance.json +24 -0
  71. gea/catalog/l06_06_nl_survey.csv +201 -0
  72. gea/catalog/l06_06_nl_survey.provenance.json +17 -0
  73. gea/catalog/l07_01_nl_excerpt.las +90 -0
  74. gea/catalog/l07_01_nl_excerpt.provenance.json +17 -0
  75. gea/catalog/mariana_1200_serpentinite_geochem.provenance.json +21 -0
  76. gea/catalog/mariana_1200_serpentinite_geochem.txt +63 -0
  77. gea/catalog/med_160_sapropels.provenance.json +22 -0
  78. gea/catalog/med_160_sapropels.txt +43 -0
  79. gea/catalog/nankai_megasplay_shear_strength.provenance.json +26 -0
  80. gea/catalog/nankai_megasplay_shear_strength.txt +42 -0
  81. gea/catalog/odp_1027b_thermal_conductivity.provenance.json +21 -0
  82. gea/catalog/odp_1027b_thermal_conductivity.txt +52 -0
  83. gea/catalog/odp_1027c_cork_temperature.provenance.json +20 -0
  84. gea/catalog/odp_1027c_cork_temperature.txt +26 -0
  85. gea/catalog/odp_1165b_thermal_conductivity.provenance.json +22 -0
  86. gea/catalog/odp_1165b_thermal_conductivity.txt +102 -0
  87. gea/catalog/odp_1274a_mantle_peridotite_mad.provenance.json +27 -0
  88. gea/catalog/odp_1274a_mantle_peridotite_mad.txt +44 -0
  89. gea/catalog/odp_504b_dike_elastic_moduli.provenance.json +27 -0
  90. gea/catalog/odp_504b_dike_elastic_moduli.txt +85 -0
  91. gea/catalog/odp_504b_leg137_borehole_fluids.provenance.json +19 -0
  92. gea/catalog/odp_504b_leg137_borehole_fluids.txt +68 -0
  93. gea/catalog/odp_735b_gabbro_elastic_moduli.provenance.json +28 -0
  94. gea/catalog/odp_735b_gabbro_elastic_moduli.txt +127 -0
  95. gea/catalog/peru_201_sulfate_reduction.provenance.json +22 -0
  96. gea/catalog/peru_201_sulfate_reduction.txt +322 -0
  97. gea/catalog/scorpio_e1_sa_excerpt.las +113 -0
  98. gea/catalog/scorpio_e1_sa_excerpt.provenance.json +17 -0
  99. gea/catalog/sumatra_362_cohesion.provenance.json +21 -0
  100. gea/catalog/sumatra_362_cohesion.txt +38 -0
  101. gea/catalog/university_6_17_no1_tx_excerpt.las +119 -0
  102. gea/catalog/university_6_17_no1_tx_excerpt.provenance.json +17 -0
  103. gea/catalog/ursa_308_xrd_mineralogy.provenance.json +21 -0
  104. gea/catalog/ursa_308_xrd_mineralogy.txt +46 -0
  105. gea/catalog/volve_15_9_19_sr_excerpt.las +183 -0
  106. gea/catalog/volve_15_9_19_sr_excerpt.provenance.json +16 -0
  107. gea/catalog/volve_15_9_19a_core_excerpt.csv +88 -0
  108. gea/catalog/volve_15_9_19a_core_excerpt.provenance.json +18 -0
  109. gea/catalog/volve_f12_f14_production_excerpt.csv +167 -0
  110. gea/catalog/volve_f12_f14_production_excerpt.provenance.json +21 -0
  111. gea/catalog/walvis_208_petm_carbonate.provenance.json +21 -0
  112. gea/catalog/walvis_208_petm_carbonate.txt +268 -0
  113. gea/catalog/woodlark_1109_rock_eval.provenance.json +23 -0
  114. gea/catalog/woodlark_1109_rock_eval.txt +30 -0
  115. gea/cli.py +125 -0
  116. gea/client_reports.py +943 -0
  117. gea/config_versioning.py +133 -0
  118. gea/correlation.py +155 -0
  119. gea/dashboard.py +390 -0
  120. gea/deviation.py +70 -0
  121. gea/downhole_engine.py +395 -0
  122. gea/drift_monitor.py +310 -0
  123. gea/earth_model.py +230 -0
  124. gea/example_register_map.json +14 -0
  125. gea/fat_sat.py +68 -0
  126. gea/follower.py +98 -0
  127. gea/forward_model.py +139 -0
  128. gea/gamma.py +176 -0
  129. gea/gauge_specs.py +112 -0
  130. gea/gravity_reference.py +116 -0
  131. gea/inverse_engine.py +215 -0
  132. gea/matplotlib_demo.py +85 -0
  133. gea/modbus.py +229 -0
  134. gea/model_card.py +248 -0
  135. gea/operator_app.py +442 -0
  136. gea/ports.py +340 -0
  137. gea/profile_catalog.py +773 -0
  138. gea/project.py +213 -0
  139. gea/qt6_downhole_app.py +144 -0
  140. gea/quartz_hpht_extension.py +152 -0
  141. gea/reconciler.py +206 -0
  142. gea/rock_inventory.py +404 -0
  143. gea/sample_record.py +430 -0
  144. gea/sample_well_profile.csv +15 -0
  145. gea/sbom.py +116 -0
  146. gea/segy.py +181 -0
  147. gea/service_life.py +173 -0
  148. gea/shell.py +107 -0
  149. gea/sla_report.py +199 -0
  150. gea/store_forward.py +234 -0
  151. gea/strata_join.py +186 -0
  152. gea/survey_cmd.py +264 -0
  153. gea/survey_view.py +138 -0
  154. gea/telemetry.py +306 -0
  155. gea/tool_library.py +260 -0
  156. gea/well_assembler.py +457 -0
  157. gea/well_test_validation.py +369 -0
  158. gea_program-0.1.0.dist-info/METADATA +138 -0
  159. gea_program-0.1.0.dist-info/RECORD +163 -0
  160. gea_program-0.1.0.dist-info/WHEEL +5 -0
  161. gea_program-0.1.0.dist-info/entry_points.txt +2 -0
  162. gea_program-0.1.0.dist-info/licenses/LICENSE +373 -0
  163. gea_program-0.1.0.dist-info/top_level.txt +1 -0
gea/well_assembler.py ADDED
@@ -0,0 +1,457 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ """Well assembler: one well object from mixed catalogue pieces (v1.42.0).
5
+
6
+ Finish-sequence step 2 (independent evaluation, adopted 2026-08-27): twenty-
7
+ plus catalogued datasets were ingested but STRANDED - the engine consumed only
8
+ depth/pressure/temperature templates. This module assembles a WellAssembly
9
+ from the verbatim catalogue entries of ONE site (temperature + trajectory +
10
+ density + pressure + attachments), exposes measured-value lookups that REFUSE
11
+ outside measured coverage (no silent clamping, no invented values), derives
12
+ overburden by integrating the site's own measured density, and emits the
13
+ engine's WellProfile so measured wells - not linear gradient templates -
14
+ drive simulations (finish-sequence step 3 consumes this).
15
+
16
+ Discipline unchanged: every number traces to a catalogue entry (which traces
17
+ to a public archive via its provenance sidecar); every derivation is labeled
18
+ in the object it produces; refusal messages name exactly what is missing.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass, field
23
+ from typing import Dict, List, Optional, Tuple
24
+
25
+ import numpy as np
26
+
27
+ from .downhole_engine import WellProfile
28
+ from .profile_catalog import CATALOG
29
+
30
+ M_TO_FT = 3.280839895 # exact definition: 1 m = 1/0.3048 ft
31
+ _TRAPEZOID = getattr(np, "trapezoid", getattr(np, "trapz", None))
32
+ # numpy >= 2.0 removed the trapz alias (CI numpy is newer than the authoring
33
+ # sandbox's proxy-pinned 2.2.6, which still carried it - the v0.403.0
34
+ # remanufacture lesson): prefer the current name, fall back for numpy 1.x.
35
+ G_MS2 = 9.80665 # standard gravity (SI definition)
36
+ RHO_FRESHWATER_KGM3 = 1000.0 # labeled derivation input, not a measurement
37
+ RHO_SEAWATER_KGM3 = 1025.0 # labeled derivation input (matches catalogue WBD-identity audits)
38
+
39
+
40
+ def _c_to_f(c: float) -> float:
41
+ return c * 9.0 / 5.0 + 32.0
42
+
43
+
44
+ @dataclass
45
+ class WellComponent:
46
+ """One catalogue entry playing one role in an assembly."""
47
+ role: str
48
+ entry: str
49
+ channel: str
50
+ unit: str
51
+ depths: np.ndarray # ascending, NaN-free, paired with values
52
+ values: np.ndarray
53
+ provenance: dict
54
+
55
+ def coverage(self) -> Tuple[float, float, int]:
56
+ return float(self.depths[0]), float(self.depths[-1]), int(len(self.depths))
57
+
58
+
59
+ @dataclass
60
+ class WellAssembly:
61
+ """A well built from mixed catalogue pieces - the object the engine eats.
62
+
63
+ Lookups are STRICT by default: asking for a value outside the component's
64
+ measured depth coverage raises (naming the coverage) instead of silently
65
+ clamping - the assembly never invents data the archive did not measure.
66
+ """
67
+ name: str
68
+ site: str
69
+ water_depth_m: float = 0.0
70
+ components: Dict[str, WellComponent] = field(default_factory=dict)
71
+ attachments: Dict[str, object] = field(default_factory=dict) # role -> LiveStream (verbatim)
72
+
73
+ # -- construction ------------------------------------------------------
74
+ def add(self, role: str, entry_name: str, channel: str) -> None:
75
+ entry = CATALOG[entry_name]
76
+ st = entry.stream()
77
+ if channel not in st.channels:
78
+ raise KeyError(
79
+ f"assembly '{self.name}': entry '{entry_name}' has no channel "
80
+ f"'{channel}' (has: {list(st.channels)})")
81
+ d = np.asarray(st.index, dtype=float)
82
+ v = np.asarray(st.channels[channel].values, dtype=float)
83
+ m = ~(np.isnan(d) | np.isnan(v))
84
+ d, v = d[m], v[m]
85
+ order = np.argsort(d, kind="stable")
86
+ d, v = d[order], v[order]
87
+ self.components[role] = WellComponent(
88
+ role=role, entry=entry_name, channel=channel,
89
+ unit=st.channels[channel].unit, depths=d, values=v,
90
+ provenance=dict(entry.provenance))
91
+
92
+ def add_hole_filtered(self, role: str, entry_name: str, channel: str,
93
+ hole_prefix: str) -> None:
94
+ """Add a component from a MULTI-SITE table entry, keeping only the rows
95
+ whose Hole starts with hole_prefix (v1.70.0, driven by Exp 308 Table T2
96
+ - one verbatim archive, many sites). The filter is processing, not
97
+ archive editing: the entry stays whole; the component records the
98
+ filter in its provenance (Rule 7)."""
99
+ entry = CATALOG[entry_name]
100
+ st = entry.stream()
101
+ holes = st.meta.get("hole")
102
+ if holes is None:
103
+ raise ValueError(
104
+ f"assembly '{self.name}': entry '{entry_name}' carries no "
105
+ "row-aligned hole column - add_hole_filtered needs one")
106
+ if channel not in st.channels:
107
+ raise KeyError(
108
+ f"assembly '{self.name}': entry '{entry_name}' has no channel "
109
+ f"'{channel}' (has: {list(st.channels)})")
110
+ mask = np.array([h.startswith(hole_prefix) for h in holes])
111
+ d = np.asarray(st.index, dtype=float)[mask]
112
+ v = np.asarray(st.channels[channel].values, dtype=float)[mask]
113
+ m = ~(np.isnan(d) | np.isnan(v))
114
+ d, v = d[m], v[m]
115
+ if len(d) == 0:
116
+ raise ValueError(
117
+ f"assembly '{self.name}': no rows of '{entry_name}' match "
118
+ f"hole prefix '{hole_prefix}'")
119
+ order = np.argsort(d, kind="stable")
120
+ prov = dict(entry.provenance)
121
+ prov["component_filter"] = (
122
+ f"rows with Hole startswith '{hole_prefix}' ({int(mask.sum())} of "
123
+ f"{len(holes)} table rows; filter applied by the assembler, "
124
+ "archive untouched)")
125
+ self.components[role] = WellComponent(
126
+ role=role, entry=entry_name, channel=channel,
127
+ unit=st.channels[channel].unit, depths=d[order], values=v[order],
128
+ provenance=prov)
129
+
130
+ def attach(self, role: str, entry_name: str) -> None:
131
+ """Carry a whole entry verbatim (strength tables, fluids, elastics...)."""
132
+ self.attachments[role] = CATALOG[entry_name].stream()
133
+
134
+ # -- measured lookups (strict) -----------------------------------------
135
+ def value_at(self, role: str, depth_m: float, strict: bool = True) -> float:
136
+ if role not in self.components:
137
+ raise NotImplementedError(
138
+ f"assembly '{self.name}' has no '{role}' component - the site's "
139
+ f"catalogue pieces are {sorted(self.components)}; the assembly "
140
+ "refuses rather than invent one (add a catalogue entry for it)")
141
+ c = self.components[role]
142
+ lo, hi, _ = c.coverage()
143
+ if strict and not (lo <= depth_m <= hi):
144
+ raise ValueError(
145
+ f"assembly '{self.name}': depth {depth_m:g} m is outside the "
146
+ f"MEASURED {role} coverage {lo:g}-{hi:g} m ({c.entry}/{c.channel}) "
147
+ "- strict mode refuses to extrapolate beyond what was measured")
148
+ return float(np.interp(depth_m, c.depths, c.values))
149
+
150
+ def temperature_C_at(self, depth_m: float, strict: bool = True) -> float:
151
+ return self.value_at("temperature", depth_m, strict)
152
+
153
+ def density_gcc_at(self, depth_m: float, strict: bool = True) -> float:
154
+ return self.value_at("density", depth_m, strict)
155
+
156
+ def pressure_kPa_at(self, depth_m: float, strict: bool = True) -> float:
157
+ return self.value_at("pressure", depth_m, strict)
158
+
159
+ def tvd_at(self, md_m: float, strict: bool = True) -> float:
160
+ return self.value_at("trajectory", md_m, strict)
161
+
162
+ # -- derivations (labeled) --------------------------------------------
163
+ def overburden_kPa(self, depth_m: float) -> float:
164
+ """Vertical stress from the site's OWN measured density: integral of
165
+ rho*g dz over the measured density profile (trapezoid), from the
166
+ shallowest measured station down to depth_m. Labeled derivation -
167
+ the density is measured, the integral is arithmetic."""
168
+ c = self.components.get("density")
169
+ if c is None:
170
+ raise NotImplementedError(
171
+ f"assembly '{self.name}' has no density component - overburden "
172
+ "refuses without measured density")
173
+ lo, hi, _ = c.coverage()
174
+ if not (lo <= depth_m <= hi):
175
+ raise ValueError(
176
+ f"overburden: depth {depth_m:g} m outside measured density "
177
+ f"coverage {lo:g}-{hi:g} m")
178
+ d = np.append(c.depths[c.depths < depth_m], depth_m)
179
+ r = np.append(c.values[c.depths < depth_m],
180
+ float(np.interp(depth_m, c.depths, c.values)))
181
+ return float(_TRAPEZOID(r * 1000.0 * G_MS2, d) / 1000.0) # kPa
182
+
183
+ # -- the engine bridge -------------------------------------------------
184
+ def to_engine_profile(self, pressure_source: str = "auto",
185
+ n_points: int = 64) -> WellProfile:
186
+ """Emit the engine's WellProfile from MEASURED components.
187
+
188
+ Temperature: required, measured; the profile spans exactly the
189
+ measured temperature coverage (never wider).
190
+ Pressure: 'measured' uses the pressure component (refuses if absent);
191
+ 'hydrostatic_freshwater'/'hydrostatic_seawater' are labeled
192
+ derivations (rho*g*(z + water_depth)); 'auto' prefers measured, else
193
+ seawater when the site is submarine, else freshwater.
194
+ The chosen method is recorded in the profile's name (Rule 7)."""
195
+ if "temperature" not in self.components:
196
+ raise NotImplementedError(
197
+ f"assembly '{self.name}' has no measured temperature - the "
198
+ "engine bridge refuses to substitute a gradient template "
199
+ "(that is exactly the stranding this module exists to end)")
200
+ tlo, thi, _ = self.components["temperature"].coverage()
201
+ if pressure_source == "auto":
202
+ if "pressure" in self.components:
203
+ pressure_source = "measured"
204
+ elif self.water_depth_m > 0:
205
+ pressure_source = "hydrostatic_seawater"
206
+ else:
207
+ pressure_source = "hydrostatic_freshwater"
208
+ if pressure_source == "measured":
209
+ plo, phi, _ = self.components["pressure"].coverage()
210
+ lo, hi = max(tlo, plo), min(thi, phi)
211
+ if lo >= hi:
212
+ raise ValueError(
213
+ f"assembly '{self.name}': measured temperature "
214
+ f"({tlo:g}-{thi:g} m) and pressure ({plo:g}-{phi:g} m) "
215
+ "coverages do not overlap - no honest joint profile exists")
216
+ else:
217
+ lo, hi = tlo, thi
218
+ depths = np.linspace(lo, hi, n_points)
219
+ temps_F = [_c_to_f(self.temperature_C_at(z)) for z in depths]
220
+ if pressure_source == "measured":
221
+ press_psi = [self.pressure_kPa_at(z) * 0.1450377377 for z in depths]
222
+ else:
223
+ rho = (RHO_SEAWATER_KGM3 if pressure_source == "hydrostatic_seawater"
224
+ else RHO_FRESHWATER_KGM3)
225
+ press_psi = [(rho * G_MS2 * (z + self.water_depth_m)) / 1000.0
226
+ * 0.1450377377 for z in depths]
227
+ return WellProfile(
228
+ depths_ft=[z * M_TO_FT for z in depths],
229
+ pressures_psi=press_psi, temps_F=temps_F,
230
+ name=f"{self.name} [T=measured({self.components['temperature'].entry}); "
231
+ f"P={pressure_source}]")
232
+
233
+ def summary(self) -> dict:
234
+ return {
235
+ "name": self.name, "site": self.site,
236
+ "water_depth_m": self.water_depth_m,
237
+ "components": {r: {"entry": c.entry, "channel": c.channel,
238
+ "unit": c.unit, "coverage_m": c.coverage()}
239
+ for r, c in self.components.items()},
240
+ "attachments": {r: getattr(s, "name", "?")
241
+ for r, s in self.attachments.items()},
242
+ }
243
+
244
+
245
+ # -- generic constructor ----------------------------------------------------
246
+ def assemble(name: str, site: str, components: Dict[str, Tuple[str, str]],
247
+ attachments: Optional[Dict[str, str]] = None,
248
+ water_depth_m: float = 0.0) -> WellAssembly:
249
+ """Build a WellAssembly from catalogue pieces: components maps
250
+ role -> (catalogue entry, channel); attachments maps role -> entry."""
251
+ w = WellAssembly(name=name, site=site, water_depth_m=water_depth_m)
252
+ for role, (entry_name, channel) in components.items():
253
+ w.add(role, entry_name, channel)
254
+ for role, entry_name in (attachments or {}).items():
255
+ w.attach(role, entry_name)
256
+ return w
257
+
258
+
259
+ # -- built-in assemblies (the site families the catalogue already holds) ----
260
+ def assemble_ktb_hb() -> WellAssembly:
261
+ """KTB main hole (Germany): measured temperature (HLOG246 TMP3) +
262
+ verticality trajectory + BHGM in-situ density + strength attachment -
263
+ the four-file family the catalogue accumulated across entries 12-17."""
264
+ return assemble(
265
+ "KTB-HB", "KTB Hauptbohrung, Windischeschenbach, Germany (continental deep hole)",
266
+ components={
267
+ "temperature": ("ktb_hb_hlog246_temperature", "TMP3"),
268
+ "trajectory": ("ktb_hb_tvd_0_9080_excerpt", "TVD"),
269
+ "density": ("ktb_hb_bhgm_density", "RHO"),
270
+ },
271
+ attachments={
272
+ "strength": "ktb_hb_rockmech_compress",
273
+ })
274
+
275
+
276
+ def assemble_odp_504b() -> WellAssembly:
277
+ """DSDP/ODP 504B (Costa Rica Rift): measured density + sonic velocity
278
+ from the paired 1979 tables, with the fluids and dike-elastics entries
279
+ attached verbatim. No in-situ temperature profile is catalogued for the
280
+ hole, so to_engine_profile HONESTLY REFUSES for this assembly."""
281
+ return assemble(
282
+ "ODP-504B", "DSDP/ODP Hole 504B, Costa Rica Rift flank, Panama Basin",
283
+ components={
284
+ "density": ("dsdp_504b_physical_properties", "WBD"),
285
+ "velocity": ("dsdp_504b_sound_velocity", "Vp"),
286
+ },
287
+ attachments={
288
+ "fluids": "odp_504b_leg137_borehole_fluids",
289
+ "elastics": "odp_504b_dike_elastic_moduli",
290
+ },
291
+ water_depth_m=3460.0)
292
+
293
+
294
+ def assemble_site_1027() -> WellAssembly:
295
+ """ODP Site 1027 (Juan de Fuca): the CORK observatory's 1999 equilibrium
296
+ temperature column drives the assembly; shipboard thermal conductivity
297
+ rides as attachment (the heat-flow-closure pair, entries 20-21)."""
298
+ return assemble(
299
+ "ODP-1027", "ODP Site 1027, Juan de Fuca Ridge flank (CORK observatory)",
300
+ components={
301
+ "temperature": ("odp_1027c_cork_temperature", "t (1999)"),
302
+ },
303
+ attachments={
304
+ "thermal_conductivity": "odp_1027b_thermal_conductivity",
305
+ },
306
+ water_depth_m=2656.2)
307
+
308
+
309
+ def assemble_u1324() -> WellAssembly:
310
+ """IODP U1324 (Gulf of Mexico): MEASURED pore pressure (penetrometer u2,
311
+ PANGAEA 725472) joined by MEASURED in-situ temperature (Exp 308 Table T2,
312
+ the 18 U1324B/C DVTPP+T2P equilibrium stations, hole-filtered from the
313
+ verbatim multi-site table) - the catalogue's first site with BOTH engine
314
+ coordinates measured in the formation. The engine bridge, which honestly
315
+ refused this assembly from v1.42.0 until the temperature column existed,
316
+ now accepts it (v1.70.0). The Table T2 water-depth identity
317
+ (BOH mbsl - BOH mbsf = 1056.8 m on every U1324B row) independently
318
+ re-derives the water_depth_m constant below."""
319
+ w = assemble(
320
+ "IODP-U1324", "IODP Site U1324, Ursa Basin, Gulf of Mexico (overpressure)",
321
+ components={
322
+ "pressure": ("iodp_u1324_pore_pressure", "u2 (hydrostatic fluid pressure)"),
323
+ "overburden_archived": ("iodp_u1324_pore_pressure", "Overb press"),
324
+ },
325
+ water_depth_m=1056.8)
326
+ w.add_hole_filtered("temperature", "gom_308_t2p_insitu", "Tend degC", "U1324")
327
+ return w
328
+
329
+
330
+ def assemble_retama_403h() -> WellAssembly:
331
+ """Retama Ranch #403H (OPERATOR TIER, v1.73.0): the catalogue's first
332
+ modern unconventional horizontal well as an ASSEMBLY - measured trajectory
333
+ (MD -> TVD from the 182-station drift survey, minimum-curvature-verified
334
+ to 0.005 ft) with the plan-tracking table and the connection-by-connection
335
+ drag report attached verbatim. No formation temperature or pressure was
336
+ measured, so to_engine_profile HONESTLY REFUSES - the assembly serves
337
+ trajectory lookups and the operator attachments. Raises KeyError on
338
+ machines without the private operator tier (by design: the tier is
339
+ per-machine and never required)."""
340
+ return assemble(
341
+ "RETAMA-403H",
342
+ "Retama Ranch #403H, Hawkville (Eagle Ford Shale), Webb County, Texas "
343
+ "(Kimmeridge Energy, rig H&P431, KB 746 ft) - OPERATOR TIER, private",
344
+ components={
345
+ "trajectory": ("retama_403h_drift_survey", "TVD (ft)"),
346
+ },
347
+ attachments={
348
+ "plan_tracking": "retama_403h_projections_plan",
349
+ "drag_report": "retama_403h_drag_report",
350
+ })
351
+
352
+
353
+ def reconcile_survey_tvd(entry_a: str, entry_b: str,
354
+ channel_a: str = "TVD (ft)",
355
+ channel_b: str = "TVD (ft)",
356
+ agree_ft: float = 0.1) -> dict:
357
+ """Two-stream reconciliation for SURVEYS (v1.73.0): compare two archives'
358
+ TVD integrations of the same wellbore at their SHARED stations only (no
359
+ interpolation between archives - stations either match in MD or are
360
+ listed as unshared). Returns per-station deltas, the worst divergence and
361
+ where it lives, the count of stations agreeing within `agree_ft`
362
+ (disclosed threshold), and each side's unshared stations. Honest by
363
+ construction: it never averages the two archives into a 'truth' - it
364
+ reports where and how much they disagree and leaves both intact."""
365
+ sa = CATALOG[entry_a].stream()
366
+ sb = CATALOG[entry_b].stream()
367
+ amap = {float(m): float(v) for m, v in zip(sa.index, sa.channels[channel_a].values)
368
+ if v == v}
369
+ bmap = {float(m): float(v) for m, v in zip(sb.index, sb.channels[channel_b].values)
370
+ if v == v}
371
+ shared = sorted(set(amap) & set(bmap))
372
+ if not shared:
373
+ return {"status": "NO_SHARED_STATIONS", "a": entry_a, "b": entry_b}
374
+ deltas = [(m, bmap[m] - amap[m]) for m in shared]
375
+ worst = max(deltas, key=lambda d: abs(d[1]))
376
+ return {
377
+ "status": "OK", "a": entry_a, "b": entry_b,
378
+ "shared_stations": len(shared),
379
+ "only_a": sorted(set(amap) - set(bmap)),
380
+ "only_b": sorted(set(bmap) - set(amap)),
381
+ "agree_within_ft": agree_ft,
382
+ "n_agreeing": sum(1 for _, d in deltas if abs(d) <= agree_ft),
383
+ "worst_delta_ft": worst[1], "worst_at_md_ft": worst[0],
384
+ "first_disagreement_md_ft": next((m for m, d in deltas if abs(d) > agree_ft), None),
385
+ "deltas": deltas,
386
+ }
387
+
388
+
389
+ BUILTIN_ASSEMBLIES = {
390
+ "ktb_hb": assemble_ktb_hb,
391
+ "odp_504b": assemble_odp_504b,
392
+ "site_1027": assemble_site_1027,
393
+ "u1324": assemble_u1324,
394
+ "retama_403h": assemble_retama_403h, # OPERATOR TIER: raises where the private data is absent
395
+ }
396
+
397
+
398
+ # -- finish-sequence step 3: measured wells as engine defaults --------------
399
+ BAR_TO_PSI = 14.503773773 # exact: 1 bar = 100 kPa x 0.1450377...
400
+
401
+ def demo_config(well: str, n_gauges: int = 6, **cfg_kwargs):
402
+ """A SimulatorConfig whose base P/T come from a MEASURED catalogue
403
+ assembly: gauges are hung evenly inside the assembly's measured
404
+ temperature window and the engine interpolates the archived log - the
405
+ 0.465 psi/ft / 0.018 F/ft templates never enter. Refuses (via the
406
+ assembly bridge) for sites without measured temperature."""
407
+ from .downhole_engine import SimulatorConfig
408
+ if well not in BUILTIN_ASSEMBLIES:
409
+ raise KeyError(f"unknown assembly '{well}' - built-ins: "
410
+ f"{sorted(BUILTIN_ASSEMBLIES)}")
411
+ a = BUILTIN_ASSEMBLIES[well]()
412
+ prof = a.to_engine_profile()
413
+ lo, hi = prof.depths_ft[0], prof.depths_ft[-1]
414
+ span = hi - lo
415
+ depths = [lo + span * (i + 1) / (n_gauges + 1) for i in range(n_gauges)]
416
+ return SimulatorConfig(td_ft=hi, sensor_depths_ft=depths, profile=prof,
417
+ **cfg_kwargs)
418
+
419
+
420
+ def production_live_stream(entry_name: str, well_tag: str,
421
+ station_md_ft: float) -> Tuple[object, Dict[str, float]]:
422
+ """Adapt a catalogued production time-series into the reconciler's live
423
+ leg: the MEASURED downhole-gauge pressure channel (bar) becomes
424
+ P_raw_psi_S1 (psi, conversion labeled in meta), NaN days are dropped
425
+ with the count recorded, and the station MD must be CALLER-SUPPLIED -
426
+ the excerpt's provenance does not state the gauge depth, and the
427
+ assembler does not invent one."""
428
+ from .ports import LiveStream, StreamChannel
429
+ if station_md_ft is None or station_md_ft <= 0:
430
+ raise NotImplementedError(
431
+ "production_live_stream requires station_md_ft: the catalogued "
432
+ "excerpt does not state the downhole gauge depth, and the "
433
+ "assembler refuses to invent one (supply it from the well's "
434
+ "completion records)")
435
+ src = CATALOG[entry_name].stream()
436
+ chan = f"AVG_DOWNHOLE_PRESSURE[{well_tag}]"
437
+ if chan not in src.channels:
438
+ raise KeyError(f"'{entry_name}' has no channel '{chan}' - wells in "
439
+ f"this entry: {sorted(set(n[n.find('[')+1:-1] for n in src.channels if '[' in n))}")
440
+ p_bar = np.asarray(src.channels[chan].values, dtype=float)
441
+ t_s = np.asarray(src.index, dtype=float)
442
+ keep = ~np.isnan(p_bar)
443
+ dropped = int((~keep).sum())
444
+ stream = LiveStream(
445
+ name=f"{entry_name}[{well_tag}] measured downhole P (bar->psi x{BAR_TO_PSI})",
446
+ source_format=src.source_format, index_kind='time_s',
447
+ index=t_s[keep],
448
+ channels={'P_raw_psi_S1': StreamChannel(
449
+ name='P_raw_psi_S1', unit='psi',
450
+ values=p_bar[keep] * BAR_TO_PSI)},
451
+ meta={**src.meta,
452
+ 'adapter': 'production_live_stream (v1.43.0)',
453
+ 'source_channel': chan, 'source_unit': 'bar',
454
+ 'conversion': f'psi = bar x {BAR_TO_PSI} (exact)',
455
+ 'nan_days_dropped': str(dropped),
456
+ 'station_md_ft': f'{station_md_ft:g} (CALLER-SUPPLIED - not in the archived excerpt)'})
457
+ return stream, {'P_raw_psi_S1': float(station_md_ft)}