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/store_forward.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
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
|
+
"""store_forward — the edge buffer: store-and-forward with chronological,
|
|
5
|
+
rate-controlled replay, duplicate suppression and a gap report
|
|
6
|
+
(SOW 4.2.1.6; SLA 5.0 remote-site table).
|
|
7
|
+
|
|
8
|
+
When the link from the field edge to the OT lake is down, samples are
|
|
9
|
+
buffered at the edge (a spool file, one JSON line per record, capacity in
|
|
10
|
+
hours of the tag cadence). When the link returns, live samples pass through
|
|
11
|
+
and the backlog is replayed in chronological order at a controlled rate so
|
|
12
|
+
the replay cannot flood the link; every delivered record is stamped with its
|
|
13
|
+
ingest time at the receiving layer, so latency becomes measurable per
|
|
14
|
+
record and per layer, and every (tag, timestamp) is delivered once - a
|
|
15
|
+
replayed record already delivered is suppressed and counted.
|
|
16
|
+
|
|
17
|
+
The simulation driver replays a historian export against a link schedule
|
|
18
|
+
(outage windows) with an injected clock, so the same code path serves an
|
|
19
|
+
acceptance test, a client demonstration and a live edge.
|
|
20
|
+
|
|
21
|
+
delivered = records with ingest_timestamp_utc set (source_layer OT_LAKE
|
|
22
|
+
for live delivery, OT_LAKE_REPLAY for replayed backlog)
|
|
23
|
+
gap report = per tag: samples expected, delivered live, delivered by
|
|
24
|
+
replay, dropped over capacity, duplicates suppressed,
|
|
25
|
+
true gaps after replay (missing in the source itself),
|
|
26
|
+
latency p50/p95/max, chronological order verified
|
|
27
|
+
|
|
28
|
+
Headless-safe: numpy only.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
from dataclasses import dataclass, replace, field
|
|
36
|
+
from datetime import datetime, timedelta, timezone
|
|
37
|
+
from typing import Dict, Iterable, List, Optional, Tuple
|
|
38
|
+
|
|
39
|
+
import numpy as np
|
|
40
|
+
|
|
41
|
+
from .sample_record import SampleRecord, parse_utc
|
|
42
|
+
|
|
43
|
+
CAPACITY_HOURS = 72.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _iso(dt: datetime) -> str:
|
|
47
|
+
return dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class BufferConfig:
|
|
52
|
+
capacity_hours: float = CAPACITY_HOURS
|
|
53
|
+
cadence_s: float = 60.0
|
|
54
|
+
replay_rate_per_s: float = 50.0 # records per second on replay (link protection)
|
|
55
|
+
replay_slot_s: float = 1.0 # replay is metered in slots of this length
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class OutageWindow:
|
|
60
|
+
start_utc: str
|
|
61
|
+
end_utc: str
|
|
62
|
+
|
|
63
|
+
def contains(self, t: datetime) -> bool:
|
|
64
|
+
return parse_utc(self.start_utc) <= t < parse_utc(self.end_utc)
|
|
65
|
+
|
|
66
|
+
def duration_h(self) -> float:
|
|
67
|
+
return (parse_utc(self.end_utc) - parse_utc(self.start_utc)).total_seconds() / 3600.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class StoreForwardBuffer:
|
|
71
|
+
"""Edge buffer for one site (all tags)."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, config: BufferConfig | None = None, spool_path: Optional[str] = None, n_tags: int = 1):
|
|
74
|
+
self.cfg = config or BufferConfig()
|
|
75
|
+
self.spool_path = spool_path
|
|
76
|
+
self.n_tags = max(1, n_tags)
|
|
77
|
+
self.capacity_records = int(self.cfg.capacity_hours * 3600.0 / self.cfg.cadence_s) * self.n_tags
|
|
78
|
+
self.backlog: List[SampleRecord] = []
|
|
79
|
+
self.delivered_keys: set = set()
|
|
80
|
+
self.stats = {'buffered': 0, 'replayed': 0, 'live': 0, 'dropped_over_capacity': 0,
|
|
81
|
+
'duplicates_suppressed': 0, 'peak_backlog': 0}
|
|
82
|
+
if spool_path and os.path.exists(spool_path):
|
|
83
|
+
with open(spool_path, encoding='utf-8') as f:
|
|
84
|
+
self.backlog = [SampleRecord(**json.loads(l)) for l in f if l.strip()]
|
|
85
|
+
|
|
86
|
+
def _spool(self) -> None:
|
|
87
|
+
if self.spool_path:
|
|
88
|
+
with open(self.spool_path, 'w', encoding='utf-8') as f:
|
|
89
|
+
for r in self.backlog:
|
|
90
|
+
f.write(json.dumps(r.row(), default=str) + '\n')
|
|
91
|
+
|
|
92
|
+
def offer(self, rec: SampleRecord, link_up: bool, now: datetime) -> Optional[SampleRecord]:
|
|
93
|
+
"""One sample arrives at the edge at `now`. Returns the delivered record
|
|
94
|
+
when the link is up (stamped), else buffers and returns None."""
|
|
95
|
+
key = (rec.tag_id, rec.timestamp_utc)
|
|
96
|
+
if link_up:
|
|
97
|
+
if key in self.delivered_keys:
|
|
98
|
+
self.stats['duplicates_suppressed'] += 1
|
|
99
|
+
return None
|
|
100
|
+
self.delivered_keys.add(key)
|
|
101
|
+
self.stats['live'] += 1
|
|
102
|
+
return replace(rec, ingest_timestamp_utc=_iso(now), source_layer='OT_LAKE')
|
|
103
|
+
if len(self.backlog) >= self.capacity_records:
|
|
104
|
+
self.backlog.pop(0)
|
|
105
|
+
self.stats['dropped_over_capacity'] += 1
|
|
106
|
+
self.backlog.append(rec)
|
|
107
|
+
self.stats['buffered'] += 1
|
|
108
|
+
self.stats['peak_backlog'] = max(self.stats['peak_backlog'], len(self.backlog))
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
def replay(self, now: datetime, budget: Optional[int] = None) -> List[SampleRecord]:
|
|
112
|
+
"""Deliver up to `budget` backlog records (default: rate x slot) in
|
|
113
|
+
chronological order, stamped with `now`."""
|
|
114
|
+
if not self.backlog:
|
|
115
|
+
return []
|
|
116
|
+
n = int(self.cfg.replay_rate_per_s * self.cfg.replay_slot_s) if budget is None else int(budget)
|
|
117
|
+
self.backlog.sort(key=lambda r: (r.timestamp_utc, r.tag_id))
|
|
118
|
+
out: List[SampleRecord] = []
|
|
119
|
+
while self.backlog and len(out) < n:
|
|
120
|
+
r = self.backlog.pop(0)
|
|
121
|
+
key = (r.tag_id, r.timestamp_utc)
|
|
122
|
+
if key in self.delivered_keys:
|
|
123
|
+
self.stats['duplicates_suppressed'] += 1
|
|
124
|
+
continue
|
|
125
|
+
self.delivered_keys.add(key)
|
|
126
|
+
self.stats['replayed'] += 1
|
|
127
|
+
out.append(replace(r, ingest_timestamp_utc=_iso(now), source_layer='OT_LAKE_REPLAY'))
|
|
128
|
+
self._spool()
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
def occupancy_pct(self) -> float:
|
|
132
|
+
return round(100.0 * len(self.backlog) / self.capacity_records, 2) if self.capacity_records else 0.0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Simulation driver
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
def simulate(records: Iterable[SampleRecord], outages: List[OutageWindow], config: BufferConfig | None = None,
|
|
139
|
+
edge_latency_s: float = 2.0, spool_path: Optional[str] = None) -> dict:
|
|
140
|
+
"""Drive the buffer with the records' own timestamps as the clock: each
|
|
141
|
+
sample arrives at the edge `edge_latency_s` after its timestamp; the link
|
|
142
|
+
is down inside any outage window; after an outage the backlog replays at
|
|
143
|
+
the configured rate in one-second slots interleaved with live traffic."""
|
|
144
|
+
recs = sorted(records, key=lambda r: (r.timestamp_utc, r.tag_id))
|
|
145
|
+
if not recs:
|
|
146
|
+
return {'delivered': [], 'stats': {}, 'gap_report': {}, 'outages': []}
|
|
147
|
+
tags = sorted({r.tag_id for r in recs})
|
|
148
|
+
cfg = config or BufferConfig()
|
|
149
|
+
buf = StoreForwardBuffer(cfg, spool_path=spool_path, n_tags=len(tags))
|
|
150
|
+
delivered: List[SampleRecord] = []
|
|
151
|
+
link_events: List[dict] = []
|
|
152
|
+
prev_up = None
|
|
153
|
+
# walk time in cadence steps from first to last sample, replaying in slots
|
|
154
|
+
t0 = parse_utc(recs[0].timestamp_utc)
|
|
155
|
+
t1 = parse_utc(recs[-1].timestamp_utc)
|
|
156
|
+
by_time: Dict[str, List[SampleRecord]] = {}
|
|
157
|
+
for r in recs:
|
|
158
|
+
by_time.setdefault(r.timestamp_utc, []).append(r)
|
|
159
|
+
times = sorted(by_time)
|
|
160
|
+
step = timedelta(seconds=cfg.cadence_s)
|
|
161
|
+
t = t0
|
|
162
|
+
ti = 0
|
|
163
|
+
replay_slots = 0
|
|
164
|
+
while t <= t1 + step or buf.backlog:
|
|
165
|
+
now = t + timedelta(seconds=edge_latency_s)
|
|
166
|
+
up = not any(o.contains(now) for o in outages)
|
|
167
|
+
if up != prev_up:
|
|
168
|
+
link_events.append({'timestamp_utc': _iso(now), 'link': 'UP' if up else 'DOWN', 'backlog': len(buf.backlog)})
|
|
169
|
+
prev_up = up
|
|
170
|
+
# samples with this timestamp arrive
|
|
171
|
+
while ti < len(times) and parse_utc(times[ti]) <= t:
|
|
172
|
+
for r in by_time[times[ti]]:
|
|
173
|
+
d = buf.offer(r, up, now)
|
|
174
|
+
if d is not None:
|
|
175
|
+
delivered.append(d)
|
|
176
|
+
ti += 1
|
|
177
|
+
# replay backlog in slots while the link is up
|
|
178
|
+
if up and buf.backlog:
|
|
179
|
+
slots = max(1, int(cfg.cadence_s / cfg.replay_slot_s))
|
|
180
|
+
for k in range(slots):
|
|
181
|
+
if not buf.backlog:
|
|
182
|
+
break
|
|
183
|
+
out = buf.replay(now + timedelta(seconds=k * cfg.replay_slot_s))
|
|
184
|
+
delivered.extend(out)
|
|
185
|
+
replay_slots += 1
|
|
186
|
+
t += step
|
|
187
|
+
if t > t1 + timedelta(hours=cfg.capacity_hours * 2) :
|
|
188
|
+
break
|
|
189
|
+
return {'delivered': delivered, 'stats': dict(buf.stats), 'replay_slots': replay_slots,
|
|
190
|
+
'outages': [{'start_utc': o.start_utc, 'end_utc': o.end_utc, 'duration_h': round(o.duration_h(), 3)} for o in outages],
|
|
191
|
+
'link_events': link_events, 'config': {'capacity_hours': cfg.capacity_hours, 'cadence_s': cfg.cadence_s,
|
|
192
|
+
'replay_rate_per_s': cfg.replay_rate_per_s, 'capacity_records': buf.capacity_records,
|
|
193
|
+
'edge_latency_s': edge_latency_s},
|
|
194
|
+
'gap_report': gap_report(recs, delivered, tags), 'final_backlog': len(buf.backlog)}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def gap_report(source: List[SampleRecord], delivered: List[SampleRecord], tags: Optional[List[str]] = None) -> dict:
|
|
198
|
+
tags = tags or sorted({r.tag_id for r in source})
|
|
199
|
+
out: Dict[str, dict] = {}
|
|
200
|
+
dl: Dict[str, List[SampleRecord]] = {}
|
|
201
|
+
for d in delivered:
|
|
202
|
+
dl.setdefault(d.tag_id, []).append(d)
|
|
203
|
+
for tag in tags:
|
|
204
|
+
src = [r for r in source if r.tag_id == tag]
|
|
205
|
+
got = dl.get(tag, [])
|
|
206
|
+
keys_src = {r.timestamp_utc for r in src}
|
|
207
|
+
keys_got = [r.timestamp_utc for r in got]
|
|
208
|
+
live = sum(1 for r in got if r.source_layer == 'OT_LAKE')
|
|
209
|
+
rep = sum(1 for r in got if r.source_layer == 'OT_LAKE_REPLAY')
|
|
210
|
+
missing = sorted(keys_src - set(keys_got))
|
|
211
|
+
true_gaps = sum(1 for r in src if r.quality_flag == 'GAP')
|
|
212
|
+
lat = np.array([r.latency_s() for r in got if r.latency_s() is not None], dtype=float)
|
|
213
|
+
# chronological: replayed records must be non-decreasing in sample time within the replay sequence
|
|
214
|
+
rep_times = [r.timestamp_utc for r in got if r.source_layer == 'OT_LAKE_REPLAY']
|
|
215
|
+
chrono = rep_times == sorted(rep_times)
|
|
216
|
+
dup = len(keys_got) - len(set(keys_got))
|
|
217
|
+
out[tag] = {'expected': len(src), 'delivered': len(got), 'delivered_live': live, 'delivered_by_replay': rep,
|
|
218
|
+
'not_delivered': len(missing), 'source_gaps_in_data': true_gaps,
|
|
219
|
+
'duplicates_in_delivery': dup, 'chronological_replay': chrono,
|
|
220
|
+
'latency_p50_s': (round(float(np.percentile(lat, 50)), 1) if len(lat) else None),
|
|
221
|
+
'latency_p95_s': (round(float(np.percentile(lat, 95)), 1) if len(lat) else None),
|
|
222
|
+
'latency_max_s': (round(float(lat.max()), 1) if len(lat) else None),
|
|
223
|
+
'pct_within_2min': (round(100.0 * float(np.mean(lat <= 120)), 2) if len(lat) else None),
|
|
224
|
+
'pct_within_5min': (round(100.0 * float(np.mean(lat <= 300)), 2) if len(lat) else None)}
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def parse_outages(specs: Iterable[str]) -> List[OutageWindow]:
|
|
229
|
+
"""'START,END' ISO pairs -> OutageWindow list."""
|
|
230
|
+
out = []
|
|
231
|
+
for s in specs:
|
|
232
|
+
a, b = s.split(',')
|
|
233
|
+
out.append(OutageWindow(a.strip(), b.strip()))
|
|
234
|
+
return out
|
gea/strata_join.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
"""strata_join - the depth-join / joint-distribution layer of the strata-inference engine.
|
|
5
|
+
|
|
6
|
+
v1.69.0 (first layer of the ground-strata imaging mission, Daniel's 2026-08-28 direction).
|
|
7
|
+
|
|
8
|
+
WHAT THIS IS
|
|
9
|
+
The catalogue holds 50 wells, but its entries are single-property tables.
|
|
10
|
+
Probabilistic strata inference needs JOINT observations: many properties,
|
|
11
|
+
same borehole, overlapping depths. This module depth-aligns the catalogue
|
|
12
|
+
entries that share a borehole into co-located joint property tables, and
|
|
13
|
+
exposes the empirical structure those tables contain:
|
|
14
|
+
|
|
15
|
+
joint_table(well) - depth-binned matrix of co-located properties
|
|
16
|
+
pair_stats(well, a, b) - co-located sample count + Pearson r for a pair
|
|
17
|
+
conditional(well, t, g, v) - empirical P(target | given=v): k-nearest
|
|
18
|
+
co-located bins -> estimate, spread, support
|
|
19
|
+
library_pairs() - every property pair in the library with
|
|
20
|
+
enough co-located support to mean anything
|
|
21
|
+
|
|
22
|
+
HONESTY RULES (same discipline as the catalogue itself)
|
|
23
|
+
- No interpolation is invented: a depth bin carries a property value only
|
|
24
|
+
if the archive actually sampled that property inside the bin (bin mean).
|
|
25
|
+
- Thin data REFUSES: pair statistics below MIN_PAIR_N co-located bins and
|
|
26
|
+
conditionals below MIN_COND_N return a REFUSED status instead of a number.
|
|
27
|
+
- Extrapolation is labeled: a conditional queried outside the observed
|
|
28
|
+
range of the conditioning property says so in its own output.
|
|
29
|
+
- Every number is recomputed from the verbatim catalogue archives at call
|
|
30
|
+
time; nothing here is fitted, stored, or tuned.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import math
|
|
36
|
+
|
|
37
|
+
from .profile_catalog import CATALOG
|
|
38
|
+
|
|
39
|
+
# Wells with >= 2 depth-indexed catalogue entries whose coverage overlaps.
|
|
40
|
+
# well -> (default_bin_m, {property: (catalogue_entry, channel)})
|
|
41
|
+
WELL_GROUPS = {
|
|
42
|
+
"504b": (5.0, {
|
|
43
|
+
"porosity": ("dsdp_504b_physical_properties", "Poros"),
|
|
44
|
+
"wet_bulk_density": ("dsdp_504b_physical_properties", "WBD"),
|
|
45
|
+
"grain_density": ("dsdp_504b_physical_properties", "Density grain"),
|
|
46
|
+
"water_content": ("dsdp_504b_physical_properties", "Water wm"),
|
|
47
|
+
"vp": ("dsdp_504b_sound_velocity", "Vp"),
|
|
48
|
+
}),
|
|
49
|
+
"ktb_hb": (250.0, {
|
|
50
|
+
"density": ("ktb_hb_bhgm_density", "RHO"),
|
|
51
|
+
"temperature": ("ktb_hb_hlog246_temperature", "TMP3"),
|
|
52
|
+
"compressive_strength": ("ktb_hb_rockmech_compress",
|
|
53
|
+
"COMPRESSIVE_STRENGTH"),
|
|
54
|
+
"youngs_modulus": ("ktb_hb_rockmech_compress", "E_MODUL"),
|
|
55
|
+
}),
|
|
56
|
+
"site_1027": (60.0, {
|
|
57
|
+
"thermal_conductivity": ("odp_1027b_thermal_conductivity", "k"),
|
|
58
|
+
"cork_temperature": ("odp_1027c_cork_temperature", "t (1999)"),
|
|
59
|
+
}),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
MIN_PAIR_N = 8 # co-located bins below this: pair statistics refuse
|
|
63
|
+
MIN_COND_N = 5 # neighbours below this: conditional refuses
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _series(entry, channel):
|
|
67
|
+
"""Depth-aligned finite samples of one catalogue channel."""
|
|
68
|
+
st = CATALOG[entry].stream()
|
|
69
|
+
if st.index_kind != "depth":
|
|
70
|
+
raise ValueError("%s is not depth-indexed" % entry)
|
|
71
|
+
ch = st.channels[channel]
|
|
72
|
+
out = [(float(d), float(v)) for d, v in zip(st.index, ch.values)
|
|
73
|
+
if v == v]
|
|
74
|
+
return out, ch.unit
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def joint_table(well, bin_m=None):
|
|
78
|
+
"""Depth-binned co-located property matrix for one well group.
|
|
79
|
+
|
|
80
|
+
Returns {'well', 'bin_m', 'bins': [centres], 'units': {prop: unit},
|
|
81
|
+
'columns': {prop: [mean-or-None per bin]}, 'counts': {prop: [n per bin]}}.
|
|
82
|
+
A bin row exists only where at least two properties are populated.
|
|
83
|
+
"""
|
|
84
|
+
default_bin, props = WELL_GROUPS[well]
|
|
85
|
+
bin_m = float(bin_m or default_bin)
|
|
86
|
+
data, units = {}, {}
|
|
87
|
+
lo, hi = math.inf, -math.inf
|
|
88
|
+
for prop, (entry, channel) in props.items():
|
|
89
|
+
samples, unit = _series(entry, channel)
|
|
90
|
+
data[prop] = samples
|
|
91
|
+
units[prop] = unit
|
|
92
|
+
if samples:
|
|
93
|
+
lo = min(lo, min(d for d, _ in samples))
|
|
94
|
+
hi = max(hi, max(d for d, _ in samples))
|
|
95
|
+
first = math.floor(lo / bin_m) * bin_m
|
|
96
|
+
nbins = int(math.ceil((hi - first) / bin_m)) + 1
|
|
97
|
+
sums = {p: [0.0] * nbins for p in props}
|
|
98
|
+
counts = {p: [0] * nbins for p in props}
|
|
99
|
+
for prop, samples in data.items():
|
|
100
|
+
for d, v in samples:
|
|
101
|
+
i = int((d - first) / bin_m)
|
|
102
|
+
sums[prop][i] += v
|
|
103
|
+
counts[prop][i] += 1
|
|
104
|
+
keep = [i for i in range(nbins)
|
|
105
|
+
if sum(1 for p in props if counts[p][i]) >= 2]
|
|
106
|
+
return {
|
|
107
|
+
"well": well, "bin_m": bin_m,
|
|
108
|
+
"bins": [first + (i + 0.5) * bin_m for i in keep],
|
|
109
|
+
"units": units,
|
|
110
|
+
"columns": {p: [(sums[p][i] / counts[p][i]) if counts[p][i] else None
|
|
111
|
+
for i in keep] for p in props},
|
|
112
|
+
"counts": {p: [counts[p][i] for i in keep] for p in props},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _colocated(well, a, b, bin_m=None):
|
|
117
|
+
t = joint_table(well, bin_m)
|
|
118
|
+
xa, xb = t["columns"][a], t["columns"][b]
|
|
119
|
+
return [(x, y) for x, y in zip(xa, xb)
|
|
120
|
+
if x is not None and y is not None], t["bin_m"]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def pair_stats(well, a, b, bin_m=None):
|
|
124
|
+
"""Co-located Pearson statistics for one property pair; refuses when thin."""
|
|
125
|
+
pairs, used_bin = _colocated(well, a, b, bin_m)
|
|
126
|
+
n = len(pairs)
|
|
127
|
+
if n < MIN_PAIR_N:
|
|
128
|
+
return {"status": "REFUSED_THIN_DATA", "well": well, "pair": (a, b),
|
|
129
|
+
"n": n, "min_n": MIN_PAIR_N, "bin_m": used_bin}
|
|
130
|
+
xs = [p[0] for p in pairs]
|
|
131
|
+
ys = [p[1] for p in pairs]
|
|
132
|
+
mx, my = sum(xs) / n, sum(ys) / n
|
|
133
|
+
sx = math.sqrt(sum((x - mx) ** 2 for x in xs) / (n - 1))
|
|
134
|
+
sy = math.sqrt(sum((y - my) ** 2 for y in ys) / (n - 1))
|
|
135
|
+
if sx == 0.0 or sy == 0.0:
|
|
136
|
+
return {"status": "REFUSED_ZERO_VARIANCE", "well": well,
|
|
137
|
+
"pair": (a, b), "n": n, "bin_m": used_bin}
|
|
138
|
+
r = (sum((x - mx) * (y - my) for x, y in pairs) / (n - 1)) / (sx * sy)
|
|
139
|
+
return {"status": "OK", "well": well, "pair": (a, b), "n": n,
|
|
140
|
+
"bin_m": used_bin, "pearson_r": r,
|
|
141
|
+
"mean": {a: mx, b: my}, "std": {a: sx, b: sy},
|
|
142
|
+
"range": {a: (min(xs), max(xs)), b: (min(ys), max(ys))}}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def conditional(well, target, given, value, k=7, bin_m=None):
|
|
146
|
+
"""Empirical estimate of `target` given `given` = value, from the k
|
|
147
|
+
nearest co-located bins. Honest output: estimate, spread, the support
|
|
148
|
+
actually used, and an extrapolation flag; refuses when thin."""
|
|
149
|
+
pairs, used_bin = _colocated(well, given, target, bin_m)
|
|
150
|
+
if len(pairs) < MIN_PAIR_N:
|
|
151
|
+
return {"status": "REFUSED_THIN_DATA", "well": well, "n": len(pairs),
|
|
152
|
+
"min_n": MIN_PAIR_N, "bin_m": used_bin}
|
|
153
|
+
value = float(value)
|
|
154
|
+
lo = min(g for g, _ in pairs)
|
|
155
|
+
hi = max(g for g, _ in pairs)
|
|
156
|
+
near = sorted(pairs, key=lambda p: abs(p[0] - value))[:max(int(k), 1)]
|
|
157
|
+
n = len(near)
|
|
158
|
+
if n < MIN_COND_N:
|
|
159
|
+
return {"status": "REFUSED_THIN_DATA", "well": well, "n": n,
|
|
160
|
+
"min_n": MIN_COND_N, "bin_m": used_bin}
|
|
161
|
+
tv = [t for _, t in near]
|
|
162
|
+
est = sum(tv) / n
|
|
163
|
+
spread = (math.sqrt(sum((t - est) ** 2 for t in tv) / (n - 1))
|
|
164
|
+
if n > 1 else 0.0)
|
|
165
|
+
return {"status": "OK", "well": well, "target": target,
|
|
166
|
+
"given": {given: value}, "estimate": est, "std": spread, "n": n,
|
|
167
|
+
"support": {given: (min(g for g, _ in near),
|
|
168
|
+
max(g for g, _ in near))},
|
|
169
|
+
"extrapolation": not (lo <= value <= hi), "bin_m": used_bin}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def library_pairs(bin_m=None):
|
|
173
|
+
"""Every property pair in every well group, with co-located support.
|
|
174
|
+
OK rows sorted by |r| descending; refused rows listed after, so thin
|
|
175
|
+
joins stay visible instead of disappearing."""
|
|
176
|
+
ok, refused = [], []
|
|
177
|
+
for well, (_, props) in WELL_GROUPS.items():
|
|
178
|
+
names = sorted(props)
|
|
179
|
+
for i, a in enumerate(names):
|
|
180
|
+
for b in names[i + 1:]:
|
|
181
|
+
s = pair_stats(well, a, b, bin_m)
|
|
182
|
+
(ok if s["status"] == "OK" else refused).append(s)
|
|
183
|
+
ok.sort(key=lambda s: -abs(s["pearson_r"]))
|
|
184
|
+
return {"ok": ok, "refused": refused,
|
|
185
|
+
"wells": sorted(WELL_GROUPS), "n_ok": len(ok),
|
|
186
|
+
"n_refused": len(refused)}
|