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/downhole_engine.py ADDED
@@ -0,0 +1,395 @@
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
+ """downhole_engine — the simulation engine of the GEA Downhole Simulator.
5
+
6
+ Faithful port of the 22Aug2026 template's converged design (imperial units:
7
+ ft / degF / psi; six quartz gauges; noise + transient events; rolling history;
8
+ CSV export), adjusted to the gea-program repo: drift comes from the
9
+ canonical-primitive physics layer, and the template's per-sensor "K_MEX"/
10
+ "Phi_res" fields are the renamed engineering trims (knob ruling, 2026-08-22).
11
+
12
+ Headless-safe by design: no matplotlib/Qt imports here — the engine runs and
13
+ exports under the fidelity gate with no display.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import csv
19
+ import random
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+ from typing import List, Optional
24
+
25
+ import numpy as np
26
+
27
+ from .quartz_hpht_extension import (
28
+ calculate_quartz_transducer_hpht_program,
29
+ canonical_suppression,
30
+ conventional_drift,
31
+ PROGRAM_MODEL_AVAILABLE,
32
+ )
33
+
34
+ # Well geometry defaults (port of the template's 6,200 m TD well to the
35
+ # converged imperial layout: TD ~20,300 ft, six gauges spanning the string).
36
+ DEFAULT_TD_FT = 20300.0
37
+ DEFAULT_SENSOR_DEPTHS_FT = [2600.0, 6200.0, 9800.0, 13400.0, 17000.0, 20000.0]
38
+
39
+
40
+ @dataclass
41
+ class WellProfile:
42
+ """A real well profile (survey/log): depth vs pressure and temperature.
43
+
44
+ Load with `load_well_profile_csv`; when attached to SimulatorConfig, the
45
+ engine interpolates base P/T from the profile instead of linear gradients.
46
+ """
47
+ depths_ft: List[float]
48
+ pressures_psi: List[float]
49
+ temps_F: List[float]
50
+ name: str = "profile"
51
+
52
+ def interp(self, depth_ft: float) -> tuple:
53
+ p = float(np.interp(depth_ft, self.depths_ft, self.pressures_psi))
54
+ tF = float(np.interp(depth_ft, self.depths_ft, self.temps_F))
55
+ return p, tF
56
+
57
+
58
+ def load_well_profile_csv(path) -> WellProfile:
59
+ """Load a well profile CSV with header: depth_ft,pressure_psi,temp_F
60
+ (rows in any depth order; sorted on load). Extra columns are ignored.
61
+ """
62
+ d, pr, tf = [], [], []
63
+ with Path(path).open(newline="") as f:
64
+ reader = csv.DictReader(f)
65
+ for row in reader:
66
+ d.append(float(row["depth_ft"]))
67
+ pr.append(float(row["pressure_psi"]))
68
+ tf.append(float(row["temp_F"]))
69
+ order = np.argsort(d)
70
+ return WellProfile(depths_ft=[d[i] for i in order],
71
+ pressures_psi=[pr[i] for i in order],
72
+ temps_F=[tf[i] for i in order],
73
+ name=Path(path).stem)
74
+
75
+
76
+ def make_sensor_string(n: int, td_ft: float = DEFAULT_TD_FT,
77
+ start_ft: float = 2000.0) -> List[float]:
78
+ """Evenly spaced N-gauge string from start_ft to just above TD."""
79
+ if n < 1:
80
+ raise ValueError("need at least one gauge")
81
+ if n == 1:
82
+ return [td_ft * 0.985]
83
+ return list(np.linspace(start_ft, td_ft * 0.985, n))
84
+
85
+
86
+ @dataclass
87
+ class Sensor:
88
+ depth_ft: float
89
+ cal_offset_P: float = 0.0
90
+ cal_offset_T: float = 0.0
91
+ k_structural_trim: float = 1.0 # engineering trim (renamed from template 'K_MEX' knob)
92
+ phi_coupling_trim: float = 1.0 # engineering trim (renamed from template 'Phi_res' knob)
93
+ name: str = ""
94
+ tool_name: str = "quartz_pt_program_geoq177_30k" # v1.47.0 mixed strings: which library tool sits here
95
+
96
+
97
+ @dataclass
98
+ class SimulatorConfig:
99
+ td_ft: float = DEFAULT_TD_FT
100
+ sensor_depths_ft: List[float] = field(default_factory=lambda: DEFAULT_SENSOR_DEPTHS_FT.copy())
101
+ surface_temp_F: float = 75.0 # anchor: surface ambient (template)
102
+ surface_pressure_psi: float = 14.7 # anchor: 1 atm
103
+ temp_gradient_F_per_ft: float = 0.018 # anchor: geothermal gradient (template)
104
+ pressure_gradient_psi_per_ft: float = 0.465 # anchor: hydrostatic gradient (industry)
105
+ global_k_structural_trim: float = 1.0
106
+ global_phi_coupling_trim: float = 1.0
107
+ noise_scale: float = 1.0
108
+ event_probability: float = 0.27 # template transient-event rate
109
+ history_length: int = 400
110
+ profile: Optional[WellProfile] = None # real well profile (CSV); overrides gradients
111
+ comparison_mode: bool = True # twin-gauge GEA-vs-conventional tracking
112
+ gauge_spec: object = None # GaugeSpec (v1.5.0, gauge_specs); None = template anchors
113
+ deviation: object = None # DeviationSurvey (v1.6.0): sensors at MD, physics at TVD
114
+ toolstring: object = None # ToolString (v1.47.0): mixed per-station tool models
115
+ acknowledge_over_rating: bool = False # v1.47.0: the ONLY way past the in-engine rating block
116
+
117
+
118
+ class DownholeEngine:
119
+ """Deep-well quartz-gauge string simulator (GEA-stabilized drift)."""
120
+
121
+ def __init__(self, config: SimulatorConfig | None = None):
122
+ self.cfg = config or SimulatorConfig()
123
+ self.time = 0.0
124
+ self.sensors: List[Sensor] = []
125
+ self._build_sensors()
126
+ self._init_state()
127
+ if self.cfg.toolstring is not None:
128
+ self._enforce_rating()
129
+
130
+ def _enforce_rating(self) -> None:
131
+ """v1.47.0: the rating check runs INSIDE the engine, not only as a
132
+ print - a tool over its cited rating at its station (against the
133
+ REAL profile when one is attached) blocks construction unless the
134
+ operator's explicit acknowledge_over_rating rides in the config."""
135
+ from .tool_library import rating_check
136
+ report = rating_check(self.cfg.toolstring,
137
+ profile=self.cfg.profile,
138
+ deviation=self.cfg.deviation)
139
+ blocks = [r for r in report if not r["ok"]]
140
+ self.rating_report = report
141
+ if blocks and not self.cfg.acknowledge_over_rating:
142
+ names = "; ".join(
143
+ f"{b['tool']}@{b['md_ft']:.0f}ft ({b['station_temp_C']}C > "
144
+ f"{b['temp_rating_C']}C rated)" for b in blocks)
145
+ raise RuntimeError(
146
+ f"ENGINE RATING BLOCK: {names}. The check runs inside the "
147
+ "engine (v1.47.0) - set acknowledge_over_rating=True in the "
148
+ "config as an explicit operator decision, or fix the string.")
149
+
150
+ # -- construction -------------------------------------------------------
151
+ def _build_sensors(self) -> None:
152
+ if self.cfg.toolstring is not None:
153
+ stations = sorted(self.cfg.toolstring.stations, key=lambda x: x[0])
154
+ self.sensors = [
155
+ Sensor(depth_ft=float(md),
156
+ k_structural_trim=self.cfg.global_k_structural_trim,
157
+ phi_coupling_trim=self.cfg.global_phi_coupling_trim,
158
+ name=f"S{i + 1}", tool_name=tn)
159
+ for i, (md, tn) in enumerate(stations)
160
+ ]
161
+ self.cfg.sensor_depths_ft = [s.depth_ft for s in self.sensors]
162
+ else:
163
+ self.sensors = [
164
+ Sensor(depth_ft=float(d),
165
+ k_structural_trim=self.cfg.global_k_structural_trim,
166
+ phi_coupling_trim=self.cfg.global_phi_coupling_trim,
167
+ name=f"S{i + 1}")
168
+ for i, d in enumerate(self.cfg.sensor_depths_ft)
169
+ ]
170
+
171
+ def _physics_depth_ft(self, md_ft: float) -> float:
172
+ """Sensor addresses are MD (position on the string); pressure and
173
+ temperature are set by TVD (v1.6.0 deviation support)."""
174
+ if self.cfg.deviation is not None:
175
+ return float(self.cfg.deviation.tvd_of(md_ft))
176
+ return float(md_ft)
177
+
178
+ def _init_state(self) -> None:
179
+ tvds = [self._physics_depth_ft(s.depth_ft) for s in self.sensors]
180
+ if self.cfg.profile is not None:
181
+ pairs = [self.cfg.profile.interp(tvd) for tvd in tvds] # profiles are TVD-indexed
182
+ self.base_P = np.array([p for p, _ in pairs], dtype=float)
183
+ self.base_T = np.array([tF for _, tF in pairs], dtype=float)
184
+ else:
185
+ self.base_P = np.array(
186
+ [self.cfg.surface_pressure_psi + tvd * self.cfg.pressure_gradient_psi_per_ft
187
+ for tvd in tvds], dtype=float)
188
+ self.base_T = np.array(
189
+ [self.cfg.surface_temp_F + tvd * self.cfg.temp_gradient_F_per_ft
190
+ for tvd in tvds], dtype=float)
191
+ self.P = self.base_P.copy()
192
+ self.T = self.base_T.copy()
193
+ self.history_t: List[float] = [0.0]
194
+ self.history_P: List[np.ndarray] = [self.P.copy()]
195
+ self.history_T: List[np.ndarray] = [self.T.copy()]
196
+
197
+ # -- physics ------------------------------------------------------------
198
+ def compute_drift(self, sensor: Sensor, temp_F: float, pressure_psi: float) -> float:
199
+ temp_C = (temp_F - 32.0) * 5.0 / 9.0
200
+ depth_m = sensor.depth_ft / 3.28084
201
+ r = calculate_quartz_transducer_hpht_program(
202
+ depth_m=depth_m, temp_c=temp_C, pressure_psi=pressure_psi,
203
+ k_structural_trim=sensor.k_structural_trim,
204
+ phi_coupling_trim=sensor.phi_coupling_trim,
205
+ spec=self.cfg.gauge_spec)
206
+ return float(r["value"]["drift_pct"])
207
+
208
+ def station_drift_legs(self, i: int) -> dict:
209
+ """v1.47.0 mixed strings: each station's drift legs come from ITS
210
+ tool's runnable model - and tools without a runnable model REFUSE
211
+ (status says why) instead of borrowing the quartz curve."""
212
+ from .tool_library import TOOL_LIBRARY, piezoresistive_drift
213
+ s = self.sensors[i]
214
+ tool = TOOL_LIBRARY.get(s.tool_name)
215
+ t_C = (float(self.T[i]) - 32.0) * 5.0 / 9.0
216
+ out = {"station": s.name, "md_ft": s.depth_ft, "tool": s.tool_name,
217
+ "tool_class": tool.tool_class if tool else "?",
218
+ "drift_pct": None, "conventional_drift_pct": None}
219
+ if tool is None:
220
+ out["status"] = f"UNKNOWN_TOOL '{s.tool_name}' - refused"
221
+ return out
222
+ if tool.tool_class == "QUARTZ_PT_GAUGE":
223
+ out["conventional_drift_pct"] = round(
224
+ conventional_drift(t_C, float(self.P[i]),
225
+ spec=self.cfg.gauge_spec), 4)
226
+ if "conventional" in s.tool_name:
227
+ out["status"] = ("NO_PROGRAM_LEG: conventional instrument - the "
228
+ "reference leg only (twin comparison needs "
229
+ "the GEA tool at this station)")
230
+ else:
231
+ out["drift_pct"] = round(
232
+ self.compute_drift(s, float(self.T[i]),
233
+ float(self.P[i])), 4)
234
+ out["status"] = "TWIN_LEGS"
235
+ elif tool.tool_class == "PIEZORESISTIVE_PT_GAUGE":
236
+ out["conventional_drift_pct"] = round(piezoresistive_drift(t_C), 4)
237
+ out["status"] = ("PIEZO_CLASS_ENVELOPE (labeled representative "
238
+ "fit, vendor-scatter disclosed) - NO_PROGRAM_MODEL: "
239
+ "no GEA piezo derivation in the corpus, refused "
240
+ "rather than invented")
241
+ else:
242
+ out["status"] = ("PARAMETERS_USER_SUPPLIED: no vendor datasheet "
243
+ "fetched for this tool class - drift model "
244
+ "REFUSED; station still streams well P/T")
245
+ return out
246
+
247
+ def mixed_summary(self) -> dict:
248
+ """Per-station tool report + aggregates computed ONLY over stations
249
+ possessing both legs, with the counts disclosed."""
250
+ rows = [self.station_drift_legs(i) for i in range(len(self.sensors))]
251
+ twin = [r for r in rows if r["status"] == "TWIN_LEGS"]
252
+ agg = None
253
+ if twin:
254
+ uq = float(np.mean([r["drift_pct"] for r in twin]))
255
+ cv = float(np.mean([r["conventional_drift_pct"] for r in twin]))
256
+ agg = {"avg_program_drift_pct": round(uq, 4),
257
+ "avg_conventional_drift_pct": round(cv, 4),
258
+ "measured_ratio_mean": round(cv / uq, 4) if uq > 0 else None}
259
+ return {"stations": rows,
260
+ "twin_leg_stations": len(twin),
261
+ "single_or_refused_stations": len(rows) - len(twin),
262
+ "aggregate_over_twin_stations_only": agg}
263
+
264
+ def _noise_drifts(self) -> np.ndarray:
265
+ """Drift values feeding the template noise-suppression map: each
266
+ station's own runnable model; refused stations contribute 0.0
267
+ (neutral suppression - template noise unshaped, per mixed_summary)."""
268
+ if self.cfg.toolstring is None:
269
+ return self.current_drifts
270
+ out = []
271
+ for i in range(len(self.sensors)):
272
+ legs = self.station_drift_legs(i)
273
+ d = legs["drift_pct"]
274
+ if d is None:
275
+ d = legs["conventional_drift_pct"]
276
+ out.append(0.0 if d is None else float(d))
277
+ return np.array(out)
278
+
279
+ @property
280
+ def current_drifts(self) -> np.ndarray:
281
+ return np.array([self.compute_drift(s, self.T[i], self.P[i])
282
+ for i, s in enumerate(self.sensors)])
283
+
284
+ @property
285
+ def current_conventional_drifts(self) -> np.ndarray:
286
+ """Comparison-mode reference leg: conventional gauges at the same T/P."""
287
+ out = []
288
+ for i in range(len(self.sensors)):
289
+ temp_C = (self.T[i] - 32.0) * 5.0 / 9.0
290
+ out.append(conventional_drift(temp_C, self.P[i], spec=self.cfg.gauge_spec))
291
+ return np.array(out)
292
+
293
+ def comparison_summary(self) -> dict:
294
+ """Twin-gauge report: measured conventional/GEA drift ratio vs the
295
+ suppression prediction (equal when no gauge clips)."""
296
+ uq = self.current_drifts
297
+ cv = self.current_conventional_drifts
298
+ ratios = cv / np.where(uq > 0, uq, np.nan)
299
+ return {
300
+ 'avg_program_drift_pct': round(float(np.mean(uq)), 4),
301
+ 'avg_conventional_drift_pct': round(float(np.mean(cv)), 4),
302
+ 'measured_ratio_mean': round(float(np.nanmean(ratios)), 4),
303
+ 'predicted_ratio_suppression': round(canonical_suppression(
304
+ self.cfg.global_k_structural_trim, self.cfg.global_phi_coupling_trim), 4),
305
+ 'per_sensor_ratio': [round(float(r), 4) for r in ratios],
306
+ }
307
+
308
+ # -- stepping (template-faithful) ---------------------------------------
309
+ def step(self, dt: float = 0.12) -> None:
310
+ self.time += dt
311
+ n = len(self.sensors)
312
+ drifts = self._noise_drifts()
313
+ suppression = np.clip(1.0 - (drifts / 0.28), 0.25, 1.0) # template noise-suppression map
314
+ noise_P = np.random.normal(0, 28 * self.cfg.noise_scale, n) * suppression
315
+ noise_T = np.random.normal(0, 0.9 * self.cfg.noise_scale, n) * suppression
316
+ event = 0.0
317
+ if random.random() < self.cfg.event_probability:
318
+ event = 110.0 * np.sin(self.time / 7.2) * float(np.mean(suppression))
319
+ self.P = self.base_P + noise_P + event
320
+ self.T = self.base_T + noise_T
321
+ for i, s in enumerate(self.sensors):
322
+ self.P[i] += s.cal_offset_P
323
+ self.T[i] += s.cal_offset_T
324
+ self.P = np.clip(self.P, 50, 28000) # template plausibility bounds
325
+ self.T = np.clip(self.T, 40, 550)
326
+ self.history_t.append(self.time)
327
+ self.history_P.append(self.P.copy())
328
+ self.history_T.append(self.T.copy())
329
+ if len(self.history_t) > self.cfg.history_length:
330
+ self.history_t = self.history_t[-self.cfg.history_length:]
331
+ self.history_P = self.history_P[-self.cfg.history_length:]
332
+ self.history_T = self.history_T[-self.cfg.history_length:]
333
+
334
+ def set_global_trims(self, k_structural_trim: float, phi_coupling_trim: float) -> None:
335
+ self.cfg.global_k_structural_trim = float(k_structural_trim)
336
+ self.cfg.global_phi_coupling_trim = float(phi_coupling_trim)
337
+ for s in self.sensors:
338
+ s.k_structural_trim = float(k_structural_trim)
339
+ s.phi_coupling_trim = float(phi_coupling_trim)
340
+
341
+ # -- export (template-faithful) -----------------------------------------
342
+ def export_csv(self, path: str | None = None) -> Path:
343
+ if path is None:
344
+ path = f"downhole_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
345
+ p = Path(path)
346
+ with p.open("w", newline="") as f:
347
+ writer = csv.writer(f)
348
+ header = ["time_s"]
349
+ for s in self.sensors:
350
+ header += [f"P_{s.name}_{s.depth_ft:.0f}ft", f"T_{s.name}_{s.depth_ft:.0f}ft"]
351
+ if self.cfg.comparison_mode:
352
+ cmp_now = self.comparison_summary()
353
+ header += ["avg_program_drift_pct", "avg_conventional_drift_pct", "measured_ratio_mean"]
354
+ writer.writerow(header)
355
+ for i, t in enumerate(self.history_t):
356
+ row = [round(t, 3)]
357
+ for j in range(len(self.sensors)):
358
+ row.append(round(float(self.history_P[i][j]), 2))
359
+ row.append(round(float(self.history_T[i][j]), 2))
360
+ if self.cfg.comparison_mode:
361
+ row += [cmp_now['avg_program_drift_pct'], cmp_now['avg_conventional_drift_pct'],
362
+ cmp_now['measured_ratio_mean']]
363
+ writer.writerow(row)
364
+ return p
365
+
366
+ @property
367
+ def depths_ft(self) -> np.ndarray:
368
+ return np.array([s.depth_ft for s in self.sensors])
369
+
370
+ def summary(self) -> dict:
371
+ return {
372
+ "live": PROGRAM_MODEL_AVAILABLE,
373
+ "canonical_suppression_at_unity_trims": round(canonical_suppression(), 4),
374
+ "sensors": len(self.sensors),
375
+ "td_ft": self.cfg.td_ft,
376
+ "time_s": round(self.time, 3),
377
+ "avg_drift_pct": round(float(np.mean(self.current_drifts)), 4),
378
+ "history_points": len(self.history_t),
379
+ "profile": self.cfg.profile.name if self.cfg.profile else "linear gradients",
380
+ "deviation": self.cfg.deviation.name if self.cfg.deviation is not None else "vertical (MD == TVD)",
381
+ "gauge_spec": getattr(self.cfg.gauge_spec, 'name', None) or "template_generic (default)",
382
+ "comparison": self.comparison_summary() if self.cfg.comparison_mode else None,
383
+ }
384
+
385
+
386
+ def run_batch(wells: dict, steps: int = 100, dt: float = 0.12) -> dict:
387
+ """Multi-well batch (v1.6.0): run each named SimulatorConfig for `steps`
388
+ and return {well_name: summary}. A field-wide study in one call."""
389
+ out = {}
390
+ for name, cfg in wells.items():
391
+ eng = DownholeEngine(cfg)
392
+ for _ in range(int(steps)):
393
+ eng.step(dt=dt)
394
+ out[name] = eng.summary()
395
+ return out