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