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
@@ -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.