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