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/ports.py ADDED
@@ -0,0 +1,340 @@
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
+ """ports — the live-stream ports/plug-in layer (v1.8.0 extension).
5
+
6
+ Piece 2 of the two-stream build (Daniel's architecture, PAPER_2256 appendix 6):
7
+ READ-ONLY taps that ingest live-stream data from site logging systems into a
8
+ single normalized form — `LiveStream` — that the reconciler (piece 3) will
9
+ coordinate against the closed stream's predictions.
10
+
11
+ Design rules:
12
+ * READ-ONLY BY DESIGN. Ports ingest; they never write to, poll-configure,
13
+ or control a site system. File readers open files for reading only, and
14
+ the declared network taps are specified read-only.
15
+ * PLUG-IN REGISTRY. Every port is an entry in `PORT_REGISTRY` with an
16
+ explicit status: IMPLEMENTED entries parse today; DECLARED entries name
17
+ the protocol (from the tool library's telemetry_interface declarations)
18
+ but REFUSE to run until real site details exist — the same
19
+ no-invented-behavior pattern as the tool library's user-supplied specs.
20
+ `register_port()` lets a site plug in its own reader without touching
21
+ this module.
22
+ * ROUND-TRIP VERIFIED. The v1.3.0 telemetry layer exports field-historian
23
+ CSVs; the historian port re-ingests them bit-consistently (MISSING ->
24
+ NaN, flags carried). Closed stream -> simulated live file -> port ->
25
+ the same numbers: the ingest path is proven in simulation before it
26
+ ever touches a site.
27
+
28
+ Implemented file formats:
29
+ * historian_csv — wide-format historian export: first column timestamp
30
+ (ISO or numeric), remaining columns channels; blank cells = missing.
31
+ Auto-detects the v1.3.0 telemetry export layout (flag_* columns).
32
+ * las2 — LAS 2.0 well-log files (public standard, Canadian Well Logging
33
+ Society): ~V/~W/~C/~A sections, NULL substitution, unwrapped data.
34
+ Wrapped-mode files are REFUSED (unsupported), never mis-parsed.
35
+
36
+ Headless-safe: numpy only.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import csv
42
+ import math
43
+ from dataclasses import dataclass, field
44
+ from datetime import datetime
45
+ from pathlib import Path
46
+ from typing import Callable, Dict, List, Optional
47
+
48
+ import numpy as np
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # The normalized live-stream model
53
+ # ---------------------------------------------------------------------------
54
+ @dataclass
55
+ class StreamChannel:
56
+ name: str
57
+ unit: str
58
+ values: np.ndarray # NaN = missing
59
+ quality: Optional[List[str]] = None # per-sample flags if the source carries them
60
+
61
+ def coverage_pct(self) -> float:
62
+ n = len(self.values)
63
+ return round(100.0 * float(np.sum(~np.isnan(self.values))) / n, 2) if n else 0.0
64
+
65
+
66
+ @dataclass
67
+ class LiveStream:
68
+ """The normalized ingest product: an index (time OR depth) + channels.
69
+
70
+ index_kind is 'time_s' (elapsed seconds; historian) or 'depth' (LAS).
71
+ This is the object the reconciler hangs on the closed stream.
72
+ """
73
+ name: str
74
+ source_format: str
75
+ index_kind: str
76
+ index: np.ndarray
77
+ channels: Dict[str, StreamChannel] = field(default_factory=dict)
78
+ meta: Dict[str, str] = field(default_factory=dict)
79
+
80
+ def channel(self, name: str) -> StreamChannel:
81
+ return self.channels[name]
82
+
83
+ def summary(self) -> dict:
84
+ return {
85
+ 'name': self.name,
86
+ 'source_format': self.source_format,
87
+ 'index_kind': self.index_kind,
88
+ 'samples': int(len(self.index)),
89
+ 'channels': {n: {'unit': c.unit, 'coverage_pct': c.coverage_pct()}
90
+ for n, c in self.channels.items()},
91
+ 'meta': dict(self.meta),
92
+ }
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # historian_csv reader (auto-detects the v1.3.0 telemetry export layout)
97
+ # ---------------------------------------------------------------------------
98
+ def _parse_time(s: str, t0: Optional[datetime]) -> tuple:
99
+ try:
100
+ return float(s), t0
101
+ except ValueError:
102
+ dt = datetime.fromisoformat(s)
103
+ if t0 is None:
104
+ t0 = dt
105
+ return (dt - t0).total_seconds(), t0
106
+
107
+
108
+ def read_historian_csv(path) -> LiveStream:
109
+ """Wide-format historian export: col 0 = timestamp (ISO or numeric),
110
+ remaining columns = channels; blank cells = missing -> NaN. Columns named
111
+ flag_* (the v1.3.0 telemetry layout) become per-gauge quality flags
112
+ attached to that gauge's channels instead of numeric channels."""
113
+ p = Path(path)
114
+ with p.open(newline="") as f:
115
+ rows = list(csv.reader(f))
116
+ if len(rows) < 2:
117
+ raise ValueError(f"{p}: no data rows")
118
+ header = rows[0]
119
+ data = rows[1:]
120
+ n = len(data)
121
+ times = np.zeros(n)
122
+ t0 = None
123
+ for i, r in enumerate(data):
124
+ times[i], t0 = _parse_time(r[0], t0)
125
+ flag_cols = {j: h for j, h in enumerate(header) if h.startswith('flag_')}
126
+ chan_cols = [j for j in range(1, len(header)) if j not in flag_cols]
127
+ channels: Dict[str, StreamChannel] = {}
128
+ for j in chan_cols:
129
+ vals = np.full(n, np.nan)
130
+ for i, r in enumerate(data):
131
+ cell = r[j].strip() if j < len(r) else ''
132
+ if cell:
133
+ try:
134
+ vals[i] = float(cell)
135
+ except ValueError:
136
+ pass # non-numeric cell in a numeric channel -> missing
137
+ name = header[j]
138
+ unit = 'psi' if 'psi' in name.lower() else ('degF' if ('_f_' in name.lower() or name.lower().endswith('_f') or 't_' in name.lower()[:2]) else '')
139
+ channels[name] = StreamChannel(name=name, unit=unit, values=vals)
140
+ for j, h in flag_cols.items():
141
+ tag = h[len('flag_'):] # e.g. 'S1'
142
+ flags = [(r[j].strip() if j < len(r) else '') for r in data]
143
+ for cname, ch in channels.items():
144
+ if cname.endswith('_' + tag) or ('_' + tag + '_') in cname:
145
+ ch.quality = flags
146
+ meta = {'path': str(p), 'columns': str(len(header))}
147
+ if t0 is not None:
148
+ meta['start_time'] = t0.isoformat() # ISO origin of the elapsed-seconds index (client records)
149
+ return LiveStream(name=p.stem, source_format='historian_csv',
150
+ index_kind='time_s', index=times, channels=channels,
151
+ meta=meta)
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # LAS 2.0 reader (public well-log standard; unwrapped mode)
156
+ # ---------------------------------------------------------------------------
157
+ def read_las(path) -> LiveStream:
158
+ """LAS 2.0 reader: ~Version/~Well/~Parameter/~Curve/~ASCII sections,
159
+ NULL-value substitution -> NaN, depth-indexed curves.
160
+
161
+ WRAP. NO: one line per depth step. WRAP. YES (v1.13.0, driven by the real
162
+ Kennetcook #2 / P-129 catalogue well): records are assembled by
163
+ accumulating values until the curve count is reached - honest parsing
164
+ replaced the earlier refusal once a real wrapped file existed to verify
165
+ against. Records with a wrong value count are DROPPED, not guessed.
166
+
167
+ Meta captures WELL/COMP/FLD/DATE from ~W plus real downhole anchors from
168
+ ~P when present (BHT, TMAX, MRT1, TDL, TDD, with units) - the converter's
169
+ measured-BHT tier feeds on these."""
170
+ p = Path(path)
171
+ lines = p.read_text(encoding='utf-8', errors='ignore').splitlines()
172
+ section = ''
173
+ wrap = False
174
+ vers = 2.0
175
+ null_val = -999.25 # LAS convention default
176
+ curves: List[tuple] = [] # (mnemonic, unit)
177
+ data_rows: List[List[float]] = []
178
+ pend: List[float] = [] # wrapped-record accumulator
179
+ meta: Dict[str, str] = {}
180
+ _P_ANCHORS = ('BHT', 'TMAX', 'MRT1', 'TDL', 'TDD')
181
+ for ln in lines:
182
+ s = ln.strip()
183
+ if not s or s.startswith('#'):
184
+ continue
185
+ if s.startswith('~'):
186
+ section = s[1].upper()
187
+ continue
188
+ if section == 'V':
189
+ if s.upper().startswith('WRAP') and '.' in s:
190
+ wrap = s.split('.', 1)[1].strip().upper().startswith('YES')
191
+ if s.upper().startswith('VERS') and '.' in s:
192
+ v = s.split('.', 1)[1].split(':')[0].strip().split()
193
+ if v and _is_float(v[0]):
194
+ vers = float(v[0])
195
+ elif section == 'W':
196
+ if s.upper().startswith('NULL') and '.' in s:
197
+ body = s.split('.', 1)[1]
198
+ val = body.split(':')[0].strip().split()
199
+ if val:
200
+ try:
201
+ null_val = float(val[-1])
202
+ except ValueError:
203
+ pass
204
+ for key in ('WELL', 'COMP', 'FLD', 'DATE'):
205
+ if s.upper().startswith(key):
206
+ if vers < 2.0 and ':' in s:
207
+ # LAS 1.x convention: the VALUE sits AFTER the colon
208
+ meta[key] = s.split(':', 1)[1].strip()
209
+ else:
210
+ meta[key] = s.split(':', 1)[0].split('.', 1)[-1].strip() if '.' in s else s
211
+ elif section == 'P':
212
+ head = s.split(':', 1)[0]
213
+ if '.' in head:
214
+ mnem, rest = head.split('.', 1)
215
+ mnem = mnem.strip().upper()
216
+ if mnem in _P_ANCHORS:
217
+ parts = rest.strip().split()
218
+ if parts:
219
+ unit = parts[0] if not _is_float(parts[0]) else ''
220
+ vals = [x for x in parts if _is_float(x)]
221
+ if vals:
222
+ meta[mnem] = vals[-1]
223
+ if unit:
224
+ meta[mnem + '_UNIT'] = unit
225
+ elif section == 'C':
226
+ head = s.split(':', 1)[0]
227
+ if '.' in head:
228
+ mnem, rest = head.split('.', 1)
229
+ curves.append((mnem.strip(), rest.strip().split()[0] if rest.strip() else ''))
230
+ elif section == 'A':
231
+ try:
232
+ vals = [float(x) for x in s.split()]
233
+ except ValueError:
234
+ continue
235
+ if not wrap:
236
+ data_rows.append(vals)
237
+ else:
238
+ pend.extend(vals)
239
+ while len(pend) >= len(curves) > 0:
240
+ data_rows.append(pend[:len(curves)])
241
+ pend = pend[len(curves):]
242
+ if wrap and pend:
243
+ pass # trailing partial record dropped, not guessed
244
+ if not curves or not data_rows:
245
+ raise ValueError(f"{p}: no curves or no data (need ~Curve and ~ASCII sections)")
246
+ width = len(curves)
247
+ arr = np.full((len(data_rows), width), np.nan)
248
+ for i, r in enumerate(data_rows):
249
+ for j in range(min(width, len(r))):
250
+ arr[i, j] = r[j]
251
+ arr[arr == null_val] = np.nan
252
+ index = arr[:, 0]
253
+ channels = {m: StreamChannel(name=m, unit=u, values=arr[:, j])
254
+ for j, (m, u) in enumerate(curves) if j > 0}
255
+ return LiveStream(name=p.stem, source_format='las2',
256
+ index_kind='depth', index=index, channels=channels, meta=meta)
257
+
258
+
259
+ def _is_float(x: str) -> bool:
260
+ try:
261
+ float(x)
262
+ return True
263
+ except ValueError:
264
+ return False
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # The plug-in registry
269
+ # ---------------------------------------------------------------------------
270
+ IMPLEMENTED = "IMPLEMENTED"
271
+ DECLARED_SITE_DETAILS_REQUIRED = "DECLARED_SITE_DETAILS_REQUIRED"
272
+
273
+
274
+ @dataclass(frozen=True)
275
+ class PortSpec:
276
+ name: str
277
+ transport: str
278
+ status: str
279
+ reader: Optional[Callable] = None
280
+ detail: str = ""
281
+
282
+
283
+ def _refuse(name, need):
284
+ def f(*a, **k):
285
+ raise NotImplementedError(
286
+ f"port '{name}' is DECLARED but not implemented - {need}. "
287
+ "The registry names the protocol; it does not invent site behavior. "
288
+ "Provide site details (or a site reader via register_port).")
289
+ return f
290
+
291
+
292
+ PORT_REGISTRY: Dict[str, PortSpec] = {
293
+ 'historian_csv': PortSpec(
294
+ name='historian_csv', transport='file (wide-format historian CSV export)',
295
+ status=IMPLEMENTED, reader=read_historian_csv,
296
+ detail="round-trip verified against the v1.3.0 telemetry export layout"),
297
+ 'las2': PortSpec(
298
+ name='las2', transport='file (LAS 2.0 well log, CWLS public standard)',
299
+ status=IMPLEMENTED, reader=read_las,
300
+ detail="unwrapped mode; NULL substitution; wrapped mode refused"),
301
+ # NOTE (v1.41.0): this base entry is the PRE-IMPORT fallback only. Importing
302
+ # modbus (which the package __init__ always does) UPGRADES this entry in
303
+ # place to the real pymodbus TCP client (reader=read_modbus, status=
304
+ # IMPLEMENTED_REQUIRES_SITE_CONFIG when pymodbus is installed). A static read
305
+ # of this file alone therefore understates the shipped capability - this
306
+ # comment exists so source and runtime tell the same story.
307
+ 'modbus_g6': PortSpec(
308
+ name='modbus_g6', transport='Modbus RS485 (G6 interface card; 4-20mA analog alt.)',
309
+ status=DECLARED_SITE_DETAILS_REQUIRED, reader=_refuse('modbus_g6',
310
+ "needs the site's register map and polling parameters (target declared by the "
311
+ "tool library's surface_interface_g6 entry, GEOQ 177 spec-table footnote); "
312
+ "NOTE: modbus upgrades this entry to the real client at package import"),
313
+ detail="READ-ONLY tap; base declaration - upgraded in place by modbus at package import (see modbus.py tail)"),
314
+ 'witsml': PortSpec(
315
+ name='witsml', transport='WITSML server (rig-site data exchange standard)',
316
+ status=DECLARED_SITE_DETAILS_REQUIRED, reader=_refuse('witsml',
317
+ "needs the site's server URL, version (1.4.1/2.0), and credentials"),
318
+ detail="READ-ONLY query of log/trajectory objects"),
319
+ 'opcua': PortSpec(
320
+ name='opcua', transport='OPC-UA (plant/SCADA historian access)',
321
+ status=DECLARED_SITE_DETAILS_REQUIRED, reader=_refuse('opcua',
322
+ "needs the site's endpoint and node ids"),
323
+ detail="READ-ONLY subscription to gauge tags"),
324
+ }
325
+
326
+
327
+ def register_port(name: str, transport: str, reader: Callable, detail: str = "") -> None:
328
+ """Plug a site-specific reader in without touching this module.
329
+ The reader must return a LiveStream. Registration is additive only."""
330
+ PORT_REGISTRY[name] = PortSpec(name=name, transport=transport,
331
+ status=IMPLEMENTED, reader=reader, detail=detail)
332
+
333
+
334
+ def ingest(source, port: str = 'historian_csv') -> LiveStream:
335
+ """The single entry point: ingest a source through a named port."""
336
+ spec = PORT_REGISTRY[port]
337
+ stream = spec.reader(source)
338
+ if not isinstance(stream, LiveStream):
339
+ raise TypeError(f"port '{port}' returned {type(stream).__name__}, not LiveStream")
340
+ return stream