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.
- gea/BENCH_TEST_PROTOCOL.md +97 -0
- gea/IMPORT_RECORD.md +61 -0
- gea/__init__.py +160 -0
- gea/__main__.py +661 -0
- gea/acceptance_tests.py +1315 -0
- gea/accuracy_statement.py +181 -0
- gea/alarm_engine.py +307 -0
- gea/bench.py +144 -0
- gea/blind_harness.py +108 -0
- gea/case_study.py +229 -0
- gea/catalog/acex_lomonosov_age_depth_model.provenance.json +23 -0
- gea/catalog/acex_lomonosov_age_depth_model.txt +28 -0
- gea/catalog/agassiz77_canada_temperature.csv +68 -0
- gea/catalog/agassiz77_canada_temperature.provenance.json +17 -0
- gea/catalog/barbados_110_consolidation.provenance.json +24 -0
- gea/catalog/barbados_110_consolidation.txt +91 -0
- gea/catalog/bengal_u1452_grain_size.provenance.json +20 -0
- gea/catalog/bengal_u1452_grain_size.txt +252 -0
- gea/catalog/blake_164_methane_isotopes.provenance.json +22 -0
- gea/catalog/blake_164_methane_isotopes.txt +68 -0
- gea/catalog/chicxulub_m0077a_pwave_velocity.provenance.json +23 -0
- gea/catalog/chicxulub_m0077a_pwave_velocity.txt +735 -0
- gea/catalog/collingwood_1_28_ks_complete.las +128 -0
- gea/catalog/collingwood_1_28_ks_complete.provenance.json +17 -0
- gea/catalog/costa_rica_odp_friction_envelope.provenance.json +24 -0
- gea/catalog/costa_rica_odp_friction_envelope.txt +57 -0
- gea/catalog/dead_sea_5017_debrite_xrf_ms.provenance.json +21 -0
- gea/catalog/dead_sea_5017_debrite_xrf_ms.txt +94 -0
- gea/catalog/dsdp_504b_physical_properties.provenance.json +24 -0
- gea/catalog/dsdp_504b_physical_properties.txt +82 -0
- gea/catalog/dsdp_504b_sound_velocity.provenance.json +21 -0
- gea/catalog/dsdp_504b_sound_velocity.txt +81 -0
- gea/catalog/elgygytgyn_5011_turbidites.provenance.json +20 -0
- gea/catalog/elgygytgyn_5011_turbidites.txt +193 -0
- gea/catalog/epica_domec_co2_800kyr.provenance.json +21 -0
- gea/catalog/epica_domec_co2_800kyr.txt +265 -0
- gea/catalog/fram_909_organic_petrography.provenance.json +22 -0
- gea/catalog/fram_909_organic_petrography.txt +40 -0
- gea/catalog/gbr_325_coral_uth_ages.provenance.json +23 -0
- gea/catalog/gbr_325_coral_uth_ages.txt +71 -0
- gea/catalog/gisp2_greenland_temperature.csv +599 -0
- gea/catalog/gisp2_greenland_temperature.provenance.json +17 -0
- gea/catalog/gom_308_t2p_insitu.provenance.json +21 -0
- gea/catalog/gom_308_t2p_insitu.txt +40 -0
- gea/catalog/guaymas_385_dom_d13c.provenance.json +21 -0
- gea/catalog/guaymas_385_dom_d13c.txt +103 -0
- gea/catalog/hikurangi_u1520_friction_insitu.provenance.json +26 -0
- gea/catalog/hikurangi_u1520_friction_insitu.txt +74 -0
- gea/catalog/hydrate_ridge_204_ncr_hydrate.provenance.json +22 -0
- gea/catalog/hydrate_ridge_204_ncr_hydrate.txt +60 -0
- gea/catalog/iodp_u1324_pore_pressure.provenance.json +22 -0
- gea/catalog/iodp_u1324_pore_pressure.txt +42 -0
- gea/catalog/jfast_c0019_slow_slip_events.provenance.json +28 -0
- gea/catalog/jfast_c0019_slow_slip_events.txt +35 -0
- gea/catalog/kennetcook_2_p129_excerpt.las +139 -0
- gea/catalog/kennetcook_2_p129_excerpt.provenance.json +17 -0
- gea/catalog/ktb_hb_bhgm_density.dat +227 -0
- gea/catalog/ktb_hb_bhgm_density.provenance.json +22 -0
- gea/catalog/ktb_hb_complog_6020_excerpt.provenance.json +20 -0
- gea/catalog/ktb_hb_complog_6020_excerpt.txt +72 -0
- gea/catalog/ktb_hb_hlog246_temperature.dat +1636 -0
- gea/catalog/ktb_hb_hlog246_temperature.provenance.json +22 -0
- gea/catalog/ktb_hb_rockmech_compress.dat +33 -0
- gea/catalog/ktb_hb_rockmech_compress.provenance.json +22 -0
- gea/catalog/ktb_hb_tvd_0_9080_excerpt.dat +2817 -0
- gea/catalog/ktb_hb_tvd_0_9080_excerpt.provenance.json +20 -0
- gea/catalog/ktb_vb_rockmech_compress.dat +125 -0
- gea/catalog/ktb_vb_rockmech_compress.provenance.json +22 -0
- gea/catalog/ktb_vb_vlog251_temperature.dat +1127 -0
- gea/catalog/ktb_vb_vlog251_temperature.provenance.json +24 -0
- gea/catalog/l06_06_nl_survey.csv +201 -0
- gea/catalog/l06_06_nl_survey.provenance.json +17 -0
- gea/catalog/l07_01_nl_excerpt.las +90 -0
- gea/catalog/l07_01_nl_excerpt.provenance.json +17 -0
- gea/catalog/mariana_1200_serpentinite_geochem.provenance.json +21 -0
- gea/catalog/mariana_1200_serpentinite_geochem.txt +63 -0
- gea/catalog/med_160_sapropels.provenance.json +22 -0
- gea/catalog/med_160_sapropels.txt +43 -0
- gea/catalog/nankai_megasplay_shear_strength.provenance.json +26 -0
- gea/catalog/nankai_megasplay_shear_strength.txt +42 -0
- gea/catalog/odp_1027b_thermal_conductivity.provenance.json +21 -0
- gea/catalog/odp_1027b_thermal_conductivity.txt +52 -0
- gea/catalog/odp_1027c_cork_temperature.provenance.json +20 -0
- gea/catalog/odp_1027c_cork_temperature.txt +26 -0
- gea/catalog/odp_1165b_thermal_conductivity.provenance.json +22 -0
- gea/catalog/odp_1165b_thermal_conductivity.txt +102 -0
- gea/catalog/odp_1274a_mantle_peridotite_mad.provenance.json +27 -0
- gea/catalog/odp_1274a_mantle_peridotite_mad.txt +44 -0
- gea/catalog/odp_504b_dike_elastic_moduli.provenance.json +27 -0
- gea/catalog/odp_504b_dike_elastic_moduli.txt +85 -0
- gea/catalog/odp_504b_leg137_borehole_fluids.provenance.json +19 -0
- gea/catalog/odp_504b_leg137_borehole_fluids.txt +68 -0
- gea/catalog/odp_735b_gabbro_elastic_moduli.provenance.json +28 -0
- gea/catalog/odp_735b_gabbro_elastic_moduli.txt +127 -0
- gea/catalog/peru_201_sulfate_reduction.provenance.json +22 -0
- gea/catalog/peru_201_sulfate_reduction.txt +322 -0
- gea/catalog/scorpio_e1_sa_excerpt.las +113 -0
- gea/catalog/scorpio_e1_sa_excerpt.provenance.json +17 -0
- gea/catalog/sumatra_362_cohesion.provenance.json +21 -0
- gea/catalog/sumatra_362_cohesion.txt +38 -0
- gea/catalog/university_6_17_no1_tx_excerpt.las +119 -0
- gea/catalog/university_6_17_no1_tx_excerpt.provenance.json +17 -0
- gea/catalog/ursa_308_xrd_mineralogy.provenance.json +21 -0
- gea/catalog/ursa_308_xrd_mineralogy.txt +46 -0
- gea/catalog/volve_15_9_19_sr_excerpt.las +183 -0
- gea/catalog/volve_15_9_19_sr_excerpt.provenance.json +16 -0
- gea/catalog/volve_15_9_19a_core_excerpt.csv +88 -0
- gea/catalog/volve_15_9_19a_core_excerpt.provenance.json +18 -0
- gea/catalog/volve_f12_f14_production_excerpt.csv +167 -0
- gea/catalog/volve_f12_f14_production_excerpt.provenance.json +21 -0
- gea/catalog/walvis_208_petm_carbonate.provenance.json +21 -0
- gea/catalog/walvis_208_petm_carbonate.txt +268 -0
- gea/catalog/woodlark_1109_rock_eval.provenance.json +23 -0
- gea/catalog/woodlark_1109_rock_eval.txt +30 -0
- gea/cli.py +125 -0
- gea/client_reports.py +943 -0
- gea/config_versioning.py +133 -0
- gea/correlation.py +155 -0
- gea/dashboard.py +390 -0
- gea/deviation.py +70 -0
- gea/downhole_engine.py +395 -0
- gea/drift_monitor.py +310 -0
- gea/earth_model.py +230 -0
- gea/example_register_map.json +14 -0
- gea/fat_sat.py +68 -0
- gea/follower.py +98 -0
- gea/forward_model.py +139 -0
- gea/gamma.py +176 -0
- gea/gauge_specs.py +112 -0
- gea/gravity_reference.py +116 -0
- gea/inverse_engine.py +215 -0
- gea/matplotlib_demo.py +85 -0
- gea/modbus.py +229 -0
- gea/model_card.py +248 -0
- gea/operator_app.py +442 -0
- gea/ports.py +340 -0
- gea/profile_catalog.py +773 -0
- gea/project.py +213 -0
- gea/qt6_downhole_app.py +144 -0
- gea/quartz_hpht_extension.py +152 -0
- gea/reconciler.py +206 -0
- gea/rock_inventory.py +404 -0
- gea/sample_record.py +430 -0
- gea/sample_well_profile.csv +15 -0
- gea/sbom.py +116 -0
- gea/segy.py +181 -0
- gea/service_life.py +173 -0
- gea/shell.py +107 -0
- gea/sla_report.py +199 -0
- gea/store_forward.py +234 -0
- gea/strata_join.py +186 -0
- gea/survey_cmd.py +264 -0
- gea/survey_view.py +138 -0
- gea/telemetry.py +306 -0
- gea/tool_library.py +260 -0
- gea/well_assembler.py +457 -0
- gea/well_test_validation.py +369 -0
- gea_program-0.1.0.dist-info/METADATA +138 -0
- gea_program-0.1.0.dist-info/RECORD +163 -0
- gea_program-0.1.0.dist-info/WHEEL +5 -0
- gea_program-0.1.0.dist-info/entry_points.txt +2 -0
- gea_program-0.1.0.dist-info/licenses/LICENSE +373 -0
- 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
|