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
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}