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/telemetry.py ADDED
@@ -0,0 +1,306 @@
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
+ """telemetry — field-telemetry realism layer (v1.3.0 extension).
5
+
6
+ The engine produces clean physics samples at an arbitrary internal step. Real
7
+ permanent-gauge telemetry does not look like that: it arrives at a fixed
8
+ cadence (one reading per minute is the permanent-quartz-gauge class), and it
9
+ carries faults — telemetry-line burst dropouts (whole string goes dark),
10
+ stuck gauges (electronics freeze, both channels repeat the last value), and
11
+ single-sample spikes (electrical noise on the line). Historians tag every
12
+ sample with a quality flag, and analysis pipelines are judged by how well
13
+ they reject the garbage without touching the physics.
14
+
15
+ This module wraps the engine in exactly that: timestamped fixed-cadence
16
+ sampling, a fault injector with per-mode ground-truth masks, quality flags on
17
+ every sample, a Hampel spike-rejection pass producing cleaned channels, and —
18
+ because the injector KNOWS what it injected — a precision/recall score for
19
+ the rejection filter. Exported CSVs look like real field data, so the module
20
+ doubles as a test bench for downhole analysis pipelines.
21
+
22
+ Headless-safe: numpy only, no display imports. Deterministic under a seed.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import csv
28
+ from dataclasses import dataclass
29
+ from datetime import datetime, timedelta
30
+ from pathlib import Path
31
+ from typing import List, Optional, Tuple
32
+
33
+ import numpy as np
34
+
35
+ from .downhole_engine import DownholeEngine, SimulatorConfig
36
+
37
+
38
+ @dataclass
39
+ class TelemetryConfig:
40
+ sample_interval_s: float = 60.0 # anchor: 1 sample/min permanent-gauge telemetry class
41
+ duration_hours: float = 24.0
42
+ start_time: str = "2026-01-01T00:00:00" # timestamp origin for the field-style export
43
+ line_dropout_start_prob: float = 0.001 # per sample: telemetry-line burst dropout begins (string-wide)
44
+ line_dropout_len_samples: Tuple[int, int] = (1, 20)
45
+ gauge_stuck_start_prob: float = 0.0008 # per gauge per sample: electronics freeze begins
46
+ gauge_stuck_len_samples: Tuple[int, int] = (10, 120)
47
+ spike_prob: float = 0.002 # per channel per sample: single-sample outlier
48
+ spike_sigma_psi: float = 250.0 # spike excursion scale, pressure channel
49
+ spike_sigma_F: float = 25.0 # spike excursion scale, temperature channel
50
+ hampel_window: int = 21 # rejection filter: rolling window (odd; ~20 min context at 1/min)
51
+ hampel_n_sigma: float = 5.0 # rejection filter: MAD threshold
52
+ common_mode_veto: bool = True # unflag multi-gauge excursions (well events, not gauge faults)
53
+ stuck_min_run: int = 3 # frozen-value QC: identical consecutive samples = stuck
54
+ seed: Optional[int] = None
55
+
56
+
57
+ class TelemetryRecorder:
58
+ """Records engine output as faulted, flagged, fixed-cadence field telemetry.
59
+
60
+ Quality flags per gauge per sample: OK, MISSING (line dropout), STUCK
61
+ (frozen electronics), SPIKE (outlier on P and/or T). Ground-truth fault
62
+ masks are kept so the rejection filter can be scored honestly.
63
+ """
64
+
65
+ def __init__(self, engine: DownholeEngine | None = None,
66
+ config: TelemetryConfig | None = None):
67
+ self.engine = engine or DownholeEngine(SimulatorConfig())
68
+ self.cfg = config or TelemetryConfig()
69
+
70
+ # -- acquisition ----------------------------------------------------------
71
+ def run(self) -> "TelemetryRecorder":
72
+ cfg = self.cfg
73
+ rng = np.random.default_rng(cfg.seed)
74
+ if cfg.seed is not None:
75
+ # the engine's step() draws from the global RNGs (template-faithful);
76
+ # seed them too so a seeded telemetry run is fully reproducible
77
+ import random as _random
78
+ np.random.seed(cfg.seed)
79
+ _random.seed(cfg.seed)
80
+ n_g = len(self.engine.sensors)
81
+ n_s = int(cfg.duration_hours * 3600.0 / cfg.sample_interval_s)
82
+
83
+ self.time_s = np.arange(n_s) * cfg.sample_interval_s
84
+ self.P_raw = np.full((n_s, n_g), np.nan)
85
+ self.T_raw = np.full((n_s, n_g), np.nan)
86
+ self.flags: List[List[str]] = []
87
+ # ground-truth fault masks
88
+ self.mask_missing = np.zeros((n_s, n_g), dtype=bool)
89
+ self.mask_stuck = np.zeros((n_s, n_g), dtype=bool)
90
+ self.mask_spike_P = np.zeros((n_s, n_g), dtype=bool)
91
+ self.mask_spike_T = np.zeros((n_s, n_g), dtype=bool)
92
+
93
+ line_drop_left = 0
94
+ stuck_left = np.zeros(n_g, dtype=int)
95
+ stuck_P = np.zeros(n_g)
96
+ stuck_T = np.zeros(n_g)
97
+
98
+ for k in range(n_s):
99
+ self.engine.step(dt=cfg.sample_interval_s)
100
+ P = self.engine.P.copy()
101
+ T = self.engine.T.copy()
102
+ row_flags = ["OK"] * n_g
103
+
104
+ # telemetry-line burst dropout (string-wide)
105
+ if line_drop_left == 0 and rng.random() < cfg.line_dropout_start_prob:
106
+ line_drop_left = int(rng.integers(cfg.line_dropout_len_samples[0],
107
+ cfg.line_dropout_len_samples[1] + 1))
108
+ if line_drop_left > 0:
109
+ line_drop_left -= 1
110
+ self.mask_missing[k, :] = True
111
+ self.flags.append(["MISSING"] * n_g)
112
+ continue # nothing recorded this sample
113
+
114
+ for g in range(n_g):
115
+ # stuck electronics (per gauge, freezes both channels)
116
+ if stuck_left[g] == 0 and rng.random() < cfg.gauge_stuck_start_prob:
117
+ stuck_left[g] = int(rng.integers(cfg.gauge_stuck_len_samples[0],
118
+ cfg.gauge_stuck_len_samples[1] + 1))
119
+ stuck_P[g], stuck_T[g] = P[g], T[g]
120
+ if stuck_left[g] > 0:
121
+ stuck_left[g] -= 1
122
+ P[g], T[g] = stuck_P[g], stuck_T[g]
123
+ self.mask_stuck[k, g] = True
124
+ row_flags[g] = "STUCK"
125
+ continue # a frozen gauge doesn't also spike
126
+ # single-sample spikes (per channel)
127
+ sp = rng.random() < cfg.spike_prob
128
+ st = rng.random() < cfg.spike_prob
129
+ if sp:
130
+ P[g] += rng.normal(0.0, cfg.spike_sigma_psi)
131
+ self.mask_spike_P[k, g] = True
132
+ if st:
133
+ T[g] += rng.normal(0.0, cfg.spike_sigma_F)
134
+ self.mask_spike_T[k, g] = True
135
+ if sp or st:
136
+ row_flags[g] = "SPIKE"
137
+
138
+ self.P_raw[k, :] = P
139
+ self.T_raw[k, :] = T
140
+ self.flags.append(row_flags)
141
+
142
+ self._despike()
143
+ return self
144
+
145
+ # -- rejection filter (Hampel) --------------------------------------------
146
+ @staticmethod
147
+ def _hampel(series: np.ndarray, window: int, n_sigma: float):
148
+ """Rolling-median MAD despike. Returns (cleaned, detected_mask,
149
+ rolling_medians). NaNs (missing samples) pass through untouched and
150
+ undetected."""
151
+ x = series.copy()
152
+ n = len(x)
153
+ half = window // 2
154
+ detected = np.zeros(n, dtype=bool)
155
+ meds = series.copy()
156
+ for i in range(n):
157
+ if np.isnan(x[i]):
158
+ continue
159
+ lo, hi = max(0, i - half), min(n, i + half + 1)
160
+ w = series[lo:hi]
161
+ w = w[~np.isnan(w)]
162
+ if len(w) < 3:
163
+ continue
164
+ med = np.median(w)
165
+ meds[i] = med
166
+ mad = np.median(np.abs(w - med))
167
+ sigma = 1.4826 * mad
168
+ if sigma > 0 and abs(x[i] - med) > n_sigma * sigma:
169
+ detected[i] = True
170
+ x[i] = med
171
+ return x, detected, meds
172
+
173
+ @staticmethod
174
+ def _frozen_runs(series: np.ndarray, min_run: int) -> np.ndarray:
175
+ """Frozen-value QC (standard historian check): runs of identical
176
+ consecutive samples of length >= min_run are a stuck gauge — live
177
+ noise never repeats a float exactly."""
178
+ n = len(series)
179
+ detected = np.zeros(n, dtype=bool)
180
+ i = 0
181
+ while i < n - 1:
182
+ if not np.isnan(series[i]) and series[i + 1] == series[i]:
183
+ j = i
184
+ while j + 1 < n and series[j + 1] == series[j]:
185
+ j += 1
186
+ if j - i + 1 >= min_run:
187
+ detected[i:j + 1] = True
188
+ i = j + 1
189
+ else:
190
+ i += 1
191
+ return detected
192
+
193
+ def _despike(self) -> None:
194
+ n_s, n_g = self.P_raw.shape
195
+ self.P_clean = np.empty_like(self.P_raw)
196
+ self.T_clean = np.empty_like(self.T_raw)
197
+ self.detected_P = np.zeros((n_s, n_g), dtype=bool)
198
+ self.detected_T = np.zeros((n_s, n_g), dtype=bool)
199
+ # frozen-value detection first (gauge-level: P and T freeze together)
200
+ self.detected_stuck = np.zeros((n_s, n_g), dtype=bool)
201
+ for g in range(n_g):
202
+ self.detected_stuck[:, g] = (
203
+ self._frozen_runs(self.P_raw[:, g], self.cfg.stuck_min_run)
204
+ | self._frozen_runs(self.T_raw[:, g], self.cfg.stuck_min_run))
205
+ med_P = np.empty_like(self.P_raw)
206
+ med_T = np.empty_like(self.T_raw)
207
+ for g in range(n_g):
208
+ # stuck samples are excluded from the filter statistics (their
209
+ # zero-variance runs crush the MAD and flood the boundary with
210
+ # false spikes) and are never themselves despiked
211
+ p_stat = self.P_raw[:, g].copy()
212
+ t_stat = self.T_raw[:, g].copy()
213
+ p_stat[self.detected_stuck[:, g]] = np.nan
214
+ t_stat[self.detected_stuck[:, g]] = np.nan
215
+ self.P_clean[:, g], self.detected_P[:, g], med_P[:, g] = self._hampel(
216
+ p_stat, self.cfg.hampel_window, self.cfg.hampel_n_sigma)
217
+ self.T_clean[:, g], self.detected_T[:, g], med_T[:, g] = self._hampel(
218
+ t_stat, self.cfg.hampel_window, self.cfg.hampel_n_sigma)
219
+ st = self.detected_stuck[:, g]
220
+ self.P_clean[st, g] = self.P_raw[st, g]
221
+ self.T_clean[st, g] = self.T_raw[st, g]
222
+ if self.cfg.common_mode_veto and n_g >= 3:
223
+ # A gauge fault hits ONE gauge; a well transient hits the STRING.
224
+ # Two vetoes: (a) coincidence — independent faults essentially
225
+ # never hit two gauges on the same sample; (b) residual — at a
226
+ # flagged sample, if the OTHER gauges' median residual moved the
227
+ # same direction by a comparable amount, the excursion is physics
228
+ # (a well transient seen string-wide), not electronics.
229
+ for det, raw, clean, med in ((self.detected_P, self.P_raw, self.P_clean, med_P),
230
+ (self.detected_T, self.T_raw, self.T_clean, med_T)):
231
+ veto = np.sum(det, axis=1) >= 2
232
+ for k in np.where(np.any(det, axis=1) & ~veto)[0]:
233
+ g = int(np.argmax(det[k, :]))
234
+ r = raw[k, :] - med[k, :]
235
+ others = np.delete(r, g)
236
+ others = others[~np.isnan(others)]
237
+ if len(others) >= 2:
238
+ m = float(np.median(others))
239
+ if m * r[g] > 0 and abs(m) >= 0.3 * abs(r[g]):
240
+ veto[k] = True
241
+ for k in np.where(veto)[0]:
242
+ clean[k, :] = raw[k, :]
243
+ det[k, :] = False
244
+
245
+ # -- reporting -------------------------------------------------------------
246
+ @staticmethod
247
+ def _score(detected: np.ndarray, injected: np.ndarray) -> dict:
248
+ tp = int(np.sum(detected & injected))
249
+ fp = int(np.sum(detected & ~injected))
250
+ fn = int(np.sum(~detected & injected))
251
+ return {
252
+ 'injected': int(np.sum(injected)),
253
+ 'detected': int(np.sum(detected)),
254
+ 'true_positives': tp,
255
+ 'false_positives': fp,
256
+ 'precision': round(tp / (tp + fp), 3) if (tp + fp) else None,
257
+ 'recall': round(tp / (tp + fn), 3) if (tp + fn) else None,
258
+ }
259
+
260
+ def telemetry_summary(self) -> dict:
261
+ n_s, n_g = self.P_raw.shape
262
+ total = n_s * n_g
263
+ n_missing = int(np.sum(self.mask_missing))
264
+ return {
265
+ 'samples': n_s,
266
+ 'gauges': n_g,
267
+ 'sample_interval_s': self.cfg.sample_interval_s,
268
+ 'duration_hours': self.cfg.duration_hours,
269
+ 'uptime_pct': round(100.0 * (1.0 - n_missing / total), 2),
270
+ 'missing_samples': n_missing,
271
+ 'stuck_samples': int(np.sum(self.mask_stuck)),
272
+ 'stuck_score': self._score(self.detected_stuck, self.mask_stuck),
273
+ 'spike_score_P': self._score(self.detected_P, self.mask_spike_P),
274
+ 'spike_score_T': self._score(self.detected_T, self.mask_spike_T),
275
+ }
276
+
277
+ # -- export (field-style) --------------------------------------------------
278
+ def export_csv(self, path: str | None = None) -> Path:
279
+ """Field-historian-style CSV: ISO timestamp, then per gauge the raw
280
+ P/T, the quality flag, and the cleaned P/T. Missing samples are blank
281
+ with flag MISSING — exactly what a real export hands an analyst."""
282
+ if path is None:
283
+ path = "telemetry.csv"
284
+ p = Path(path)
285
+ t0 = datetime.fromisoformat(self.cfg.start_time)
286
+ names = [s.name for s in self.engine.sensors]
287
+ with p.open("w", newline="") as f:
288
+ w = csv.writer(f)
289
+ header = ["timestamp"]
290
+ for nm in names:
291
+ header += [f"P_raw_psi_{nm}", f"T_raw_F_{nm}", f"flag_{nm}",
292
+ f"P_clean_psi_{nm}", f"T_clean_F_{nm}"]
293
+ w.writerow(header)
294
+ for k in range(len(self.time_s)):
295
+ row = [(t0 + timedelta(seconds=float(self.time_s[k]))).isoformat()]
296
+ for g in range(len(names)):
297
+ if self.mask_missing[k, g]:
298
+ row += ["", "", "MISSING", "", ""]
299
+ else:
300
+ row += [round(float(self.P_raw[k, g]), 2),
301
+ round(float(self.T_raw[k, g]), 2),
302
+ self.flags[k][g],
303
+ round(float(self.P_clean[k, g]), 2),
304
+ round(float(self.T_clean[k, g]), 2)]
305
+ w.writerow(row)
306
+ return p
gea/tool_library.py ADDED
@@ -0,0 +1,260 @@
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
+ """tool_library — the downhole tool library (v1.7.0 extension).
5
+
6
+ Generalizes the v1.5.0 gauge-spec discipline to the whole toolstring: a cited
7
+ catalog of downhole and surface tools (`ToolSpec`), per-class drift/response
8
+ models, a `ToolString` builder, and rating checks against real well
9
+ conditions. Every entry declares the telemetry interface it speaks — the
10
+ declaration the ports/plug-in layer will implement when the program taps
11
+ running logging systems (the live-stream side of the two-stream design).
12
+
13
+ Honesty rules (Rule 7 / PAPER_2149), same as gauge_specs:
14
+ * Every catalog entry carries a mandatory `source` citation.
15
+ * Where a tool's EXISTENCE and class are verified but its quantitative
16
+ parameters were not published on the fetched pages, the entry is marked
17
+ `PARAMETERS_USER_SUPPLIED`: its numbers are None, and asking it for a
18
+ drift model raises rather than inventing vendor data.
19
+ * The piezoresistive drift model's COEFFICIENTS are a representative
20
+ engineering fit (disclosed as such); its FORM is cited — the ChampionX
21
+ Quartzdyne performance page states verbatim that piezoresistive drift is
22
+ unpredictable and increases exponentially with increasing temperature,
23
+ while quartz drift is predictable and compensatable.
24
+
25
+ Verified sources (fetched 2026-08-23):
26
+ * GEO PSI product catalog + GEOQ 177 public specification table
27
+ (geopsi.com/products/downhole-gauges/) — Quartzdyne-sensor quartz P/T
28
+ gauges (spec table), GEOP piezoresistive family, GEOVW 250 vibrating-wire,
29
+ GEOXTR 18pt thermocouple input card, GEOPulse fiber optics (DAS/DTS),
30
+ G6 interface card (Modbus RS485 + 4-20mA surface communications),
31
+ PSK downhole telemetry, up to 10 sensors per TEC line.
32
+ * ChampionX Quartzdyne performance page — quartz-vs-piezoresistive drift
33
+ character statement cited above.
34
+
35
+ Headless-safe: numpy only.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import math
41
+ from dataclasses import dataclass, field
42
+ from typing import Callable, Dict, List, Optional, Tuple
43
+
44
+ from .gauge_specs import GAUGE_SPECS, GaugeSpec
45
+ from .quartz_hpht_extension import (
46
+ calculate_quartz_transducer_hpht_program,
47
+ conventional_drift,
48
+ )
49
+
50
+ # tool classes
51
+ QUARTZ_PT = "QUARTZ_PT_GAUGE"
52
+ PIEZO_PT = "PIEZORESISTIVE_PT_GAUGE"
53
+ VIBRATING_WIRE = "VIBRATING_WIRE_GAUGE"
54
+ THERMOCOUPLE = "THERMOCOUPLE_STRING"
55
+ FIBER_DTS = "FIBER_DTS"
56
+ SURFACE_INTERFACE = "SURFACE_INTERFACE"
57
+
58
+ FULLY_SPECIFIED = "FULLY_SPECIFIED"
59
+ PARAMETERS_USER_SUPPLIED = "PARAMETERS_USER_SUPPLIED"
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ToolSpec:
64
+ """A catalog entry: what the tool is, what it measures, what it speaks.
65
+
66
+ `source` is mandatory citation prose (Rule 7). `spec_status` says whether
67
+ the numbers are published-and-verified or must come from the user's own
68
+ datasheet. `telemetry_interface` is the declaration the ports layer will
69
+ implement — the tool library names the protocol, the plug-in speaks it.
70
+ """
71
+ name: str
72
+ tool_class: str
73
+ source: str
74
+ measures: Tuple[str, ...]
75
+ telemetry_interface: str
76
+ spec_status: str = FULLY_SPECIFIED
77
+ temp_rating_C: Optional[float] = None
78
+ pressure_rating_psi: Optional[float] = None
79
+ gauge_spec: Optional[GaugeSpec] = None # quartz tools wrap a v1.5.0 GaugeSpec
80
+ drift_model: Optional[str] = None # 'quartz_program' | 'quartz_conventional' | 'piezoresistive'
81
+ params: dict = field(default_factory=dict)
82
+ notes: str = ""
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Drift/response models
87
+ # ---------------------------------------------------------------------------
88
+ def piezoresistive_drift(temp_c: float,
89
+ base_drift_pct_fs_yr: float = 0.1,
90
+ ref_temp_C: float = 25.0,
91
+ e_fold_C: float = 40.0) -> float:
92
+ """Piezoresistive P/T gauge drift (%FS/yr).
93
+
94
+ FORM (cited): ChampionX Quartzdyne performance page — "Piezoresistive
95
+ drift is unpredictable. Piezoresistive drift increases exponentially with
96
+ increasing temperature." Modeled as base * exp((T - T_ref)/e_fold).
97
+
98
+ COEFFICIENTS (disclosed, representative engineering fit — NOT vendor
99
+ data): 0.1 %FS/yr at 25 C reference with a 40 C e-folding scale. The
100
+ 'unpredictable' character means real units scatter widely around this
101
+ curve; this is a class-typical envelope for simulation, not a spec bound.
102
+ """
103
+ return base_drift_pct_fs_yr * math.exp((temp_c - ref_temp_C) / e_fold_C)
104
+
105
+
106
+ def drift_model_for(tool: ToolSpec) -> Callable[[float, float], float]:
107
+ """Return f(temp_c, pressure_psi) -> drift %FS/yr for a measuring tool.
108
+
109
+ Raises for PARAMETERS_USER_SUPPLIED entries (no invented vendor numbers)
110
+ and for non-measuring tools (surface interfaces have no drift model).
111
+ """
112
+ if tool.spec_status == PARAMETERS_USER_SUPPLIED:
113
+ raise ValueError(
114
+ f"{tool.name}: parameters are user-supplied - load your datasheet "
115
+ f"values (Rule 7: the library does not invent vendor numbers)")
116
+ if tool.drift_model == 'quartz_program':
117
+ def f(temp_c, pressure_psi, _s=tool.gauge_spec):
118
+ r = calculate_quartz_transducer_hpht_program(0.0, temp_c, pressure_psi, spec=_s)
119
+ return float(r['value']['drift_pct'])
120
+ return f
121
+ if tool.drift_model == 'quartz_conventional':
122
+ return lambda temp_c, pressure_psi, _s=tool.gauge_spec: conventional_drift(temp_c, pressure_psi, spec=_s)
123
+ if tool.drift_model == 'piezoresistive':
124
+ p = tool.params
125
+ return lambda temp_c, pressure_psi: piezoresistive_drift(
126
+ temp_c, p.get('base_drift_pct_fs_yr', 0.1),
127
+ p.get('ref_temp_C', 25.0), p.get('e_fold_C', 40.0))
128
+ raise ValueError(f"{tool.name}: no drift model (tool class {tool.tool_class})")
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # The catalog — every entry cited
133
+ # ---------------------------------------------------------------------------
134
+ _GEOPSI = ("GEO PSI product catalog + GEOQ 177 public specification table "
135
+ "(Quartzdyne sensor), geopsi.com/products/downhole-gauges/, fetched 2026-08-23")
136
+ _CHAMPIONX = ("ChampionX Quartzdyne performance page, championx.com, fetched 2026-08-23: "
137
+ "quartz drift predictable/compensatable; piezoresistive drift unpredictable, "
138
+ "increases exponentially with temperature")
139
+
140
+ TOOL_LIBRARY: Dict[str, ToolSpec] = {
141
+ 'quartz_pt_program_geoq177_30k': ToolSpec(
142
+ name='quartz_pt_program_geoq177_30k', tool_class=QUARTZ_PT,
143
+ source=_GEOPSI + "; GEA-conditioned leg (canonical suppression, PAPER_2256)",
144
+ measures=('pressure_psi', 'temperature_F'),
145
+ telemetry_interface='PSK downhole telemetry -> Modbus RS485 + 4-20mA via G6 interface card (GEOQ 177 spec table)',
146
+ temp_rating_C=177.0, pressure_rating_psi=30000.0,
147
+ gauge_spec=GAUGE_SPECS['geoq177_30k'], drift_model='quartz_program'),
148
+ 'quartz_pt_conventional_geoq177_30k': ToolSpec(
149
+ name='quartz_pt_conventional_geoq177_30k', tool_class=QUARTZ_PT,
150
+ source=_GEOPSI + "; conventional reference leg (no GEA suppression)",
151
+ measures=('pressure_psi', 'temperature_F'),
152
+ telemetry_interface='PSK downhole telemetry -> Modbus RS485 + 4-20mA via G6 interface card (GEOQ 177 spec table)',
153
+ temp_rating_C=177.0, pressure_rating_psi=30000.0,
154
+ gauge_spec=GAUGE_SPECS['geoq177_30k'], drift_model='quartz_conventional'),
155
+ 'quartz_pt_template_stressed': ToolSpec(
156
+ name='quartz_pt_template_stressed', tool_class=QUARTZ_PT,
157
+ source="22Aug2026 template thread (grok_cce7a73b): stressed-service quartz class, 0.215 %FS/yr baseline",
158
+ measures=('pressure_psi', 'temperature_F'),
159
+ telemetry_interface='per-site (template does not specify)',
160
+ temp_rating_C=200.0, pressure_rating_psi=30000.0,
161
+ gauge_spec=GAUGE_SPECS['template_generic'], drift_model='quartz_program'),
162
+ 'piezoresistive_pt_class': ToolSpec(
163
+ name='piezoresistive_pt_class', tool_class=PIEZO_PT,
164
+ source=_CHAMPIONX + "; GEO PSI GEOP family existence (product catalog). "
165
+ "Model coefficients are a representative engineering fit, DISCLOSED, not vendor data.",
166
+ measures=('pressure_psi', 'temperature_F'),
167
+ telemetry_interface='per-site (GEOP family: downhole telemetry via TEC, surface via interface card)',
168
+ temp_rating_C=150.0,
169
+ drift_model='piezoresistive',
170
+ params={'base_drift_pct_fs_yr': 0.1, 'ref_temp_C': 25.0, 'e_fold_C': 40.0},
171
+ notes="cited FORM (exponential-in-T, unpredictable); representative coefficients"),
172
+ 'vibrating_wire_geovw250': ToolSpec(
173
+ name='vibrating_wire_geovw250', tool_class=VIBRATING_WIRE,
174
+ source="GEO PSI GEOVW 250 product listing (existence + class), geopsi.com product catalog, "
175
+ "fetched 2026-08-23. Quantitative specs NOT published on the fetched page.",
176
+ measures=('pressure_psi',),
177
+ telemetry_interface='per-site (vibrating-wire frequency readout)',
178
+ spec_status=PARAMETERS_USER_SUPPLIED,
179
+ notes="user must supply datasheet parameters before this tool can be simulated"),
180
+ 'thermocouple_string_geoxtr18': ToolSpec(
181
+ name='thermocouple_string_geoxtr18', tool_class=THERMOCOUPLE,
182
+ source="GEO PSI GEOXTR 18pt Thermocouple Input Card product listing (existence + 18-point class), "
183
+ "geopsi.com product catalog, fetched 2026-08-23. Per-point specs NOT published on the fetched page.",
184
+ measures=('temperature_F',) * 1,
185
+ telemetry_interface='GEOXTR 18pt thermocouple input card',
186
+ spec_status=PARAMETERS_USER_SUPPLIED,
187
+ params={'points': 18},
188
+ notes="18 temperature points along the string; user supplies accuracy/drift from datasheet"),
189
+ 'fiber_dts_geopulse': ToolSpec(
190
+ name='fiber_dts_geopulse', tool_class=FIBER_DTS,
191
+ source="GEO PSI GEOPulse fiber-optics product family (existence + DAS/DTS class), "
192
+ "geopsi.com, fetched 2026-08-23. Spatial/thermal resolution NOT published on the fetched page.",
193
+ measures=('temperature_profile',),
194
+ telemetry_interface='fiber-optic interrogator (GEOPulse surface unit)',
195
+ spec_status=PARAMETERS_USER_SUPPLIED,
196
+ notes="distributed temperature along the whole bore; user supplies interrogator specs"),
197
+ 'surface_interface_g6': ToolSpec(
198
+ name='surface_interface_g6', tool_class=SURFACE_INTERFACE,
199
+ source="GEO PSI GEOQ 177 spec table footnote (1): surface communications Modbus RS485 and "
200
+ "4-20mA output via G6 Interface Card; up to 10 sensors per TEC line (footnote 2). Fetched 2026-08-23.",
201
+ measures=(),
202
+ telemetry_interface='Modbus RS485 + 4-20mA analog out; PSK downhole side',
203
+ notes="THE PORT TARGET: the live-stream plug-in layer will speak this interface"),
204
+ }
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Toolstring: composition + rating checks
209
+ # ---------------------------------------------------------------------------
210
+ @dataclass
211
+ class ToolString:
212
+ """A composed string: (md_ft, tool_name) stations, validated against the
213
+ library. This is the object the reconciler will hang live streams on."""
214
+ stations: List[Tuple[float, str]]
215
+ name: str = "toolstring"
216
+
217
+ def __post_init__(self):
218
+ for md, tn in self.stations:
219
+ if tn not in TOOL_LIBRARY:
220
+ raise KeyError(f"unknown tool '{tn}' - not in TOOL_LIBRARY")
221
+
222
+ def summary(self) -> dict:
223
+ by_class: Dict[str, int] = {}
224
+ for _, tn in self.stations:
225
+ c = TOOL_LIBRARY[tn].tool_class
226
+ by_class[c] = by_class.get(c, 0) + 1
227
+ return {'name': self.name, 'stations': len(self.stations), 'by_class': by_class,
228
+ 'interfaces': sorted({TOOL_LIBRARY[tn].telemetry_interface for _, tn in self.stations})}
229
+
230
+
231
+ def rating_check(toolstring: ToolString,
232
+ profile=None,
233
+ deviation=None,
234
+ surface_temp_F: float = 75.0, # anchor: template surface ambient
235
+ temp_gradient_F_per_ft: float = 0.018, # anchor: template geothermal gradient
236
+ surface_pressure_psi: float = 14.7, # anchor: 1 atm
237
+ pressure_gradient_psi_per_ft: float = 0.465 # anchor: industry hydrostatic
238
+ ) -> List[dict]:
239
+ """Check every station's tool against the well conditions AT that station
240
+ (real profile or gradients; MD->TVD deviation honored). A tool over its
241
+ temperature or pressure rating is flagged - the check that catches a
242
+ 177 C gauge hung in a 233 C kick zone before the well does."""
243
+ out = []
244
+ for md, tn in toolstring.stations:
245
+ tool = TOOL_LIBRARY[tn]
246
+ tvd = float(deviation.tvd_of(md)) if deviation is not None else float(md)
247
+ if profile is not None:
248
+ p_psi, t_F = profile.interp(tvd)
249
+ else:
250
+ p_psi = surface_pressure_psi + tvd * pressure_gradient_psi_per_ft
251
+ t_F = surface_temp_F + tvd * temp_gradient_F_per_ft
252
+ t_C = (t_F - 32.0) * 5.0 / 9.0
253
+ over_t = tool.temp_rating_C is not None and t_C > tool.temp_rating_C
254
+ over_p = tool.pressure_rating_psi is not None and p_psi > tool.pressure_rating_psi
255
+ out.append({'md_ft': round(float(md), 0), 'tool': tn,
256
+ 'station_temp_C': round(t_C, 1), 'station_pressure_psi': round(float(p_psi), 0),
257
+ 'temp_rating_C': tool.temp_rating_C, 'pressure_rating_psi': tool.pressure_rating_psi,
258
+ 'over_temp_rating': bool(over_t), 'over_pressure_rating': bool(over_p),
259
+ 'ok': not (over_t or over_p)})
260
+ return out