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
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
"""well_test_validation — stable-period detection and well-test accept/reject
|
|
5
|
+
with reason codes and an approval trail (SOW 4.2.3.1).
|
|
6
|
+
|
|
7
|
+
A well test is a period in which the well is on stream at a fixed operating
|
|
8
|
+
point and its rates and pressures are stable enough that their means can be
|
|
9
|
+
taken as the test result. The client agrees the stability logic; this module
|
|
10
|
+
keeps that logic in a CONFIG FILE (`criteria.json`), never in code, prints
|
|
11
|
+
the criteria in force (with the file's hash) on every report, and gives
|
|
12
|
+
every rejected candidate a reason code that names the rule and the number.
|
|
13
|
+
|
|
14
|
+
Roles the detector needs (mapped from channel names by `ChannelMap`):
|
|
15
|
+
on_stream hours on stream per sample (h)
|
|
16
|
+
rates one or more rate channels (oil, gas, water ...)
|
|
17
|
+
pressures one or more pressure channels (downhole, wellhead ...)
|
|
18
|
+
operating operating-point channels that must not change (choke ...)
|
|
19
|
+
|
|
20
|
+
Algorithm (greedy maximal stable segments):
|
|
21
|
+
1. Per-sample eligibility: every required channel present; on-stream
|
|
22
|
+
hours >= minimum; quality flag GOOD (from the record layer) unless
|
|
23
|
+
the criteria allow a fraction of flagged samples.
|
|
24
|
+
2. Within each run of eligible samples, grow a window from its start
|
|
25
|
+
while every stability rule holds (CV of rates and pressures, relative
|
|
26
|
+
trend, operating-point change). When it cannot grow further, a window
|
|
27
|
+
at least `min_samples` long is an ACCEPTED test; otherwise the
|
|
28
|
+
candidate is REJECTED with the rule that failed at minimum length.
|
|
29
|
+
3. Runs of ineligible samples are REJECTED candidates with the
|
|
30
|
+
eligibility rule that failed.
|
|
31
|
+
|
|
32
|
+
Reason codes: MISSING_CHANNEL, INSUFFICIENT_DURATION, ON_STREAM_BELOW_MIN,
|
|
33
|
+
QUALITY_FLAGS, RATE_UNSTABLE:<channel>, PRESSURE_UNSTABLE:<channel>,
|
|
34
|
+
TREND_EXCEEDS:<channel>, OPERATING_POINT_CHANGED:<channel>.
|
|
35
|
+
|
|
36
|
+
Approval trail: `approvals.jsonl` in the record directory; multi-level
|
|
37
|
+
(level 1 engineer, level 2 supervisor by default), each entry timestamped
|
|
38
|
+
with approver and decision; a test's status is derived from the trail.
|
|
39
|
+
|
|
40
|
+
Headless-safe: numpy only.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import hashlib
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
from dataclasses import dataclass, field, asdict
|
|
49
|
+
from datetime import datetime, timedelta, timezone
|
|
50
|
+
from typing import Dict, List, Optional
|
|
51
|
+
|
|
52
|
+
import numpy as np
|
|
53
|
+
|
|
54
|
+
from .sample_record import TagCatalogue, apply_quality_rules
|
|
55
|
+
|
|
56
|
+
DEFAULT_CRITERIA = {
|
|
57
|
+
'name': 'default well-test stability criteria',
|
|
58
|
+
'basis': 'engineering defaults for daily-cadence production data; the client agrees and versions this file',
|
|
59
|
+
'min_samples': 5,
|
|
60
|
+
'min_on_stream_hours': 23.0,
|
|
61
|
+
'max_flagged_fraction': 0.0,
|
|
62
|
+
'rate_max_cv_pct': 3.0,
|
|
63
|
+
'rate_negligible_fraction': 0.01,
|
|
64
|
+
'pressure_max_cv_pct': 1.0,
|
|
65
|
+
'max_trend_pct_over_window': 3.0,
|
|
66
|
+
'operating_max_change_pct': 2.0,
|
|
67
|
+
'approval_levels': [{'level': 1, 'role': 'production engineer'},
|
|
68
|
+
{'level': 2, 'role': 'production supervisor'}],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def write_default_criteria(path: str) -> str:
|
|
73
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
74
|
+
json.dump(DEFAULT_CRITERIA, f, indent=1)
|
|
75
|
+
return path
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_criteria(path: Optional[str]) -> dict:
|
|
79
|
+
if not path:
|
|
80
|
+
c = dict(DEFAULT_CRITERIA)
|
|
81
|
+
c['_source'] = 'built-in defaults (no criteria file given)'
|
|
82
|
+
c['_sha256'] = hashlib.sha256(json.dumps(DEFAULT_CRITERIA, sort_keys=True).encode()).hexdigest()[:16]
|
|
83
|
+
return c
|
|
84
|
+
with open(path, 'rb') as f:
|
|
85
|
+
raw = f.read()
|
|
86
|
+
c = dict(DEFAULT_CRITERIA)
|
|
87
|
+
c.update(json.loads(raw.decode('utf-8')))
|
|
88
|
+
c['_source'] = os.path.abspath(path)
|
|
89
|
+
c['_sha256'] = hashlib.sha256(raw).hexdigest()[:16]
|
|
90
|
+
return c
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class ChannelMap:
|
|
95
|
+
on_stream: Optional[str]
|
|
96
|
+
rates: Dict[str, str] # role -> channel name, e.g. {'oil': 'BORE_OIL_VOL[15/9-F-12]'}
|
|
97
|
+
pressures: Dict[str, str]
|
|
98
|
+
operating: Dict[str, str] = field(default_factory=dict)
|
|
99
|
+
|
|
100
|
+
def all_channels(self) -> Dict[str, str]:
|
|
101
|
+
out = {}
|
|
102
|
+
if self.on_stream:
|
|
103
|
+
out['on_stream'] = self.on_stream
|
|
104
|
+
out.update({f'rate:{k}': v for k, v in self.rates.items()})
|
|
105
|
+
out.update({f'pressure:{k}': v for k, v in self.pressures.items()})
|
|
106
|
+
out.update({f'operating:{k}': v for k, v in self.operating.items()})
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def volve_channel_map(well_tag: str) -> ChannelMap:
|
|
111
|
+
w = well_tag
|
|
112
|
+
return ChannelMap(on_stream=f'ON_STREAM_HRS[{w}]',
|
|
113
|
+
rates={'oil': f'BORE_OIL_VOL[{w}]', 'gas': f'BORE_GAS_VOL[{w}]', 'water': f'BORE_WAT_VOL[{w}]'},
|
|
114
|
+
pressures={'downhole': f'AVG_DOWNHOLE_PRESSURE[{w}]', 'wellhead': f'AVG_WHP_P[{w}]'},
|
|
115
|
+
operating={'choke': f'AVG_CHOKE_SIZE_P[{w}]'})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _utc(dt: Optional[datetime]) -> datetime:
|
|
119
|
+
if dt is None:
|
|
120
|
+
return datetime.now(timezone.utc).replace(microsecond=0)
|
|
121
|
+
return dt.astimezone(timezone.utc) if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _iso(dt: datetime) -> str:
|
|
125
|
+
return dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _cv_pct(x: np.ndarray) -> float:
|
|
129
|
+
m = float(np.mean(x))
|
|
130
|
+
return float(np.std(x) / abs(m) * 100.0) if m else float('inf')
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _trend_pct(x: np.ndarray) -> float:
|
|
134
|
+
if len(x) < 2:
|
|
135
|
+
return 0.0
|
|
136
|
+
t = np.arange(len(x), dtype=float)
|
|
137
|
+
slope, _ = np.polyfit(t, x, 1)
|
|
138
|
+
m = float(np.mean(x))
|
|
139
|
+
return float(abs(slope) * (len(x) - 1) / abs(m) * 100.0) if m else float('inf')
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _change_pct(x: np.ndarray) -> float:
|
|
143
|
+
m = float(np.mean(x))
|
|
144
|
+
return float((np.max(x) - np.min(x)) / abs(m) * 100.0) if m else float('inf')
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class WellTestValidator:
|
|
148
|
+
"""Detect stable periods in a time-indexed LiveStream and score them."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, criteria: dict, channel_map: ChannelMap, catalogue: Optional[TagCatalogue] = None):
|
|
151
|
+
self.c = criteria
|
|
152
|
+
self.map = channel_map
|
|
153
|
+
self.catalogue = catalogue
|
|
154
|
+
|
|
155
|
+
# -- per-sample eligibility -------------------------------------------------
|
|
156
|
+
def _eligibility(self, stream) -> List[Optional[str]]:
|
|
157
|
+
n = len(stream.index)
|
|
158
|
+
chans = self.map.all_channels()
|
|
159
|
+
missing = [v for v in chans.values() if v not in stream.channels]
|
|
160
|
+
if missing:
|
|
161
|
+
return [f'MISSING_CHANNEL:{",".join(missing)}'] * n
|
|
162
|
+
reason: List[Optional[str]] = [None] * n
|
|
163
|
+
arrays = {k: np.asarray(stream.channels[v].values, dtype=float) for k, v in chans.items()}
|
|
164
|
+
flagged = {}
|
|
165
|
+
if self.catalogue is not None:
|
|
166
|
+
t = np.asarray(stream.index, dtype=float)
|
|
167
|
+
for k, v in chans.items():
|
|
168
|
+
if v in self.catalogue:
|
|
169
|
+
fl = apply_quality_rules(arrays[k], t, self.catalogue.get(v),
|
|
170
|
+
getattr(stream.channels[v], 'quality', None))
|
|
171
|
+
flagged[k] = [q != 'GOOD' for q, _ in fl]
|
|
172
|
+
for i in range(n):
|
|
173
|
+
for k, a in arrays.items():
|
|
174
|
+
if np.isnan(a[i]):
|
|
175
|
+
reason[i] = f'MISSING_VALUE:{k}'
|
|
176
|
+
break
|
|
177
|
+
if reason[i]:
|
|
178
|
+
continue
|
|
179
|
+
if 'on_stream' in arrays and arrays['on_stream'][i] < self.c['min_on_stream_hours']:
|
|
180
|
+
reason[i] = f"ON_STREAM_BELOW_MIN:{arrays['on_stream'][i]:g}h<{self.c['min_on_stream_hours']:g}h"
|
|
181
|
+
continue
|
|
182
|
+
if self.c.get('max_flagged_fraction', 0.0) <= 0.0:
|
|
183
|
+
bad = [k for k, fl in flagged.items() if fl[i]]
|
|
184
|
+
if bad:
|
|
185
|
+
reason[i] = f'QUALITY_FLAGS:{",".join(bad)}'
|
|
186
|
+
return reason
|
|
187
|
+
|
|
188
|
+
# -- window stability -------------------------------------------------------
|
|
189
|
+
def _window_check(self, stream, i: int, j: int) -> Optional[str]:
|
|
190
|
+
"""None if [i:j) is stable under every rule, else the reason code."""
|
|
191
|
+
c = self.c
|
|
192
|
+
means = {role: abs(float(np.mean(np.asarray(stream.channels[chan].values, dtype=float)[i:j])))
|
|
193
|
+
for role, chan in self.map.rates.items()}
|
|
194
|
+
floor = c.get('rate_negligible_fraction', 0.0) * (max(means.values()) if means else 0.0)
|
|
195
|
+
for role, chan in self.map.rates.items():
|
|
196
|
+
if means[role] < floor:
|
|
197
|
+
continue # negligible stream (e.g. water at first oil): not a stability criterion, disclosed in criteria
|
|
198
|
+
x = np.asarray(stream.channels[chan].values, dtype=float)[i:j]
|
|
199
|
+
cv = _cv_pct(x)
|
|
200
|
+
if cv > c['rate_max_cv_pct']:
|
|
201
|
+
return f'RATE_UNSTABLE:{role}:cv {cv:.2f}%>{c["rate_max_cv_pct"]:g}%'
|
|
202
|
+
tr = _trend_pct(x)
|
|
203
|
+
if tr > c['max_trend_pct_over_window']:
|
|
204
|
+
return f'TREND_EXCEEDS:{role}:{tr:.2f}%>{c["max_trend_pct_over_window"]:g}%'
|
|
205
|
+
for role, chan in self.map.pressures.items():
|
|
206
|
+
x = np.asarray(stream.channels[chan].values, dtype=float)[i:j]
|
|
207
|
+
cv = _cv_pct(x)
|
|
208
|
+
if cv > c['pressure_max_cv_pct']:
|
|
209
|
+
return f'PRESSURE_UNSTABLE:{role}:cv {cv:.2f}%>{c["pressure_max_cv_pct"]:g}%'
|
|
210
|
+
tr = _trend_pct(x)
|
|
211
|
+
if tr > c['max_trend_pct_over_window']:
|
|
212
|
+
return f'TREND_EXCEEDS:{role}:{tr:.2f}%>{c["max_trend_pct_over_window"]:g}%'
|
|
213
|
+
for role, chan in self.map.operating.items():
|
|
214
|
+
x = np.asarray(stream.channels[chan].values, dtype=float)[i:j]
|
|
215
|
+
ch = _change_pct(x)
|
|
216
|
+
if ch > c['operating_max_change_pct']:
|
|
217
|
+
return f'OPERATING_POINT_CHANGED:{role}:{ch:.2f}%>{c["operating_max_change_pct"]:g}%'
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
def _stats(self, stream, i: int, j: int) -> dict:
|
|
221
|
+
out = {}
|
|
222
|
+
for group in ('rates', 'pressures', 'operating'):
|
|
223
|
+
for role, chan in getattr(self.map, group).items():
|
|
224
|
+
x = np.asarray(stream.channels[chan].values, dtype=float)[i:j]
|
|
225
|
+
out[f'{group[:-1] if group != "operating" else "operating"}:{role}'] = {
|
|
226
|
+
'channel': chan, 'unit': stream.channels[chan].unit, 'mean': round(float(np.mean(x)), 3),
|
|
227
|
+
'cv_pct': round(_cv_pct(x), 3), 'trend_pct': round(_trend_pct(x), 3),
|
|
228
|
+
'min': round(float(np.min(x)), 3), 'max': round(float(np.max(x)), 3)}
|
|
229
|
+
if self.map.on_stream:
|
|
230
|
+
x = np.asarray(stream.channels[self.map.on_stream].values, dtype=float)[i:j]
|
|
231
|
+
out['on_stream'] = {'channel': self.map.on_stream, 'mean_hours': round(float(np.mean(x)), 2),
|
|
232
|
+
'min_hours': round(float(np.min(x)), 2)}
|
|
233
|
+
return out
|
|
234
|
+
|
|
235
|
+
# -- the detection ------------------------------------------------------------
|
|
236
|
+
def detect(self, stream, t0_utc: Optional[str] = None) -> dict:
|
|
237
|
+
if getattr(stream, 'index_kind', 'time_s') != 'time_s':
|
|
238
|
+
raise ValueError('well-test detection needs a time-indexed stream')
|
|
239
|
+
n = len(stream.index)
|
|
240
|
+
t = np.asarray(stream.index, dtype=float)
|
|
241
|
+
origin = t0_utc or stream.meta.get('start_date') or stream.meta.get('start_time')
|
|
242
|
+
base = datetime.fromisoformat(origin.replace('Z', '')) if origin else datetime(1970, 1, 1)
|
|
243
|
+
base = base.replace(tzinfo=timezone.utc) if base.tzinfo is None else base.astimezone(timezone.utc)
|
|
244
|
+
stamp = lambda k: _iso(base + timedelta(seconds=float(t[k])))
|
|
245
|
+
elig = self._eligibility(stream)
|
|
246
|
+
mn = int(self.c['min_samples'])
|
|
247
|
+
tests: List[dict] = []
|
|
248
|
+
rejected: List[dict] = []
|
|
249
|
+
seq = 1
|
|
250
|
+
|
|
251
|
+
def _top(code: str) -> str:
|
|
252
|
+
parts = code.split(':')
|
|
253
|
+
return parts[0] if parts[0] in ('INSUFFICIENT_DURATION', 'ON_STREAM_BELOW_MIN', 'QUALITY_FLAGS',
|
|
254
|
+
'MISSING_VALUE', 'MISSING_CHANNEL') else ':'.join(parts[:2])
|
|
255
|
+
|
|
256
|
+
i = 0
|
|
257
|
+
while i < n:
|
|
258
|
+
if elig[i]:
|
|
259
|
+
j = i
|
|
260
|
+
while j < n and elig[j]:
|
|
261
|
+
j += 1
|
|
262
|
+
codes = sorted({_top(elig[k]) for k in range(i, j)})
|
|
263
|
+
rejected.append({'candidate_id': f'C{seq:03d}', 'start_utc': stamp(i), 'end_utc': stamp(j - 1),
|
|
264
|
+
'n': j - i, 'status': 'REJECTED', 'reason_codes': codes, 'primary_reason': codes[0],
|
|
265
|
+
'detail': '; '.join(sorted({elig[k] for k in range(i, j)})[:4])})
|
|
266
|
+
seq += 1
|
|
267
|
+
i = j
|
|
268
|
+
continue
|
|
269
|
+
j = i + 1
|
|
270
|
+
while j < n and not elig[j]:
|
|
271
|
+
j += 1
|
|
272
|
+
run_end = j
|
|
273
|
+
covered = np.zeros(n, dtype=bool)
|
|
274
|
+
fails: Dict[int, str] = {}
|
|
275
|
+
k = i
|
|
276
|
+
while k < run_end:
|
|
277
|
+
if run_end - k < mn:
|
|
278
|
+
for q in range(k, run_end):
|
|
279
|
+
fails.setdefault(q, f'INSUFFICIENT_DURATION:{run_end - k} samples < min_samples {mn}')
|
|
280
|
+
break
|
|
281
|
+
fail = self._window_check(stream, k, k + mn)
|
|
282
|
+
if fail:
|
|
283
|
+
fails.setdefault(k, fail)
|
|
284
|
+
k += 1
|
|
285
|
+
continue
|
|
286
|
+
e = k + mn
|
|
287
|
+
while e < run_end and self._window_check(stream, k, e + 1) is None:
|
|
288
|
+
e += 1
|
|
289
|
+
st = self._stats(stream, k, e)
|
|
290
|
+
tests.append({'test_id': f'WT{seq:03d}', 'start_utc': stamp(k), 'end_utc': stamp(e - 1), 'n': e - k,
|
|
291
|
+
'status': 'ACCEPTED', 'reason_codes': [], 'statistics': st,
|
|
292
|
+
'virtual_rates': {role: st[f'rate:{role}']['mean'] for role in self.map.rates}})
|
|
293
|
+
seq += 1
|
|
294
|
+
covered[k:e] = True
|
|
295
|
+
k = e
|
|
296
|
+
# rejected candidates: contiguous eligible samples not inside an accepted test
|
|
297
|
+
q = i
|
|
298
|
+
while q < run_end:
|
|
299
|
+
if covered[q]:
|
|
300
|
+
q += 1
|
|
301
|
+
continue
|
|
302
|
+
r = q
|
|
303
|
+
while r < run_end and not covered[r]:
|
|
304
|
+
r += 1
|
|
305
|
+
seg_fails = [fails[x] for x in range(q, r) if x in fails]
|
|
306
|
+
if not seg_fails:
|
|
307
|
+
seg_fails = [f'INSUFFICIENT_DURATION:{r - q} samples < min_samples {mn}']
|
|
308
|
+
tops = [_top(f) for f in seg_fails]
|
|
309
|
+
stab = [t_ for t_ in tops if t_ != 'INSUFFICIENT_DURATION']
|
|
310
|
+
top = max(sorted(set(stab or tops)), key=(stab or tops).count)
|
|
311
|
+
rejected.append({'candidate_id': f'C{seq:03d}', 'start_utc': stamp(q), 'end_utc': stamp(r - 1),
|
|
312
|
+
'n': r - q, 'status': 'REJECTED', 'reason_codes': sorted(set(tops)),
|
|
313
|
+
'primary_reason': top,
|
|
314
|
+
'detail': next(f for f in seg_fails if _top(f) == top)})
|
|
315
|
+
seq += 1
|
|
316
|
+
q = r
|
|
317
|
+
i = run_end
|
|
318
|
+
rejected.sort(key=lambda r: r['start_utc'])
|
|
319
|
+
# merge adjacent rejected candidates with identical reason codes (readability)
|
|
320
|
+
merged: List[dict] = list(rejected)
|
|
321
|
+
return {'stream': stream.name, 'n_samples': n, 'window': [stamp(0), stamp(n - 1)] if n else None,
|
|
322
|
+
'criteria': self.c, 'channel_map': self.map.all_channels(),
|
|
323
|
+
'tests': tests, 'rejected': merged, 'n_accepted': len(tests), 'n_rejected': len(merged),
|
|
324
|
+
'eligible_samples': int(sum(1 for r in elig if not r)),
|
|
325
|
+
'samples_in_accepted_tests': int(sum(x['n'] for x in tests))}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
# Approval trail
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
class ApprovalTrail:
|
|
332
|
+
def __init__(self, record_dir: str, levels: Optional[List[dict]] = None):
|
|
333
|
+
self.dir = str(record_dir)
|
|
334
|
+
os.makedirs(self.dir, exist_ok=True)
|
|
335
|
+
self.path = os.path.join(self.dir, 'approvals.jsonl')
|
|
336
|
+
self.levels = levels or DEFAULT_CRITERIA['approval_levels']
|
|
337
|
+
|
|
338
|
+
def entries(self) -> List[dict]:
|
|
339
|
+
if not os.path.exists(self.path):
|
|
340
|
+
return []
|
|
341
|
+
with open(self.path, encoding='utf-8') as f:
|
|
342
|
+
return [json.loads(l) for l in f if l.strip()]
|
|
343
|
+
|
|
344
|
+
def approve(self, test_id: str, approver: str, level: int, decision: str = 'APPROVED',
|
|
345
|
+
note: str = '', now: Optional[datetime] = None) -> dict:
|
|
346
|
+
if decision not in ('APPROVED', 'REJECTED', 'CORRECTED'):
|
|
347
|
+
raise ValueError('decision must be APPROVED, REJECTED or CORRECTED')
|
|
348
|
+
if level not in {l['level'] for l in self.levels}:
|
|
349
|
+
raise ValueError(f'unknown approval level {level}; levels {[l["level"] for l in self.levels]}')
|
|
350
|
+
e = {'test_id': test_id, 'level': level, 'role': next(l['role'] for l in self.levels if l['level'] == level),
|
|
351
|
+
'approver': approver, 'decision': decision, 'note': note, 'timestamp_utc': _iso(_utc(now))}
|
|
352
|
+
with open(self.path, 'a', encoding='utf-8') as f:
|
|
353
|
+
f.write(json.dumps(e, sort_keys=True) + '\n')
|
|
354
|
+
return e
|
|
355
|
+
|
|
356
|
+
def status_of(self, test_id: str) -> dict:
|
|
357
|
+
es = [e for e in self.entries() if e['test_id'] == test_id]
|
|
358
|
+
by_level = {}
|
|
359
|
+
for e in es:
|
|
360
|
+
by_level[e['level']] = e # latest decision per level wins
|
|
361
|
+
if any(e['decision'] == 'REJECTED' for e in by_level.values()):
|
|
362
|
+
st = 'REJECTED_ON_REVIEW'
|
|
363
|
+
elif all(lv['level'] in by_level and by_level[lv['level']]['decision'] in ('APPROVED', 'CORRECTED') for lv in self.levels):
|
|
364
|
+
st = 'APPROVED_ALL_LEVELS'
|
|
365
|
+
elif by_level:
|
|
366
|
+
st = f'PENDING_LEVEL_{min(lv["level"] for lv in self.levels if lv["level"] not in by_level)}'
|
|
367
|
+
else:
|
|
368
|
+
st = 'PENDING_LEVEL_1'
|
|
369
|
+
return {'test_id': test_id, 'status': st, 'decisions': [by_level[k] for k in sorted(by_level)]}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gea-program
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GEA-Program: downhole gauge monitoring - canonical measurement records, quality rules, drift evaluation with a re-fit change log, well-test validation, alarm management, model cards, store-and-forward, monthly SLA measurement, FAT/SAT protocols and a dashboard, generated from live objects in the client's reporting outline.
|
|
5
|
+
Author: Daniel T. Murphy
|
|
6
|
+
License: MPL-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/Daniel8Murphy0007/GEA-Program
|
|
8
|
+
Project-URL: Source, https://github.com/Daniel8Murphy0007/GEA-Program
|
|
9
|
+
Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: numpy>=1.24
|
|
18
|
+
Provides-Extra: plotting
|
|
19
|
+
Requires-Dist: matplotlib>=3.7; extra == "plotting"
|
|
20
|
+
Provides-Extra: desktop
|
|
21
|
+
Requires-Dist: PyQt6>=6.5; extra == "desktop"
|
|
22
|
+
Requires-Dist: matplotlib>=3.7; extra == "desktop"
|
|
23
|
+
Provides-Extra: modbus
|
|
24
|
+
Requires-Dist: pymodbus>=3.0; extra == "modbus"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# GEA-Program
|
|
28
|
+
|
|
29
|
+
Downhole gauge monitoring for production operations: the program takes a well's
|
|
30
|
+
historian data and returns the reports a client engineer reads, in the outline
|
|
31
|
+
and language of a production-operations scope of work.
|
|
32
|
+
|
|
33
|
+
Every number in every report is recomputed from the source data at generation
|
|
34
|
+
time. Reports are gated for vocabulary: the program's internal register never
|
|
35
|
+
reaches a client document. Nothing unmeasured is ever reported as met.
|
|
36
|
+
|
|
37
|
+
## What it does
|
|
38
|
+
|
|
39
|
+
- **Canonical measurement record** (`gea/sample_record.py`): one record shape for
|
|
40
|
+
every sample - tag, UTC timestamp, value, unit, quality flag, the rule and limit
|
|
41
|
+
that fired, source layer, ingest timestamp. Quality rules: RANGE / ROC /
|
|
42
|
+
FLATLINE / SPIKE / STALE / GAP; ranges from the gauge datasheet, cited.
|
|
43
|
+
- **Gauge drift and reconciliation** (`reconciler.py`, `drift_monitor.py`): the
|
|
44
|
+
live pressure series against the well baseline; classification with the
|
|
45
|
+
numbers beside it; scheduled evaluation log; CURRENT / STALE; re-fit change
|
|
46
|
+
log with before/after coefficients; approve -> apply -> re-evaluate; SLA clocks;
|
|
47
|
+
annual re-fit cap.
|
|
48
|
+
- **Accuracy statement** (`accuracy_statement.py`): MAPE with a seeded bootstrap
|
|
49
|
+
90 % CI; the band read at the conservative end of the interval.
|
|
50
|
+
- **Well-test validation** (`well_test_validation.py`): stable-period detection
|
|
51
|
+
with the client-agreed criteria in a file (hashed on every report), reason
|
|
52
|
+
codes naming the failing value, virtual rates, two-level approval trail.
|
|
53
|
+
- **Alarm management** (`alarm_engine.py`): setpoint / deadband / on-delay state
|
|
54
|
+
machine, event log, ISA-18.2-style KPIs printed against their targets.
|
|
55
|
+
- **Model cards** (`model_card.py`): one card per model - inputs with sources,
|
|
56
|
+
settings with basis, calibration data with provenance, recomputed evaluation,
|
|
57
|
+
limitations, re-fit history, component hashes.
|
|
58
|
+
- **Store-and-forward** (`store_forward.py`): 72 h edge buffer, chronological
|
|
59
|
+
rate-controlled replay, duplicate suppression, per-record latency.
|
|
60
|
+
- **Configuration versioning, SBOM, monthly SLA, FAT/SAT**
|
|
61
|
+
(`config_versioning.py`, `sbom.py`, `sla_report.py`, `fat_sat.py`).
|
|
62
|
+
- **Dashboard** (`dashboard.py`): tiles, well ranking, alarm wall, drill-down to
|
|
63
|
+
every report; light and dark; status is always an icon with a label.
|
|
64
|
+
- **Survey track** (`earth_model.py`, `strata_join.py`, `inverse_engine.py`,
|
|
65
|
+
`blind_harness.py`, `rock_inventory.py`, `forward_model.py`, ...): strata
|
|
66
|
+
property estimation over a public co-located library, blind-scored on every run.
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
pip install -e .
|
|
72
|
+
gea quickstart # a real catalogue well -> its reports -> the dashboard; the KTB strata survey
|
|
73
|
+
gea survey mywell.las # a LAS file in, one strata report out
|
|
74
|
+
gea dashboard --catalog-well volve_f12_f14_production_excerpt:15/9-F-12:10000 --td 10500 --out dashboard
|
|
75
|
+
gea client-report --report accuracy --out client_report
|
|
76
|
+
gea model-cards --out model_cards
|
|
77
|
+
gea sbom --out sbom
|
|
78
|
+
gea accept # the product gate (157 checks)
|
|
79
|
+
gea guide # the click-by-click tester guide (docs/TESTER_GUIDE.md)
|
|
80
|
+
gea gui # the desktop window (pip install "gea-program[desktop]")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`gea` is the front door (`gea/cli.py`); every other subcommand passes through to
|
|
84
|
+
`python -m gea`. PATH-proof form: `python -m gea.cli ...`. Open `dashboard/index.html`.
|
|
85
|
+
|
|
86
|
+
## Layout
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
gea/ the package: engines, record layer, reports, monitor, dashboard, cli, shell, acceptance suite
|
|
90
|
+
gea/catalog/ 52 public archive entries, each with a provenance file
|
|
91
|
+
docs/ TESTER_GUIDE.md, REQUIREMENTS_MATRIX.md (the scope-of-work mirror that shaped the reports),
|
|
92
|
+
commercial/ (pilot proposal, bench readiness, renders), HISTORY.md (development history)
|
|
93
|
+
tools/ import_from_star_magic.py (the importer), register_audit.py, native/ (cli.py, shell.py sources)
|
|
94
|
+
tests/ pytest wrapper around the acceptance suite and the no-corpus check
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Where the code came from
|
|
98
|
+
|
|
99
|
+
The package was imported from the `uqff_downhole_simulator` package of the
|
|
100
|
+
Star-Magic-Program repository by `tools/import_from_star_magic.py`, which renames
|
|
101
|
+
the modules, removes every dependency on that repository's physics corpus, puts
|
|
102
|
+
the gravity kernel on standard constants (CODATA 2018 G, standard gravity, IUGG
|
|
103
|
+
mean radius), keeps the rock inventory's seventeen published density anchors and
|
|
104
|
+
Vp ranges without their decompositions, and drops the two modules that existed
|
|
105
|
+
only to compose numbers from that corpus. `gea/IMPORT_RECORD.md` lists the
|
|
106
|
+
source commit and every file's hash. `tools/register_audit.py` lists what
|
|
107
|
+
remains of the source program's vocabulary in comments and docstrings.
|
|
108
|
+
|
|
109
|
+
Two statements the product carries on its own model cards: the gauge aging
|
|
110
|
+
envelope's lower bound is an engineering model with no field validation on
|
|
111
|
+
record, and five of the fourteen back-tested strata quantities are NOT
|
|
112
|
+
ACCEPTABLE at the 95 % target. Both are printed, never claimed otherwise.
|
|
113
|
+
|
|
114
|
+
## Shipping
|
|
115
|
+
|
|
116
|
+
`.\ship.ps1` (PowerShell) gates, commits, tags and pushes in one screen: version in
|
|
117
|
+
`pyproject.toml` must equal `gea.__version__` (`-Bump x.y.z` sets both), the tag must
|
|
118
|
+
not exist anywhere, every version in `SHIP_LOG.md` must have its tag, `python -m gea
|
|
119
|
+
accept` must be green, `SHIP_MESSAGE.txt` must start with the tag; then commit, tag,
|
|
120
|
+
push, and the remote tag must be seen before SHIPPED is printed. `-DryRun` runs every
|
|
121
|
+
check and changes nothing; `-NoPush` stops after the local tag.
|
|
122
|
+
|
|
123
|
+
## Publishing to PyPI
|
|
124
|
+
|
|
125
|
+
The name `gea-program` is free on PyPI as of 2026-09-29; a PyPI project is created by
|
|
126
|
+
its first upload, there is nothing to "start" beforehand except the trusted publisher.
|
|
127
|
+
One-time setup, before the first tag is pushed: sign in to PyPI -> your account ->
|
|
128
|
+
Publishing -> "Add a new pending publisher": project name `gea-program`, owner
|
|
129
|
+
`Daniel8Murphy0007`, repository `GEA-Program`, workflow `release-to-pypi.yml`,
|
|
130
|
+
environment `pypi`. Then in GitHub -> Settings -> Environments create `pypi`. From then
|
|
131
|
+
on `.\ship.ps1` pushes the tag and `.github/workflows/release-to-pypi.yml` gates, builds,
|
|
132
|
+
verifies the wheel and publishes; the package page is
|
|
133
|
+
https://pypi.org/project/gea-program/ after the first successful run.
|
|
134
|
+
|
|
135
|
+
## Licence
|
|
136
|
+
|
|
137
|
+
Mozilla Public License 2.0 (MPL-2.0); see `LICENSE`. Every source file carries the MPL-2.0
|
|
138
|
+
header (Exhibit A). Copyright (c) 2026 Daniel T. Murphy.
|