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/inverse_engine.py ADDED
@@ -0,0 +1,215 @@
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
+ """inverse_engine - Part 3 of the subsurface surveying tool: THE
5
+ INVERSE ENGINE (measurement -> strata, with uncertainty).
6
+
7
+ v1.77.0. The direction the whole mission points: read the ground. This first
8
+ inverse composes the two layers already built and validated:
9
+
10
+ measured borehole gravity (the catalogue's own archives)
11
+ -> implied density column (K2 forward model inverted;
12
+ GEA constants g_U, G_U, R_U)
13
+ -> posterior strata properties (strata-join conditional priors:
14
+ the library's OWN joint
15
+ distributions, P(Vp | rho), ...)
16
+ -> layer-boundary candidates (density-step detector, disclosed
17
+ threshold)
18
+
19
+ EVERY estimate carries its full chain: which constants, which prior well,
20
+ how many co-located bins support it, whether the query extrapolates beyond
21
+ the prior's observed range, and the CROSS-SITE TRANSFER assumption stated
22
+ in words (a prior learned in oceanic basalt applied to continental gneiss
23
+ is an assumption, not a fact - the engine says so on every estimate).
24
+
25
+ WHAT THIS ENGINE REFUSES TO DO
26
+ - It never averages archives into a 'truth'.
27
+ - It never returns an estimate without n, spread, and support.
28
+ - It never hides that today's priors come from ONE well family; the
29
+ posterior is exactly as provincial as the library, no more.
30
+
31
+ FALSIFIABILITY
32
+ Run on the KTB gravity column with the 504B prior, the engine PREDICTS
33
+ the KTB sonic column (Vp vs depth) - a log that exists in the GFZ
34
+ archives but is NOT yet in this catalogue. Ingesting it tests the
35
+ entire chain end-to-end. The prediction is emitted, labeled
36
+ PREDICTION_AWAITING_DATA, and pinned; it can be wrong, which is the
37
+ point.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import statistics
43
+ from dataclasses import dataclass, field
44
+ from typing import Dict, List, Optional
45
+
46
+ from .forward_model import implied_density_gcc
47
+ from .profile_catalog import CATALOG
48
+ from . import strata_join as SJ
49
+
50
+ PRIOR_FAMILIES = {
51
+ # v1.79.0 (the lesson of the first scored prediction): priors are chosen
52
+ # by GEOLOGICAL FAMILY, not by whatever well the library learned first.
53
+ # 'oceanic_igneous' draws on the 504B joint tables (basalt flank);
54
+ # 'continental_crystalline' draws SITE-NATIVE pairs from the KTB
55
+ # composite-log excerpt (entry 52 - the very data that refuted the
56
+ # transferred prediction now supplies the corrected prior).
57
+ 'oceanic_igneous': {
58
+ 'kind': 'strata_join', 'source': '504b',
59
+ 'note': 'joint tables learned in oceanic basalt (DSDP/ODP 504B)'},
60
+ 'continental_crystalline': {
61
+ 'kind': 'site_pairs', 'source': 'ktb_hb_complog_6020_excerpt',
62
+ 'note': ('site-native rho-Vp pairs from the KTB composite excerpt; '
63
+ 'washout stations (RHOB <= 2.5 g/cc against enlarged '
64
+ 'caliper) excluded with the count disclosed - hole '
65
+ 'artifacts, not rock')},
66
+ }
67
+
68
+ WASHOUT_RHO_GCC = 2.5
69
+
70
+
71
+ def _site_native_pairs(entry: str):
72
+ """Co-located (rho, Vp) pairs from a composite-log entry, washouts
73
+ excluded (disclosed). Returns (pairs, n_excluded)."""
74
+ st = CATALOG[entry].stream()
75
+ rb = [float(v) for v in st.channels['RHOB (g/cm3)'].values]
76
+ dt = [float(v) for v in st.channels['DTCO (us/m)'].values]
77
+ pairs = [(r, 1e6 / t) for r, t in zip(rb, dt) if r > WASHOUT_RHO_GCC and t > 0]
78
+ return pairs, sum(1 for r in rb if 0 < r <= WASHOUT_RHO_GCC)
79
+
80
+
81
+ def _conditional_from_pairs(pairs, value: float, k: int = 7):
82
+ """k-nearest empirical conditional over raw co-located stations - same
83
+ honesty contract as the strata-join conditional (n, spread, support,
84
+ extrapolation flag; refuses when thin)."""
85
+ if len(pairs) < 8:
86
+ return {'status': 'REFUSED_THIN_DATA', 'n': len(pairs), 'min_n': 8}
87
+ lo = min(g for g, _ in pairs)
88
+ hi = max(g for g, _ in pairs)
89
+ near = sorted(pairs, key=lambda p: abs(p[0] - value))[:max(int(k), 1)]
90
+ tv = [t for _, t in near]
91
+ est = sum(tv) / len(tv)
92
+ spread = (statistics.pstdev(tv) if len(tv) > 1 else 0.0)
93
+ return {'status': 'OK', 'estimate': est, 'std': spread, 'n': len(near),
94
+ 'support': (min(g for g, _ in near), max(g for g, _ in near)),
95
+ 'extrapolation': not (lo <= value <= hi)}
96
+
97
+
98
+ CROSS_SITE_NOTE = ("cross-site transfer: prior learned at '%s' applied at "
99
+ "'%s' - an assumption the engine discloses, not a fact")
100
+
101
+
102
+ @dataclass
103
+ class StrataEstimate:
104
+ """One inverted interval: what the ground implies, and how sure."""
105
+ depth_m: float
106
+ dz_m: float
107
+ implied_density_gcc: float
108
+ posteriors: Dict[str, Dict] = field(default_factory=dict)
109
+ chain: Dict[str, str] = field(default_factory=dict)
110
+
111
+
112
+ def detect_boundaries(depths: List[float], implied_rho: List[float],
113
+ k_sigma: float = 2.0) -> List[Dict]:
114
+ """Layer-boundary candidates: consecutive implied-density changes
115
+ exceeding k_sigma population sigmas (threshold disclosed in each hit).
116
+ Candidates, not verdicts - amplitude and depth reported, nothing
117
+ smoothed away."""
118
+ if len(implied_rho) < 3:
119
+ return []
120
+ steps = [implied_rho[i] - implied_rho[i - 1] for i in range(1, len(implied_rho))]
121
+ sd = statistics.pstdev(steps)
122
+ if sd == 0.0:
123
+ return []
124
+ return [{'depth_m': depths[i + 1], 'delta_rho_gcc': steps[i],
125
+ 'n_sigma': abs(steps[i]) / sd, 'threshold_sigma': k_sigma}
126
+ for i in range(len(steps)) if abs(steps[i]) > k_sigma * sd]
127
+
128
+
129
+ def invert_gravity_column(entry: str = 'ktb_hb_bhgm_density',
130
+ prior_well: str = '504b',
131
+ targets: tuple = ('vp', 'porosity'),
132
+ rho_null_gcc: float = 0.5,
133
+ k_sigma: float = 2.0,
134
+ prior_family: str = None) -> Dict:
135
+ """The first full inversion: a measured gravity column becomes a strata
136
+ column with uncertainty. Null stations (archive density zeros) excluded
137
+ with the count disclosed; every posterior carries n/spread/support and
138
+ the extrapolation flag from the prior itself."""
139
+ fam = PRIOR_FAMILIES.get(prior_family) if prior_family else None
140
+ fam_pairs, fam_washouts = (None, 0)
141
+ if fam and fam['kind'] == 'site_pairs':
142
+ fam_pairs, fam_washouts = _site_native_pairs(fam['source'])
143
+ st = CATALOG[entry].stream()
144
+ z = [float(v) for v in st.index]
145
+ grav = [float(v) for v in st.channels['GRAV'].values]
146
+ rho_tool = [float(v) for v in st.channels['RHO'].values]
147
+ estimates: List[StrataEstimate] = []
148
+ excluded = 0
149
+ for i in range(1, len(z)):
150
+ dz = z[i] - z[i - 1]
151
+ if dz <= 0:
152
+ continue
153
+ if rho_tool[i] <= rho_null_gcc or rho_tool[i - 1] <= rho_null_gcc:
154
+ excluded += 1
155
+ continue
156
+ rho_imp = implied_density_gcc(grav[i] - grav[i - 1], dz)
157
+ if fam and fam['kind'] == 'site_pairs':
158
+ chain = {
159
+ 'measurement': '%s GRAV interstation (mGal)' % entry,
160
+ 'density_inversion': 'K2 GEA constants (forward_model)',
161
+ 'prior': 'PRIOR_FAMILY %s: %s' % (prior_family, fam['note']),
162
+ 'assumption': ('SITE-NATIVE prior (same borehole family) - '
163
+ 'in-sample at the excerpt window, disclosed; '
164
+ '%d washout stations excluded' % fam_washouts),
165
+ }
166
+ est = StrataEstimate(depth_m=z[i], dz_m=dz,
167
+ implied_density_gcc=rho_imp, chain=chain)
168
+ est.posteriors['vp'] = _conditional_from_pairs(fam_pairs, rho_imp)
169
+ estimates.append(est)
170
+ continue
171
+ est = StrataEstimate(
172
+ depth_m=z[i], dz_m=dz, implied_density_gcc=rho_imp,
173
+ chain={
174
+ 'measurement': '%s GRAV interstation (mGal)' % entry,
175
+ 'density_inversion': 'K2 GEA constants (forward_model)',
176
+ 'prior': 'strata_join joint table, well %s' % prior_well,
177
+ 'assumption': CROSS_SITE_NOTE % (prior_well, entry),
178
+ })
179
+ for t in targets:
180
+ given = ('wet_bulk_density'
181
+ if 'wet_bulk_density' in SJ.WELL_GROUPS[prior_well][1] else None)
182
+ if given is None or t not in SJ.WELL_GROUPS[prior_well][1]:
183
+ est.posteriors[t] = {'status': 'NO_PRIOR_CHANNEL'}
184
+ continue
185
+ est.posteriors[t] = SJ.conditional(prior_well, t, given, rho_imp)
186
+ estimates.append(est)
187
+
188
+ depths = [e.depth_m for e in estimates]
189
+ rhos = [e.implied_density_gcc for e in estimates]
190
+ boundaries = detect_boundaries(depths, rhos, k_sigma)
191
+ ok_vp = [e for e in estimates
192
+ if e.posteriors.get('vp', {}).get('status') == 'OK']
193
+ in_support = [e for e in ok_vp if not e.posteriors['vp']['extrapolation']]
194
+ prediction = None
195
+ if in_support:
196
+ vps = [e.posteriors['vp']['estimate'] for e in in_support]
197
+ prediction = {
198
+ 'status': 'PREDICTION_AWAITING_DATA',
199
+ 'predicted_property': 'Vp (m/s) column at %s' % entry,
200
+ 'n_intervals_in_prior_support': len(in_support),
201
+ 'vp_range_m_s': (min(vps), max(vps)),
202
+ 'vp_mean_m_s': statistics.mean(vps),
203
+ 'test': ('ingest the %s sonic log (exists in the source archive, '
204
+ 'not yet catalogued) and score these estimates - the '
205
+ 'chain is falsified if the measured column leaves the '
206
+ 'posterior spreads' % entry),
207
+ }
208
+ return {
209
+ 'entry': entry, 'prior_well': prior_well,
210
+ 'n_intervals': len(estimates), 'null_intervals_excluded': excluded,
211
+ 'n_posterior_ok': len(ok_vp), 'n_in_prior_support': len(in_support),
212
+ 'boundary_candidates': boundaries,
213
+ 'falsifiable_prediction': prediction,
214
+ 'estimates': estimates,
215
+ }
gea/matplotlib_demo.py ADDED
@@ -0,0 +1,85 @@
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
+ """matplotlib_demo — animated demo of the GEA Downhole Simulator.
5
+
6
+ Template-faithful port (22Aug2026 thread, converged imperial version): a well
7
+ schematic on the left (gauges labeled with live P/T), pressure and temperature
8
+ strip charts on the right, FuncAnimation at 120 ms, CSV export on close.
9
+
10
+ Run: python -m gea.matplotlib_demo
11
+ Needs a display (or a matplotlib backend that has one). The engine itself is
12
+ headless — only this demo draws.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import numpy as np
18
+
19
+ from .downhole_engine import SimulatorConfig, DownholeEngine
20
+
21
+
22
+ def run_demo():
23
+ import matplotlib.pyplot as plt
24
+ from matplotlib.animation import FuncAnimation
25
+
26
+ cfg = SimulatorConfig()
27
+ engine = DownholeEngine(cfg)
28
+
29
+ fig = plt.figure(figsize=(13, 8), facecolor="#0b1220")
30
+ gs = fig.add_gridspec(2, 2, width_ratios=[1, 2.2])
31
+ ax_w = fig.add_subplot(gs[:, 0], facecolor="#0b1220")
32
+ ax_p = fig.add_subplot(gs[0, 1], facecolor="#101a2e")
33
+ ax_t = fig.add_subplot(gs[1, 1], facecolor="#101a2e")
34
+
35
+ def draw_well():
36
+ ax_w.clear()
37
+ ax_w.set_facecolor("#0b1220")
38
+ ax_w.set_xlim(-1.6, 1.6)
39
+ ax_w.set_ylim(cfg.td_ft * 1.03, -600)
40
+ ax_w.set_xticks([])
41
+ ax_w.set_ylabel("Depth (ft)", color="#e0f2fe")
42
+ ax_w.tick_params(colors="#e0f2fe")
43
+ ax_w.plot([-0.28, -0.28], [0, cfg.td_ft], color="#475569", lw=3)
44
+ ax_w.plot([0.28, 0.28], [0, cfg.td_ft], color="#475569", lw=3)
45
+ ax_w.plot([-0.28, 0.28], [cfg.td_ft, cfg.td_ft], color="#475569", lw=3)
46
+ for i, s in enumerate(engine.sensors):
47
+ ax_w.plot(0, s.depth_ft, "o", ms=10, color="#22d3ee")
48
+ ax_w.text(0.45, s.depth_ft,
49
+ f"{s.name} {engine.P[i]:.0f} psi {engine.T[i]:.1f}\N{DEGREE SIGN}F",
50
+ fontsize=8, va="center", color="#e0f2fe")
51
+
52
+ def update(_frame):
53
+ engine.step()
54
+ draw_well()
55
+ t_hist = engine.history_t
56
+ for ax, series_hist, label in ((ax_p, engine.history_P, "Pressure (psi)"),
57
+ (ax_t, engine.history_T, "Temperature (\N{DEGREE SIGN}F)")):
58
+ ax.clear()
59
+ ax.set_facecolor("#101a2e")
60
+ ax.grid(True, alpha=0.3)
61
+ ax.set_ylabel(label, color="#e0f2fe")
62
+ ax.tick_params(colors="#e0f2fe")
63
+ for i, s in enumerate(engine.sensors):
64
+ ax.plot(t_hist, [row[i] for row in series_hist], lw=1.5,
65
+ label=f"{s.name} ({s.depth_ft:.0f} ft)")
66
+ ax.legend(fontsize=7, loc="upper right")
67
+ ax_t.set_xlabel("Time (s)", color="#e0f2fe")
68
+ avg_drift = float(np.mean(engine.current_drifts))
69
+ fig.suptitle(
70
+ f"GEA Deep-Well Simulator | t={engine.time:.1f}s | "
71
+ f"Avg Drift={avg_drift:.3f}% FS/yr | TD={cfg.td_ft:.0f} ft | "
72
+ f"canonical K_MEX=25/12, \N{GREEK CAPITAL LETTER PHI}_res=0.84 (locked)",
73
+ fontsize=12, color="#67e8f9")
74
+ return []
75
+
76
+ ani = FuncAnimation(fig, update, interval=120, blit=False, cache_frame_data=False)
77
+ plt.tight_layout()
78
+ plt.show()
79
+ out = engine.export_csv()
80
+ print(f"Exported: {out.resolve()}")
81
+ return ani
82
+
83
+
84
+ if __name__ == "__main__":
85
+ run_demo()
gea/modbus.py ADDED
@@ -0,0 +1,229 @@
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
+ """modbus — the real Modbus client, connectivity tier 4 (v1.11.0).
5
+
6
+ Daniel GO 2026-08-24: implement the live-protocol tap for the G6-class target
7
+ (Modbus RS485/TCP surface interface, declared by the tool library from the
8
+ GEOQ 177 spec-table footnotes) as REAL protocol code behind a guarded
9
+ optional dependency — the same pattern as the PyQt6 front-end:
10
+
11
+ pip install pymodbus (the package works fully without it;
12
+ this tier simply refuses until installed)
13
+
14
+ Honesty rules, unchanged:
15
+ * READ-ONLY. The tap issues ONLY read_holding_registers /
16
+ read_input_registers. There is no code path that writes to a device.
17
+ * REGISTER MAPS ARE USER-SUPPLIED AND CITATION-MANDATORY. No public G6
18
+ register map was published on the fetched pages, so the library ships
19
+ NO device map — a map without a `source` citation is rejected (Rule 7),
20
+ exactly like the gauge specs. The shipped example map is labeled
21
+ EXAMPLE_TEST_FIXTURE and describes the in-process loopback server used
22
+ for verification, NOT a device.
23
+ * Decoding is done with `struct` on the raw 16-bit registers (word order
24
+ from the map) — no dependence on pymodbus payload helpers, so the tap
25
+ is stable across pymodbus versions.
26
+
27
+ Output is the same normalized `LiveStream` every other port produces; the
28
+ reconciler does not know or care that the samples came over a wire.
29
+
30
+ Headless-safe: numpy + stdlib; pymodbus only inside guarded paths.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import struct
37
+ import time
38
+ from dataclasses import dataclass, field
39
+ from pathlib import Path
40
+ from typing import Dict, List, Optional
41
+
42
+ import numpy as np
43
+
44
+ from .ports import (LiveStream, StreamChannel, PortSpec, PORT_REGISTRY)
45
+
46
+ PYMODBUS_AVAILABLE = False
47
+ try:
48
+ from pymodbus.client import ModbusTcpClient # noqa: F401
49
+ PYMODBUS_AVAILABLE = True
50
+ except ImportError:
51
+ ModbusTcpClient = None
52
+
53
+ _TYPES = {'float32': 2, 'uint16': 1, 'int16': 1, 'uint32': 2, 'int32': 2}
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class RegisterEntry:
58
+ channel: str
59
+ address: int
60
+ type: str = 'float32'
61
+ unit: str = ''
62
+ scale: float = 1.0
63
+
64
+ def word_count(self) -> int:
65
+ return _TYPES[self.type]
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class RegisterMap:
70
+ """The site's register layout. `source` citation is MANDATORY (Rule 7):
71
+ a register map must name the document it came from — a G6/site manual,
72
+ or the loopback test fixture, never an invention."""
73
+ name: str
74
+ source: str
75
+ registers: List[RegisterEntry]
76
+ unit_id: int = 1
77
+ word_order: str = 'big' # register word order for 32-bit types
78
+ table: str = 'holding' # 'holding' | 'input'
79
+
80
+ def __post_init__(self):
81
+ if not self.source or len(self.source) < 20:
82
+ raise ValueError("register map requires a substantive 'source' citation "
83
+ "(Rule 7: the library does not invent device layouts)")
84
+ for r in self.registers:
85
+ if r.type not in _TYPES:
86
+ raise ValueError(f"unsupported register type '{r.type}'")
87
+ if self.table not in ('holding', 'input'):
88
+ raise ValueError("table must be 'holding' or 'input'")
89
+
90
+
91
+ def load_register_map(path_or_dict) -> RegisterMap:
92
+ d = path_or_dict
93
+ if not isinstance(d, dict):
94
+ with Path(path_or_dict).open(encoding='utf-8') as f:
95
+ d = json.load(f)
96
+ regs = [RegisterEntry(channel=r['channel'], address=int(r['address']),
97
+ type=r.get('type', 'float32'), unit=r.get('unit', ''),
98
+ scale=float(r.get('scale', 1.0)))
99
+ for r in d['registers']]
100
+ return RegisterMap(name=d.get('name', 'register_map'), source=d.get('source', ''),
101
+ registers=regs, unit_id=int(d.get('unit_id', 1)),
102
+ word_order=d.get('word_order', 'big'),
103
+ table=d.get('table', 'holding'))
104
+
105
+
106
+ def _decode(words: List[int], rtype: str, word_order: str) -> float:
107
+ if rtype == 'uint16':
108
+ return float(words[0])
109
+ if rtype == 'int16':
110
+ return float(struct.unpack('>h', struct.pack('>H', words[0]))[0])
111
+ w = words if word_order == 'big' else list(reversed(words))
112
+ raw = struct.pack('>HH', w[0], w[1])
113
+ if rtype == 'float32':
114
+ return float(struct.unpack('>f', raw)[0])
115
+ if rtype == 'uint32':
116
+ return float(struct.unpack('>I', raw)[0])
117
+ if rtype == 'int32':
118
+ return float(struct.unpack('>i', raw)[0])
119
+ raise ValueError(rtype)
120
+
121
+
122
+ class ModbusHistorianTap:
123
+ """READ-ONLY Modbus tap: polls the mapped registers, accumulates samples,
124
+ emits the normalized LiveStream. Caller schedules the polling cadence."""
125
+
126
+ def __init__(self, host: str, port: int = 502,
127
+ register_map: RegisterMap | dict | str = None,
128
+ timeout_s: float = 3.0):
129
+ if not PYMODBUS_AVAILABLE:
130
+ raise NotImplementedError(
131
+ "Modbus tier requires the optional dependency: pip install pymodbus "
132
+ "(guarded import - the rest of the package runs without it)")
133
+ if register_map is None:
134
+ raise ValueError("a register map is required (user-supplied, cited - Rule 7)")
135
+ self.map = register_map if isinstance(register_map, RegisterMap) else load_register_map(register_map)
136
+ self.host, self.port = host, int(port)
137
+ self.client = ModbusTcpClient(host, port=int(port), timeout=timeout_s)
138
+ self._t0: Optional[float] = None
139
+ self.times: List[float] = []
140
+ self.samples: Dict[str, List[float]] = {r.channel: [] for r in self.map.registers}
141
+
142
+ def connect(self) -> bool:
143
+ return bool(self.client.connect())
144
+
145
+ def close(self) -> None:
146
+ self.client.close()
147
+
148
+ def poll_once(self) -> Dict[str, float]:
149
+ """One READ-ONLY poll of every mapped register."""
150
+ now = time.monotonic()
151
+ if self._t0 is None:
152
+ self._t0 = now
153
+ out: Dict[str, float] = {}
154
+ for r in self.map.registers:
155
+ if self.map.table == 'holding':
156
+ rr = self.client.read_holding_registers(r.address, count=r.word_count(),
157
+ device_id=self.map.unit_id)
158
+ else:
159
+ rr = self.client.read_input_registers(r.address, count=r.word_count(),
160
+ device_id=self.map.unit_id)
161
+ if rr.isError():
162
+ out[r.channel] = float('nan')
163
+ else:
164
+ out[r.channel] = _decode(list(rr.registers), r.type, self.map.word_order) * r.scale
165
+ self.times.append(now - self._t0)
166
+ for ch, v in out.items():
167
+ self.samples[ch].append(v)
168
+ return out
169
+
170
+ def collect(self, n_polls: int, interval_s: float = 0.0) -> LiveStream:
171
+ for k in range(int(n_polls)):
172
+ self.poll_once()
173
+ if interval_s > 0 and k < n_polls - 1:
174
+ time.sleep(interval_s)
175
+ return self.to_live_stream()
176
+
177
+ def to_live_stream(self) -> LiveStream:
178
+ chans = {r.channel: StreamChannel(name=r.channel, unit=r.unit,
179
+ values=np.array(self.samples[r.channel], dtype=float))
180
+ for r in self.map.registers}
181
+ return LiveStream(name=f"modbus_{self.host}", source_format='modbus_g6',
182
+ index_kind='time_s', index=np.array(self.times, dtype=float),
183
+ channels=chans,
184
+ meta={'host': self.host, 'port': str(self.port),
185
+ 'map': self.map.name, 'map_source': self.map.source[:100]})
186
+
187
+
188
+ def read_modbus(config) -> LiveStream:
189
+ """The registry reader: config is a JSON path or dict with host, port,
190
+ register_map (path/dict), polls, interval_s. Read-only end to end."""
191
+ d = config
192
+ if not isinstance(d, dict):
193
+ with Path(config).open(encoding='utf-8') as f:
194
+ d = json.load(f)
195
+ missing = [k for k in ('host', 'register_map') if not d.get(k)]
196
+ if missing:
197
+ raise NotImplementedError(
198
+ "port 'modbus_g6' is IMPLEMENTED but this call lacks site details - "
199
+ f"config missing: {', '.join(missing)}. The protocol code is real "
200
+ "(loopback-verified); the library refuses ONLY because the site's "
201
+ "host/register map is not supplied, and register maps are "
202
+ "citation-mandatory (Rule 7: no invented device layouts).")
203
+ tap = ModbusHistorianTap(host=d['host'], port=int(d.get('port', 502)),
204
+ register_map=d['register_map'],
205
+ timeout_s=float(d.get('timeout_s', 3.0)))
206
+ if not tap.connect():
207
+ raise ConnectionError(f"could not connect to {d['host']}:{d.get('port', 502)} (read-only tap)")
208
+ try:
209
+ return tap.collect(int(d.get('polls', 10)), float(d.get('interval_s', 0.0)))
210
+ finally:
211
+ tap.close()
212
+
213
+
214
+ def _refuse_no_dep(*a, **k):
215
+ raise NotImplementedError(
216
+ "port 'modbus_g6' requires the optional dependency: pip install pymodbus "
217
+ "(protocol code is implemented; only the dependency is missing)")
218
+
219
+
220
+ # Upgrade the registry entry declared in ports (imported by __init__ after ports):
221
+ PORT_REGISTRY['modbus_g6'] = PortSpec(
222
+ name='modbus_g6',
223
+ transport='Modbus TCP / RS485-gateway (G6 interface card class)',
224
+ status=('IMPLEMENTED_REQUIRES_SITE_CONFIG' if PYMODBUS_AVAILABLE
225
+ else 'DECLARED_DEPENDENCY_MISSING'),
226
+ reader=(read_modbus if PYMODBUS_AVAILABLE else _refuse_no_dep),
227
+ detail=("READ-ONLY tap; real protocol code (v1.11.0); register map is user-supplied "
228
+ "and citation-mandatory - no public G6 map exists in the fetched sources, so "
229
+ "none is shipped; loopback-verified against an in-process pymodbus server"))