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