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/follower.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
"""follower — the quasi-live file-follower port (v1.10.0 extension).
|
|
5
|
+
|
|
6
|
+
Addresses the connectivity concern (Daniel, 2026-08-24) at the tier that is
|
|
7
|
+
buildable TODAY with zero network code and zero invented site behavior: most
|
|
8
|
+
historians can be configured to continuously append-export their readings to
|
|
9
|
+
a file. `HistorianFollower` watches that growing file, re-ingests it on each
|
|
10
|
+
poll, and reports how many NEW samples arrived — near-real-time monitoring
|
|
11
|
+
through the same read-only port machinery, feeding the same reconciler.
|
|
12
|
+
|
|
13
|
+
Design:
|
|
14
|
+
* READ-ONLY, like every port. The follower never touches the source file
|
|
15
|
+
beyond opening it for reading.
|
|
16
|
+
* RE-INGEST, not tail-parse. Each poll re-reads the whole file through
|
|
17
|
+
`read_historian_csv` and diffs by sample count. This is deliberately
|
|
18
|
+
simple and robust: partial trailing lines, historian rewrites, and file
|
|
19
|
+
rotation (row count DROPS -> follower resets and says so) are all handled
|
|
20
|
+
by construction rather than by fragile incremental parsing. Historian
|
|
21
|
+
exports at 1 reading/min are small; re-reading them is cheap.
|
|
22
|
+
* NO SLEEP LOOPS inside the library. `poll()` is a single step the caller
|
|
23
|
+
schedules (cron, scheduler, or the `watch()` convenience generator which
|
|
24
|
+
sleeps between polls only when the caller iterates it).
|
|
25
|
+
|
|
26
|
+
Connectivity tiers after this module (the honest ladder):
|
|
27
|
+
SIMULATE (v1.3.0) -> OFFLINE-INGEST real exports (v1.8.0)
|
|
28
|
+
-> FILE-FOLLOW quasi-live (THIS) -> LIVE PROTOCOL (declared, refusing
|
|
29
|
+
until site details; Modbus client pending Daniel's dependency ruling).
|
|
30
|
+
|
|
31
|
+
Headless-safe: numpy only.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import time
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Iterator, Optional
|
|
40
|
+
|
|
41
|
+
from .ports import LiveStream, read_historian_csv
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class FollowerPoll:
|
|
46
|
+
"""One poll result: the full current stream + what changed."""
|
|
47
|
+
stream: Optional[LiveStream]
|
|
48
|
+
total_samples: int
|
|
49
|
+
new_samples: int
|
|
50
|
+
rotated: bool # True when the file shrank (rotation/rewrite) and the follower reset
|
|
51
|
+
error: Optional[str] = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HistorianFollower:
|
|
55
|
+
"""Follows a continuously-appended historian CSV export (read-only)."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, path, reconciler=None):
|
|
58
|
+
self.path = Path(path)
|
|
59
|
+
self.reconciler = reconciler # optional: a Reconciler to run each poll
|
|
60
|
+
self._seen = 0
|
|
61
|
+
|
|
62
|
+
def poll(self) -> FollowerPoll:
|
|
63
|
+
"""One follow step: re-ingest, diff by sample count, optionally
|
|
64
|
+
reconcile. Missing/unreadable file is a soft error (sites glitch)."""
|
|
65
|
+
try:
|
|
66
|
+
stream = read_historian_csv(self.path)
|
|
67
|
+
except (OSError, ValueError) as e:
|
|
68
|
+
return FollowerPoll(stream=None, total_samples=self._seen,
|
|
69
|
+
new_samples=0, rotated=False, error=str(e)[:120])
|
|
70
|
+
n = int(len(stream.index))
|
|
71
|
+
rotated = n < self._seen
|
|
72
|
+
new = n if rotated else n - self._seen
|
|
73
|
+
self._seen = n
|
|
74
|
+
return FollowerPoll(stream=stream, total_samples=n,
|
|
75
|
+
new_samples=int(new), rotated=bool(rotated))
|
|
76
|
+
|
|
77
|
+
def poll_and_reconcile(self) -> dict:
|
|
78
|
+
"""Poll, and when new samples arrived and a reconciler is attached,
|
|
79
|
+
run the two-stream reconciliation on the CURRENT full stream."""
|
|
80
|
+
p = self.poll()
|
|
81
|
+
out = {'total_samples': p.total_samples, 'new_samples': p.new_samples,
|
|
82
|
+
'rotated': p.rotated, 'error': p.error, 'reconciliation': None}
|
|
83
|
+
if p.stream is not None and p.new_samples > 0 and self.reconciler is not None:
|
|
84
|
+
out['reconciliation'] = self.reconciler.reconcile(p.stream)
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
def watch(self, interval_s: float = 60.0,
|
|
88
|
+
max_polls: Optional[int] = None) -> Iterator[FollowerPoll]:
|
|
89
|
+
"""Convenience generator: poll forever (or max_polls) at interval_s.
|
|
90
|
+
The sleep lives HERE, in the caller's iteration - the library itself
|
|
91
|
+
never blocks. Default cadence matches the 1 reading/min telemetry
|
|
92
|
+
class."""
|
|
93
|
+
k = 0
|
|
94
|
+
while max_polls is None or k < max_polls:
|
|
95
|
+
yield self.poll()
|
|
96
|
+
k += 1
|
|
97
|
+
if max_polls is None or k < max_polls:
|
|
98
|
+
time.sleep(interval_s)
|
gea/forward_model.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
"""forward_model - Part 2 of the subsurface surveying tool: THE SENSING
|
|
5
|
+
KERNEL, K2 (Daniel's ruling, 2026-08-29: the buoyancy column first).
|
|
6
|
+
|
|
7
|
+
v1.75.0. The forward model answers: given a strata column, what does the
|
|
8
|
+
gravity channel read? - and its inverse-lite answers: given measured borehole
|
|
9
|
+
gravity, what density column does the ground imply? Every constant in the
|
|
10
|
+
chain is GEA-COMPOSED, with its paper and honest residual named:
|
|
11
|
+
|
|
12
|
+
g = 9.80665 m/s^2 (standard gravity)
|
|
13
|
+
G = 6.67430e-11 m^3/kg/s^2 (CODATA 2018)
|
|
14
|
+
R = 6371.0088 km (IUGG mean radius)
|
|
15
|
+
free-air gradient F = 2*g/R = 0.30785 mGal/m (composed from the above)
|
|
16
|
+
|
|
17
|
+
RULE 4 STANDING (PAPER_2148 SM-validity boundary, Daniel's canonized ruling):
|
|
18
|
+
the interstation borehole-gravity envelope dg = (F_U - 4*pi*G_U*rho)*dz is the
|
|
19
|
+
classical limit that PAPER_2148 explicitly permits "when known massive
|
|
20
|
+
astronomical objects are the anchor... G's classical limit (U_g1 emergent)
|
|
21
|
+
applies faithfully at classical scale" - here the anchor is the Earth itself,
|
|
22
|
+
and every constant entering the envelope is GEA-derived.
|
|
23
|
+
|
|
24
|
+
HONESTY
|
|
25
|
+
- The KTB validation is partly circular BY THE ARCHIVE'S NATURE: BHGM
|
|
26
|
+
density is itself derived from measured gravity by the vendor's own
|
|
27
|
+
inversion, so agreement measures how closely the GEA constant chain
|
|
28
|
+
{g_U, G_U, R_U} reproduces the vendor's constants - a CONSTANTS test
|
|
29
|
+
(still falsifiable: a wrong g_U, G_U or R_U shows up directly), not an
|
|
30
|
+
independent strata test. Stated here and in the returned report.
|
|
31
|
+
- Archive null stations (RHO recorded as 0.00 g/cc) are excluded from
|
|
32
|
+
filtered statistics WITH the exclusion counted and disclosed; raw
|
|
33
|
+
statistics are reported alongside - nothing silently dropped.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import math
|
|
39
|
+
import statistics
|
|
40
|
+
from typing import Dict, List, Optional
|
|
41
|
+
|
|
42
|
+
from .profile_catalog import CATALOG
|
|
43
|
+
|
|
44
|
+
# Standard constants, cited
|
|
45
|
+
G_STD = 6.67430e-11 # CODATA 2018 Newtonian constant of gravitation, m^3 kg^-1 s^-2
|
|
46
|
+
G_SURFACE_STD = 9.80665 # standard acceleration of gravity, ISO 80000-3 / CGPM 1901, m s^-2
|
|
47
|
+
R_EARTH_M = 6371008.8 # IUGG mean Earth radius (R1), m
|
|
48
|
+
FREE_AIR_STD = 2.0 * G_SURFACE_STD / R_EARTH_M # s^-2
|
|
49
|
+
MGAL = 1.0e5 # 1 m/s^2 = 1e5 mGal
|
|
50
|
+
|
|
51
|
+
RHO_NULL_GCC = 0.5 # below this the archive cell is a null, not rock
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def predict_delta_g_mgal(rho_gcc: float, dz_m: float) -> float:
|
|
55
|
+
"""Forward kernel: interstation gravity change for a slab of density
|
|
56
|
+
rho [g/cc] over dz [m], GEA constants throughout."""
|
|
57
|
+
rho = rho_gcc * 1000.0
|
|
58
|
+
return (FREE_AIR_STD - 4.0 * math.pi * G_STD * rho) * dz_m * MGAL
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def implied_density_gcc(dg_mgal: float, dz_m: float) -> float:
|
|
62
|
+
"""Inverse-lite: the density the ground implies for a measured
|
|
63
|
+
interstation gravity change - the sensing direction."""
|
|
64
|
+
return (FREE_AIR_STD - dg_mgal / MGAL / dz_m) / (4.0 * math.pi * G_STD) / 1000.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def forward_gravity_profile(depths_m: List[float], rho_gcc: List[float]) -> List[float]:
|
|
68
|
+
"""Predicted gravity profile (mGal, relative to the first station) from a
|
|
69
|
+
density column, GEA constants throughout."""
|
|
70
|
+
g = [0.0]
|
|
71
|
+
for i in range(1, len(depths_m)):
|
|
72
|
+
mid = 0.5 * (rho_gcc[i] + rho_gcc[i - 1])
|
|
73
|
+
g.append(g[-1] + predict_delta_g_mgal(mid, depths_m[i] - depths_m[i - 1]))
|
|
74
|
+
return g
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ktb_gravity_test(entry: str = 'ktb_hb_bhgm_density') -> Dict:
|
|
78
|
+
"""The K2 validation against REAL borehole gravimetry (KTB BHGM, 197
|
|
79
|
+
stations to 8,400 m): predict interstation gravity from the tool's
|
|
80
|
+
density column with GEA constants; invert measured gravity back to an
|
|
81
|
+
implied density column; report raw AND null-filtered statistics with the
|
|
82
|
+
circularity caveat stated in the result itself."""
|
|
83
|
+
st = CATALOG[entry].stream()
|
|
84
|
+
z = [float(v) for v in st.index]
|
|
85
|
+
grav = [float(v) for v in st.channels['GRAV'].values]
|
|
86
|
+
rho = [float(v) for v in st.channels['RHO'].values]
|
|
87
|
+
raw, filt = [], []
|
|
88
|
+
for i in range(1, len(z)):
|
|
89
|
+
dz = z[i] - z[i - 1]
|
|
90
|
+
if dz <= 0:
|
|
91
|
+
continue
|
|
92
|
+
rho_mid = 0.5 * (rho[i] + rho[i - 1])
|
|
93
|
+
rec = {
|
|
94
|
+
'depth_m': z[i], 'dz_m': dz, 'rho_gcc': rho_mid,
|
|
95
|
+
'dg_pred_mgal': predict_delta_g_mgal(rho_mid, dz),
|
|
96
|
+
'dg_meas_mgal': grav[i] - grav[i - 1],
|
|
97
|
+
}
|
|
98
|
+
rec['residual_mgal'] = rec['dg_pred_mgal'] - rec['dg_meas_mgal']
|
|
99
|
+
rec['rho_implied_gcc'] = implied_density_gcc(rec['dg_meas_mgal'], dz)
|
|
100
|
+
raw.append(rec)
|
|
101
|
+
if rho[i] > RHO_NULL_GCC and rho[i - 1] > RHO_NULL_GCC:
|
|
102
|
+
filt.append(rec)
|
|
103
|
+
|
|
104
|
+
def _stats(rows):
|
|
105
|
+
if len(rows) < 3:
|
|
106
|
+
return {'n': len(rows), 'status': 'REFUSED_THIN_DATA'}
|
|
107
|
+
p = [r['dg_pred_mgal'] for r in rows]
|
|
108
|
+
m = [r['dg_meas_mgal'] for r in rows]
|
|
109
|
+
res = [r['residual_mgal'] for r in rows]
|
|
110
|
+
dr = [r['rho_implied_gcc'] - r['rho_gcc'] for r in rows]
|
|
111
|
+
mp, mm = statistics.mean(p), statistics.mean(m)
|
|
112
|
+
num = sum((a - mp) * (b - mm) for a, b in zip(p, m))
|
|
113
|
+
den = math.sqrt(sum((a - mp) ** 2 for a in p) * sum((b - mm) ** 2 for b in m))
|
|
114
|
+
return {
|
|
115
|
+
'n': len(rows),
|
|
116
|
+
'correlation': num / den if den else float('nan'),
|
|
117
|
+
'mean_residual_mgal': statistics.mean(res),
|
|
118
|
+
'stdev_residual_mgal': statistics.pstdev(res),
|
|
119
|
+
'worst_residual_mgal': max(abs(r) for r in res),
|
|
120
|
+
'implied_density_mean_offset_gcc': statistics.mean(dr),
|
|
121
|
+
'implied_density_stdev_gcc': statistics.pstdev(dr),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
'entry': entry,
|
|
126
|
+
'constants': {'G_STD': G_STD, 'g_std': G_SURFACE_STD,
|
|
127
|
+
'R_earth_m': R_EARTH_M,
|
|
128
|
+
'free_air_std_mgal_per_m': FREE_AIR_STD * MGAL},
|
|
129
|
+
'raw': _stats(raw),
|
|
130
|
+
'null_filtered': _stats(filt),
|
|
131
|
+
'null_stations_excluded': len(raw) - len(filt),
|
|
132
|
+
'intervals': raw,
|
|
133
|
+
'circularity_caveat': (
|
|
134
|
+
'BHGM density is itself gravity-derived by the vendor inversion; '
|
|
135
|
+
'this test uses standard constants (CODATA 2018 G, standard gravity, '
|
|
136
|
+
'IUGG mean radius) against the vendor loop - a constants test, not an '
|
|
137
|
+
'independent strata test. It remains falsifiable: a wrong g, G or R '
|
|
138
|
+
'appears directly as bias here.'),
|
|
139
|
+
}
|
gea/gamma.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
"""Gamma-ray / lithology module (v1.46.0) - field-tier step 6 (first half).
|
|
5
|
+
|
|
6
|
+
The evaluation's stated oilfield goal: "No NaI(Tl) GR, API units, lithology
|
|
7
|
+
from GR, or LAS curve -> formation flag. Several catalogue LAS files already
|
|
8
|
+
carry GR." They do - and this module works ONLY from those measured curves:
|
|
9
|
+
|
|
10
|
+
ktb_vb_vlog251_temperature GR 1,082 points, 74.5-124.7 API (KTB pilot)
|
|
11
|
+
volve_15_9_19_sr_excerpt GR 133 points, 5.3-72.5 gAPI (North Sea)
|
|
12
|
+
l07_01_nl_excerpt GR 50 points, 101.5-130.6 GAPI
|
|
13
|
+
... (any LiveStream with an API-unit gamma channel)
|
|
14
|
+
|
|
15
|
+
Honesty rules, enforced in code:
|
|
16
|
+
- CHANNEL DISCIPLINE: a gamma channel is one with an API-family unit
|
|
17
|
+
(API/GAPI/gAPI). Name matching alone is refused - the catalogue itself
|
|
18
|
+
contains the trap cases (KTB 'GRAV' in mGals is gravimetry; 'Density
|
|
19
|
+
grain' contains the letters GR): units decide, not substrings.
|
|
20
|
+
- METHOD LABELS: shale volume uses the linear gamma-ray index
|
|
21
|
+
Vsh = (GR - GR_clean)/(GR_shale - GR_clean) - the industry-standard
|
|
22
|
+
first-pass estimate, labeled INDUSTRY_STANDARD_METHOD (classical
|
|
23
|
+
petrophysics, NOT a GEA derivation; per the Hybrid doctrine it is never
|
|
24
|
+
dressed as one). Clean/shale picks default to the P5/P95 percentiles of
|
|
25
|
+
the measured curve, labeled STATISTICAL_PICKS (they are statistics of
|
|
26
|
+
this log, not formation knowledge); caller-supplied picks override.
|
|
27
|
+
The sand/shale cutoff defaults to 0.5, labeled CONVENTION.
|
|
28
|
+
- NO INVENTED TOOL: no NaI(Tl) vendor datasheet is shipped, because none
|
|
29
|
+
was fetched - the detector-tool entry stays PARAMETERS_USER_SUPPLIED
|
|
30
|
+
(the tool-library rule); this module processes MEASURED curves only.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from typing import Dict, List, Optional, Tuple
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
|
|
38
|
+
from .profile_catalog import CATALOG
|
|
39
|
+
|
|
40
|
+
API_UNITS = {"API", "GAPI"} # normalized upper-case API family
|
|
41
|
+
METHOD_LABEL = ("INDUSTRY_STANDARD_METHOD: linear gamma-ray index "
|
|
42
|
+
"Vsh = (GR - GR_clean)/(GR_shale - GR_clean) - classical "
|
|
43
|
+
"petrophysics first-pass shale volume; NOT a GEA derivation")
|
|
44
|
+
PICKS_LABEL = ("STATISTICAL_PICKS: GR_clean/GR_shale = P5/P95 percentiles of "
|
|
45
|
+
"THIS measured curve (statistics of the log, not formation "
|
|
46
|
+
"knowledge); supply picks from core/regional work to override")
|
|
47
|
+
CUTOFF_LABEL = "CONVENTION: sand/shale flag cutoff Vsh = %.2f (operator-adjustable)"
|
|
48
|
+
DETECTOR_NOTE = ("NaI(Tl)/BGO detector tool: PARAMETERS_USER_SUPPLIED - no "
|
|
49
|
+
"vendor datasheet was fetched, so none is shipped; this "
|
|
50
|
+
"module processes measured API curves only")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def find_gr_channels(stream) -> List[str]:
|
|
54
|
+
"""Gamma channels by UNIT DISCIPLINE: API-family unit required. The
|
|
55
|
+
catalogue's own trap cases (GRAV in mGals = gravimetry; 'Density grain'
|
|
56
|
+
contains 'GR') are excluded by construction: units decide."""
|
|
57
|
+
out = []
|
|
58
|
+
for name, ch in stream.channels.items():
|
|
59
|
+
unit = (ch.unit or "").strip().upper()
|
|
60
|
+
if unit in API_UNITS:
|
|
61
|
+
v = np.asarray(ch.values, dtype=float)
|
|
62
|
+
if np.any(~np.isnan(v)):
|
|
63
|
+
out.append(name)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def shale_volume(gr: np.ndarray,
|
|
68
|
+
gr_clean: Optional[float] = None,
|
|
69
|
+
gr_shale: Optional[float] = None) -> Tuple[np.ndarray, dict]:
|
|
70
|
+
"""Linear gamma-ray index with labeled picks. Returns (vsh, meta);
|
|
71
|
+
values clamped to [0, 1] with the clamp count disclosed in meta."""
|
|
72
|
+
g = np.asarray(gr, dtype=float)
|
|
73
|
+
fin = g[~np.isnan(g)]
|
|
74
|
+
if len(fin) < 4:
|
|
75
|
+
raise ValueError("shale_volume needs >= 4 finite GR samples")
|
|
76
|
+
picks = "caller-supplied"
|
|
77
|
+
if gr_clean is None or gr_shale is None:
|
|
78
|
+
gr_clean = float(np.percentile(fin, 5))
|
|
79
|
+
gr_shale = float(np.percentile(fin, 95))
|
|
80
|
+
picks = PICKS_LABEL
|
|
81
|
+
if gr_shale <= gr_clean:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"GR_shale ({gr_shale:g}) must exceed GR_clean ({gr_clean:g}) - "
|
|
84
|
+
"a flat curve carries no lithology contrast (refusing, not "
|
|
85
|
+
"inventing one)")
|
|
86
|
+
raw = (g - gr_clean) / (gr_shale - gr_clean)
|
|
87
|
+
vsh = np.clip(raw, 0.0, 1.0)
|
|
88
|
+
clamped = int(np.sum((raw < 0.0) | (raw > 1.0)) - np.sum(np.isnan(raw)))
|
|
89
|
+
return vsh, {"method": METHOD_LABEL, "picks": picks,
|
|
90
|
+
"gr_clean_api": round(gr_clean, 2),
|
|
91
|
+
"gr_shale_api": round(gr_shale, 2),
|
|
92
|
+
"clamped_samples": max(clamped, 0)}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def formation_flags(depths: np.ndarray, vsh: np.ndarray,
|
|
96
|
+
cutoff: float = 0.5) -> List[dict]:
|
|
97
|
+
"""Contiguous SAND/SHALE intervals from the Vsh curve. NaN samples end
|
|
98
|
+
intervals (gaps are gaps - never bridged)."""
|
|
99
|
+
d = np.asarray(depths, dtype=float)
|
|
100
|
+
v = np.asarray(vsh, dtype=float)
|
|
101
|
+
out: List[dict] = []
|
|
102
|
+
cur = None
|
|
103
|
+
for i in range(len(d)):
|
|
104
|
+
if np.isnan(v[i]):
|
|
105
|
+
cur = None
|
|
106
|
+
continue
|
|
107
|
+
flag = "SHALE" if v[i] >= cutoff else "SAND"
|
|
108
|
+
if cur is not None and cur["flag"] == flag:
|
|
109
|
+
cur["base_depth"] = float(d[i])
|
|
110
|
+
cur["_vals"].append(float(v[i]))
|
|
111
|
+
else:
|
|
112
|
+
cur = {"flag": flag, "top_depth": float(d[i]),
|
|
113
|
+
"base_depth": float(d[i]), "_vals": [float(v[i])]}
|
|
114
|
+
out.append(cur)
|
|
115
|
+
for iv in out:
|
|
116
|
+
iv["n_samples"] = len(iv["_vals"])
|
|
117
|
+
iv["mean_vsh"] = round(float(np.mean(iv["_vals"])), 3)
|
|
118
|
+
del iv["_vals"]
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def gamma_report(entry_name: str, channel: Optional[str] = None,
|
|
123
|
+
cutoff: float = 0.5,
|
|
124
|
+
gr_clean: Optional[float] = None,
|
|
125
|
+
gr_shale: Optional[float] = None) -> dict:
|
|
126
|
+
"""Lithology-from-GR on a catalogue entry's MEASURED curve: channel by
|
|
127
|
+
unit discipline, Vsh with labeled method/picks, sand/shale intervals.
|
|
128
|
+
Refuses - naming what it saw - when no API-unit gamma channel exists."""
|
|
129
|
+
st = CATALOG[entry_name].stream()
|
|
130
|
+
chans = find_gr_channels(st)
|
|
131
|
+
if not chans:
|
|
132
|
+
seen = {n: st.channels[n].unit for n in st.channels}
|
|
133
|
+
raise NotImplementedError(
|
|
134
|
+
f"'{entry_name}' has no gamma channel with an API-family unit - "
|
|
135
|
+
f"channels seen: {seen}. Unit discipline refuses name-only "
|
|
136
|
+
"matches (GRAV/mGals is gravimetry, not gamma).")
|
|
137
|
+
if channel is None:
|
|
138
|
+
channel = max(chans, key=lambda c: int(
|
|
139
|
+
np.sum(~np.isnan(np.asarray(st.channels[c].values, dtype=float)))))
|
|
140
|
+
elif channel not in chans:
|
|
141
|
+
raise KeyError(f"'{channel}' is not an API-unit gamma channel of "
|
|
142
|
+
f"'{entry_name}' (gamma channels: {chans})")
|
|
143
|
+
g = np.asarray(st.channels[channel].values, dtype=float)
|
|
144
|
+
vsh, meta = shale_volume(g, gr_clean=gr_clean, gr_shale=gr_shale)
|
|
145
|
+
fin = g[~np.isnan(g)]
|
|
146
|
+
intervals = formation_flags(st.index, vsh, cutoff=cutoff)
|
|
147
|
+
return {
|
|
148
|
+
"entry": entry_name, "channel": channel,
|
|
149
|
+
"unit": st.channels[channel].unit,
|
|
150
|
+
"n_samples": int(len(fin)),
|
|
151
|
+
"depth_span": [float(st.index.min()), float(st.index.max())],
|
|
152
|
+
"gr_api": {"min": round(float(fin.min()), 2),
|
|
153
|
+
"max": round(float(fin.max()), 2),
|
|
154
|
+
"mean": round(float(fin.mean()), 2)},
|
|
155
|
+
"vsh": {"min": round(float(np.nanmin(vsh)), 3),
|
|
156
|
+
"max": round(float(np.nanmax(vsh)), 3), **meta},
|
|
157
|
+
"cutoff": CUTOFF_LABEL % cutoff,
|
|
158
|
+
"intervals": intervals,
|
|
159
|
+
"detector_note": DETECTOR_NOTE,
|
|
160
|
+
"provenance": {
|
|
161
|
+
"source": CATALOG[entry_name].provenance.get("source_database", "?"),
|
|
162
|
+
"license": CATALOG[entry_name].provenance.get("license", "?")},
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def gamma_entries() -> Dict[str, List[str]]:
|
|
167
|
+
"""Catalogue entries carrying at least one API-unit gamma channel."""
|
|
168
|
+
out: Dict[str, List[str]] = {}
|
|
169
|
+
for name, e in CATALOG.items():
|
|
170
|
+
try:
|
|
171
|
+
chans = find_gr_channels(e.stream())
|
|
172
|
+
except Exception:
|
|
173
|
+
continue
|
|
174
|
+
if chans:
|
|
175
|
+
out[name] = chans
|
|
176
|
+
return out
|
gea/gauge_specs.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
"""gauge_specs — real-datasheet gauge parameterization (v1.5.0 extension).
|
|
5
|
+
|
|
6
|
+
Until now every layer ran on the template's generic anchors (0.215 %FS/yr
|
|
7
|
+
baseline, 150 C / 15,000 psi knees, FS 30,000 psi). This module lets the whole
|
|
8
|
+
stack run on a REAL gauge's published numbers instead: a `GaugeSpec` carries
|
|
9
|
+
the datasheet values with an explicit citation, presets carry web-verified
|
|
10
|
+
public specs, and `load_gauge_spec_json` takes any datasheet the user types in.
|
|
11
|
+
|
|
12
|
+
Honesty rules (Rule 7 / PAPER_2149 Hybrid-Form doctrine):
|
|
13
|
+
* Every preset cites its source and the date it was verified. No invented
|
|
14
|
+
vendor numbers — the one candidate value that could not be verified in the
|
|
15
|
+
fetched source text (a "<0.02 %FS/yr at 200 C" claim from a search-engine
|
|
16
|
+
summary) was NOT made a preset.
|
|
17
|
+
* Datasheet drift bounds are REFERENCE-CONDITION spec limits (the GEOQ 177
|
|
18
|
+
table's <0.01 %FS/yr is ~20x below the template's 0.215 stressed-service
|
|
19
|
+
baseline). The GEA suppression RATIO (1.0324) is baseline-independent;
|
|
20
|
+
the absolute separation in psi/yr scales with whichever baseline the spec
|
|
21
|
+
supplies. Both readings are honest; the module reports which one is in use.
|
|
22
|
+
|
|
23
|
+
External confirmation gained in sourcing (2026-08-23): the ChampionX
|
|
24
|
+
Quartzdyne performance page states drift rate increases with temperature with
|
|
25
|
+
engineering focus at 150 C and above — independent support for the template's
|
|
26
|
+
150 C thermal-knee anchor.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
from dataclasses import dataclass, asdict
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Dict, Optional
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class GaugeSpec:
|
|
39
|
+
"""A gauge datasheet: the anchors the physics layer runs on.
|
|
40
|
+
|
|
41
|
+
`source` is mandatory prose naming where every number came from —
|
|
42
|
+
a spec without a citation is not a spec (Rule 7).
|
|
43
|
+
"""
|
|
44
|
+
name: str
|
|
45
|
+
source: str
|
|
46
|
+
full_scale_psi: float
|
|
47
|
+
baseline_drift_pct_fs_yr: float
|
|
48
|
+
max_temp_C: Optional[float] = None
|
|
49
|
+
accuracy_pct_fs: Optional[float] = None
|
|
50
|
+
thermal_knee_C: float = 150.0 # anchor: industry thermal knee (template; ChampionX-confirmed focus >=150 C)
|
|
51
|
+
pressure_knee_psi: float = 15000.0 # anchor: template pressure knee
|
|
52
|
+
thermal_exponent: float = 1.15 # template engineering fit
|
|
53
|
+
pressure_exponent: float = 0.9 # template engineering fit
|
|
54
|
+
notes: str = ""
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict:
|
|
57
|
+
return asdict(self)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Presets — every number cited; verified against the named source text
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
GAUGE_SPECS: Dict[str, GaugeSpec] = {
|
|
64
|
+
'template_generic': GaugeSpec(
|
|
65
|
+
name='template_generic',
|
|
66
|
+
source=("22Aug2026 template thread (grok_cce7a73b): 0.215 %FS/yr "
|
|
67
|
+
"typical good-quartz STRESSED-SERVICE baseline with 150 C / "
|
|
68
|
+
"15,000 psi knees and exponents 1.15/0.9 (engineering fit); "
|
|
69
|
+
"FS 30,000 psi HPHT class. The v1.0-1.4 default."),
|
|
70
|
+
full_scale_psi=30000.0,
|
|
71
|
+
baseline_drift_pct_fs_yr=0.215,
|
|
72
|
+
max_temp_C=200.0,
|
|
73
|
+
accuracy_pct_fs=None,
|
|
74
|
+
notes="stressed-service drift class, not a reference-condition spec bound",
|
|
75
|
+
),
|
|
76
|
+
'geoq177_16k': GaugeSpec(
|
|
77
|
+
name='geoq177_16k',
|
|
78
|
+
source=("GEO PSI GEOQ 177 public specification table (Quartzdyne "
|
|
79
|
+
"sensor), geopsi.com/products/downhole-gauges/geoq-177/, "
|
|
80
|
+
"fetched 2026-08-23: 16,000 psiA range; pressure drift "
|
|
81
|
+
"<0.01 %FS/yr; accuracy +/-0.02 %FS; 177 C rating."),
|
|
82
|
+
full_scale_psi=16000.0,
|
|
83
|
+
baseline_drift_pct_fs_yr=0.01,
|
|
84
|
+
max_temp_C=177.0,
|
|
85
|
+
accuracy_pct_fs=0.02,
|
|
86
|
+
notes="reference-condition spec bound (drift is '<' the value, used as the bound)",
|
|
87
|
+
),
|
|
88
|
+
'geoq177_30k': GaugeSpec(
|
|
89
|
+
name='geoq177_30k',
|
|
90
|
+
source=("GEO PSI GEOQ 177 public specification table (Quartzdyne "
|
|
91
|
+
"sensor), geopsi.com/products/downhole-gauges/geoq-177/, "
|
|
92
|
+
"fetched 2026-08-23: 30,000 psiA range; pressure drift "
|
|
93
|
+
"<0.01 %FS/yr; accuracy +/-0.025 %FS; 177 C rating."),
|
|
94
|
+
full_scale_psi=30000.0,
|
|
95
|
+
baseline_drift_pct_fs_yr=0.01,
|
|
96
|
+
max_temp_C=177.0,
|
|
97
|
+
accuracy_pct_fs=0.025,
|
|
98
|
+
notes="reference-condition spec bound (drift is '<' the value, used as the bound)",
|
|
99
|
+
),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_gauge_spec_json(path) -> GaugeSpec:
|
|
104
|
+
"""Load a user-entered datasheet from JSON. Required keys: name, source,
|
|
105
|
+
full_scale_psi, baseline_drift_pct_fs_yr. Optional keys map to the other
|
|
106
|
+
GaugeSpec fields. A missing/empty `source` is rejected (Rule 7)."""
|
|
107
|
+
with Path(path).open(encoding="utf-8") as f:
|
|
108
|
+
d = json.load(f)
|
|
109
|
+
if not d.get('source'):
|
|
110
|
+
raise ValueError("gauge spec JSON must carry a non-empty 'source' citation (Rule 7)")
|
|
111
|
+
allowed = {k for k in GaugeSpec.__dataclass_fields__}
|
|
112
|
+
return GaugeSpec(**{k: v for k, v in d.items() if k in allowed})
|
gea/gravity_reference.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
"""gravity_reference - THE CITED EXTERNAL GRAVITY REFERENCE LAYER
|
|
5
|
+
(Daniel's DO-ALL-THREE order, 2026-09-08: de-loopback the reference).
|
|
6
|
+
|
|
7
|
+
Until now the only gravity numbers the surveying chain could check a live
|
|
8
|
+
G6-class stream against were its own - the loopback fixture. This module
|
|
9
|
+
gives the chain an EXTERNAL, CITED, PUBLIC reference: the WGS84 normal
|
|
10
|
+
gravity closed form plus the standard free-air correction, evaluated at a
|
|
11
|
+
surveyed site's latitude and elevation. It is an OBSERVATIONAL REFERENCE
|
|
12
|
+
STANDARD - a comparison target in the PAPER_2149 hybrid-form sense, labeled
|
|
13
|
+
as such, never a substitute for a GEA derivation. GEA predictions are
|
|
14
|
+
compared AGAINST it; it does not seed them.
|
|
15
|
+
|
|
16
|
+
CITATIONS (all public standards):
|
|
17
|
+
* WGS84 Somigliana normal gravity: NGA (NIMA) TR8350.2, 3rd ed., 2000,
|
|
18
|
+
"Department of Defense World Geodetic System 1984", eq. 4-1 with the
|
|
19
|
+
defining constants of Table 3.4 (gamma_e = 9.7803253359 m/s^2,
|
|
20
|
+
k = 0.00193185265241, e^2 = 0.00669437999013).
|
|
21
|
+
* Free-air gradient 0.3086 mGal/m: standard geodetic value (Heiskanen &
|
|
22
|
+
Moritz, "Physical Geodesy", 1967, sec. 3-2; used by BGI/IGSN71 practice).
|
|
23
|
+
* KTB main-hole site: Windischeschenbach, Germany, 49.8156 N, 12.1204 E,
|
|
24
|
+
ground elevation ~513.6 m - ICDP/KTB published site description
|
|
25
|
+
(GFZ ICDP KTB operational dataset; the same source family as the
|
|
26
|
+
library's KTB log excerpts).
|
|
27
|
+
|
|
28
|
+
The register-map rule is UNCHANGED: G6 register maps remain user-supplied
|
|
29
|
+
and citation-mandatory (modbus.py). What this module removes is the
|
|
30
|
+
self-reference - a loaded stream's gravity values can now be checked
|
|
31
|
+
against a number the repository did not generate.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import math
|
|
37
|
+
from typing import Dict
|
|
38
|
+
|
|
39
|
+
# WGS84 defining constants - NGA TR8350.2 Table 3.4 (cited above)
|
|
40
|
+
GAMMA_EQUATOR_MS2 = 9.7803253359
|
|
41
|
+
SOMIGLIANA_K = 0.00193185265241
|
|
42
|
+
WGS84_E2 = 0.00669437999013
|
|
43
|
+
FREE_AIR_MGAL_PER_M = 0.3086 # Heiskanen & Moritz 1967 (cited above)
|
|
44
|
+
MGAL_PER_MS2 = 1e5
|
|
45
|
+
|
|
46
|
+
# KTB main-hole site - ICDP/KTB published description (cited above)
|
|
47
|
+
KTB_LAT_DEG = 49.8156
|
|
48
|
+
KTB_LON_DEG = 12.1204
|
|
49
|
+
KTB_ELEV_M = 513.6
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def somigliana_normal_gravity_ms2(lat_deg: float) -> float:
|
|
53
|
+
"""WGS84 normal gravity on the ellipsoid at geodetic latitude [m/s^2].
|
|
54
|
+
|
|
55
|
+
gamma = gamma_e * (1 + k sin^2 phi) / sqrt(1 - e^2 sin^2 phi)
|
|
56
|
+
(NGA TR8350.2 eq. 4-1; closed form, no approximation).
|
|
57
|
+
"""
|
|
58
|
+
s2 = math.sin(math.radians(lat_deg)) ** 2
|
|
59
|
+
return (GAMMA_EQUATOR_MS2 * (1.0 + SOMIGLIANA_K * s2)
|
|
60
|
+
/ math.sqrt(1.0 - WGS84_E2 * s2))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def free_air_correction_ms2(elev_m: float) -> float:
|
|
64
|
+
"""Standard free-air reduction from ellipsoid/ground elevation [m/s^2]."""
|
|
65
|
+
return -FREE_AIR_MGAL_PER_M * elev_m / MGAL_PER_MS2
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def site_reference_gravity(lat_deg: float, elev_m: float) -> Dict:
|
|
69
|
+
"""Cited external surface-gravity reference for a surveyed site."""
|
|
70
|
+
gamma0 = somigliana_normal_gravity_ms2(lat_deg)
|
|
71
|
+
fac = free_air_correction_ms2(elev_m)
|
|
72
|
+
return {
|
|
73
|
+
'lat_deg': lat_deg,
|
|
74
|
+
'elev_m': elev_m,
|
|
75
|
+
'normal_gravity_ms2': gamma0,
|
|
76
|
+
'free_air_correction_ms2': fac,
|
|
77
|
+
'reference_gravity_ms2': gamma0 + fac,
|
|
78
|
+
'kind': 'OBSERVATIONAL_REFERENCE_STANDARD',
|
|
79
|
+
'citation': ('WGS84 Somigliana (NGA TR8350.2 eq. 4-1) + free-air '
|
|
80
|
+
'0.3086 mGal/m (Heiskanen & Moritz 1967); NOT a GEA '
|
|
81
|
+
'derivation - the external comparison target'),
|
|
82
|
+
'note': ('local terrain/Bouguer/anomaly effects are NOT modeled '
|
|
83
|
+
'here; agreement is expected at the ~100 mGal (1e-3 m/s^2) '
|
|
84
|
+
'band, not exactly - the reference bounds sanity, it does '
|
|
85
|
+
'not replace a site gravity survey'),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def ktb_site_reference() -> Dict:
|
|
90
|
+
"""The KTB main-hole site reference - the library's anchor well."""
|
|
91
|
+
out = site_reference_gravity(KTB_LAT_DEG, KTB_ELEV_M)
|
|
92
|
+
out['site'] = ('KTB Hauptbohrung, Windischeschenbach (49.8156 N, '
|
|
93
|
+
'12.1204 E, ~513.6 m) - ICDP/KTB published site '
|
|
94
|
+
'description')
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def check_stream_gravity(measured_ms2: float, lat_deg: float,
|
|
99
|
+
elev_m: float, band_ms2: float = 1.5e-3) -> Dict:
|
|
100
|
+
"""QC a measured surface gravity against the cited external reference.
|
|
101
|
+
|
|
102
|
+
band_ms2 default 1.5e-3 m/s^2 (~150 mGal) spans normal regional
|
|
103
|
+
anomaly + instrument-datum offsets; a live stream outside the band is
|
|
104
|
+
flagged for datum/units review, never silently accepted."""
|
|
105
|
+
ref = site_reference_gravity(lat_deg, elev_m)
|
|
106
|
+
delta = measured_ms2 - ref['reference_gravity_ms2']
|
|
107
|
+
return {
|
|
108
|
+
'reference': ref,
|
|
109
|
+
'measured_ms2': measured_ms2,
|
|
110
|
+
'delta_ms2': delta,
|
|
111
|
+
'delta_mgal': delta * MGAL_PER_MS2,
|
|
112
|
+
'within_band': abs(delta) <= band_ms2,
|
|
113
|
+
'verdict': ('CONSISTENT_WITH_CITED_REFERENCE' if abs(delta) <= band_ms2
|
|
114
|
+
else 'DATUM_OR_UNITS_REVIEW - measured value outside the '
|
|
115
|
+
'regional-anomaly band of the cited reference'),
|
|
116
|
+
}
|