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/drift_monitor.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
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
|
+
"""drift_monitor — drift evaluation as a scheduled job with a log.
|
|
5
|
+
|
|
6
|
+
The reconciler classifies; this module runs it on a cadence, keeps the
|
|
7
|
+
record, ages the result into a staleness state, and turns a
|
|
8
|
+
CALIBRATION_OFFSET into a change-log entry with before/after coefficients
|
|
9
|
+
that a named approver applies - or that the annual re-fit cap blocks.
|
|
10
|
+
|
|
11
|
+
State lives in one directory as append-only JSON lines plus one small JSON
|
|
12
|
+
state file, so the client can read every evaluation and every change with
|
|
13
|
+
any text tool:
|
|
14
|
+
|
|
15
|
+
<log_dir>/evaluations.jsonl one line per station per evaluation
|
|
16
|
+
<log_dir>/change_log.jsonl PROPOSED / APPLIED / REJECTED / BLOCKED entries
|
|
17
|
+
<log_dir>/state.json applied offset corrections per station,
|
|
18
|
+
last evaluation, re-fit count per year
|
|
19
|
+
|
|
20
|
+
Clock: every public method takes `now` (aware UTC datetime) so a scheduled
|
|
21
|
+
run, a replay and an acceptance test are the same code path with an injected
|
|
22
|
+
clock. Nothing here reads the wall clock unless `now` is omitted.
|
|
23
|
+
|
|
24
|
+
SLA clocks (business days from the evaluation that detected drift):
|
|
25
|
+
notify 1, fallback 2, re-fit/redeploy 10; evaluation cadence 24 h; safety
|
|
26
|
+
models revert immediately (not modelled here - no safety model in scope).
|
|
27
|
+
Re-fits per station per calendar year are capped (default 4, SOW 4.2.26);
|
|
28
|
+
a proposal beyond the cap is logged BLOCKED_ANNUAL_LIMIT, never applied.
|
|
29
|
+
|
|
30
|
+
Headless-safe: numpy only.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import hashlib
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
from dataclasses import dataclass, replace
|
|
39
|
+
from datetime import datetime, timedelta, timezone
|
|
40
|
+
from typing import Dict, List, Optional
|
|
41
|
+
|
|
42
|
+
import numpy as np
|
|
43
|
+
|
|
44
|
+
from .reconciler import Reconciler, ReconcilerConfig
|
|
45
|
+
from .ports import LiveStream, StreamChannel
|
|
46
|
+
|
|
47
|
+
EVALUATE_EVERY_H = 24.0
|
|
48
|
+
NOTIFY_BD, FALLBACK_BD, REFIT_BD = 1, 2, 10
|
|
49
|
+
MAX_REFITS_PER_YEAR = 4
|
|
50
|
+
DRIFT_CLASSES = ('CALIBRATION_OFFSET', 'UNEXPLAINED_OFFSET', 'UNEXPLAINED_TREND')
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _utc(dt: Optional[datetime]) -> datetime:
|
|
54
|
+
if dt is None:
|
|
55
|
+
return datetime.now(timezone.utc).replace(microsecond=0)
|
|
56
|
+
return dt.astimezone(timezone.utc) if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _iso(dt: datetime) -> str:
|
|
60
|
+
return dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse(s: str) -> datetime:
|
|
64
|
+
s = s.strip()
|
|
65
|
+
if s.endswith('Z'):
|
|
66
|
+
s = s[:-1]
|
|
67
|
+
dt = datetime.fromisoformat(s)
|
|
68
|
+
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def business_days_after(start: datetime, n: int) -> datetime:
|
|
72
|
+
d, added = start, 0
|
|
73
|
+
while added < n:
|
|
74
|
+
d += timedelta(days=1)
|
|
75
|
+
if d.weekday() < 5:
|
|
76
|
+
added += 1
|
|
77
|
+
return d
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class MonitorConfig:
|
|
82
|
+
evaluate_every_h: float = EVALUATE_EVERY_H
|
|
83
|
+
max_refits_per_year: int = MAX_REFITS_PER_YEAR
|
|
84
|
+
notify_bd: int = NOTIFY_BD
|
|
85
|
+
fallback_bd: int = FALLBACK_BD
|
|
86
|
+
refit_bd: int = REFIT_BD
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DriftMonitor:
|
|
90
|
+
"""One well's drift evaluations, on a cadence, with the record."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, well_config, log_dir: str, config: MonitorConfig | None = None,
|
|
93
|
+
reconciler_config: ReconcilerConfig | None = None, well_name: str = 'well'):
|
|
94
|
+
self.well = well_config
|
|
95
|
+
self.well_name = well_name
|
|
96
|
+
self.cfg = config or MonitorConfig()
|
|
97
|
+
self.rcfg = reconciler_config
|
|
98
|
+
self.log_dir = str(log_dir)
|
|
99
|
+
os.makedirs(self.log_dir, exist_ok=True)
|
|
100
|
+
self.state_path = os.path.join(self.log_dir, 'state.json')
|
|
101
|
+
self.eval_path = os.path.join(self.log_dir, 'evaluations.jsonl')
|
|
102
|
+
self.change_path = os.path.join(self.log_dir, 'change_log.jsonl')
|
|
103
|
+
self.state = self._load_state()
|
|
104
|
+
|
|
105
|
+
# -- state ----------------------------------------------------------------
|
|
106
|
+
def _load_state(self) -> dict:
|
|
107
|
+
if os.path.exists(self.state_path):
|
|
108
|
+
with open(self.state_path, encoding='utf-8') as f:
|
|
109
|
+
return json.load(f)
|
|
110
|
+
return {'well': self.well_name, 'corrections_psi': {}, 'last_evaluation': None,
|
|
111
|
+
'evaluation_count': 0, 'refits_applied': {}, 'next_entry_seq': 1}
|
|
112
|
+
|
|
113
|
+
def _save_state(self) -> None:
|
|
114
|
+
with open(self.state_path, 'w', encoding='utf-8') as f:
|
|
115
|
+
json.dump(self.state, f, indent=1, sort_keys=True)
|
|
116
|
+
|
|
117
|
+
def _append(self, path: str, entry: dict) -> None:
|
|
118
|
+
with open(path, 'a', encoding='utf-8') as f:
|
|
119
|
+
f.write(json.dumps(entry, sort_keys=True, default=str) + '\n')
|
|
120
|
+
|
|
121
|
+
def _read(self, path: str) -> List[dict]:
|
|
122
|
+
if not os.path.exists(path):
|
|
123
|
+
return []
|
|
124
|
+
with open(path, encoding='utf-8') as f:
|
|
125
|
+
return [json.loads(line) for line in f if line.strip()]
|
|
126
|
+
|
|
127
|
+
def _entry_id(self, prefix: str, now: datetime) -> str:
|
|
128
|
+
seq = self.state['next_entry_seq']
|
|
129
|
+
self.state['next_entry_seq'] = seq + 1
|
|
130
|
+
return f"{prefix}-{now.strftime('%Y%m%dT%H%M%SZ')}-{seq:04d}"
|
|
131
|
+
|
|
132
|
+
# -- corrections applied to the live leg ------------------------------------
|
|
133
|
+
def corrected_stream(self, stream: LiveStream) -> LiveStream:
|
|
134
|
+
"""The live stream with every APPLIED offset correction subtracted
|
|
135
|
+
from its station channel (the correction is logged; the raw file is
|
|
136
|
+
never rewritten)."""
|
|
137
|
+
corr = self.state.get('corrections_psi', {})
|
|
138
|
+
if not corr:
|
|
139
|
+
return stream
|
|
140
|
+
channels: Dict[str, StreamChannel] = {}
|
|
141
|
+
for name, ch in stream.channels.items():
|
|
142
|
+
c = float(corr.get(name, 0.0))
|
|
143
|
+
channels[name] = replace(ch, values=np.asarray(ch.values, dtype=float) - c) if c else ch
|
|
144
|
+
meta = dict(stream.meta)
|
|
145
|
+
meta['corrections_applied_psi'] = json.dumps({k: v for k, v in corr.items() if k in stream.channels})
|
|
146
|
+
return LiveStream(name=stream.name, source_format=stream.source_format, index_kind=stream.index_kind,
|
|
147
|
+
index=stream.index, channels=channels, meta=meta)
|
|
148
|
+
|
|
149
|
+
# -- scheduling ---------------------------------------------------------------
|
|
150
|
+
def next_due(self) -> Optional[datetime]:
|
|
151
|
+
last = self.state.get('last_evaluation')
|
|
152
|
+
if not last:
|
|
153
|
+
return None
|
|
154
|
+
return _parse(last['timestamp_utc']) + timedelta(hours=self.cfg.evaluate_every_h)
|
|
155
|
+
|
|
156
|
+
def staleness(self, now: Optional[datetime] = None) -> dict:
|
|
157
|
+
now = _utc(now)
|
|
158
|
+
last = self.state.get('last_evaluation')
|
|
159
|
+
if not last:
|
|
160
|
+
return {'status': 'NEVER_EVALUATED', 'age_h': None, 'cadence_h': self.cfg.evaluate_every_h,
|
|
161
|
+
'next_due_utc': None, 'as_of_utc': _iso(now)}
|
|
162
|
+
age_h = (now - _parse(last['timestamp_utc'])).total_seconds() / 3600.0
|
|
163
|
+
due = self.next_due()
|
|
164
|
+
return {'status': 'CURRENT' if age_h <= self.cfg.evaluate_every_h else 'STALE',
|
|
165
|
+
'age_h': round(age_h, 2), 'cadence_h': self.cfg.evaluate_every_h,
|
|
166
|
+
'last_evaluation_utc': last['timestamp_utc'], 'last_evaluation_id': last['evaluation_id'],
|
|
167
|
+
'next_due_utc': _iso(due) if due else None,
|
|
168
|
+
'overdue_h': round(max(0.0, age_h - self.cfg.evaluate_every_h), 2), 'as_of_utc': _iso(now)}
|
|
169
|
+
|
|
170
|
+
def run_scheduled(self, stream: LiveStream, station_map: Optional[Dict[str, float]] = None,
|
|
171
|
+
now: Optional[datetime] = None, force: bool = False) -> dict:
|
|
172
|
+
"""The scheduled-job entry point: evaluate when due (or forced), else
|
|
173
|
+
record that the run was not due and return the staleness state."""
|
|
174
|
+
now = _utc(now)
|
|
175
|
+
due = self.next_due()
|
|
176
|
+
if force or due is None or now >= due:
|
|
177
|
+
return self.evaluate(stream, station_map, now=now)
|
|
178
|
+
return {'action': 'SKIPPED_NOT_DUE', 'as_of_utc': _iso(now), 'next_due_utc': _iso(due),
|
|
179
|
+
'staleness': self.staleness(now)}
|
|
180
|
+
|
|
181
|
+
# -- the evaluation -------------------------------------------------------------
|
|
182
|
+
def evaluate(self, stream: LiveStream, station_map: Optional[Dict[str, float]] = None,
|
|
183
|
+
now: Optional[datetime] = None) -> dict:
|
|
184
|
+
now = _utc(now)
|
|
185
|
+
eval_id = self._entry_id('EVAL', now)
|
|
186
|
+
live = self.corrected_stream(stream)
|
|
187
|
+
rep = Reconciler(self.well, self.rcfg).reconcile(live, station_map=station_map)
|
|
188
|
+
drift_stations = [s for s in rep['stations'] if s['classification'] in DRIFT_CLASSES]
|
|
189
|
+
clocks = None
|
|
190
|
+
if drift_stations:
|
|
191
|
+
clocks = {'notify_due': business_days_after(now, self.cfg.notify_bd).strftime('%Y-%m-%d'),
|
|
192
|
+
'fallback_due': business_days_after(now, self.cfg.fallback_bd).strftime('%Y-%m-%d'),
|
|
193
|
+
'refit_due': business_days_after(now, self.cfg.refit_bd).strftime('%Y-%m-%d')}
|
|
194
|
+
proposals = []
|
|
195
|
+
for s in rep['stations']:
|
|
196
|
+
entry = {'evaluation_id': eval_id, 'timestamp_utc': _iso(now), 'well': self.well_name,
|
|
197
|
+
'station': s['channel'], 'md_ft': s.get('md_ft'), 'classification': s['classification'],
|
|
198
|
+
'n': s.get('n'), 'span_years': s.get('span_years'), 'bias_psi': s.get('bias_psi'),
|
|
199
|
+
'slope_psi_yr': s.get('slope_psi_yr'), 'noise_sigma_psi': s.get('noise_sigma_psi'),
|
|
200
|
+
'transient_count': s.get('transient_count'),
|
|
201
|
+
'drift_envelope_psi_yr': s.get('drift_envelope_psi_yr'),
|
|
202
|
+
'correction_in_force_psi': float(self.state['corrections_psi'].get(s['channel'], 0.0)),
|
|
203
|
+
'drift_detected': s['classification'] in DRIFT_CLASSES}
|
|
204
|
+
self._append(self.eval_path, entry)
|
|
205
|
+
if s['classification'] == 'CALIBRATION_OFFSET':
|
|
206
|
+
proposals.append(self.propose_refit(s['channel'], float(s['bias_psi']), eval_id, now))
|
|
207
|
+
elif s['classification'] in ('UNEXPLAINED_OFFSET', 'UNEXPLAINED_TREND'):
|
|
208
|
+
proposals.append(self._log_change({'type': 'FALLBACK', 'station': s['channel'],
|
|
209
|
+
'evaluation_id': eval_id, 'timestamp_utc': _iso(now),
|
|
210
|
+
'status': 'PROPOSED', 'classification': s['classification'],
|
|
211
|
+
'bias_psi': s.get('bias_psi'), 'slope_psi_yr': s.get('slope_psi_yr'),
|
|
212
|
+
'detail': 'hold model output for this station; investigate'}, now))
|
|
213
|
+
self.state['last_evaluation'] = {'evaluation_id': eval_id, 'timestamp_utc': _iso(now),
|
|
214
|
+
'drift_detected': bool(drift_stations),
|
|
215
|
+
'classification_counts': rep['classification_counts']}
|
|
216
|
+
self.state['evaluation_count'] = int(self.state.get('evaluation_count', 0)) + 1
|
|
217
|
+
self._save_state()
|
|
218
|
+
return {'action': 'EVALUATED', 'evaluation_id': eval_id, 'timestamp_utc': _iso(now),
|
|
219
|
+
'evaluation': rep, 'drift_detected': bool(drift_stations), 'sla_clocks': clocks,
|
|
220
|
+
'proposals': proposals, 'staleness': self.staleness(now)}
|
|
221
|
+
|
|
222
|
+
# -- change log -----------------------------------------------------------------
|
|
223
|
+
def _log_change(self, entry: dict, now: datetime) -> dict:
|
|
224
|
+
entry = dict(entry)
|
|
225
|
+
entry.setdefault('entry_id', self._entry_id('CHG', now))
|
|
226
|
+
self._append(self.change_path, entry)
|
|
227
|
+
self._save_state()
|
|
228
|
+
return entry
|
|
229
|
+
|
|
230
|
+
def _refits_this_year(self, station: str, year: int) -> int:
|
|
231
|
+
return int(self.state.get('refits_applied', {}).get(station, {}).get(str(year), 0))
|
|
232
|
+
|
|
233
|
+
def propose_refit(self, station: str, bias_psi: float, evaluation_id: str,
|
|
234
|
+
now: Optional[datetime] = None) -> dict:
|
|
235
|
+
"""CALIBRATION_OFFSET -> PROPOSED offset correction with before/after.
|
|
236
|
+
Blocked (logged, not applied) beyond the annual cap."""
|
|
237
|
+
now = _utc(now)
|
|
238
|
+
before = float(self.state['corrections_psi'].get(station, 0.0))
|
|
239
|
+
after = round(before + bias_psi, 3)
|
|
240
|
+
used = self._refits_this_year(station, now.year)
|
|
241
|
+
entry = {'type': 'REFIT_OFFSET', 'station': station, 'evaluation_id': evaluation_id,
|
|
242
|
+
'timestamp_utc': _iso(now), 'coefficient': 'offset_correction_psi',
|
|
243
|
+
'before': before, 'after': after, 'measured_bias_psi': round(bias_psi, 3),
|
|
244
|
+
'refits_applied_this_year': used, 'annual_cap': self.cfg.max_refits_per_year}
|
|
245
|
+
if used >= self.cfg.max_refits_per_year:
|
|
246
|
+
entry.update({'status': 'BLOCKED_ANNUAL_LIMIT',
|
|
247
|
+
'detail': f'{used} re-fits already applied to {station} in {now.year}; cap {self.cfg.max_refits_per_year}'})
|
|
248
|
+
else:
|
|
249
|
+
entry.update({'status': 'PROPOSED', 'detail': 'awaiting approval'})
|
|
250
|
+
return self._log_change(entry, now)
|
|
251
|
+
|
|
252
|
+
def approve(self, entry_id: str, approver: str, now: Optional[datetime] = None,
|
|
253
|
+
decision: str = 'APPLIED', note: str = '') -> dict:
|
|
254
|
+
"""Apply (or reject) a PROPOSED entry. Applying a REFIT_OFFSET writes
|
|
255
|
+
the correction into state and counts against the annual cap."""
|
|
256
|
+
now = _utc(now)
|
|
257
|
+
entries = self._read(self.change_path)
|
|
258
|
+
src = next((e for e in entries if e.get('entry_id') == entry_id), None)
|
|
259
|
+
if src is None:
|
|
260
|
+
raise KeyError(f'no change-log entry {entry_id}')
|
|
261
|
+
if src.get('status') != 'PROPOSED':
|
|
262
|
+
raise ValueError(f"entry {entry_id} is {src.get('status')}, not PROPOSED")
|
|
263
|
+
if decision not in ('APPLIED', 'REJECTED'):
|
|
264
|
+
raise ValueError('decision must be APPLIED or REJECTED')
|
|
265
|
+
if decision == 'APPLIED' and src.get('type') == 'REFIT_OFFSET':
|
|
266
|
+
st = src['station']
|
|
267
|
+
used = self._refits_this_year(st, now.year)
|
|
268
|
+
if used >= self.cfg.max_refits_per_year:
|
|
269
|
+
decision = 'BLOCKED_ANNUAL_LIMIT'
|
|
270
|
+
else:
|
|
271
|
+
self.state['corrections_psi'][st] = float(src['after'])
|
|
272
|
+
self.state.setdefault('refits_applied', {}).setdefault(st, {})[str(now.year)] = used + 1
|
|
273
|
+
out = dict(src)
|
|
274
|
+
out.update({'status': decision, 'approver': approver, 'decided_utc': _iso(now),
|
|
275
|
+
'note': note, 'supersedes_entry_id': entry_id})
|
|
276
|
+
out['entry_id'] = self._entry_id('CHG', now)
|
|
277
|
+
return self._log_change(out, now)
|
|
278
|
+
|
|
279
|
+
# -- views ------------------------------------------------------------------------
|
|
280
|
+
def history(self, station: Optional[str] = None, last: int = 50) -> List[dict]:
|
|
281
|
+
rows = self._read(self.eval_path)
|
|
282
|
+
if station:
|
|
283
|
+
rows = [r for r in rows if r.get('station') == station]
|
|
284
|
+
return rows[-last:]
|
|
285
|
+
|
|
286
|
+
def change_log(self, last: int = 50) -> List[dict]:
|
|
287
|
+
return self._read(self.change_path)[-last:]
|
|
288
|
+
|
|
289
|
+
def open_proposals(self) -> List[dict]:
|
|
290
|
+
entries = self._read(self.change_path)
|
|
291
|
+
superseded = {e.get('supersedes_entry_id') for e in entries if e.get('supersedes_entry_id')}
|
|
292
|
+
return [e for e in entries if e.get('status') == 'PROPOSED' and e.get('entry_id') not in superseded]
|
|
293
|
+
|
|
294
|
+
def status(self, now: Optional[datetime] = None) -> dict:
|
|
295
|
+
now = _utc(now)
|
|
296
|
+
return {'well': self.well_name, 'as_of_utc': _iso(now), 'staleness': self.staleness(now),
|
|
297
|
+
'last_evaluation': self.state.get('last_evaluation'),
|
|
298
|
+
'evaluation_count': self.state.get('evaluation_count', 0),
|
|
299
|
+
'corrections_psi': dict(self.state.get('corrections_psi', {})),
|
|
300
|
+
'refits_applied': self.state.get('refits_applied', {}),
|
|
301
|
+
'open_proposals': self.open_proposals(),
|
|
302
|
+
'log_dir': self.log_dir,
|
|
303
|
+
'log_sha256': {os.path.basename(p): _sha(p) for p in (self.eval_path, self.change_path) if os.path.exists(p)}}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _sha(path: str) -> str:
|
|
307
|
+
h = hashlib.sha256()
|
|
308
|
+
with open(path, 'rb') as f:
|
|
309
|
+
h.update(f.read())
|
|
310
|
+
return h.hexdigest()[:16]
|
gea/earth_model.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
"""earth_model - Part 1 of the subsurface surveying tool: THE EARTH MODEL.
|
|
5
|
+
|
|
6
|
+
v1.74.0 (Daniel's 2026-08-29 gap analysis: "we are making a geological
|
|
7
|
+
subsurface surveying tool" - this is the container that holds the map).
|
|
8
|
+
|
|
9
|
+
WHAT THIS IS
|
|
10
|
+
Every catalogue entry so far has been a 1-D column at a scattered site.
|
|
11
|
+
This module registers those sites into ONE geographic frame:
|
|
12
|
+
|
|
13
|
+
- sites are grouped by ARCHIVE-DECLARED coordinates (rounded to 0.01
|
|
14
|
+
deg, ~1.1 km - the grouping resolution, disclosed) so that multiple
|
|
15
|
+
entries drilled into the same ground become one Site with the union
|
|
16
|
+
of their measured properties;
|
|
17
|
+
- every value is placed on a COMMON VERTICAL FRAME where the archive
|
|
18
|
+
supplies a reference elevation: z_ref_m = site elevation - depth
|
|
19
|
+
(metres relative to sea level; marine sites carry negative seafloor
|
|
20
|
+
elevations, the Dome C ice sheet +3233 m, Retama's KB +227.4 m);
|
|
21
|
+
- per-value UNCERTAINTY is carried where the archive supplies it (a
|
|
22
|
+
channel whose name extends the property's with 'std'), and is None
|
|
23
|
+
- never invented - everywhere else;
|
|
24
|
+
- entries whose archives declare no coordinates are listed in
|
|
25
|
+
`unregistered` with the reason, never placed by guesswork.
|
|
26
|
+
|
|
27
|
+
HONESTY RULES
|
|
28
|
+
- Coordinates come from the archives themselves (PANGAEA/IODP headers,
|
|
29
|
+
the Retama drag-report well block). Nothing is geolocated by memory.
|
|
30
|
+
- Sites without a reference elevation get vertical_frame
|
|
31
|
+
'DEPTH_ONLY_NO_DATUM' and their values stay in native depth.
|
|
32
|
+
- The model never interpolates between sites; it registers, measures
|
|
33
|
+
distances (haversine, WGS-84 mean radius), and reports.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import math
|
|
39
|
+
import re
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from typing import Dict, List, Optional, Tuple
|
|
42
|
+
|
|
43
|
+
from .profile_catalog import CATALOG
|
|
44
|
+
|
|
45
|
+
EARTH_RADIUS_KM = 6371.0088 # IUGG mean Earth radius
|
|
46
|
+
GROUP_DECIMALS = 2 # site-grouping resolution ~1.1 km (disclosed)
|
|
47
|
+
FT_TO_M = 0.3048 # exact definition
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
51
|
+
"""Great-circle distance between two points, km."""
|
|
52
|
+
p1, p2 = math.radians(lat1), math.radians(lat2)
|
|
53
|
+
dp = math.radians(lat2 - lat1)
|
|
54
|
+
dl = math.radians(lon2 - lon1)
|
|
55
|
+
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
|
56
|
+
return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class PropertyRecord:
|
|
61
|
+
"""One measured property series at a site, vertically registered."""
|
|
62
|
+
property: str
|
|
63
|
+
unit: str
|
|
64
|
+
entry: str
|
|
65
|
+
depths_m: List[float] # native depth (m unless noted)
|
|
66
|
+
values: List[float]
|
|
67
|
+
z_ref_m: Optional[List[float]] # elevation-referenced (m rel. sea level) or None
|
|
68
|
+
sigma: Optional[List[float]] # archive-supplied uncertainty or None
|
|
69
|
+
depth_unit: str = "m"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Site:
|
|
74
|
+
key: Tuple[float, float]
|
|
75
|
+
latitude: float
|
|
76
|
+
longitude: float
|
|
77
|
+
elevation_m: Optional[float] # reference elevation (seafloor/surface/KB)
|
|
78
|
+
elevation_datum: str # e.g. 'archive elevation', 'KB (drag report)'
|
|
79
|
+
vertical_frame: str # 'ELEVATION_REFERENCED' | 'DEPTH_ONLY_NO_DATUM'
|
|
80
|
+
entries: List[str] = field(default_factory=list)
|
|
81
|
+
records: List[PropertyRecord] = field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
def properties(self) -> List[str]:
|
|
84
|
+
return sorted({r.property for r in self.records})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _retama_coords() -> Optional[Tuple[float, float, float]]:
|
|
88
|
+
"""Parse the Retama coordinates from the drag report's VERBATIM well
|
|
89
|
+
block (archive-sourced, not remembered). Returns (lat, lon, kb_m)."""
|
|
90
|
+
if 'retama_403h_drag_report' not in CATALOG:
|
|
91
|
+
return None
|
|
92
|
+
meta = CATALOG['retama_403h_drag_report'].stream().meta.get('well', '')
|
|
93
|
+
m = re.search(r'Latitude\s+([\-0-9.]+).*?Longitude\s+([\-0-9.]+)'
|
|
94
|
+
r'.*?KB Elevation \(ft\)\s+([\-0-9.]+)', meta)
|
|
95
|
+
if not m:
|
|
96
|
+
return None
|
|
97
|
+
return float(m.group(1)), float(m.group(2)), float(m.group(3)) * FT_TO_M
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _sigma_for(channels, name):
|
|
101
|
+
"""Archive-supplied uncertainty channel for `name`, if one exists."""
|
|
102
|
+
for cn, ch in channels.items():
|
|
103
|
+
if cn != name and cn.startswith(name) and 'std' in cn.lower():
|
|
104
|
+
return [float(v) for v in ch.values]
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class EarthModel:
|
|
109
|
+
"""The registered library: sites in one frame, refusals with reasons."""
|
|
110
|
+
|
|
111
|
+
def __init__(self):
|
|
112
|
+
self.sites: Dict[Tuple[float, float], Site] = {}
|
|
113
|
+
self.unregistered: List[Tuple[str, str]] = []
|
|
114
|
+
self._build()
|
|
115
|
+
|
|
116
|
+
# -- construction ------------------------------------------------------
|
|
117
|
+
def _add_records(self, site: Site, entry_name: str, st, depth_unit="m",
|
|
118
|
+
depth_scale=1.0, md_indexed=False) -> None:
|
|
119
|
+
"""md_indexed=True marks DEVIATED wells whose index is MEASURED depth:
|
|
120
|
+
vertical registration then comes from the entry's own row-aligned TVD
|
|
121
|
+
channel (never from MD - a horizontal well's MD is not a height), and
|
|
122
|
+
entries without a TVD channel stay unregistered vertically (z=None).
|
|
123
|
+
Near-vertical scientific boreholes use index depth directly; that
|
|
124
|
+
approximation (depth-below-surface ~ true vertical depth) is the
|
|
125
|
+
model's stated assumption for hole inclinations < a few degrees."""
|
|
126
|
+
site.entries.append(entry_name)
|
|
127
|
+
if st.index_kind != 'depth':
|
|
128
|
+
return # ordinal/time entries register the site only
|
|
129
|
+
depths = [float(d) * depth_scale for d in st.index]
|
|
130
|
+
tvd_m = None
|
|
131
|
+
if md_indexed:
|
|
132
|
+
tvd_ch = next((ch for cn, ch in st.channels.items()
|
|
133
|
+
if cn.upper().startswith('TVD')), None)
|
|
134
|
+
if tvd_ch is not None:
|
|
135
|
+
tvd_m = [float(v) * depth_scale for v in tvd_ch.values]
|
|
136
|
+
for cn, ch in st.channels.items():
|
|
137
|
+
if 'std' in cn.lower():
|
|
138
|
+
continue # uncertainty channels attach to their property
|
|
139
|
+
vals = [float(v) for v in ch.values]
|
|
140
|
+
if site.elevation_m is None:
|
|
141
|
+
z = None
|
|
142
|
+
elif md_indexed:
|
|
143
|
+
z = ([site.elevation_m - t for t in tvd_m]
|
|
144
|
+
if tvd_m is not None else None)
|
|
145
|
+
else:
|
|
146
|
+
z = [site.elevation_m - d for d in depths]
|
|
147
|
+
site.records.append(PropertyRecord(
|
|
148
|
+
property=cn, unit=ch.unit, entry=entry_name,
|
|
149
|
+
depths_m=depths, values=vals, z_ref_m=z,
|
|
150
|
+
sigma=_sigma_for(st.channels, cn), depth_unit=depth_unit))
|
|
151
|
+
|
|
152
|
+
def _site_for(self, lat: float, lon: float, elev, datum: str) -> Site:
|
|
153
|
+
key = (round(lat, GROUP_DECIMALS), round(lon, GROUP_DECIMALS))
|
|
154
|
+
if key not in self.sites:
|
|
155
|
+
self.sites[key] = Site(
|
|
156
|
+
key=key, latitude=lat, longitude=lon,
|
|
157
|
+
elevation_m=(float(elev) if elev is not None else None),
|
|
158
|
+
elevation_datum=datum,
|
|
159
|
+
vertical_frame=('ELEVATION_REFERENCED' if elev is not None
|
|
160
|
+
else 'DEPTH_ONLY_NO_DATUM'))
|
|
161
|
+
return self.sites[key]
|
|
162
|
+
|
|
163
|
+
def _build(self) -> None:
|
|
164
|
+
retama = _retama_coords()
|
|
165
|
+
for name, e in sorted(CATALOG.items()):
|
|
166
|
+
if name.startswith('retama_403h'):
|
|
167
|
+
if retama is None:
|
|
168
|
+
self.unregistered.append((name, 'retama drag-report well block absent'))
|
|
169
|
+
continue
|
|
170
|
+
lat, lon, kb = retama
|
|
171
|
+
site = self._site_for(lat, lon, kb, 'KB (drag-report well block, 746 ft)')
|
|
172
|
+
# operator survey depths are in FEET
|
|
173
|
+
self._add_records(site, name, e.stream(), depth_unit='ft->m',
|
|
174
|
+
depth_scale=FT_TO_M, md_indexed=True)
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
st = e.stream()
|
|
178
|
+
except Exception as ex:
|
|
179
|
+
self.unregistered.append((name, 'non-stream entry: %s' % type(ex).__name__))
|
|
180
|
+
continue
|
|
181
|
+
lat, lon = st.meta.get('latitude'), st.meta.get('longitude')
|
|
182
|
+
if not (lat and lon):
|
|
183
|
+
self.unregistered.append((name, 'archive declares no coordinates'))
|
|
184
|
+
continue
|
|
185
|
+
site = self._site_for(float(lat), float(lon), st.meta.get('elevation_m'),
|
|
186
|
+
'archive elevation')
|
|
187
|
+
self._add_records(site, name, st)
|
|
188
|
+
|
|
189
|
+
# -- geography ---------------------------------------------------------
|
|
190
|
+
def distance_km(self, a: Tuple[float, float], b: Tuple[float, float]) -> float:
|
|
191
|
+
sa, sb = self.sites[a], self.sites[b]
|
|
192
|
+
return haversine_km(sa.latitude, sa.longitude, sb.latitude, sb.longitude)
|
|
193
|
+
|
|
194
|
+
def nearest(self, lat: float, lon: float, k: int = 3):
|
|
195
|
+
ranked = sorted(self.sites.values(),
|
|
196
|
+
key=lambda s: haversine_km(lat, lon, s.latitude, s.longitude))
|
|
197
|
+
return [(s.key, round(haversine_km(lat, lon, s.latitude, s.longitude), 1))
|
|
198
|
+
for s in ranked[:k]]
|
|
199
|
+
|
|
200
|
+
def span_km(self) -> Tuple[float, Tuple, Tuple]:
|
|
201
|
+
keys = list(self.sites)
|
|
202
|
+
best = (0.0, None, None)
|
|
203
|
+
for i, a in enumerate(keys):
|
|
204
|
+
for b in keys[i + 1:]:
|
|
205
|
+
d = self.distance_km(a, b)
|
|
206
|
+
if d > best[0]:
|
|
207
|
+
best = (d, a, b)
|
|
208
|
+
return best
|
|
209
|
+
|
|
210
|
+
# -- reporting ---------------------------------------------------------
|
|
211
|
+
def census(self) -> dict:
|
|
212
|
+
n_rec = sum(len(s.records) for s in self.sites.values())
|
|
213
|
+
n_unc = sum(1 for s in self.sites.values() for r in s.records
|
|
214
|
+
if r.sigma is not None)
|
|
215
|
+
span, a, b = self.span_km()
|
|
216
|
+
lats = [s.latitude for s in self.sites.values()]
|
|
217
|
+
return {
|
|
218
|
+
'sites': len(self.sites),
|
|
219
|
+
'registered_entries': sum(len(s.entries) for s in self.sites.values()),
|
|
220
|
+
'unregistered_entries': len(self.unregistered),
|
|
221
|
+
'property_records': n_rec,
|
|
222
|
+
'records_with_archive_uncertainty': n_unc,
|
|
223
|
+
'multi_entry_sites': sum(1 for s in self.sites.values() if len(s.entries) > 1),
|
|
224
|
+
'elevation_referenced_sites': sum(1 for s in self.sites.values()
|
|
225
|
+
if s.vertical_frame == 'ELEVATION_REFERENCED'),
|
|
226
|
+
'latitude_span_deg': round(max(lats) - min(lats), 2),
|
|
227
|
+
'great_circle_span_km': round(span, 1),
|
|
228
|
+
'span_endpoints': (a, b),
|
|
229
|
+
'grouping_resolution_deg': 10 ** -GROUP_DECIMALS,
|
|
230
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "EXAMPLE_TEST_FIXTURE_loopback_map",
|
|
3
|
+
"source": "EXAMPLE TEST FIXTURE ONLY (v1.11.0, 2026-08-24): describes the in-process pymodbus loopback server used to verify the tap - NOT a device register map. No public G6 register map exists in the fetched sources; obtain your site's map from the G6/site documentation and cite it here (Rule 7).",
|
|
4
|
+
"unit_id": 1,
|
|
5
|
+
"word_order": "big",
|
|
6
|
+
"table": "holding",
|
|
7
|
+
"registers": [
|
|
8
|
+
{"channel": "P_raw_psi_S1", "address": 0, "type": "float32", "unit": "psi"},
|
|
9
|
+
{"channel": "T_raw_F_S1", "address": 2, "type": "float32", "unit": "degF"},
|
|
10
|
+
{"channel": "P_raw_psi_S2", "address": 4, "type": "float32", "unit": "psi"},
|
|
11
|
+
{"channel": "T_raw_F_S2", "address": 6, "type": "float32", "unit": "degF"},
|
|
12
|
+
{"channel": "status_word", "address": 8, "type": "uint16", "unit": ""}
|
|
13
|
+
]
|
|
14
|
+
}
|
gea/fat_sat.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
"""fat_sat — the acceptance suite rendered as a Factory / Site Acceptance
|
|
5
|
+
Test protocol (SOW 4.2.20 FAT; 4.2.21 SAT).
|
|
6
|
+
|
|
7
|
+
The in-package acceptance suite already checks the product end to end; a
|
|
8
|
+
client witnesses those checks as a numbered protocol: step, section, check,
|
|
9
|
+
expected, actual (PASS / FAIL), witness. This module runs the selected
|
|
10
|
+
sections through the suite's own `ok()` hook and renders the protocol with
|
|
11
|
+
a signature block. A FAT is run on the build before delivery; a SAT runs the
|
|
12
|
+
same steps on the installed environment and records that environment (from
|
|
13
|
+
the SBOM). Checks whose wording belongs to the program's internal register
|
|
14
|
+
are excluded from the client protocol and counted, so the protocol reads in
|
|
15
|
+
the client's vocabulary without altering any check.
|
|
16
|
+
|
|
17
|
+
Headless-safe: standard library only.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import tempfile
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from typing import Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
# Sections offered to the client protocol, in run order: (key, function name, title)
|
|
27
|
+
CLIENT_SECTIONS = [
|
|
28
|
+
('A', 'section_a_cli', 'Headless command line: runs, exports, determinism, ingest round-trip'),
|
|
29
|
+
('C', 'section_c_reconciler', 'Two-stream reconciliation and classification'),
|
|
30
|
+
('F', 'section_f_ports', 'Ingest ports (historian CSV, LAS)'),
|
|
31
|
+
('AA', 'section_aa_client_reports', 'Client report family: records, quality, drift, accuracy, monitor, well tests, alarms, model cards, resilience, configuration, SBOM, SLA, FAT/SAT, dashboard'),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_protocol(kind: str = 'FAT', sections: Optional[List[str]] = None) -> dict:
|
|
36
|
+
"""Run the selected sections and return the protocol rows."""
|
|
37
|
+
from . import acceptance_tests as AT
|
|
38
|
+
from .client_reports import forbidden_terms
|
|
39
|
+
keys = [k for k, _, _ in CLIENT_SECTIONS] if not sections else sections
|
|
40
|
+
rows: List[dict] = []
|
|
41
|
+
excluded = 0
|
|
42
|
+
started = datetime.now(timezone.utc)
|
|
43
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
44
|
+
for key, fn, title in CLIENT_SECTIONS:
|
|
45
|
+
if key not in keys:
|
|
46
|
+
continue
|
|
47
|
+
before = len(AT._RESULTS)
|
|
48
|
+
f = getattr(AT, fn)
|
|
49
|
+
try:
|
|
50
|
+
f(tmp) if 'tmp' in f.__code__.co_varnames[:f.__code__.co_argcount] else f()
|
|
51
|
+
except Exception as ex: # a crash is a FAIL row, never a silent skip
|
|
52
|
+
AT._RESULTS.append((False, f'{key}: section raised {type(ex).__name__}: {ex}'))
|
|
53
|
+
for passed, msg in AT._RESULTS[before:]:
|
|
54
|
+
if forbidden_terms(msg):
|
|
55
|
+
excluded += 1
|
|
56
|
+
continue
|
|
57
|
+
rows.append({'step': len(rows) + 1, 'section': key, 'section_title': title, 'check': msg,
|
|
58
|
+
'expected': 'PASS', 'actual': 'PASS' if passed else 'FAIL', 'witness': ''})
|
|
59
|
+
finished = datetime.now(timezone.utc)
|
|
60
|
+
n_fail = sum(1 for r in rows if r['actual'] == 'FAIL')
|
|
61
|
+
env = None
|
|
62
|
+
if kind.upper() == 'SAT':
|
|
63
|
+
from .sbom import generate
|
|
64
|
+
env = generate()
|
|
65
|
+
return {'kind': kind.upper(), 'started_utc': started.strftime('%Y-%m-%dT%H:%M:%SZ'),
|
|
66
|
+
'finished_utc': finished.strftime('%Y-%m-%dT%H:%M:%SZ'), 'sections': keys, 'rows': rows,
|
|
67
|
+
'n_steps': len(rows), 'n_pass': len(rows) - n_fail, 'n_fail': n_fail, 'internal_checks_excluded': excluded,
|
|
68
|
+
'result': 'ACCEPTED' if n_fail == 0 and rows else 'NOT ACCEPTED', 'environment': env}
|