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/segy.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
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
|
+
"""segy - Part 6 of the subsurface surveying tool: SEISMIC INGEST
|
|
5
|
+
(v1.83.0). SEG-Y is the industry's seismic interchange format; this reader
|
|
6
|
+
brings the first WAVEFIELD modality into the read-only ingestion family.
|
|
7
|
+
|
|
8
|
+
STATUS, honestly: READER_VALIDATED_BY_ROUND_TRIP; AWAITING_FIELD_SEGY.
|
|
9
|
+
No field SEG-Y exists in the catalogue yet, so validation is by exact
|
|
10
|
+
round-trip against a minimal spec-conformant writer (used ONLY for
|
|
11
|
+
validation - the writer never touches archives). The day a licensed field
|
|
12
|
+
volume lands, this reader is its door; until then no seismic claim is made.
|
|
13
|
+
|
|
14
|
+
SCOPE (SEG-Y rev 1, the working subset):
|
|
15
|
+
- 3200-byte textual header (EBCDIC auto-detected and transcoded, or ASCII)
|
|
16
|
+
- 400-byte binary file header: sample interval (bytes 3217-3218),
|
|
17
|
+
samples/trace (3221-3222), data format code (3225-3226)
|
|
18
|
+
- traces: 240-byte trace headers (inline/crossline at the rev1 standard
|
|
19
|
+
byte positions 189/193, source coords at 73/77 with the scalar at 71)
|
|
20
|
+
+ samples in format 5 (IEEE float32 big-endian) or format 1 (IBM
|
|
21
|
+
float32, converted exactly)
|
|
22
|
+
- anything else REFUSES with the format code named - no guessing
|
|
23
|
+
|
|
24
|
+
READ-ONLY doctrine unchanged: ingest never writes, never modifies, never
|
|
25
|
+
'fixes' a volume. Byte-swapped or nonconforming files refuse visibly.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import struct
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Dict, List
|
|
33
|
+
|
|
34
|
+
SUPPORTED_FORMATS = {1: 'IBM float32', 5: 'IEEE float32'}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ibm_to_float(b: bytes) -> float:
|
|
38
|
+
"""Exact IBM System/360 hexadecimal float -> Python float."""
|
|
39
|
+
(u,) = struct.unpack('>I', b)
|
|
40
|
+
if u == 0:
|
|
41
|
+
return 0.0
|
|
42
|
+
sign = -1.0 if u >> 31 else 1.0
|
|
43
|
+
exponent = ((u >> 24) & 0x7F) - 64
|
|
44
|
+
mantissa = (u & 0x00FFFFFF) / float(0x1000000)
|
|
45
|
+
return sign * mantissa * (16.0 ** exponent)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _float_to_ibm(x: float) -> bytes:
|
|
49
|
+
"""Exact inverse for the round-trip validator (writer is test-only)."""
|
|
50
|
+
if x == 0.0:
|
|
51
|
+
return b'\x00\x00\x00\x00'
|
|
52
|
+
sign = 0x80000000 if x < 0 else 0
|
|
53
|
+
x = abs(x)
|
|
54
|
+
exponent = 64
|
|
55
|
+
while x >= 1.0:
|
|
56
|
+
x /= 16.0
|
|
57
|
+
exponent += 1
|
|
58
|
+
while x < 0.0625 and exponent > 0:
|
|
59
|
+
x *= 16.0
|
|
60
|
+
exponent -= 1
|
|
61
|
+
mantissa = int(x * 0x1000000) & 0x00FFFFFF
|
|
62
|
+
return struct.pack('>I', sign | (exponent << 24) | mantissa)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class SegyTrace:
|
|
67
|
+
inline: int
|
|
68
|
+
crossline: int
|
|
69
|
+
source_x: float
|
|
70
|
+
source_y: float
|
|
71
|
+
samples: List[float] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class SegyVolume:
|
|
76
|
+
textual_header: str
|
|
77
|
+
encoding: str # 'EBCDIC' | 'ASCII'
|
|
78
|
+
sample_interval_us: int
|
|
79
|
+
n_samples: int
|
|
80
|
+
format_code: int
|
|
81
|
+
format_name: str
|
|
82
|
+
traces: List[SegyTrace] = field(default_factory=list)
|
|
83
|
+
status: str = 'READER_VALIDATED_BY_ROUND_TRIP; AWAITING_FIELD_SEGY'
|
|
84
|
+
|
|
85
|
+
def summary(self) -> Dict:
|
|
86
|
+
return {'n_traces': len(self.traces),
|
|
87
|
+
'n_samples': self.n_samples,
|
|
88
|
+
'sample_interval_us': self.sample_interval_us,
|
|
89
|
+
'trace_length_ms': self.n_samples * self.sample_interval_us / 1000.0,
|
|
90
|
+
'format': self.format_name, 'encoding': self.encoding,
|
|
91
|
+
'status': self.status}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read_segy(path: str) -> SegyVolume:
|
|
95
|
+
"""Read a SEG-Y rev1 file (see module scope). Refuses unsupported
|
|
96
|
+
format codes and truncated files by name, never by guess."""
|
|
97
|
+
with open(path, 'rb') as f:
|
|
98
|
+
raw_text = f.read(3200)
|
|
99
|
+
if len(raw_text) < 3200:
|
|
100
|
+
raise ValueError('SEG-Y refused: file shorter than the 3200-byte '
|
|
101
|
+
'textual header')
|
|
102
|
+
if raw_text[:1] == b'C':
|
|
103
|
+
text, enc = raw_text.decode('ascii', 'replace'), 'ASCII'
|
|
104
|
+
else:
|
|
105
|
+
text, enc = raw_text.decode('cp037', 'replace'), 'EBCDIC'
|
|
106
|
+
bin_hdr = f.read(400)
|
|
107
|
+
if len(bin_hdr) < 400:
|
|
108
|
+
raise ValueError('SEG-Y refused: truncated binary file header')
|
|
109
|
+
(interval,) = struct.unpack('>H', bin_hdr[16:18])
|
|
110
|
+
(n_samples,) = struct.unpack('>H', bin_hdr[20:22])
|
|
111
|
+
(fmt,) = struct.unpack('>H', bin_hdr[24:26])
|
|
112
|
+
if fmt not in SUPPORTED_FORMATS:
|
|
113
|
+
raise ValueError('SEG-Y refused: data format code %d is outside '
|
|
114
|
+
'the supported subset %s - no guessing'
|
|
115
|
+
% (fmt, sorted(SUPPORTED_FORMATS)))
|
|
116
|
+
if n_samples == 0:
|
|
117
|
+
raise ValueError('SEG-Y refused: binary header declares zero '
|
|
118
|
+
'samples per trace')
|
|
119
|
+
vol = SegyVolume(textual_header=text, encoding=enc,
|
|
120
|
+
sample_interval_us=interval, n_samples=n_samples,
|
|
121
|
+
format_code=fmt, format_name=SUPPORTED_FORMATS[fmt])
|
|
122
|
+
trace_bytes = n_samples * 4
|
|
123
|
+
while True:
|
|
124
|
+
th = f.read(240)
|
|
125
|
+
if not th:
|
|
126
|
+
break
|
|
127
|
+
if len(th) < 240:
|
|
128
|
+
raise ValueError('SEG-Y refused: truncated trace header at '
|
|
129
|
+
'trace %d' % (len(vol.traces) + 1))
|
|
130
|
+
data = f.read(trace_bytes)
|
|
131
|
+
if len(data) < trace_bytes:
|
|
132
|
+
raise ValueError('SEG-Y refused: truncated samples at trace '
|
|
133
|
+
'%d' % (len(vol.traces) + 1))
|
|
134
|
+
(scal,) = struct.unpack('>h', th[70:72])
|
|
135
|
+
(sx,) = struct.unpack('>i', th[72:76])
|
|
136
|
+
(sy,) = struct.unpack('>i', th[76:80])
|
|
137
|
+
(il,) = struct.unpack('>i', th[188:192])
|
|
138
|
+
(xl,) = struct.unpack('>i', th[192:196])
|
|
139
|
+
k = (abs(scal) if scal else 1)
|
|
140
|
+
factor = (1.0 / k) if scal < 0 else float(k)
|
|
141
|
+
if fmt == 5:
|
|
142
|
+
samples = list(struct.unpack('>%df' % n_samples, data))
|
|
143
|
+
else:
|
|
144
|
+
samples = [_ibm_to_float(data[i:i + 4])
|
|
145
|
+
for i in range(0, trace_bytes, 4)]
|
|
146
|
+
vol.traces.append(SegyTrace(
|
|
147
|
+
inline=il, crossline=xl,
|
|
148
|
+
source_x=sx * factor, source_y=sy * factor,
|
|
149
|
+
samples=samples))
|
|
150
|
+
return vol
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def write_segy_minimal(path: str, traces: List[List[float]],
|
|
154
|
+
sample_interval_us: int = 2000,
|
|
155
|
+
fmt: int = 5) -> None:
|
|
156
|
+
"""TEST-ONLY minimal spec-conformant writer: exists solely so the reader
|
|
157
|
+
can be validated by exact round-trip in the absence of licensed field
|
|
158
|
+
data. Never used on archives; never part of ingestion."""
|
|
159
|
+
n_samples = len(traces[0])
|
|
160
|
+
with open(path, 'wb') as f:
|
|
161
|
+
header_line = ('C 1 segy round-trip validation volume - '
|
|
162
|
+
'TEST ONLY, not field data').ljust(80)
|
|
163
|
+
f.write((header_line + ' ' * (3200 - 80)).encode('ascii')[:3200])
|
|
164
|
+
bin_hdr = bytearray(400)
|
|
165
|
+
bin_hdr[16:18] = struct.pack('>H', sample_interval_us)
|
|
166
|
+
bin_hdr[20:22] = struct.pack('>H', n_samples)
|
|
167
|
+
bin_hdr[24:26] = struct.pack('>H', fmt)
|
|
168
|
+
f.write(bytes(bin_hdr))
|
|
169
|
+
for t_i, samples in enumerate(traces):
|
|
170
|
+
th = bytearray(240)
|
|
171
|
+
th[70:72] = struct.pack('>h', 1)
|
|
172
|
+
th[72:76] = struct.pack('>i', 1000 + t_i)
|
|
173
|
+
th[76:80] = struct.pack('>i', 2000 + t_i)
|
|
174
|
+
th[188:192] = struct.pack('>i', 10 + t_i)
|
|
175
|
+
th[192:196] = struct.pack('>i', 20 + t_i)
|
|
176
|
+
f.write(bytes(th))
|
|
177
|
+
if fmt == 5:
|
|
178
|
+
f.write(struct.pack('>%df' % n_samples, *samples))
|
|
179
|
+
else:
|
|
180
|
+
for s in samples:
|
|
181
|
+
f.write(_float_to_ibm(s))
|
gea/service_life.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
"""service_life — long-horizon drift accumulation (v1.2.0 extension).
|
|
5
|
+
|
|
6
|
+
The v1.0/1.1 layers report drift as an instantaneous RATE (%FS/yr). This module
|
|
7
|
+
integrates that rate over months/years of simulated service, twin-leg at every
|
|
8
|
+
station (GEA-stabilized vs conventional), producing the DIVERGENCE CURVES a
|
|
9
|
+
real bench test or field trial would record: two error traces separating at
|
|
10
|
+
exactly the rate the canonical suppression predicts. Adds optional periodic
|
|
11
|
+
recalibration resets (workover/recal events) and a small random-walk component
|
|
12
|
+
for realism (defaults chosen so the deterministic rate dominates).
|
|
13
|
+
|
|
14
|
+
This is the full simulation instrument for the PAPER_2250 LABORATORY-tier
|
|
15
|
+
quartz bench test (PAPER_2256 sec 5): the separation you'd measure, not just
|
|
16
|
+
the ratio you'd predict.
|
|
17
|
+
|
|
18
|
+
Headless-safe: numpy only, no display imports.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import csv
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import List, Optional
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
from .downhole_engine import DownholeEngine, SimulatorConfig
|
|
31
|
+
from .quartz_hpht_extension import (
|
|
32
|
+
calculate_quartz_transducer_hpht_program,
|
|
33
|
+
canonical_suppression,
|
|
34
|
+
conventional_drift,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ServiceLifeConfig:
|
|
40
|
+
years: float = 5.0 # simulated service horizon
|
|
41
|
+
dt_days: float = 7.0 # accumulation step (one reading per week class)
|
|
42
|
+
full_scale_psi: float = 30000.0 # anchor: HPHT quartz-gauge full-scale class (industry)
|
|
43
|
+
error_budget_pct_fs: float = 0.5 # anchor: typical permanent-gauge total-error spec budget
|
|
44
|
+
recalibration_interval_years: Optional[float] = None # None = never recalibrated (permanent install)
|
|
45
|
+
random_walk_pct_fs_per_sqrt_yr: float = 0.002 # small stochastic component (realism; 0 = deterministic)
|
|
46
|
+
seed: Optional[int] = None # rng seed (deterministic runs for tests)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ServiceLifeSimulator:
|
|
50
|
+
"""Accumulates twin-leg gauge error over simulated service years.
|
|
51
|
+
|
|
52
|
+
Rates are evaluated once per sensor at the engine's base station T/P
|
|
53
|
+
(permanent-install conditions); both legs share the same industry baseline
|
|
54
|
+
and stress dressing, differing ONLY by the canonical suppression — so the
|
|
55
|
+
accumulated-error ratio converges to the suppression composition and the
|
|
56
|
+
separation grows linearly at the predicted rate.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, engine: DownholeEngine | None = None,
|
|
60
|
+
config: ServiceLifeConfig | None = None):
|
|
61
|
+
self.engine = engine or DownholeEngine(SimulatorConfig())
|
|
62
|
+
self.cfg = config or ServiceLifeConfig()
|
|
63
|
+
spec = getattr(self.engine.cfg, 'gauge_spec', None) # v1.5.0: engine's datasheet spec
|
|
64
|
+
if spec is not None:
|
|
65
|
+
from dataclasses import replace as _replace
|
|
66
|
+
self.cfg = _replace(self.cfg, full_scale_psi=float(spec.full_scale_psi))
|
|
67
|
+
n = len(self.engine.sensors)
|
|
68
|
+
# Per-sensor rates (%FS/yr) at base station conditions
|
|
69
|
+
self.rate = np.zeros(n)
|
|
70
|
+
self.conv_rate = np.zeros(n)
|
|
71
|
+
for i, s in enumerate(self.engine.sensors):
|
|
72
|
+
temp_C = (float(self.engine.base_T[i]) - 32.0) * 5.0 / 9.0
|
|
73
|
+
p_psi = float(self.engine.base_P[i])
|
|
74
|
+
r = calculate_quartz_transducer_hpht_program(
|
|
75
|
+
depth_m=s.depth_ft / 3.28084, temp_c=temp_C, pressure_psi=p_psi,
|
|
76
|
+
k_structural_trim=s.k_structural_trim,
|
|
77
|
+
phi_coupling_trim=s.phi_coupling_trim,
|
|
78
|
+
spec=spec)
|
|
79
|
+
self.rate[i] = r["value"]["drift_pct"]
|
|
80
|
+
self.conv_rate[i] = conventional_drift(temp_C, p_psi, spec=spec)
|
|
81
|
+
self._reset_history()
|
|
82
|
+
|
|
83
|
+
def _reset_history(self) -> None:
|
|
84
|
+
n = len(self.engine.sensors)
|
|
85
|
+
self.time_years: List[float] = [0.0]
|
|
86
|
+
self.err: List[np.ndarray] = [np.zeros(n)] # accumulated error, %FS
|
|
87
|
+
self.conv_err: List[np.ndarray] = [np.zeros(n)]
|
|
88
|
+
self.recal_times: List[float] = []
|
|
89
|
+
|
|
90
|
+
# -- run ------------------------------------------------------------------
|
|
91
|
+
def run(self) -> "ServiceLifeSimulator":
|
|
92
|
+
"""Integrate accumulated error over the configured horizon."""
|
|
93
|
+
self._reset_history()
|
|
94
|
+
n = len(self.engine.sensors)
|
|
95
|
+
dt = self.cfg.dt_days / 365.25
|
|
96
|
+
rng = np.random.default_rng(self.cfg.seed)
|
|
97
|
+
rw = self.cfg.random_walk_pct_fs_per_sqrt_yr
|
|
98
|
+
t = 0.0
|
|
99
|
+
uq = np.zeros(n)
|
|
100
|
+
cv = np.zeros(n)
|
|
101
|
+
since_recal = 0.0
|
|
102
|
+
while t < self.cfg.years - 1e-12:
|
|
103
|
+
t += dt
|
|
104
|
+
since_recal += dt
|
|
105
|
+
uq = uq + self.rate * dt + rng.normal(0.0, rw * np.sqrt(dt), n)
|
|
106
|
+
cv = cv + self.conv_rate * dt + rng.normal(0.0, rw * np.sqrt(dt), n)
|
|
107
|
+
if (self.cfg.recalibration_interval_years is not None
|
|
108
|
+
and since_recal >= self.cfg.recalibration_interval_years - 1e-12):
|
|
109
|
+
uq = np.zeros(n) # recal zeroes both legs
|
|
110
|
+
cv = np.zeros(n)
|
|
111
|
+
since_recal = 0.0
|
|
112
|
+
self.recal_times.append(round(t, 6))
|
|
113
|
+
self.time_years.append(round(t, 6))
|
|
114
|
+
self.err.append(uq.copy())
|
|
115
|
+
self.conv_err.append(cv.copy())
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
# -- reporting --------------------------------------------------------------
|
|
119
|
+
def divergence_summary(self) -> dict:
|
|
120
|
+
"""The bench-test report: predicted vs measured separation, ratio check,
|
|
121
|
+
and the service-life arithmetic (time to error budget, extra years)."""
|
|
122
|
+
fs = self.cfg.full_scale_psi
|
|
123
|
+
budget = self.cfg.error_budget_pct_fs
|
|
124
|
+
sep_rate = self.conv_rate - self.rate # %FS/yr
|
|
125
|
+
uq_final = self.err[-1]
|
|
126
|
+
cv_final = self.conv_err[-1]
|
|
127
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
128
|
+
ratio_final = np.where(uq_final > 0, cv_final / uq_final, np.nan)
|
|
129
|
+
t_budget_program = budget / self.rate # yr to spec budget
|
|
130
|
+
t_budget_conv = budget / self.conv_rate
|
|
131
|
+
return {
|
|
132
|
+
'horizon_years': self.cfg.years,
|
|
133
|
+
'full_scale_psi': fs,
|
|
134
|
+
'rate_pct_fs_yr': [round(float(r), 4) for r in self.rate],
|
|
135
|
+
'conv_rate_pct_fs_yr': [round(float(r), 4) for r in self.conv_rate],
|
|
136
|
+
'predicted_separation_rate_pct_fs_yr': [round(float(r), 5) for r in sep_rate],
|
|
137
|
+
'predicted_separation_rate_psi_yr': [round(float(r) * fs / 100.0, 2) for r in sep_rate],
|
|
138
|
+
'final_separation_psi': [round(float(cv_final[i] - uq_final[i]) * fs / 100.0, 2)
|
|
139
|
+
for i in range(len(sep_rate))],
|
|
140
|
+
'measured_ratio_final': [round(float(r), 4) for r in ratio_final],
|
|
141
|
+
'predicted_ratio_suppression': round(canonical_suppression(
|
|
142
|
+
self.engine.cfg.global_k_structural_trim,
|
|
143
|
+
self.engine.cfg.global_phi_coupling_trim), 4),
|
|
144
|
+
'error_budget_pct_fs': budget,
|
|
145
|
+
'years_to_budget_program': [round(float(t), 2) for t in t_budget_program],
|
|
146
|
+
'years_to_budget_conventional': [round(float(t), 2) for t in t_budget_conv],
|
|
147
|
+
'extra_service_life_years': [round(float(t_budget_program[i] - t_budget_conv[i]), 2)
|
|
148
|
+
for i in range(len(sep_rate))],
|
|
149
|
+
'recalibrations': self.recal_times,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
def export_csv(self, path: str | None = None) -> Path:
|
|
153
|
+
"""Divergence curves: per-sensor accumulated error (psi) for both legs
|
|
154
|
+
plus the separation, one row per accumulation step."""
|
|
155
|
+
if path is None:
|
|
156
|
+
path = "service_life.csv"
|
|
157
|
+
p = Path(path)
|
|
158
|
+
fs = self.cfg.full_scale_psi
|
|
159
|
+
names = [s.name for s in self.engine.sensors]
|
|
160
|
+
with p.open("w", newline="") as f:
|
|
161
|
+
w = csv.writer(f)
|
|
162
|
+
header = ["time_years"]
|
|
163
|
+
for nm in names:
|
|
164
|
+
header += [f"err_psi_{nm}", f"conv_err_psi_{nm}", f"separation_psi_{nm}"]
|
|
165
|
+
w.writerow(header)
|
|
166
|
+
for i, t in enumerate(self.time_years):
|
|
167
|
+
row = [round(t, 4)]
|
|
168
|
+
for j in range(len(names)):
|
|
169
|
+
uq = float(self.err[i][j]) * fs / 100.0
|
|
170
|
+
cv = float(self.conv_err[i][j]) * fs / 100.0
|
|
171
|
+
row += [round(uq, 3), round(cv, 3), round(cv - uq, 3)]
|
|
172
|
+
w.writerow(row)
|
|
173
|
+
return p
|
gea/shell.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
"""gea.shell - the desktop window (desktop extra): Wells | Reports | Geology.
|
|
5
|
+
|
|
6
|
+
Everything the window shows is the same machinery the command line uses;
|
|
7
|
+
the window never computes anything of its own. Wells browses the catalogue
|
|
8
|
+
with each entry's provenance; Reports runs the report family for a selected
|
|
9
|
+
well into a folder and opens the dashboard; Geology is the operator surface
|
|
10
|
+
from the survey track when its renderers are installed.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import webbrowser
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _require_qt() -> bool:
|
|
20
|
+
try:
|
|
21
|
+
from PyQt6 import QtWidgets # noqa: F401
|
|
22
|
+
return True
|
|
23
|
+
except ImportError:
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main() -> int:
|
|
28
|
+
if not _require_qt():
|
|
29
|
+
print('gea gui needs PyQt6: pip install "gea-program[desktop]"')
|
|
30
|
+
return 3
|
|
31
|
+
from PyQt6.QtWidgets import (QApplication, QMainWindow, QTabWidget, QWidget, QVBoxLayout, QHBoxLayout,
|
|
32
|
+
QListWidget, QTextEdit, QPushButton, QLabel, QFileDialog, QLineEdit)
|
|
33
|
+
from . import __version__
|
|
34
|
+
from .profile_catalog import CATALOG
|
|
35
|
+
|
|
36
|
+
app = QApplication(sys.argv)
|
|
37
|
+
win = QMainWindow()
|
|
38
|
+
win.setWindowTitle(f'GEA {__version__} - downhole gauge monitoring')
|
|
39
|
+
tabs = QTabWidget()
|
|
40
|
+
|
|
41
|
+
# ---- Wells: the catalogue with provenance ----
|
|
42
|
+
wells = QWidget(); wl = QHBoxLayout(wells)
|
|
43
|
+
wlist = QListWidget(); wlist.addItems(sorted(CATALOG))
|
|
44
|
+
wdetail = QTextEdit(); wdetail.setReadOnly(True)
|
|
45
|
+
|
|
46
|
+
def show_well(item):
|
|
47
|
+
e = CATALOG[item.text()]
|
|
48
|
+
wdetail.setPlainText('\n'.join(f'{k}: {v}' for k, v in sorted(e.provenance.items())))
|
|
49
|
+
wlist.itemClicked.connect(show_well)
|
|
50
|
+
wl.addWidget(wlist, 1); wl.addWidget(wdetail, 2)
|
|
51
|
+
tabs.addTab(wells, 'Wells')
|
|
52
|
+
|
|
53
|
+
# ---- Reports: run the family for a well and open the dashboard ----
|
|
54
|
+
rep = QWidget(); rl = QVBoxLayout(rep)
|
|
55
|
+
info = QLabel('Production wells in the catalogue carry downhole pressure; give the well tag and the gauge station depth (ft).')
|
|
56
|
+
entry = QLineEdit('volve_f12_f14_production_excerpt'); entry.setPlaceholderText('catalogue entry')
|
|
57
|
+
tag = QLineEdit('15/9-F-12'); tag.setPlaceholderText('well tag inside the entry')
|
|
58
|
+
md = QLineEdit('10000'); md.setPlaceholderText('station MD, ft (from completion records)')
|
|
59
|
+
td = QLineEdit('10500'); td.setPlaceholderText('total depth, ft')
|
|
60
|
+
hist = QLineEdit(); hist.setPlaceholderText('optional: historian CSV for alarms and resilience')
|
|
61
|
+
pick = QPushButton('Choose historian CSV...')
|
|
62
|
+
run = QPushButton('Generate reports and open the dashboard')
|
|
63
|
+
out = QTextEdit(); out.setReadOnly(True)
|
|
64
|
+
|
|
65
|
+
def choose():
|
|
66
|
+
p, _ = QFileDialog.getOpenFileName(win, 'Historian CSV', '', 'CSV (*.csv)')
|
|
67
|
+
if p:
|
|
68
|
+
hist.setText(p)
|
|
69
|
+
|
|
70
|
+
def do_run():
|
|
71
|
+
from .__main__ import main as sub
|
|
72
|
+
folder = QFileDialog.getExistingDirectory(win, 'Output folder for the reports')
|
|
73
|
+
if not folder:
|
|
74
|
+
return
|
|
75
|
+
args = ['dashboard', '--catalog-well', f'{entry.text()}:{tag.text()}:{md.text()}', '--td', td.text(),
|
|
76
|
+
'--name', tag.text(), '--out', folder]
|
|
77
|
+
if hist.text():
|
|
78
|
+
args += ['--file', hist.text()]
|
|
79
|
+
out.setPlainText('running: gea ' + ' '.join(args)); app.processEvents()
|
|
80
|
+
import io, contextlib
|
|
81
|
+
buf = io.StringIO()
|
|
82
|
+
with contextlib.redirect_stdout(buf):
|
|
83
|
+
rc = sub(args)
|
|
84
|
+
out.setPlainText(buf.getvalue() + f'\nexit {rc}')
|
|
85
|
+
idx = os.path.join(folder, 'index.html')
|
|
86
|
+
if rc == 0 and os.path.exists(idx):
|
|
87
|
+
webbrowser.open('file://' + os.path.abspath(idx))
|
|
88
|
+
pick.clicked.connect(choose); run.clicked.connect(do_run)
|
|
89
|
+
for w in (info, entry, tag, md, td, hist, pick, run, out):
|
|
90
|
+
rl.addWidget(w)
|
|
91
|
+
tabs.addTab(rep, 'Reports')
|
|
92
|
+
|
|
93
|
+
# ---- Geology: the survey-track operator surface, when its renderers exist ----
|
|
94
|
+
try:
|
|
95
|
+
from .project import build_geology_tab
|
|
96
|
+
tabs.addTab(build_geology_tab(), 'Geology')
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
win.setCentralWidget(tabs)
|
|
101
|
+
win.resize(1100, 700)
|
|
102
|
+
win.show()
|
|
103
|
+
return app.exec()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == '__main__':
|
|
107
|
+
raise SystemExit(main())
|
gea/sla_report.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
"""sla_report — the monthly SLA measurement from the program's own records
|
|
5
|
+
(SLA 2.0 measurement; 5.0 post-implementation; drift and staleness SLA;
|
|
6
|
+
1.0 latency definitions; SOW 4.2.26 re-fit cap).
|
|
7
|
+
|
|
8
|
+
Inputs are the record files the other components already write; nothing is
|
|
9
|
+
typed in:
|
|
10
|
+
drift monitor log dir evaluations.jsonl, change_log.jsonl
|
|
11
|
+
store-and-forward result the Data Resilience machine JSON (+ records CSV)
|
|
12
|
+
alarm event log JSON lines from the alarm engine
|
|
13
|
+
well-test record dir approvals.jsonl
|
|
14
|
+
accuracy statement JSON the Accuracy Statement machine JSON
|
|
15
|
+
config store versions and rollbacks in the window
|
|
16
|
+
|
|
17
|
+
Every SLA line is measured / target / status; a line whose input is absent
|
|
18
|
+
is NOT MEASURED, never assumed met. Business-day arithmetic matches the
|
|
19
|
+
drift monitor's.
|
|
20
|
+
|
|
21
|
+
Headless-safe: numpy only.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import calendar
|
|
27
|
+
import csv
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
from datetime import datetime, timedelta, timezone
|
|
31
|
+
from typing import Dict, List, Optional
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
|
|
35
|
+
from .sample_record import parse_utc
|
|
36
|
+
from .drift_monitor import business_days_after
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def month_window(month: str) -> tuple:
|
|
40
|
+
y, m = int(month[:4]), int(month[5:7])
|
|
41
|
+
start = datetime(y, m, 1, tzinfo=timezone.utc)
|
|
42
|
+
end = datetime(y, m, calendar.monthrange(y, m)[1], 23, 59, 59, tzinfo=timezone.utc)
|
|
43
|
+
return start, end
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _lines(path: Optional[str]) -> List[dict]:
|
|
47
|
+
if not path or not os.path.exists(path):
|
|
48
|
+
return []
|
|
49
|
+
with open(path, encoding='utf-8') as f:
|
|
50
|
+
return [json.loads(l) for l in f if l.strip()]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _in(ts: str, a: datetime, b: datetime) -> bool:
|
|
54
|
+
try:
|
|
55
|
+
t = parse_utc(ts)
|
|
56
|
+
except Exception:
|
|
57
|
+
return False
|
|
58
|
+
return a <= t <= b
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _bd_between(a: str, b: str) -> int:
|
|
62
|
+
ta, tb = parse_utc(a), parse_utc(b)
|
|
63
|
+
n, d = 0, ta
|
|
64
|
+
while d.date() < tb.date():
|
|
65
|
+
d += timedelta(days=1)
|
|
66
|
+
if d.weekday() < 5:
|
|
67
|
+
n += 1
|
|
68
|
+
return n
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def measure(month: str, monitor_log_dir: Optional[str] = None, store_forward_json: Optional[str] = None,
|
|
72
|
+
alarm_log: Optional[str] = None, well_test_dir: Optional[str] = None, accuracy_json: Optional[str] = None,
|
|
73
|
+
config_dir: Optional[str] = None, refit_cap_per_year: int = 4) -> dict:
|
|
74
|
+
a, b = month_window(month)
|
|
75
|
+
days = (b - a).days + 1
|
|
76
|
+
lines: List[dict] = []
|
|
77
|
+
|
|
78
|
+
def line(area, metric, measured, target, status, note=''):
|
|
79
|
+
lines.append({'area': area, 'metric': metric, 'measured': measured, 'target': target, 'status': status, 'note': note})
|
|
80
|
+
|
|
81
|
+
# --- drift and staleness SLA ------------------------------------------------------
|
|
82
|
+
evs = [e for e in _lines(os.path.join(monitor_log_dir, 'evaluations.jsonl')) if _in(e['timestamp_utc'], a, b)] if monitor_log_dir else []
|
|
83
|
+
chg = [e for e in _lines(os.path.join(monitor_log_dir, 'change_log.jsonl'))] if monitor_log_dir else []
|
|
84
|
+
if monitor_log_dir and (evs or chg):
|
|
85
|
+
eval_days = {parse_utc(e['timestamp_utc']).date() for e in evs}
|
|
86
|
+
first_eval = min((parse_utc(e['timestamp_utc']) for e in evs), default=None)
|
|
87
|
+
# adherence counted from the first evaluation in the month (a site commissioned mid-month is not penalised for the days before)
|
|
88
|
+
days_due = (b.date() - first_eval.date()).days + 1 if first_eval else days
|
|
89
|
+
adherence = round(100.0 * len(eval_days) / days_due, 1) if days_due else 0.0
|
|
90
|
+
line('Model drift', 'Drift evaluation cadence (days with an evaluation / days due)', f'{len(eval_days)} / {days_due} ({adherence} %)',
|
|
91
|
+
'>= 1 evaluation every 24 h', 'MET' if adherence >= 100.0 else 'NOT MET')
|
|
92
|
+
detections = sorted({e['evaluation_id'] for e in evs if e.get('drift_detected')})
|
|
93
|
+
line('Model drift', 'Evaluations with drift detected', f'{len(detections)} of {len({e["evaluation_id"] for e in evs})}', 'reported', 'REPORTED')
|
|
94
|
+
proposals = [c for c in chg if c.get('status') == 'PROPOSED' and _in(c['timestamp_utc'], a, b)]
|
|
95
|
+
decided = [c for c in chg if c.get('status') in ('APPLIED', 'REJECTED', 'BLOCKED_ANNUAL_LIMIT') and c.get('decided_utc') and _in(c['decided_utc'], a, b)]
|
|
96
|
+
refits = [c for c in decided if c.get('type') == 'REFIT_OFFSET' and c['status'] == 'APPLIED']
|
|
97
|
+
late = [c for c in refits if _bd_between(c['timestamp_utc'], c['decided_utc']) > 10]
|
|
98
|
+
line('Model drift', 'Re-fit / redeploy within 10 business days of detection', f'{len(refits) - len(late)} of {len(refits)} within',
|
|
99
|
+
'<= 10 business days', ('MET' if not late else 'NOT MET') if refits else 'NOT MEASURED', 'no re-fit in the month' if not refits else '')
|
|
100
|
+
fall = [c for c in chg if c.get('type') == 'FALLBACK' and c.get('decided_utc') and _in(c['decided_utc'], a, b)]
|
|
101
|
+
late_f = [c for c in fall if _bd_between(c['timestamp_utc'], c['decided_utc']) > 2]
|
|
102
|
+
line('Model drift', 'Fallback decided within 2 business days', f'{len(fall) - len(late_f)} of {len(fall)} within', '<= 2 business days',
|
|
103
|
+
('MET' if not late_f else 'NOT MET') if fall else 'NOT MEASURED', 'no fallback in the month' if not fall else '')
|
|
104
|
+
year_refits: Dict[str, int] = {}
|
|
105
|
+
for c in chg:
|
|
106
|
+
if c.get('type') == 'REFIT_OFFSET' and c.get('status') == 'APPLIED' and c.get('decided_utc') and parse_utc(c['decided_utc']).year == a.year:
|
|
107
|
+
year_refits[c['station']] = year_refits.get(c['station'], 0) + 1
|
|
108
|
+
worst = max(year_refits.values()) if year_refits else 0
|
|
109
|
+
line('Model drift', f'Re-fits per station in {a.year} (year to date)', f'max {worst}', f'<= {refit_cap_per_year} per year', 'MET' if worst <= refit_cap_per_year else 'NOT MET')
|
|
110
|
+
open_props = [c for c in proposals if not any(d.get('supersedes_entry_id') == c.get('entry_id') for d in chg)]
|
|
111
|
+
line('Model drift', 'Proposals awaiting decision at month end', str(len(open_props)), '0', 'MET' if not open_props else 'ATTENTION')
|
|
112
|
+
else:
|
|
113
|
+
line('Model drift', 'Drift evaluation cadence', '-', '>= 1 evaluation every 24 h', 'NOT MEASURED', 'no monitor log supplied')
|
|
114
|
+
|
|
115
|
+
# --- latency and remote-site availability ----------------------------------------------
|
|
116
|
+
if store_forward_json and os.path.exists(store_forward_json):
|
|
117
|
+
sim = json.load(open(store_forward_json, encoding='utf-8')).get('simulation', {})
|
|
118
|
+
gr = sim.get('gap_report', {})
|
|
119
|
+
rec_csv = store_forward_json.replace('.json', '_records.csv')
|
|
120
|
+
lat = []
|
|
121
|
+
if os.path.exists(rec_csv):
|
|
122
|
+
with open(rec_csv, newline='', encoding='utf-8') as f:
|
|
123
|
+
for r in csv.DictReader(f):
|
|
124
|
+
if r.get('ingest_timestamp_utc') and _in(r['timestamp_utc'], a, b):
|
|
125
|
+
lat.append((parse_utc(r['ingest_timestamp_utc']) - parse_utc(r['timestamp_utc'])).total_seconds())
|
|
126
|
+
lat = np.array(lat, dtype=float)
|
|
127
|
+
if len(lat):
|
|
128
|
+
p95, p99 = float(np.percentile(lat, 95)), float(np.percentile(lat, 99))
|
|
129
|
+
w2, w5 = 100.0 * float(np.mean(lat <= 120)), 100.0 * float(np.mean(lat <= 300))
|
|
130
|
+
line('Latency', 'End-to-end latency p95 (field edge to OT lake)', f'{p95:.0f} s', '<= 120 s (95 %)', 'MET' if p95 <= 120 else 'NOT MET',
|
|
131
|
+
'includes outage replay; SLA 7.0 exclusion applies to documented telecom outages')
|
|
132
|
+
line('Latency', 'End-to-end latency p99', f'{p99:.0f} s', '<= 300 s (99 %)', 'MET' if p99 <= 300 else 'NOT MET')
|
|
133
|
+
line('Latency', 'Records within 2 min / within 5 min', f'{w2:.2f} % / {w5:.2f} %', '95 % / 99 %', 'MET' if (w2 >= 95 and w5 >= 99) else 'NOT MET')
|
|
134
|
+
outs = sim.get('outages', [])
|
|
135
|
+
tot_h = sum(o['duration_h'] for o in outs)
|
|
136
|
+
hours = days * 24.0
|
|
137
|
+
avail = 100.0 * (1 - tot_h / hours)
|
|
138
|
+
line('Remote site', 'Link availability (outage hours / month hours)', f'{avail:.3f} % ({tot_h:.2f} h down)', '>= 99 %', 'MET' if avail >= 99.0 else 'NOT MET')
|
|
139
|
+
st = sim.get('stats', {})
|
|
140
|
+
line('Remote site', 'Store-and-forward: samples lost over capacity', str(st.get('dropped_over_capacity', 0)), '0 (72 h buffer)', 'MET' if not st.get('dropped_over_capacity') else 'NOT MET')
|
|
141
|
+
dup = sum(v.get('duplicates_in_delivery', 0) for v in gr.values())
|
|
142
|
+
line('Remote site', 'Duplicates in delivered stream', str(dup), '0', 'MET' if dup == 0 else 'NOT MET')
|
|
143
|
+
line('Remote site', 'Chronological replay', 'verified' if all(v.get('chronological_replay') for v in gr.values()) else 'VIOLATED', 'required',
|
|
144
|
+
'MET' if all(v.get('chronological_replay') for v in gr.values()) else 'NOT MET')
|
|
145
|
+
else:
|
|
146
|
+
line('Latency', 'End-to-end latency', '-', '95 % <= 2 min; 99 % <= 5 min', 'NOT MEASURED', 'no store-and-forward record supplied')
|
|
147
|
+
line('Remote site', 'Link availability', '-', '>= 99 %', 'NOT MEASURED')
|
|
148
|
+
|
|
149
|
+
# --- alarms ---------------------------------------------------------------------------------
|
|
150
|
+
al = [e for e in _lines(alarm_log) if _in(e['timestamp_utc'], a, b)] if alarm_log else []
|
|
151
|
+
if al:
|
|
152
|
+
acts = [e for e in al if e['event'] == 'ACTIVATED']
|
|
153
|
+
acked = [e for e in al if e['event'] == 'ACKNOWLEDGED']
|
|
154
|
+
rtn_un = [e for e in al if e['event'] == 'RTN_UNACKED']
|
|
155
|
+
line('Alarms', 'Activations in the month', str(len(acts)), 'reported', 'REPORTED')
|
|
156
|
+
line('Alarms', 'Alarms that returned to normal unacknowledged', f'{len(rtn_un)} of {len(acts)}', '0', 'MET' if not rtn_un else 'ATTENTION')
|
|
157
|
+
p1 = [e for e in acts if e['priority'] == 'P1']
|
|
158
|
+
line('Alarms', 'P1 activations', str(len(p1)), 'each with a documented response', 'REPORTED')
|
|
159
|
+
else:
|
|
160
|
+
line('Alarms', 'Activations in the month', '-', 'reported', 'NOT MEASURED', 'no alarm event log supplied')
|
|
161
|
+
|
|
162
|
+
# --- well tests ------------------------------------------------------------------------------
|
|
163
|
+
ap = [e for e in _lines(os.path.join(well_test_dir, 'approvals.jsonl')) if _in(e['timestamp_utc'], a, b)] if well_test_dir else []
|
|
164
|
+
if ap:
|
|
165
|
+
tests = {e['test_id'] for e in ap}
|
|
166
|
+
line('Well tests', 'Well tests with approval activity', str(len(tests)), 'reported', 'REPORTED')
|
|
167
|
+
line('Well tests', 'Approval decisions recorded', str(len(ap)), 'timestamped, named', 'MET' if all(e.get('approver') for e in ap) else 'NOT MET')
|
|
168
|
+
else:
|
|
169
|
+
line('Well tests', 'Well tests approved', '-', 'reported', 'NOT MEASURED', 'no approval trail supplied')
|
|
170
|
+
|
|
171
|
+
# --- accuracy --------------------------------------------------------------------------------
|
|
172
|
+
if accuracy_json and os.path.exists(accuracy_json):
|
|
173
|
+
bt = json.load(open(accuracy_json, encoding='utf-8')).get('backtest', {})
|
|
174
|
+
bands = bt.get('bands', {})
|
|
175
|
+
n_ok = bt.get('n_ok', 0)
|
|
176
|
+
line('Accuracy', 'Quantities meeting >= 95 % at the conservative 90 % CI end', f"{bands.get('MEETS_TARGET', 0)} of {n_ok}", 'all claimed quantities >= 95 %',
|
|
177
|
+
'MET' if bands.get('MEETS_TARGET', 0) == n_ok else 'PARTIAL', 'quantities below target are excluded from any claim')
|
|
178
|
+
line('Accuracy', 'Quantities NOT ACCEPTABLE (< 85 %)', str(bands.get('NOT_ACCEPTABLE', 0)), '0 among claimed quantities', 'REPORTED')
|
|
179
|
+
else:
|
|
180
|
+
line('Accuracy', 'Accuracy statement', '-', '>= 95 % at 90 % CI', 'NOT MEASURED', 'no accuracy statement supplied')
|
|
181
|
+
|
|
182
|
+
# --- change and continuity ------------------------------------------------------------------------
|
|
183
|
+
if config_dir and os.path.isdir(config_dir):
|
|
184
|
+
from .config_versioning import ConfigStore
|
|
185
|
+
cs = ConfigStore(config_dir)
|
|
186
|
+
changes = [e for n in cs.names() for e in cs.history(n) if _in(e['timestamp_utc'], a, b)]
|
|
187
|
+
line('Change & continuity', 'Configuration versions committed in the month', str(len(changes)), 'every change versioned with author and diff',
|
|
188
|
+
'MET' if all(e.get('author') for e in changes) else 'NOT MET')
|
|
189
|
+
line('Change & continuity', 'Rollbacks in the month', str(sum(1 for e in changes if e.get('rollback_of'))), 'reported', 'REPORTED')
|
|
190
|
+
else:
|
|
191
|
+
line('Change & continuity', 'Configuration versions', '-', 'every change versioned', 'NOT MEASURED', 'no config store supplied')
|
|
192
|
+
|
|
193
|
+
status_counts: Dict[str, int] = {}
|
|
194
|
+
for l in lines:
|
|
195
|
+
status_counts[l['status']] = status_counts.get(l['status'], 0) + 1
|
|
196
|
+
return {'month': month, 'window': [a.strftime('%Y-%m-%dT%H:%M:%SZ'), b.strftime('%Y-%m-%dT%H:%M:%SZ')], 'lines': lines,
|
|
197
|
+
'status_counts': status_counts,
|
|
198
|
+
'inputs': {'monitor_log_dir': monitor_log_dir, 'store_forward_json': store_forward_json, 'alarm_log': alarm_log,
|
|
199
|
+
'well_test_dir': well_test_dir, 'accuracy_json': accuracy_json, 'config_dir': config_dir}}
|