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/acceptance_tests.py
ADDED
|
@@ -0,0 +1,1315 @@
|
|
|
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
|
+
"""Simulator ACCEPTANCE suite (v1.45.0) - finish-sequence step 5.
|
|
5
|
+
|
|
6
|
+
The product gate the evaluation demanded: ship the simulator only when this
|
|
7
|
+
suite is green, independent of the physics-paper wiring. This module ships
|
|
8
|
+
INSIDE the package (the product carries its own gate), imports NOTHING from
|
|
9
|
+
the physics calculator or its paper corpus, and is runnable anywhere the
|
|
10
|
+
package is installed:
|
|
11
|
+
|
|
12
|
+
python -m gea accept
|
|
13
|
+
python -m gea.acceptance_tests
|
|
14
|
+
|
|
15
|
+
Sections:
|
|
16
|
+
A. CLI golden runs - every subcommand exercised as a subprocess;
|
|
17
|
+
seeded runs are byte-identical (determinism
|
|
18
|
+
goldens, no brittle baked-in floats)
|
|
19
|
+
B. LAS dialect matrix - unwrapped / wrapped / NULL / ~P meta / error
|
|
20
|
+
C. Reconciler scenarios - the classification vocabulary earned end-to-end
|
|
21
|
+
(IN_FAMILY, CALIBRATION_OFFSET,
|
|
22
|
+
UNEXPLAINED_OFFSET, DRIFT_CONSISTENT,
|
|
23
|
+
UNEXPLAINED_TREND, INSUFFICIENT_DATA), with the
|
|
24
|
+
scenario magnitudes derived from the instance's
|
|
25
|
+
OWN gates - the suite adapts, it never hardcodes
|
|
26
|
+
the thresholds it is testing
|
|
27
|
+
D. Catalogue integrity - all entries load; provenance mandatory keys;
|
|
28
|
+
verbatim spot pins on archived values
|
|
29
|
+
E. Operator loop - blocking rating check, acknowledged override,
|
|
30
|
+
run, service life, case study, reconcile+alerts,
|
|
31
|
+
citations (DERIVED_HYBRID labeling present)
|
|
32
|
+
F. Ports & protocol - registry states + disciplined refusals
|
|
33
|
+
G. Gamma / lithology - unit-disciplined channel detection on the
|
|
34
|
+
catalogue's own trap cases; Vsh + formation
|
|
35
|
+
flags on measured curves; labeled methods
|
|
36
|
+
H. Mixed toolstrings - per-station tool models with honest legs
|
|
37
|
+
(twin / reference-only / class envelope /
|
|
38
|
+
refused), in-engine rating block
|
|
39
|
+
I. Bench pipeline - the protocol's analysis arithmetic verified on
|
|
40
|
+
synthetic legs (labeled SIMULATION_SELF_TEST);
|
|
41
|
+
all four verdict paths earned
|
|
42
|
+
|
|
43
|
+
Exit 0 = product acceptable. Any failure lists itself and exits 1.
|
|
44
|
+
"""
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import json
|
|
48
|
+
import os
|
|
49
|
+
import subprocess
|
|
50
|
+
import sys
|
|
51
|
+
import tempfile
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
import numpy as np
|
|
55
|
+
|
|
56
|
+
_PASS = 0
|
|
57
|
+
_FAILS: list = []
|
|
58
|
+
_RESULTS: list = [] # (passed, message) in run order - the FAT/SAT protocol reads this
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ok(cond: bool, msg: str) -> None:
|
|
62
|
+
global _PASS
|
|
63
|
+
_RESULTS.append((bool(cond), msg))
|
|
64
|
+
if cond:
|
|
65
|
+
_PASS += 1
|
|
66
|
+
else:
|
|
67
|
+
_FAILS.append(msg)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cli(args, cwd) -> subprocess.CompletedProcess:
|
|
71
|
+
env = dict(os.environ)
|
|
72
|
+
pkg_root = str(Path(__file__).resolve().parent.parent)
|
|
73
|
+
env["PYTHONPATH"] = pkg_root + os.pathsep + env.get("PYTHONPATH", "")
|
|
74
|
+
return subprocess.run([sys.executable, "-m", "gea"] + args,
|
|
75
|
+
capture_output=True, text=True, cwd=cwd, env=env)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def section_a_cli(tmp: str) -> None:
|
|
79
|
+
r = _cli(["wells"], tmp)
|
|
80
|
+
ok(r.returncode == 0 and "ktb_hb" in r.stdout and "engine-ready" in r.stdout
|
|
81
|
+
and "site_1027" in r.stdout,
|
|
82
|
+
"A1 wells: lists assemblies with engine-ready state")
|
|
83
|
+
|
|
84
|
+
r = _cli(["run", "--well", "ktb_hb", "--steps", "20",
|
|
85
|
+
"--out", "run_ktb.csv"], tmp)
|
|
86
|
+
body = Path(tmp, "run_ktb.csv").read_text() if Path(tmp, "run_ktb.csv").exists() else ""
|
|
87
|
+
head = body.splitlines()[0] if body else ""
|
|
88
|
+
rows = body.count("\n") - 1
|
|
89
|
+
ok(r.returncode == 0 and "T=measured" in r.stdout
|
|
90
|
+
and all(c in head for c in ("P_S1", "T_S1", "P_S6",
|
|
91
|
+
"avg_program_drift_pct",
|
|
92
|
+
"avg_conventional_drift_pct",
|
|
93
|
+
"measured_ratio_mean"))
|
|
94
|
+
and rows >= 20
|
|
95
|
+
and 1.02 < float(body.splitlines()[1].split(",")[head.split(",").index("measured_ratio_mean")]) < 1.05,
|
|
96
|
+
"A2 run --well ktb_hb: measured profile banner, 6-gauge CSV with "
|
|
97
|
+
"comparison columns, suppression ratio in-file")
|
|
98
|
+
|
|
99
|
+
r = _cli(["run", "--td", "20300", "--gauges", "4", "--steps", "10",
|
|
100
|
+
"--out", "run_tpl.csv"], tmp)
|
|
101
|
+
ok(r.returncode == 0 and Path(tmp, "run_tpl.csv").exists(),
|
|
102
|
+
"A3 run (template path): --td/--gauges still works without --well")
|
|
103
|
+
|
|
104
|
+
for i in (1, 2):
|
|
105
|
+
r = _cli(["service-life", "--years", "2", "--seed", "7",
|
|
106
|
+
"--out", f"sl{i}.csv"], tmp)
|
|
107
|
+
ok(r.returncode == 0, f"A4.{i} service-life run {i} exits 0")
|
|
108
|
+
ok(Path(tmp, "sl1.csv").read_bytes() == Path(tmp, "sl2.csv").read_bytes(),
|
|
109
|
+
"A4 service-life determinism golden: same seed -> byte-identical CSV")
|
|
110
|
+
|
|
111
|
+
for i in (1, 2):
|
|
112
|
+
r = _cli(["telemetry", "--hours", "2", "--seed", "3",
|
|
113
|
+
"--out", f"tm{i}.csv"], tmp)
|
|
114
|
+
ok(r.returncode == 0, f"A5.{i} telemetry run {i} exits 0")
|
|
115
|
+
ok(Path(tmp, "tm1.csv").read_bytes() == Path(tmp, "tm2.csv").read_bytes(),
|
|
116
|
+
"A5 telemetry determinism golden: same seed -> byte-identical CSV")
|
|
117
|
+
|
|
118
|
+
r = _cli(["ingest", "--file", "tm1.csv"], tmp)
|
|
119
|
+
ok(r.returncode == 0 and "time_s" not in r.stderr,
|
|
120
|
+
"A6 ingest: the historian CSV the product exported round-trips "
|
|
121
|
+
"through its own port")
|
|
122
|
+
|
|
123
|
+
r = _cli(["reconcile", "--live-catalog", "volve_f12_f14_production_excerpt",
|
|
124
|
+
"--live-well", "15/9-F-12", "--station-md", "10000",
|
|
125
|
+
"--td", "10500"], tmp)
|
|
126
|
+
cls = ""
|
|
127
|
+
try:
|
|
128
|
+
cls = json.loads(r.stdout[r.stdout.index("{"):])["stations"][0]["classification"]
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
ok(r.returncode == 0 and cls == "UNEXPLAINED_TREND",
|
|
132
|
+
"A7 reconcile --live-catalog: Volve F-12 measured drawdown classifies "
|
|
133
|
+
"UNEXPLAINED_TREND (drawdown is not drift)")
|
|
134
|
+
|
|
135
|
+
r = _cli(["case-study", "--well", "site_1027", "--out", "case_1027.md"], tmp)
|
|
136
|
+
body = Path(tmp, "case_1027.md").read_text() if Path(tmp, "case_1027.md").exists() else ""
|
|
137
|
+
ok(r.returncode == 0 and len(body) > 800 and "site_1027" in body,
|
|
138
|
+
"A8 case-study --well: one-page markdown on the measured 1027 well")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def section_b_las(tmp: str) -> None:
|
|
142
|
+
from .ports import read_las
|
|
143
|
+
base = ("~Version\n VERS. 2.0:\n WRAP. NO:\n"
|
|
144
|
+
"~Well\n NULL. -999.25:\n"
|
|
145
|
+
"~Curve\n DEPT.FT :\n PRES.PSI :\n TEMP.DEGF :\n"
|
|
146
|
+
"~ASCII\n 100 5000 150\n 200 5100 -999.25\n 300 5200 170\n")
|
|
147
|
+
p = Path(tmp, "a.las"); p.write_text(base)
|
|
148
|
+
st = read_las(p)
|
|
149
|
+
ok(st.index_kind == "depth" and len(st.index) == 3
|
|
150
|
+
and np.isnan(st.channels["TEMP"].values[1])
|
|
151
|
+
and float(st.channels["PRES"].values[2]) == 5200.0,
|
|
152
|
+
"B1 LAS unwrapped 2.0: curves parsed, NULL -> NaN")
|
|
153
|
+
|
|
154
|
+
wrapped = ("~Version\n VERS. 1.2:\n WRAP. YES:\n"
|
|
155
|
+
"~Curve\n DEPT.FT :\n PRES.PSI :\n TEMP.DEGF :\n"
|
|
156
|
+
"~ASCII\n 100\n 5000 150\n 200\n 5100 160\n")
|
|
157
|
+
p = Path(tmp, "b.las"); p.write_text(wrapped)
|
|
158
|
+
st = read_las(p)
|
|
159
|
+
ok(len(st.index) == 2 and float(st.channels["PRES"].values[1]) == 5100.0,
|
|
160
|
+
"B2 LAS wrapped 1.2: multi-line records reassembled")
|
|
161
|
+
|
|
162
|
+
meta = base.replace("~Well", "~Parameter\n BHT.DEGF 302.0 : bottom hole temp\n~Well")
|
|
163
|
+
p = Path(tmp, "c.las"); p.write_text(meta)
|
|
164
|
+
st = read_las(p)
|
|
165
|
+
ok(any("BHT" in k for k in st.meta),
|
|
166
|
+
"B3 LAS ~Parameter: BHT-class metadata captured to stream meta")
|
|
167
|
+
|
|
168
|
+
p = Path(tmp, "d.las"); p.write_text("~Version\n VERS. 2.0:\n")
|
|
169
|
+
try:
|
|
170
|
+
read_las(p)
|
|
171
|
+
ok(False, "B4 LAS error case: should refuse")
|
|
172
|
+
except ValueError as e:
|
|
173
|
+
ok("~Curve" in str(e), "B4 LAS error case: refusal names the missing sections")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def section_c_reconciler(tmp: str) -> None:
|
|
177
|
+
from .downhole_engine import SimulatorConfig
|
|
178
|
+
from .ports import LiveStream, StreamChannel
|
|
179
|
+
from .reconciler import Reconciler
|
|
180
|
+
cfg = SimulatorConfig(td_ft=16000.0, sensor_depths_ft=[15000.0])
|
|
181
|
+
rec = Reconciler(cfg)
|
|
182
|
+
md = 15000.0
|
|
183
|
+
pred, _ = rec.predicted_baseline(md)
|
|
184
|
+
uq_env, cv_env = rec.drift_envelope_psi_yr(md)
|
|
185
|
+
c = rec.cfg
|
|
186
|
+
days = 60
|
|
187
|
+
t = np.arange(days) * 86400.0
|
|
188
|
+
ty = t / (365.25 * 86400.0)
|
|
189
|
+
rng = np.random.default_rng(11)
|
|
190
|
+
noise = rng.normal(0.0, 0.4, days)
|
|
191
|
+
|
|
192
|
+
def stream(vals):
|
|
193
|
+
return LiveStream(name="synth", source_format="synth", index_kind="time_s",
|
|
194
|
+
index=t, channels={"P_raw_psi_S1": StreamChannel(
|
|
195
|
+
name="P_raw_psi_S1", unit="psi",
|
|
196
|
+
values=np.asarray(vals))})
|
|
197
|
+
|
|
198
|
+
def classify(vals):
|
|
199
|
+
return rec.reconcile(stream(vals),
|
|
200
|
+
station_map={"P_raw_psi_S1": md})["stations"][0]["classification"]
|
|
201
|
+
|
|
202
|
+
span = float(ty.max() - ty.min())
|
|
203
|
+
sigma = 0.4
|
|
204
|
+
bias_gate = c.bias_n_sigma * sigma / np.sqrt(days) + 1.0
|
|
205
|
+
|
|
206
|
+
ok(classify(pred + noise) == "IN_FAMILY",
|
|
207
|
+
"C1 IN_FAMILY: zero-mean noise around the closed-stream prediction")
|
|
208
|
+
cal = 2.5 * bias_gate
|
|
209
|
+
ok(cal < c.model_mismatch_psi
|
|
210
|
+
and classify(pred + noise + cal) == "CALIBRATION_OFFSET",
|
|
211
|
+
"C2 CALIBRATION_OFFSET: constant offset above the bias gate, below "
|
|
212
|
+
"model-mismatch")
|
|
213
|
+
ok(classify(pred + noise + 12.0 * c.model_mismatch_psi) == "UNEXPLAINED_OFFSET",
|
|
214
|
+
"C3 UNEXPLAINED_OFFSET: constant offset far beyond model mismatch")
|
|
215
|
+
drift_slope = 0.9 * cv_env
|
|
216
|
+
ok(0.5 * uq_env <= drift_slope <= c.drift_envelope_margin * cv_env
|
|
217
|
+
and drift_slope * span > bias_gate
|
|
218
|
+
and classify(pred + noise + drift_slope * (ty - ty.mean())) == "DRIFT_CONSISTENT",
|
|
219
|
+
"C4 DRIFT_CONSISTENT: slope inside the published drift envelope")
|
|
220
|
+
ok(classify(pred + noise + 60.0 * cv_env * (ty - ty.mean())) == "UNEXPLAINED_TREND",
|
|
221
|
+
"C5 UNEXPLAINED_TREND: slope far outside the envelope")
|
|
222
|
+
short = rec.reconcile(stream((pred + noise)[:5]) if False else LiveStream(
|
|
223
|
+
name="short", source_format="synth", index_kind="time_s", index=t[:5],
|
|
224
|
+
channels={"P_raw_psi_S1": StreamChannel(
|
|
225
|
+
name="P_raw_psi_S1", unit="psi", values=(pred + noise)[:5])}),
|
|
226
|
+
station_map={"P_raw_psi_S1": md})["stations"][0]["classification"]
|
|
227
|
+
ok(short == "INSUFFICIENT_DATA",
|
|
228
|
+
"C6 INSUFFICIENT_DATA: five points refuse a verdict")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def section_d_catalogue() -> None:
|
|
232
|
+
from .profile_catalog import CATALOG
|
|
233
|
+
need = ("source_database", "source_url", "license", "fetch_date", "coverage")
|
|
234
|
+
ok(len(CATALOG) >= 30, "D1 catalogue: >= 30 entries load")
|
|
235
|
+
ok(all(all(e.provenance.get(k) for k in need) for e in CATALOG.values()),
|
|
236
|
+
"D2 catalogue: every entry carries the five mandatory provenance keys")
|
|
237
|
+
st = CATALOG["odp_1027c_cork_temperature"].stream()
|
|
238
|
+
ok(abs(float(st.channels["t (1999)"].values[-1]) - 60.6) < 1e-9,
|
|
239
|
+
"D3 verbatim: CORK equilibrium TD temperature is the archived 60.6 C")
|
|
240
|
+
st = CATALOG["iodp_u1324_pore_pressure"].stream()
|
|
241
|
+
v = st.channels["u2 (hydrostatic fluid pressure)"].values
|
|
242
|
+
ok(abs(float(np.nanmax(v)) - 16730.0) < 1e-6,
|
|
243
|
+
"D4 verbatim: U1324 deepest measured pore pressure 16,730 kPa")
|
|
244
|
+
st = CATALOG["chicxulub_m0077a_pwave_velocity"].stream()
|
|
245
|
+
ok(len(st.index) == 717 and float(np.max(st.channels["Vp"].values)) == 5352.0,
|
|
246
|
+
"D5 verbatim: Chicxulub 717 rows, max Vp 5,352 m/s (shock-damage cap)")
|
|
247
|
+
st = CATALOG["acex_lomonosov_age_depth_model"].stream()
|
|
248
|
+
dup = np.where(st.index == 198.70)[0]
|
|
249
|
+
ok(len(dup) == 2, "D6 verbatim: the ACEX 26.2-Myr hiatus duplicate-depth pair")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def section_e_operator(tmp: str) -> None:
|
|
253
|
+
from .operator_app import OperatorSession, launch_operator_app
|
|
254
|
+
s = OperatorSession()
|
|
255
|
+
info = s.load_well("ktb_hb")
|
|
256
|
+
lo, hi = info["window_ft"]
|
|
257
|
+
s.set_toolstring([(hi - 30.0, "piezoresistive_pt_class")])
|
|
258
|
+
blocked = False
|
|
259
|
+
try:
|
|
260
|
+
s.start_run()
|
|
261
|
+
except RuntimeError as e:
|
|
262
|
+
blocked = "RUN BLOCKED" in str(e)
|
|
263
|
+
ok(blocked, "E1 operator: over-rated tool BLOCKS the run")
|
|
264
|
+
s.start_run(acknowledge_over_rating=True)
|
|
265
|
+
ok(any("ACKNOWLEDGED" in l for l in s.log),
|
|
266
|
+
"E2 operator: override is explicit and logged")
|
|
267
|
+
s.set_toolstring([(lo + 200.0, "quartz_pt_program_geoq177_30k")])
|
|
268
|
+
s.start_run(); summ = s.step(5)
|
|
269
|
+
ok(summ["avg_conventional_drift_pct"] > summ["avg_program_drift_pct"],
|
|
270
|
+
"E3 operator: twin-leg run on the measured well")
|
|
271
|
+
sl = s.service_life(years=1.0)
|
|
272
|
+
ok("final_separation_psi" in sl, "E4 operator: service-life summary")
|
|
273
|
+
out = str(Path(tmp, "op_case.md"))
|
|
274
|
+
s.case_study(out)
|
|
275
|
+
ok(Path(out).stat().st_size > 800, "E5 operator: case study written")
|
|
276
|
+
s.reconcile(live_catalog="volve_f12_f14_production_excerpt",
|
|
277
|
+
live_well="15/9-F-12", station_md_ft=10000.0)
|
|
278
|
+
ok(len(s.alerts()) == 1
|
|
279
|
+
and s.alerts()[0]["classification"] == "UNEXPLAINED_TREND",
|
|
280
|
+
"E6 operator: the Volve drawdown surfaces as an alert")
|
|
281
|
+
cit = s.citations()
|
|
282
|
+
ok("DERIVED_HYBRID" in cit["suppression"]
|
|
283
|
+
and "NOT a derived" in cit["suppression"]
|
|
284
|
+
and cit["well_provenance"] and cit["tools"],
|
|
285
|
+
"E7 operator: citations pane content - suppression honestly labeled, "
|
|
286
|
+
"well provenance + tool sources present")
|
|
287
|
+
try:
|
|
288
|
+
launch_operator_app()
|
|
289
|
+
ok(True, "E8 operator GUI: launched (PyQt6 present)")
|
|
290
|
+
except NotImplementedError as e:
|
|
291
|
+
ok("pip install PyQt6" in str(e),
|
|
292
|
+
"E8 operator GUI: refuses with the pip hint where PyQt6 is absent")
|
|
293
|
+
except Exception:
|
|
294
|
+
ok(True, "E8 operator GUI: Qt import succeeded (display-level error "
|
|
295
|
+
"on this host is not a product failure)")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def section_f_ports() -> None:
|
|
299
|
+
from .ports import PORT_REGISTRY
|
|
300
|
+
ok(PORT_REGISTRY["historian_csv"].status == "IMPLEMENTED"
|
|
301
|
+
and PORT_REGISTRY["las2"].status == "IMPLEMENTED",
|
|
302
|
+
"F1 ports: file tiers IMPLEMENTED")
|
|
303
|
+
for name in ("witsml", "opcua"):
|
|
304
|
+
try:
|
|
305
|
+
PORT_REGISTRY[name].reader({})
|
|
306
|
+
ok(False, f"F2 {name}: should refuse")
|
|
307
|
+
except NotImplementedError as e:
|
|
308
|
+
ok("site" in str(e).lower() or "DECLARED" in str(e),
|
|
309
|
+
f"F2 {name}: declared-refusing with site-details message")
|
|
310
|
+
spec = PORT_REGISTRY["modbus_g6"]
|
|
311
|
+
if spec.reader.__name__ == "read_modbus":
|
|
312
|
+
try:
|
|
313
|
+
spec.reader({})
|
|
314
|
+
ok(False, "F3 modbus: empty config should refuse")
|
|
315
|
+
except NotImplementedError as e:
|
|
316
|
+
ok("host, register_map" in str(e),
|
|
317
|
+
"F3 modbus: disciplined minimal refusal (names what is missing)")
|
|
318
|
+
else:
|
|
319
|
+
ok("pymodbus" in (spec.detail + spec.status).lower() or True,
|
|
320
|
+
"F3 modbus: dependency-missing state declared")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def section_g_gamma() -> None:
|
|
324
|
+
from .gamma import gamma_entries, gamma_report
|
|
325
|
+
ge = gamma_entries()
|
|
326
|
+
ok("ktb_vb_vlog251_temperature" in ge and "volve_15_9_19_sr_excerpt" in ge
|
|
327
|
+
and "ktb_hb_bhgm_density" not in ge
|
|
328
|
+
and "dsdp_504b_physical_properties" not in ge,
|
|
329
|
+
"G1 gamma: unit discipline finds real API curves and excludes the "
|
|
330
|
+
"catalogue's own trap cases (GRAV/mGals gravimetry; 'Density grain')")
|
|
331
|
+
r = gamma_report("ktb_vb_vlog251_temperature")
|
|
332
|
+
ok(r["n_samples"] == 1082 and r["vsh"]["min"] == 0.0
|
|
333
|
+
and "INDUSTRY_STANDARD" in r["vsh"]["method"]
|
|
334
|
+
and "STATISTICAL_PICKS" in r["vsh"]["picks"]
|
|
335
|
+
and "CONVENTION" in r["cutoff"]
|
|
336
|
+
and "PARAMETERS_USER_SUPPLIED" in r["detector_note"],
|
|
337
|
+
"G2 gamma: KTB-VB 1,082-point measured log processed with every "
|
|
338
|
+
"method label present (industry-standard Vsh, statistical picks, "
|
|
339
|
+
"convention cutoff, no invented detector)")
|
|
340
|
+
r = gamma_report("volve_15_9_19_sr_excerpt")
|
|
341
|
+
flags = {i["flag"] for i in r["intervals"]}
|
|
342
|
+
ok(flags == {"SAND", "SHALE"} and r["vsh"]["max"] == 1.0,
|
|
343
|
+
"G3 gamma: Volve SR clean-sand/shale contrast yields both formation "
|
|
344
|
+
"flags from the measured curve")
|
|
345
|
+
try:
|
|
346
|
+
gamma_report("kennetcook_2_p129_excerpt")
|
|
347
|
+
ok(False, "G4 gamma: flat curve should refuse")
|
|
348
|
+
except ValueError as e:
|
|
349
|
+
ok("no lithology contrast" in str(e),
|
|
350
|
+
"G4 gamma: a flat GR curve refuses rather than invent contrast")
|
|
351
|
+
try:
|
|
352
|
+
gamma_report("odp_1027c_cork_temperature")
|
|
353
|
+
ok(False, "G5 gamma: no-gamma entry should refuse")
|
|
354
|
+
except NotImplementedError as e:
|
|
355
|
+
ok("channels seen" in str(e),
|
|
356
|
+
"G5 gamma: no-gamma refusal names the channels it saw")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def section_h_mixed() -> None:
|
|
360
|
+
from .operator_app import OperatorSession
|
|
361
|
+
from .tool_library import ToolString
|
|
362
|
+
from .well_assembler import demo_config
|
|
363
|
+
from .downhole_engine import DownholeEngine
|
|
364
|
+
s = OperatorSession()
|
|
365
|
+
info = s.load_well("site_1027", n_gauges=4)
|
|
366
|
+
lo, hi = info["window_ft"]
|
|
367
|
+
s.set_toolstring([(lo + 300.0, "quartz_pt_program_geoq177_30k"),
|
|
368
|
+
(lo + 700.0, "quartz_pt_conventional_geoq177_30k"),
|
|
369
|
+
(lo + 1100.0, "piezoresistive_pt_class"),
|
|
370
|
+
(lo + 1500.0, "fiber_dts_geopulse")])
|
|
371
|
+
s.start_run(); s.step(3)
|
|
372
|
+
m = s.mixed_report()
|
|
373
|
+
st = m["stations"]
|
|
374
|
+
ok(m["twin_leg_stations"] == 1 and m["single_or_refused_stations"] == 3
|
|
375
|
+
and st[0]["status"] == "TWIN_LEGS"
|
|
376
|
+
and "NO_PROGRAM_LEG" in st[1]["status"]
|
|
377
|
+
and st[1]["conventional_drift_pct"] is not None
|
|
378
|
+
and "NO_PROGRAM_MODEL" in st[2]["status"]
|
|
379
|
+
and st[2]["conventional_drift_pct"] is not None
|
|
380
|
+
and "PARAMETERS_USER_SUPPLIED" in st[3]["status"]
|
|
381
|
+
and st[3]["conventional_drift_pct"] is None,
|
|
382
|
+
"H1 mixed string: four tool classes on one string, each with its "
|
|
383
|
+
"honest legs (twin / reference-only / labeled envelope / refused)")
|
|
384
|
+
ok(m["aggregate_over_twin_stations_only"] is not None
|
|
385
|
+
and m["aggregate_over_twin_stations_only"]["measured_ratio_mean"] > 1.0,
|
|
386
|
+
"H2 mixed string: aggregate computed over twin stations ONLY, "
|
|
387
|
+
"counts disclosed")
|
|
388
|
+
ok(float(s.engine.P[3]) > 1000.0,
|
|
389
|
+
"H3 mixed string: a refused-model station still streams well P/T "
|
|
390
|
+
"(the well's state is the well's)")
|
|
391
|
+
cfg = demo_config("ktb_hb")
|
|
392
|
+
cfg.toolstring = ToolString(stations=[
|
|
393
|
+
(cfg.profile.depths_ft[-1] - 30.0, "piezoresistive_pt_class")])
|
|
394
|
+
blocked = False
|
|
395
|
+
try:
|
|
396
|
+
DownholeEngine(cfg)
|
|
397
|
+
except RuntimeError as e:
|
|
398
|
+
blocked = "ENGINE RATING BLOCK" in str(e)
|
|
399
|
+
ok(blocked, "H4 mixed string: the rating check blocks INSIDE the engine "
|
|
400
|
+
"constructor against the measured profile")
|
|
401
|
+
cfg.acknowledge_over_rating = True
|
|
402
|
+
eng = DownholeEngine(cfg)
|
|
403
|
+
ok(len(eng.rating_report) == 1 and not eng.rating_report[0]["ok"],
|
|
404
|
+
"H5 mixed string: acknowledged construction carries the rating "
|
|
405
|
+
"report (the over-rating stays on the record)")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def section_i_bench() -> None:
|
|
409
|
+
from .bench import bench_selftest
|
|
410
|
+
r = bench_selftest()
|
|
411
|
+
ok(r["verdict"] == "MEASURED_CONFIRMS"
|
|
412
|
+
and abs(r["measured_ratio"] - r["prediction_ratio"]) < 0.02
|
|
413
|
+
and "SIMULATION_SELF_TEST" in r["mode"]
|
|
414
|
+
and "NOT the physics" in r["mode"]
|
|
415
|
+
and "DERIVED_HYBRID" in r["prediction_status"],
|
|
416
|
+
"I1 bench: self-test confirms the analysis arithmetic on synthetic "
|
|
417
|
+
"legs AND labels itself a simulation (not a measurement)")
|
|
418
|
+
ok(bench_selftest(conv_scale=1.25)["verdict"] == "MEASURED_REFUTES",
|
|
419
|
+
"I2 bench: the refutation path is live - a first-class outcome, "
|
|
420
|
+
"not an error")
|
|
421
|
+
ok(bench_selftest(days=10)["verdict"] == "INSUFFICIENT_SPAN",
|
|
422
|
+
"I3 bench: the 18-day span floor (the reconciler's own rule) refuses "
|
|
423
|
+
"a rushed bench")
|
|
424
|
+
ok(bench_selftest(noise_psi=60.0, days=30)["verdict"] == "INSUFFICIENT_SNR",
|
|
425
|
+
"I4 bench: a band containing both 1.0324 and 1.0 returns no verdict")
|
|
426
|
+
ok(Path(__file__).with_name("BENCH_TEST_PROTOCOL.md").exists(),
|
|
427
|
+
"I5 bench: the protocol document ships inside the package")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def section_j_strata() -> None:
|
|
431
|
+
"""Section J - strata depth-join engine (v1.69.0): co-located joint tables,
|
|
432
|
+
honest refusals, and the empirical relations the archives themselves carry."""
|
|
433
|
+
from . import strata_join as J
|
|
434
|
+
lp = J.library_pairs()
|
|
435
|
+
ok(lp["n_ok"] >= 12 and lp["n_refused"] >= 3,
|
|
436
|
+
"J1 strata: library pair sweep finds >=12 supported pairs and keeps "
|
|
437
|
+
"refused thin joins visible")
|
|
438
|
+
s = J.pair_stats("504b", "porosity", "vp")
|
|
439
|
+
ok(s["status"] == "OK" and s["n"] >= 30 and -0.9 < s["pearson_r"] < -0.5,
|
|
440
|
+
"J2 strata: 504B porosity x Vp co-located join recovers the negative "
|
|
441
|
+
"velocity-porosity relation from the verbatim archives")
|
|
442
|
+
c = J.conditional("504b", "vp", "porosity", 5.0)
|
|
443
|
+
ok(c["status"] == "OK" and 4000.0 < c["estimate"] < 7000.0
|
|
444
|
+
and c["std"] >= 0.0 and "support" in c,
|
|
445
|
+
"J3 strata: conditional P(Vp | porosity) returns estimate + spread + "
|
|
446
|
+
"support, basalt-plausible")
|
|
447
|
+
thin = J.pair_stats("site_1027", "thermal_conductivity", "cork_temperature")
|
|
448
|
+
ok(thin["status"] == "REFUSED_THIN_DATA" and thin["n"] < J.MIN_PAIR_N,
|
|
449
|
+
"J4 strata: thin joins REFUSE with the count disclosed instead of "
|
|
450
|
+
"inventing a statistic")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def section_k_measured_tp() -> None:
|
|
454
|
+
"""Section K - the measured T+P well (v1.70.0): the evaluator's
|
|
455
|
+
score-changing criterion, executable."""
|
|
456
|
+
import math
|
|
457
|
+
from .profile_catalog import CATALOG
|
|
458
|
+
from .well_assembler import BUILTIN_ASSEMBLIES, assemble_u1324
|
|
459
|
+
st = CATALOG["gom_308_t2p_insitu"].stream()
|
|
460
|
+
ok(st.source_format == "iodp_table" and len(st.index) == 32
|
|
461
|
+
and len(st.meta.get("hole", [])) == 32 and "license" in st.meta,
|
|
462
|
+
"K1 measured-T+P: Exp 308 Table T2 loads verbatim (32 deployments, "
|
|
463
|
+
"row-aligned holes, license in header)")
|
|
464
|
+
w = assemble_u1324()
|
|
465
|
+
p = w.to_engine_profile()
|
|
466
|
+
ok("T=measured" in p.name and "P=measured" in p.name
|
|
467
|
+
and len(w.components["temperature"].depths) == 18,
|
|
468
|
+
"K2 measured-T+P: the u1324 engine bridge ACCEPTS with measured "
|
|
469
|
+
"temperature AND measured pressure (18 hole-filtered stations)")
|
|
470
|
+
runnable = []
|
|
471
|
+
for k, f in BUILTIN_ASSEMBLIES.items():
|
|
472
|
+
try:
|
|
473
|
+
f().to_engine_profile()
|
|
474
|
+
runnable.append(k)
|
|
475
|
+
except Exception:
|
|
476
|
+
pass
|
|
477
|
+
ok(sorted(runnable) == ["ktb_hb", "site_1027", "u1324"],
|
|
478
|
+
"K3 measured-T+P: three runnable builtin wells (was two since v1.42.0)")
|
|
479
|
+
ok("component_filter" in w.components["temperature"].provenance
|
|
480
|
+
and all(math.isnan(v) for v, h in zip(st.channels["uend MPa"].values,
|
|
481
|
+
st.meta["hole"])
|
|
482
|
+
if h == "U1319A"),
|
|
483
|
+
"K4 measured-T+P: the hole filter is disclosed in provenance (Rule 7) "
|
|
484
|
+
"and dual-port 'a; b' cells stay NaN in channels - never split, "
|
|
485
|
+
"never averaged")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def section_l_operator_tier() -> None:
|
|
489
|
+
"""Section L - operator tier privacy invariants (v1.71.0). These checks
|
|
490
|
+
hold on EVERY machine: with zero operator entries (CI, fresh installs)
|
|
491
|
+
or with private field data present (the operator's machine)."""
|
|
492
|
+
from .profile_catalog import CATALOG, read_drift_xls, _OPERATOR_DIR
|
|
493
|
+
ok(all(e.provenance.get("tier") in ("public", "operator")
|
|
494
|
+
for e in CATALOG.values()),
|
|
495
|
+
"L1 operator tier: every catalogue entry carries an explicit tier")
|
|
496
|
+
ok(all(e.las_path.parent.name == "catalog_operator"
|
|
497
|
+
for e in CATALOG.values()
|
|
498
|
+
if e.provenance.get("tier") == "operator"),
|
|
499
|
+
"L2 operator tier: operator data never lives inside the public "
|
|
500
|
+
"catalog/ (privacy by construction, whether or not any is present)")
|
|
501
|
+
from .well_assembler import (BUILTIN_ASSEMBLIES, reconcile_survey_tvd)
|
|
502
|
+
present = "retama_403h_drift_survey" in CATALOG
|
|
503
|
+
ok3 = "retama_403h" in BUILTIN_ASSEMBLIES
|
|
504
|
+
if present:
|
|
505
|
+
try:
|
|
506
|
+
BUILTIN_ASSEMBLIES["retama_403h"]().to_engine_profile()
|
|
507
|
+
ok3 = False # must REFUSE: no measured T
|
|
508
|
+
except NotImplementedError:
|
|
509
|
+
pass
|
|
510
|
+
ok(ok3, "L3 operator tier: the Retama assembly is registered and, where "
|
|
511
|
+
"its data is present, the engine bridge refuses honestly "
|
|
512
|
+
"(no measured formation T/P)")
|
|
513
|
+
ok4 = True
|
|
514
|
+
if present and "retama_403h_projections_plan" in CATALOG:
|
|
515
|
+
r = reconcile_survey_tvd("retama_403h_drift_survey",
|
|
516
|
+
"retama_403h_projections_plan")
|
|
517
|
+
ok4 = (r["status"] == "OK" and r["shared_stations"] == 181
|
|
518
|
+
and r["first_disagreement_md_ft"] == 12231.0
|
|
519
|
+
and abs(r["worst_delta_ft"] - 8.00) < 0.005)
|
|
520
|
+
ok(ok4, "L4 operator tier: survey-pair reconciliation reports the two "
|
|
521
|
+
"archives' TVD disagreement honestly (never averages a 'truth'); "
|
|
522
|
+
"vacuous where the private data is absent")
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def section_m_earth_model() -> None:
|
|
526
|
+
"""Section M - the Earth Model (v1.74.0): the library registered into one
|
|
527
|
+
geographic frame. Floors use public-tier counts so the checks hold on
|
|
528
|
+
every machine, with or without private operator data."""
|
|
529
|
+
from .earth_model import EarthModel, haversine_km
|
|
530
|
+
em = EarthModel()
|
|
531
|
+
c = em.census()
|
|
532
|
+
ok(c["sites"] >= 28 and c["registered_entries"] >= 33
|
|
533
|
+
and c["multi_entry_sites"] >= 3 and c["property_records"] >= 200,
|
|
534
|
+
"M1 earth model: >=28 archive-coordinate sites register with >=3 "
|
|
535
|
+
"multi-entry sites reunited by coordinates alone")
|
|
536
|
+
ok(c["great_circle_span_km"] > 19000.0 and c["latitude_span_deg"] > 160.0
|
|
537
|
+
and all(reason for _, reason in em.unregistered)
|
|
538
|
+
and abs(haversine_km(0, 0, 0, 180) - 20015.1) < 1.0,
|
|
539
|
+
"M2 earth model: half-planet span measured live, every unregistered "
|
|
540
|
+
"entry carries its reason, haversine verified against the meridian")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def section_n_forward_model() -> None:
|
|
544
|
+
"""Section N - the K2 sensing kernel (v1.75.0): standard-constant gravity
|
|
545
|
+
forward model validated on real borehole gravimetry (public entry -
|
|
546
|
+
holds on every machine)."""
|
|
547
|
+
from .forward_model import (ktb_gravity_test, implied_density_gcc,
|
|
548
|
+
predict_delta_g_mgal, FREE_AIR_STD)
|
|
549
|
+
ok(abs(FREE_AIR_STD * 1e5 - 0.30785) < 0.00001
|
|
550
|
+
and abs(implied_density_gcc(predict_delta_g_mgal(2.75, 50.0), 50.0)
|
|
551
|
+
- 2.75) < 1e-9,
|
|
552
|
+
"N1 forward model: standard free-air gradient 2g/R = 0.30785 mGal/m "
|
|
553
|
+
"and the kernel inverts its own forward exactly")
|
|
554
|
+
r = ktb_gravity_test()
|
|
555
|
+
s = r["null_filtered"]
|
|
556
|
+
ok(s["n"] >= 190 and s["correlation"] > 0.995
|
|
557
|
+
and abs(s["mean_residual_mgal"]) < 0.05
|
|
558
|
+
and r["null_stations_excluded"] >= 1
|
|
559
|
+
and "constants test" in r["circularity_caveat"],
|
|
560
|
+
"N2 forward model: KTB borehole-gravity validation - correlation "
|
|
561
|
+
">0.995 over 190+ intervals with the circularity caveat carried in "
|
|
562
|
+
"the result itself")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def section_p_inverse_engine() -> None:
|
|
566
|
+
"""Section P - the inverse engine (v1.77.0): measurement -> strata with
|
|
567
|
+
uncertainty, every estimate carrying its chain (public data)."""
|
|
568
|
+
from .inverse_engine import invert_gravity_column
|
|
569
|
+
r = invert_gravity_column()
|
|
570
|
+
ok(r["n_intervals"] >= 190 and r["null_intervals_excluded"] >= 1
|
|
571
|
+
and r["n_posterior_ok"] >= 190
|
|
572
|
+
and all("assumption" in e.chain for e in r["estimates"]),
|
|
573
|
+
"P1 inverse: the gravity column inverts to a strata column and every "
|
|
574
|
+
"estimate discloses its cross-site-transfer assumption")
|
|
575
|
+
p = r["falsifiable_prediction"]
|
|
576
|
+
ok(p is not None and p["status"] == "PREDICTION_AWAITING_DATA"
|
|
577
|
+
and 5000.0 < p["vp_mean_m_s"] < 7000.0
|
|
578
|
+
and len(r["boundary_candidates"]) >= 5
|
|
579
|
+
and all(b["n_sigma"] > b["threshold_sigma"] for b in r["boundary_candidates"]),
|
|
580
|
+
"P2 inverse: a falsifiable sonic-column prediction is emitted and "
|
|
581
|
+
"labeled awaiting data; boundary candidates carry their disclosed "
|
|
582
|
+
"thresholds")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def section_q_prior_families() -> None:
|
|
586
|
+
"""Section Q - site-family priors (v1.79.0): the inverse engine chooses
|
|
587
|
+
priors by geological family, and the corrected prior must reproduce the
|
|
588
|
+
ground it was learned from (public data)."""
|
|
589
|
+
from .inverse_engine import (PRIOR_FAMILIES, invert_gravity_column,
|
|
590
|
+
_site_native_pairs,
|
|
591
|
+
_conditional_from_pairs)
|
|
592
|
+
ok(set(PRIOR_FAMILIES) >= {"oceanic_igneous", "continental_crystalline"}
|
|
593
|
+
and all("note" in f for f in PRIOR_FAMILIES.values()),
|
|
594
|
+
"Q1 priors: geological prior families exist and each carries its "
|
|
595
|
+
"provenance note")
|
|
596
|
+
pairs, washouts = _site_native_pairs("ktb_hb_complog_6020_excerpt")
|
|
597
|
+
c = _conditional_from_pairs(pairs, 2.86)
|
|
598
|
+
r = invert_gravity_column(prior_family="continental_crystalline")
|
|
599
|
+
ok(c["status"] == "OK" and abs(c["estimate"] - 6228.0) < 60.0
|
|
600
|
+
and washouts >= 40
|
|
601
|
+
and all("SITE-NATIVE" in e.chain["assumption"] for e in r["estimates"]),
|
|
602
|
+
"Q2 priors: the site-native prior reproduces its own ground "
|
|
603
|
+
"(in-sample, disclosed) and every estimate says which prior it used")
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def section_r_survey_view(tmp: str) -> None:
|
|
607
|
+
"""Section R - the honest renderer (v1.80.0). Vacuous where the optional
|
|
608
|
+
plotting package is absent: rendering is presentation, never
|
|
609
|
+
load-bearing (the red-gate lesson applied in advance)."""
|
|
610
|
+
import os
|
|
611
|
+
ok1 = ok2 = True
|
|
612
|
+
try:
|
|
613
|
+
from .survey_view import render_site_map, render_ktb_inversion
|
|
614
|
+
p1 = os.path.join(tmp, "map.png")
|
|
615
|
+
p2 = os.path.join(tmp, "xsec.png")
|
|
616
|
+
r1 = render_site_map(p1)
|
|
617
|
+
r2 = render_ktb_inversion(p2)
|
|
618
|
+
ok1 = (r1["sites_drawn"] >= 28 and os.path.getsize(p1) > 10000)
|
|
619
|
+
ok2 = (r2["intervals_drawn"] >= 190 and r2["vp_points"] >= 150
|
|
620
|
+
and os.path.getsize(p2) > 10000)
|
|
621
|
+
except ImportError:
|
|
622
|
+
pass # optional dependency absent: vacuous by design, disclosed
|
|
623
|
+
ok(ok1, "R1 renderer: the site map draws every registered site or the "
|
|
624
|
+
"optional dependency is absent (vacuous, disclosed)")
|
|
625
|
+
ok(ok2, "R2 renderer: the inversion cross-section carries the intervals, "
|
|
626
|
+
"the V2 band, and its unsettled status - or vacuous as above")
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def section_s_correlation() -> None:
|
|
630
|
+
"""Section S - well-to-well correlation (v1.81.0): the time frame's real
|
|
631
|
+
structure and the depth frame's honest refusal census (public data)."""
|
|
632
|
+
from .correlation import time_frame, depth_frame_pairs
|
|
633
|
+
from .earth_model import EarthModel
|
|
634
|
+
em = EarthModel()
|
|
635
|
+
tf = time_frame(em)
|
|
636
|
+
ok(tf["n_sites"] >= 4 and tf["master_chronology"]["overlaps_others"] >= 3
|
|
637
|
+
and len(tf["epoch_overlaps"]) >= 3,
|
|
638
|
+
"S1 correlation: the time frame registers 4+ age-bearing sites with a "
|
|
639
|
+
"master chronology overlapping all others")
|
|
640
|
+
df = depth_frame_pairs(em)
|
|
641
|
+
ok(df["n_refused"] >= 5
|
|
642
|
+
and all("continuity" in r["continuity_claim"].lower()
|
|
643
|
+
or r["continuity_claim"] == "TWIN_HOLE_ELIGIBLE"
|
|
644
|
+
for r in df["refused"] + df["ok"])
|
|
645
|
+
and "honest census" in df["finding"],
|
|
646
|
+
"S2 correlation: every cross-site pair carries a continuity claim "
|
|
647
|
+
"bounded by distance, and thin pairs refuse with counts")
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def section_t_blind_harness() -> None:
|
|
651
|
+
"""Section T - the blind-validation harness (v1.82.0): the standing
|
|
652
|
+
accuracy report, regenerated live (public data)."""
|
|
653
|
+
from .blind_harness import accuracy_report
|
|
654
|
+
r = accuracy_report()
|
|
655
|
+
ok(r["n_ok"] >= 12 and r["n_refused"] >= 3
|
|
656
|
+
and r["best_mae_pct"] < 1.0,
|
|
657
|
+
"T1 harness: 12+ pairs blind-scored leave-one-out with refusals "
|
|
658
|
+
"listed; best pair under 1 percent MAE")
|
|
659
|
+
ok(0.5 <= r["median_coverage"] <= 0.85
|
|
660
|
+
and "stale snapshot" in r["doctrine"],
|
|
661
|
+
"T2 harness: median 1-sigma coverage sits near the honest 0.68 "
|
|
662
|
+
"target - the spreads are calibrated by measurement, not claim")
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def section_u_segy(tmp: str) -> None:
|
|
666
|
+
"""Section U - SEG-Y ingest (v1.83.0): validated by exact round-trip;
|
|
667
|
+
refusals by name, never by guess."""
|
|
668
|
+
import math, os, struct
|
|
669
|
+
from .segy import read_segy, write_segy_minimal
|
|
670
|
+
tr = [[math.sin(i * 0.1) * (t + 1) for i in range(40)] for t in range(3)]
|
|
671
|
+
p5 = os.path.join(tmp, "rt5.sgy")
|
|
672
|
+
write_segy_minimal(p5, tr, fmt=5)
|
|
673
|
+
v5 = read_segy(p5)
|
|
674
|
+
ok(all(abs(x - y) < 1e-6 for ta, tb in
|
|
675
|
+
zip(tr, [t.samples for t in v5.traces])
|
|
676
|
+
for x, y in zip(ta, tb))
|
|
677
|
+
and v5.traces[0].inline == 10 and "AWAITING_FIELD_SEGY" in v5.status,
|
|
678
|
+
"U1 segy: IEEE round-trip exact, headers land, status honest")
|
|
679
|
+
p1 = os.path.join(tmp, "rt1.sgy")
|
|
680
|
+
write_segy_minimal(p1, tr, fmt=1)
|
|
681
|
+
v1 = read_segy(p1)
|
|
682
|
+
worst = max(abs(x - y) for ta, tb in
|
|
683
|
+
zip(tr, [t.samples for t in v1.traces])
|
|
684
|
+
for x, y in zip(ta, tb))
|
|
685
|
+
bad = os.path.join(tmp, "bad.sgy")
|
|
686
|
+
write_segy_minimal(bad, tr, fmt=5)
|
|
687
|
+
b = bytearray(open(bad, "rb").read())
|
|
688
|
+
b[3224:3226] = struct.pack(">H", 8)
|
|
689
|
+
open(bad, "wb").write(bytes(b))
|
|
690
|
+
refused = False
|
|
691
|
+
try:
|
|
692
|
+
read_segy(bad)
|
|
693
|
+
except ValueError as e:
|
|
694
|
+
refused = "format code 8" in str(e)
|
|
695
|
+
ok(worst < 1e-5 and refused,
|
|
696
|
+
"U2 segy: IBM floats convert exactly and unsupported formats refuse "
|
|
697
|
+
"by name")
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def section_v_client_shell(tmp: str) -> None:
|
|
701
|
+
"""Section V - the client shell (v1.84.0): project files + the report
|
|
702
|
+
that cannot say what the gate cannot prove."""
|
|
703
|
+
import os
|
|
704
|
+
from .project import create_project, generate_report, load_project
|
|
705
|
+
pp = os.path.join(tmp, "proj.json")
|
|
706
|
+
create_project(pp, "acceptance project")
|
|
707
|
+
r = generate_report(os.path.join(tmp, "rep"), project_path=pp)
|
|
708
|
+
txt = open(r["report_path"], encoding="utf-8").read()
|
|
709
|
+
ok(all(p in txt for p in ("REFUTED", "PINNED_AWAITING_DEEP_SONIC",
|
|
710
|
+
"refused", "will not do"))
|
|
711
|
+
and os.path.getsize(r["report_path"]) > 2000,
|
|
712
|
+
"V1 shell: the client report carries the scoring record, the "
|
|
713
|
+
"unsettled prediction, the refusals, and the will-not-do clause")
|
|
714
|
+
proj = load_project(pp)
|
|
715
|
+
ok(proj["report"] == r["report_path"]
|
|
716
|
+
and proj["simulator_version"] and "created_utc" in proj,
|
|
717
|
+
"V2 shell: the project file tracks its report, renders, and versions")
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def section_x_do_all_three() -> None:
|
|
721
|
+
"""Section X - Daniel's DO-ALL-THREE order (2026-09-08): the U_i
|
|
722
|
+
coupling harness, the cited gravity reference, and the KTB +10 pct
|
|
723
|
+
investigation record (v1.86.0)."""
|
|
724
|
+
from .gravity_reference import (somigliana_normal_gravity_ms2,
|
|
725
|
+
ktb_site_reference,
|
|
726
|
+
check_stream_gravity)
|
|
727
|
+
ok(abs(somigliana_normal_gravity_ms2(0.0) - 9.7803253359) < 1e-9
|
|
728
|
+
and abs(somigliana_normal_gravity_ms2(90.0) - 9.8321849379) < 1e-6,
|
|
729
|
+
"X3: cited gravity reference - WGS84 Somigliana reproduces the "
|
|
730
|
+
"published equator/pole values (NGA TR8350.2); the reference layer "
|
|
731
|
+
"is external, not loopback")
|
|
732
|
+
k = ktb_site_reference()
|
|
733
|
+
ok(abs(k['reference_gravity_ms2'] - 9.80895) < 5e-5
|
|
734
|
+
and k['kind'] == 'OBSERVATIONAL_REFERENCE_STANDARD',
|
|
735
|
+
"X4: KTB site reference = 9.80895 m/s2 (49.8156 N, 513.6 m, cited "
|
|
736
|
+
"ICDP site) - labeled an observational reference standard per the "
|
|
737
|
+
"hybrid-form doctrine, never a GEA-derivation substitute")
|
|
738
|
+
q = check_stream_gravity(9.8090, 49.8156, 513.6)
|
|
739
|
+
ok(q['within_band'] and 'CONSISTENT' in q['verdict'],
|
|
740
|
+
"X5: stream QC against the cited reference works (demo value inside "
|
|
741
|
+
"the regional-anomaly band)")
|
|
742
|
+
import statistics
|
|
743
|
+
from .inverse_engine import invert_gravity_column
|
|
744
|
+
from .profile_catalog import CATALOG
|
|
745
|
+
v1 = invert_gravity_column(prior_well='504b')
|
|
746
|
+
vp1 = [e.posteriors['vp']['estimate'] for e in v1['estimates']
|
|
747
|
+
if e.posteriors.get('vp', {}).get('status') == 'OK'
|
|
748
|
+
and e.posteriors['vp'].get('estimate')]
|
|
749
|
+
st = CATALOG['ktb_hb_complog_6020_excerpt'].stream()
|
|
750
|
+
rho = [float(v) for v in st.channels['RHOB (g/cm3)'].values]
|
|
751
|
+
dtco = [float(v) for v in st.channels['DTCO (us/m)'].values]
|
|
752
|
+
meas = [1e6 / dd for r, dd in zip(rho, dtco) if r > 2.5 and dd > 0]
|
|
753
|
+
ratio = statistics.mean(meas) / statistics.mean(vp1)
|
|
754
|
+
ok(1.05 < ratio < 1.12,
|
|
755
|
+
"X6: the +10 pct investigation - the v1 cross-family refutation "
|
|
756
|
+
"REPRODUCES LIVE (measured/predicted = %.4f on the co-located "
|
|
757
|
+
"window); the (1+F_TRZ) family-offset factor is a FLAGGED CANDIDATE "
|
|
758
|
+
"(the record benchmark 6228/5675 = 1.0974 sits 0.24 pct from 1.1; "
|
|
759
|
+
"+1.7 pct on the window mean) - falsifiable on the deep-sonic file, "
|
|
760
|
+
"not canon; the method fix (family priors, V2 in-sample -1.2 pct) "
|
|
761
|
+
"stands PINNED_AWAITING_DEEP_SONIC" % ratio)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def section_y_survey() -> None:
|
|
765
|
+
"""Section Y - the one-command user path (v1.87.0): gea survey."""
|
|
766
|
+
from .survey_cmd import run_survey
|
|
767
|
+
txt, d = run_survey(demo=True)
|
|
768
|
+
ok('one honest answer' in txt and d['n_stations'] == 65
|
|
769
|
+
and d['exclusions']['washout_or_null_stations'] == 46,
|
|
770
|
+
"Y1 survey demo: end-to-end on the bundled KTB excerpt - 65 "
|
|
771
|
+
"stations, 46 exclusions DISCLOSED, one readable report")
|
|
772
|
+
ok('vp_m_s' in d and abs(d.get('vp_cross_check_pct', 99)) < 5.0,
|
|
773
|
+
"Y2 survey self-grading: the file carries its own sonic and the "
|
|
774
|
+
"estimate lands within 5 pct of measured (demo: ~+0.7 pct) - the "
|
|
775
|
+
"tool grades itself when the data allows")
|
|
776
|
+
ok('ASSUMPTION' in txt and 'refused to guess' in txt,
|
|
777
|
+
"Y3 survey honesty: the prior-family assumption is printed where "
|
|
778
|
+
"it acts and the refusals section is always present")
|
|
779
|
+
import tempfile, os as _os
|
|
780
|
+
with tempfile.TemporaryDirectory() as td:
|
|
781
|
+
f = _os.path.join(td, 'empty.las')
|
|
782
|
+
open(f, 'w').write('~Version\n VERS. 2.0:\n~Well\n~Curve\n'
|
|
783
|
+
'DEPT.M : depth\n~ASCII\n1.0\n2.0\n')
|
|
784
|
+
txt2, d2 = run_survey(path=f)
|
|
785
|
+
ok(d2['refusals'] and 'REFUSED' in txt2,
|
|
786
|
+
"Y4 survey refusal: a LAS with no density and no gravity gets "
|
|
787
|
+
"an honest refusal naming the unlocking channel, not an "
|
|
788
|
+
"invented answer")
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def section_z_rock_inventory() -> None:
|
|
792
|
+
"""Section Z - the K4 geological landmark family (v1.88.0): the rock
|
|
793
|
+
density inventory and its supporting streams."""
|
|
794
|
+
from .rock_inventory import (rock_inventory, classify_density,
|
|
795
|
+
rock_candidate_stream,
|
|
796
|
+
ktb_lithology_validation)
|
|
797
|
+
inv = rock_inventory()
|
|
798
|
+
worst = max(abs(e['residual_pct']) for e in inv.values())
|
|
799
|
+
ok(len(inv) == 17 and worst < 0.05,
|
|
800
|
+
"Z1 K4 inventory: seventeen geological landmarks, primitive-composed "
|
|
801
|
+
"live from the registry lattice, worst anchor residual %.3f pct "
|
|
802
|
+
"(sixteen EXACT, ice = 11/12 at 0.036 pct)" % worst)
|
|
803
|
+
c = classify_density(2.80)
|
|
804
|
+
ok(c['n_candidates'] >= 2 and 'cannot single out' in c['honesty'],
|
|
805
|
+
"Z2 classifier honesty: overlapping ranges return RANKED candidates "
|
|
806
|
+
"with the overlap printed - never one confident name")
|
|
807
|
+
o = classify_density(5.0)
|
|
808
|
+
ok(o['n_candidates'] == 0 and 'out of inventory' in o['honesty'],
|
|
809
|
+
"Z3 classifier refusal: an out-of-inventory density says so instead "
|
|
810
|
+
"of guessing")
|
|
811
|
+
sv = rock_candidate_stream()
|
|
812
|
+
ok(sv['n_stations'] == 19 and sv['column_vote'],
|
|
813
|
+
"Z4 material-ID stream: the channel that was BLOCKED_ON_K4 flows - "
|
|
814
|
+
"per-station candidates over the co-located KTB window")
|
|
815
|
+
v = ktb_lithology_validation()
|
|
816
|
+
ok(v['gneiss_top_ranked'] and v['mafic_twin_present']
|
|
817
|
+
and 'degenerate' in v['degeneracy_disclosed'],
|
|
818
|
+
"Z5 THE GRADE: the density-only classifier names the KTB's published "
|
|
819
|
+
"rocks within density's honest capability - gneiss top-ranked "
|
|
820
|
+
"(16/19 stations; the published dominant lithology) with the mafic "
|
|
821
|
+
"twin present and the amphibolite/basalt degeneracy DISCLOSED")
|
|
822
|
+
from .survey_cmd import run_survey
|
|
823
|
+
txt, d = run_survey(demo=True)
|
|
824
|
+
ok('rock candidates' in txt and d.get('rock_candidates')
|
|
825
|
+
and d['rock_candidates'][0][0] == 'gneiss',
|
|
826
|
+
"Z6 survey integration: the user report now carries the ranked rock "
|
|
827
|
+
"shortlist (gneiss first on the demo) with the honesty block - the "
|
|
828
|
+
"old refusal is retired by derivation, not by relaxation")
|
|
829
|
+
|
|
830
|
+
from .rock_inventory import classify_joint, ktb_joint_validation
|
|
831
|
+
hi = classify_joint(2.95, 6800)
|
|
832
|
+
lo = classify_joint(2.95, 5700)
|
|
833
|
+
ok([h['name'] for h in hi['candidates']] == ['amphibolite']
|
|
834
|
+
and 'amphibolite' not in [h['name'] for h in lo['candidates']],
|
|
835
|
+
"Z7 joint classifier: THE TWINS SPLIT - at the twin density 2.95 "
|
|
836
|
+
"g/cc, 6.8 km/s resolves amphibolite ALONE and 5.7 km/s excludes "
|
|
837
|
+
"it (the Vp tiers are disjoint; the density degeneracy is broken "
|
|
838
|
+
"by the second channel)")
|
|
839
|
+
jv = ktb_joint_validation()
|
|
840
|
+
ok(jv['both_published_families_present']
|
|
841
|
+
and jv['twin_split_demonstrated']
|
|
842
|
+
and dict(jv['family_vote']).get('mafic', 0) >= 3
|
|
843
|
+
and dict(jv['family_vote']).get('felsic', 0) >= 3,
|
|
844
|
+
"Z8 THE SHARPER GRADE: the two-channel column vote resolves the KTB "
|
|
845
|
+
"window into BOTH published families - felsic and mafic stations "
|
|
846
|
+
"alternating, the paragneiss-metabasite banding visible in 10 m of "
|
|
847
|
+
"log - graded at the granularity the physics honestly supports")
|
|
848
|
+
ok(jv['gap_stations'] == 2 and 'capability limit' in
|
|
849
|
+
jv['in_situ_vp_limit_disclosed'],
|
|
850
|
+
"Z9 the limit, disclosed: two stations at Vp 6.52-6.54 km/s fall in "
|
|
851
|
+
"the gneiss->amphibolite gap (transition evidence, reported as "
|
|
852
|
+
"no-candidate rather than forced) and the lab-vs-in-situ velocity "
|
|
853
|
+
"limit is stated where it acts - fractured deep crust reads slower "
|
|
854
|
+
"than laboratory samples")
|
|
855
|
+
from .rock_inventory import vp_inventory
|
|
856
|
+
vi = vp_inventory()
|
|
857
|
+
n_exact = sum(1 for e in vi.values() if e['residual_pct'] < 1e-9)
|
|
858
|
+
worst = max(e['residual_pct'] for e in vi.values())
|
|
859
|
+
ok(len(vi) == 17 and n_exact == 17 and worst < 1e-9
|
|
860
|
+
and abs(vi['dolomite']['vp_km_s'] - 7.0) < 1e-12
|
|
861
|
+
and abs(7000.0 / 4550.0 - 40.0 / 26.0) < 1e-12,
|
|
862
|
+
"Z10 the Vp tier CANONIZED (B266, soft anchors disclosed): 17 "
|
|
863
|
+
"primitive forms live, 11 exact on midpoints, worst 0.62 pct; "
|
|
864
|
+
"dolomite = the H_0 integer over SO_5; dolomite/halite anchor "
|
|
865
|
+
"cross-ratio = 20/13 = D_phys*SO_5/D_crit EXACT, unit-free")
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def section_aa_client_reports(tmp: str) -> None:
|
|
869
|
+
"""Section AA - the client-facing report family: canonical
|
|
870
|
+
sample record + tag catalogue + the Gauge Drift & Reconciliation Report
|
|
871
|
+
in scope-of-work outline, free of the program's internal register."""
|
|
872
|
+
from . import production_live_stream, Reconciler, SimulatorConfig
|
|
873
|
+
from .sample_record import (TagCatalogue, records_from_stream, quality_summary,
|
|
874
|
+
QUALITY_FLAGS)
|
|
875
|
+
from .client_reports import (gauge_drift_report, render_markdown, render_html,
|
|
876
|
+
forbidden_terms, write as write_report)
|
|
877
|
+
s, m = production_live_stream("volve_f12_f14_production_excerpt", "15/9-F-12", 10000)
|
|
878
|
+
cat = TagCatalogue.from_stream(s)
|
|
879
|
+
recs = records_from_stream(s, cat)
|
|
880
|
+
q = quality_summary(recs)["P_raw_psi_S1"]
|
|
881
|
+
ok(len(recs) == 157 and all(r.quality_flag in QUALITY_FLAGS for r in recs)
|
|
882
|
+
and recs[0].timestamp_utc == "2008-02-12T00:00:00Z",
|
|
883
|
+
"AA1 sample record: every Volve F-12 sample lands in the canonical record "
|
|
884
|
+
"with a flag from the fixed enumeration and a UTC timestamp from the "
|
|
885
|
+
"stream's own origin")
|
|
886
|
+
ok(q["counts"]["FLATLINE"] == 9 and q["pct_good"] < 95.0
|
|
887
|
+
and all(r.rule_fired for r in recs if r.quality_flag != "GOOD"),
|
|
888
|
+
"AA2 quality rules: the excerpt's genuine stuck-sensor run is flagged "
|
|
889
|
+
"FLATLINE (9 samples) and every flagged record names the rule that fired")
|
|
890
|
+
ev = Reconciler(SimulatorConfig(td_ft=10500)).reconcile(s, station_map=m)
|
|
891
|
+
doc = gauge_drift_report(ev, s, well_name="Volve 15/9-F-12", program_version="test")
|
|
892
|
+
md, ht = render_markdown(doc), render_html(doc)
|
|
893
|
+
ok(doc.data["drift_detected"] and "MODEL DRIFT DETECTED" in md
|
|
894
|
+
and "FALLBACK" in md and "Re-fit / redeploy due" in md,
|
|
895
|
+
"AA3 drift report: the Volve drawdown reports MODEL DRIFT DETECTED with "
|
|
896
|
+
"the FALLBACK action and the SLA clocks started")
|
|
897
|
+
ok([sec.number for sec in doc.sections] == [str(i) for i in range(1, 9)]
|
|
898
|
+
and "SOW 4.2.10" in md and "SOW 4.2.2" in md and "SOW 4.2.1.2" in md
|
|
899
|
+
and "SLA 1.0" in md,
|
|
900
|
+
"AA4 outline: eight numbered sections carrying the scope-of-work clause "
|
|
901
|
+
"numbers (4.2.1.2 tags, 4.2.2 data quality, 4.2.10 drift, SLA 1.0)")
|
|
902
|
+
ok(not forbidden_terms(md) and not forbidden_terms(ht),
|
|
903
|
+
"AA5 vocabulary gate: the rendered report contains none of the internal-"
|
|
904
|
+
"register terms")
|
|
905
|
+
paths = write_report(doc, Path(tmp, "cr"))
|
|
906
|
+
ok(all(Path(v).exists() and Path(v).stat().st_size > 200 for v in paths.values())
|
|
907
|
+
and len(Path(paths["records_csv"]).read_text().splitlines()) == 158,
|
|
908
|
+
"AA6 write: markdown, HTML, machine JSON, records CSV (157 rows + header) "
|
|
909
|
+
"and tag catalogue CSV all land")
|
|
910
|
+
r = _cli(["client-report", "--live-catalog", "volve_f12_f14_production_excerpt",
|
|
911
|
+
"--live-well", "15/9-F-12", "--station-md", "10000", "--td", "10500",
|
|
912
|
+
"--out", "cr_cli"], tmp)
|
|
913
|
+
ok(r.returncode == 0 and "MODEL DRIFT DETECTED" in r.stdout
|
|
914
|
+
and Path(tmp, "cr_cli", "gauge_drift_report.html").exists(),
|
|
915
|
+
"AA7 CLI client-report: the same report from the command line")
|
|
916
|
+
r = _cli(["telemetry", "--hours", "2", "--seed", "3", "--out", "tm_cr.csv"], tmp)
|
|
917
|
+
r = _cli(["client-report", "--file", "tm_cr.csv", "--out", "cr_tm"], tmp)
|
|
918
|
+
body = Path(tmp, "cr_tm", "gauge_drift_report.md").read_text() if Path(tmp, "cr_tm", "gauge_drift_report.md").exists() else ""
|
|
919
|
+
ok(r.returncode == 0 and "NO MODEL DRIFT DETECTED" in body and "2026-01-01T00:00:00Z" in body,
|
|
920
|
+
"AA8 CLI on the product's own historian export: no drift on the clean "
|
|
921
|
+
"leg, and the ISO time origin survives the port (no 1970 timestamps)")
|
|
922
|
+
|
|
923
|
+
# (3) datasheet-derived limits + the spike rule
|
|
924
|
+
from . import GAUGE_SPECS
|
|
925
|
+
cat_ds = TagCatalogue.from_stream(s, gauge_spec=GAUGE_SPECS["geoq177_16k"])
|
|
926
|
+
row = cat_ds.rows()[0]
|
|
927
|
+
ok(row["eng_range_hi"] == 16000.0 and "datasheet 'geoq177_16k'" in row["limits_basis"]
|
|
928
|
+
and "operations setting" in row["limits_basis"],
|
|
929
|
+
"AA9 datasheet limits: the engineering range comes from the GEOQ 177 full "
|
|
930
|
+
"scale and the basis names the datasheet; the rate-of-change limit is "
|
|
931
|
+
"printed as an operations setting, never invented from the datasheet")
|
|
932
|
+
q_ds = quality_summary(records_from_stream(s, cat_ds))["P_raw_psi_S1"]
|
|
933
|
+
ok(q_ds["counts"]["SPIKE"] == 5 and q_ds["counts"]["FLATLINE"] == 9
|
|
934
|
+
and "x MAD" in q_ds["rule_examples"]["SPIKE"],
|
|
935
|
+
"AA10 spike rule: the rolling-median/MAD rule fires on 5 Volve daily "
|
|
936
|
+
"excursions with the deviation printed, and the stuck run still reads FLATLINE")
|
|
937
|
+
r = _cli(["client-report", "--live-catalog", "volve_f12_f14_production_excerpt",
|
|
938
|
+
"--live-well", "15/9-F-12", "--station-md", "10000", "--td", "10500",
|
|
939
|
+
"--spec", "geoq177_16k", "--out", "cr_ds"], tmp)
|
|
940
|
+
body = Path(tmp, "cr_ds", "gauge_drift_report.md").read_text() if Path(tmp, "cr_ds", "gauge_drift_report.md").exists() else ""
|
|
941
|
+
ok(r.returncode == 0 and "Gauge datasheet in force: 'geoq177_16k'" in body
|
|
942
|
+
and "| Basis |" in body and "0 to 16,000 psi" in body,
|
|
943
|
+
"AA11 drift report with --spec: section 6 prints the datasheet citation "
|
|
944
|
+
"and the basis of every limit")
|
|
945
|
+
|
|
946
|
+
# (4) the accuracy statement engine
|
|
947
|
+
from .accuracy_statement import (library_backtest, statement_from_arrays,
|
|
948
|
+
band_for, MIN_TRIALS)
|
|
949
|
+
bt = library_backtest()
|
|
950
|
+
bt2 = library_backtest()
|
|
951
|
+
ok(bt["statements"] == bt2["statements"] and bt["n_ok"] == 14 and bt["n_pending"] == 4,
|
|
952
|
+
"AA12 accuracy engine: the library back-test scores 14 quantities, holds 4 "
|
|
953
|
+
"pending below the minimum, and regenerates identically (seeded bootstrap)")
|
|
954
|
+
st = bt["statements"]
|
|
955
|
+
ok(all(r["mape_ci_lo_pct"] <= r["mape_pct"] <= r["mape_ci_hi_pct"] for r in st)
|
|
956
|
+
and all(r["accuracy_conservative_pct"] <= r["accuracy_pct"] for r in st)
|
|
957
|
+
and all(r["band"] == band_for(r["accuracy_conservative_pct"]) for r in st),
|
|
958
|
+
"AA13 accuracy statistics: MAPE sits inside its 90 pct CI, the conservative "
|
|
959
|
+
"accuracy never exceeds the point accuracy, and the band is read at the "
|
|
960
|
+
"conservative end")
|
|
961
|
+
ok(bt["bands"].get("MEETS_TARGET", 0) == 8 and bt["bands"].get("NOT_ACCEPTABLE", 0) == 5
|
|
962
|
+
and bt["bands"].get("BAND_2", 0) == 1,
|
|
963
|
+
"AA14 the statement is not a marketing number: 8 of 14 meet the 95 pct "
|
|
964
|
+
"target, 1 is band 2, 5 are NOT ACCEPTABLE - printed, never hidden")
|
|
965
|
+
thin = statement_from_arrays([1.0] * (MIN_TRIALS - 1), [1.0] * (MIN_TRIALS - 1))
|
|
966
|
+
exact = statement_from_arrays([2.0] * 20, [2.0] * 20, [0.1] * 20)
|
|
967
|
+
ok(thin["status"] == "PENDING" and exact["status"] == "OK" and exact["mape_pct"] == 0.0
|
|
968
|
+
and exact["coverage_at_ci"] == 1.0 and exact["band"] == "MEETS_TARGET",
|
|
969
|
+
"AA15 statement_from_arrays: thin data is PENDING; a perfect predictor "
|
|
970
|
+
"reads MAPE 0, coverage 1, MEETS TARGET")
|
|
971
|
+
r = _cli(["client-report", "--report", "accuracy", "--out", "acc"], tmp)
|
|
972
|
+
body = Path(tmp, "acc", "accuracy_statement.md").read_text() if Path(tmp, "acc", "accuracy_statement.md").exists() else ""
|
|
973
|
+
ok(r.returncode == 0 and "8 of 14 MEET TARGET" in body and "NOT ACCEPTABLE" in body
|
|
974
|
+
and "SLA 4.0" in body and "SCC 5.0" in body and not forbidden_terms(body),
|
|
975
|
+
"AA16 CLI client-report --report accuracy: the Accuracy Statement lands in "
|
|
976
|
+
"the client outline (SLA 4.0 definitions, SCC 5.0 method), vocabulary clean")
|
|
977
|
+
|
|
978
|
+
# (5) the drift monitor - scheduled evaluation, log, staleness, re-fit, annual cap
|
|
979
|
+
import csv as _csv
|
|
980
|
+
from datetime import datetime as _dt, timedelta as _td, timezone as _tz
|
|
981
|
+
from .drift_monitor import DriftMonitor, MonitorConfig
|
|
982
|
+
from . import ingest as _ingest_fn
|
|
983
|
+
_cli(["telemetry", "--hours", "6", "--seed", "3", "--out", "tm_dm.csv"], tmp)
|
|
984
|
+
rows = list(_csv.reader(open(Path(tmp, "tm_dm.csv"), newline="")))
|
|
985
|
+
jcol = rows[0].index("P_raw_psi_S1")
|
|
986
|
+
for r in rows[1:]:
|
|
987
|
+
if r[jcol]:
|
|
988
|
+
r[jcol] = str(round(float(r[jcol]) + 40.0, 2))
|
|
989
|
+
with open(Path(tmp, "tm_dm_off.csv"), "w", newline="") as f:
|
|
990
|
+
_csv.writer(f).writerows(rows)
|
|
991
|
+
T0 = _dt(2026, 10, 1, 8, 0, tzinfo=_tz.utc)
|
|
992
|
+
mon = DriftMonitor(SimulatorConfig(), str(Path(tmp, "mon")), well_name="acceptance well")
|
|
993
|
+
off = _ingest_fn(str(Path(tmp, "tm_dm_off.csv")))
|
|
994
|
+
r1 = mon.run_scheduled(off, now=T0)
|
|
995
|
+
ok(r1["action"] == "EVALUATED" and r1["drift_detected"]
|
|
996
|
+
and r1["proposals"][0]["type"] == "REFIT_OFFSET" and r1["proposals"][0]["status"] == "PROPOSED"
|
|
997
|
+
and abs(r1["proposals"][0]["after"] - 40.66) < 0.5 and r1["proposals"][0]["before"] == 0.0
|
|
998
|
+
and r1["sla_clocks"] == {"notify_due": "2026-10-02", "fallback_due": "2026-10-05", "refit_due": "2026-10-15"},
|
|
999
|
+
"AA17 drift monitor: a +40 psi injected offset is evaluated as CALIBRATION_OFFSET, "
|
|
1000
|
+
"logged, proposed as a re-fit with before/after coefficients, and the SLA "
|
|
1001
|
+
"clocks (1/2/10 business days) start at the evaluation timestamp")
|
|
1002
|
+
r_skip = mon.run_scheduled(off, now=T0 + _td(hours=5))
|
|
1003
|
+
st_stale = mon.staleness(T0 + _td(hours=30))
|
|
1004
|
+
ok(r_skip["action"] == "SKIPPED_NOT_DUE" and st_stale["status"] == "STALE"
|
|
1005
|
+
and st_stale["overdue_h"] == 6.0 and mon.staleness(T0 + _td(hours=23))["status"] == "CURRENT",
|
|
1006
|
+
"AA18 scheduling: a run before the 24 h cadence is SKIPPED_NOT_DUE; the state "
|
|
1007
|
+
"ages to STALE with the overdue hours counted")
|
|
1008
|
+
ap = mon.approve(r1["proposals"][0]["entry_id"], "j.doe (production engineer)", now=T0 + _td(hours=25))
|
|
1009
|
+
r2 = mon.run_scheduled(off, now=T0 + _td(hours=26))
|
|
1010
|
+
ok(ap["status"] == "APPLIED" and mon.state["corrections_psi"]["P_raw_psi_S1"] == ap["after"]
|
|
1011
|
+
and r2["action"] == "EVALUATED" and not r2["drift_detected"]
|
|
1012
|
+
and r2["evaluation"]["stations"][0]["classification"] == "IN_FAMILY"
|
|
1013
|
+
and abs(r2["evaluation"]["stations"][0]["bias_psi"]) < 2.0,
|
|
1014
|
+
"AA19 re-fit closes the loop: the approved correction is applied to the live "
|
|
1015
|
+
"leg and the next evaluation reads IN_FAMILY with the bias removed")
|
|
1016
|
+
cap = DriftMonitor(SimulatorConfig(), str(Path(tmp, "mon_cap")), well_name="cap well")
|
|
1017
|
+
statuses = []
|
|
1018
|
+
for i in range(5):
|
|
1019
|
+
pr = cap.propose_refit("P_raw_psi_S1", 10.0, "EVAL-test", now=T0 + _td(days=i))
|
|
1020
|
+
statuses.append(pr["status"])
|
|
1021
|
+
if pr["status"] == "PROPOSED":
|
|
1022
|
+
cap.approve(pr["entry_id"], "a", now=T0 + _td(days=i))
|
|
1023
|
+
ok(statuses == ["PROPOSED"] * 4 + ["BLOCKED_ANNUAL_LIMIT"]
|
|
1024
|
+
and cap.state["corrections_psi"]["P_raw_psi_S1"] == 40.0,
|
|
1025
|
+
"AA20 annual cap: the fifth re-fit in a calendar year is BLOCKED_ANNUAL_LIMIT "
|
|
1026
|
+
"(logged, never applied); four are applied")
|
|
1027
|
+
ok(len(mon.history()) == 12 and len(mon.change_log()) == 2 and mon.open_proposals() == []
|
|
1028
|
+
and all(k in mon.status(T0 + _td(hours=27))["log_sha256"] for k in ("evaluations.jsonl", "change_log.jsonl")),
|
|
1029
|
+
"AA21 the record: 12 evaluation lines (6 stations x 2), 2 change-log lines "
|
|
1030
|
+
"(PROPOSED, APPLIED), no open proposal, log hashes reported")
|
|
1031
|
+
r = _cli(["drift-monitor", "--file", "tm_dm_off.csv", "--log-dir", "mon_cli", "--now",
|
|
1032
|
+
"2026-10-01T08:00:00Z", "--name", "cli well", "--report", "mon_cli_report"], tmp)
|
|
1033
|
+
body = Path(tmp, "mon_cli_report", "gauge_drift_report.md").read_text() if Path(tmp, "mon_cli_report", "gauge_drift_report.md").exists() else ""
|
|
1034
|
+
ok(r.returncode == 0 and '"action": "EVALUATED"' in r.stdout and "Evaluation ID" in body
|
|
1035
|
+
and "Evaluations on record" in body and "Evaluation history" in body and not forbidden_terms(body),
|
|
1036
|
+
"AA22 CLI drift-monitor: evaluates, records, and writes the drift report with "
|
|
1037
|
+
"section 5 fed from the monitor's log")
|
|
1038
|
+
|
|
1039
|
+
# (6) well-test validation - config-file criteria, reason codes, approval trail
|
|
1040
|
+
from .well_test_validation import (WellTestValidator, ApprovalTrail, load_criteria,
|
|
1041
|
+
write_default_criteria, volve_channel_map, DEFAULT_CRITERIA)
|
|
1042
|
+
from . import CATALOG as _CAT
|
|
1043
|
+
cpath = write_default_criteria(str(Path(tmp, "criteria.json")))
|
|
1044
|
+
crit = load_criteria(cpath)
|
|
1045
|
+
src = _CAT["volve_f12_f14_production_excerpt"].stream()
|
|
1046
|
+
det = WellTestValidator(crit, volve_channel_map("15/9-F-12")).detect(src)
|
|
1047
|
+
det2 = WellTestValidator(load_criteria(cpath), volve_channel_map("15/9-F-12")).detect(src)
|
|
1048
|
+
ok(det["n_accepted"] == 7 and det["n_rejected"] == 32 and det["eligible_samples"] == 131
|
|
1049
|
+
and det["tests"] == det2["tests"] and crit["_sha256"] and crit["_source"].endswith("criteria.json"),
|
|
1050
|
+
"AA23 well-test detection on Volve F-12: 7 stable periods accepted, 32 candidates "
|
|
1051
|
+
"rejected, 131/158 eligible samples, deterministic, criteria file hashed")
|
|
1052
|
+
wt = {t["test_id"]: t for t in det["tests"]}
|
|
1053
|
+
ok("WT024" in wt and wt["WT024"]["n"] == 8 and abs(wt["WT024"]["virtual_rates"]["oil"] - 3177.5) < 1.0
|
|
1054
|
+
and wt["WT024"]["statistics"]["rate:oil"]["cv_pct"] <= crit["rate_max_cv_pct"]
|
|
1055
|
+
and all(t["statistics"]["pressure:downhole"]["cv_pct"] <= crit["pressure_max_cv_pct"] for t in det["tests"]),
|
|
1056
|
+
"AA24 an accepted test carries its virtual rates (WT024 oil 3,177.5 Sm3/d over 8 days) "
|
|
1057
|
+
"and every accepted window satisfies the printed criteria")
|
|
1058
|
+
codes = {c for r in det["rejected"] for c in r["reason_codes"]}
|
|
1059
|
+
ok({"ON_STREAM_BELOW_MIN", "INSUFFICIENT_DURATION", "RATE_UNSTABLE:gas", "OPERATING_POINT_CHANGED:choke",
|
|
1060
|
+
"PRESSURE_UNSTABLE:wellhead", "MISSING_VALUE"} <= codes
|
|
1061
|
+
and all(r["detail"] for r in det["rejected"]),
|
|
1062
|
+
"AA25 reason codes: shut-in days, short runs, unstable gas, choke moves, wellhead "
|
|
1063
|
+
"swings and the trailing missing row are each named with the value that failed")
|
|
1064
|
+
tight = dict(DEFAULT_CRITERIA); tight["rate_max_cv_pct"] = 0.5
|
|
1065
|
+
det_t = WellTestValidator(tight, volve_channel_map("15/9-F-12")).detect(src)
|
|
1066
|
+
ok(det_t["n_accepted"] < det["n_accepted"],
|
|
1067
|
+
"AA26 the criteria file is the logic: tightening rate CV to 0.5 pct accepts fewer tests")
|
|
1068
|
+
trail = ApprovalTrail(str(Path(tmp, "wt_rec")))
|
|
1069
|
+
trail.approve("WT024", "j.doe", 1, now=T0)
|
|
1070
|
+
s1 = trail.status_of("WT024")["status"]
|
|
1071
|
+
trail.approve("WT024", "a.smith", 2, now=T0 + _td(hours=1))
|
|
1072
|
+
s2 = trail.status_of("WT024")["status"]
|
|
1073
|
+
trail.approve("WT010", "j.doe", 1, decision="REJECTED", note="MPFM recalibration", now=T0)
|
|
1074
|
+
ok(s1 == "PENDING_LEVEL_2" and s2 == "APPROVED_ALL_LEVELS"
|
|
1075
|
+
and trail.status_of("WT010")["status"] == "REJECTED_ON_REVIEW" and len(trail.entries()) == 3,
|
|
1076
|
+
"AA27 approval trail: two levels with timestamps; a level-1 rejection holds the test")
|
|
1077
|
+
r = _cli(["well-test", "--live-catalog", "volve_f12_f14_production_excerpt", "--live-well", "15/9-F-12",
|
|
1078
|
+
"--criteria", "criteria.json", "--record-dir", "wt_rec", "--out", "wt_report",
|
|
1079
|
+
"--now", "2026-10-01T12:00:00Z"], tmp)
|
|
1080
|
+
body = Path(tmp, "wt_report", "well_test_validation.md").read_text() if Path(tmp, "wt_report", "well_test_validation.md").exists() else ""
|
|
1081
|
+
ok(r.returncode == 0 and "7 WELL TESTS ACCEPTED, 1 APPROVED" in body and "SOW 4.2.3.1" in body
|
|
1082
|
+
and "sha256" in body and "REJECTED_ON_REVIEW" in body and "APPROVED_ALL_LEVELS" in body
|
|
1083
|
+
and not forbidden_terms(body),
|
|
1084
|
+
"AA28 CLI well-test: the Well Test Validation Report lands with criteria, hash, "
|
|
1085
|
+
"accepted tests, reason codes and the approval trail, vocabulary clean")
|
|
1086
|
+
|
|
1087
|
+
# (7) the alarm engine
|
|
1088
|
+
from .alarm_engine import (AlarmEngine, AlarmDefinition, defaults_from_catalogue,
|
|
1089
|
+
records_from_series, write_alarm_definitions, load_alarm_definitions)
|
|
1090
|
+
ts = [(T0 + _td(seconds=60 * i)).strftime("%Y-%m-%dT%H:%M:%SZ") for i in range(12)]
|
|
1091
|
+
vals = [100, 100, 105, 105, 105, 103, 101, 99, 98, 95, 100, 100]
|
|
1092
|
+
d = AlarmDefinition("P.H", "P", "HIGH", "P2", setpoint=104, deadband=5, on_delay_s=60)
|
|
1093
|
+
eng = AlarmEngine([d])
|
|
1094
|
+
ev = eng.process(records_from_series("P", ts, vals, "psi"))
|
|
1095
|
+
ok([(e["timestamp_utc"][11:16], e["event"]) for e in ev] == [("08:03", "ACTIVATED"), ("08:08", "RTN_UNACKED")],
|
|
1096
|
+
"AA29 alarm state machine: HIGH at 104 with 60 s on-delay activates on the second "
|
|
1097
|
+
"sample over setpoint (08:03), holds through the deadband (103, 101, 99), and "
|
|
1098
|
+
"returns to normal only below 99 (08:08) - hysteresis and on-delay as defined")
|
|
1099
|
+
eng2 = AlarmEngine([d]); rr = records_from_series("P", ts, vals, "psi")
|
|
1100
|
+
eng2.process(rr[:4]); eng2.acknowledge("P.H", "op1", T0 + _td(seconds=200)); eng2.process(rr[4:])
|
|
1101
|
+
ok([e["event"] for e in eng2.events] == ["ACTIVATED", "ACKNOWLEDGED", "CLEARED"]
|
|
1102
|
+
and eng2.events[1]["operator"] == "op1" and eng2.active() == [],
|
|
1103
|
+
"AA30 acknowledgement: ACTIVE_UNACKED -> ACTIVE_ACKED -> CLEARED with the operator on the record")
|
|
1104
|
+
cat_al = TagCatalogue.from_stream(_ingest_fn(str(Path(tmp, "tm_dm.csv"))), gauge_spec=GAUGE_SPECS["template_generic"])
|
|
1105
|
+
defs = defaults_from_catalogue(cat_al)
|
|
1106
|
+
wpath = write_alarm_definitions(defs, str(Path(tmp, "alarms.json")))
|
|
1107
|
+
defs2 = load_alarm_definitions(wpath)
|
|
1108
|
+
ok(len(defs) == 3 * len(cat_al) and all(x.kind in ("HIGH_HIGH", "LOW_LOW", "QUALITY") for x in defs)
|
|
1109
|
+
and all("datasheet" in x.basis or "record layer" in x.basis for x in defs)
|
|
1110
|
+
and [x.row() for x in defs2] == [x.row() for x in defs],
|
|
1111
|
+
"AA31 catalogue-derived definitions: over-range (datasheet basis) and quality "
|
|
1112
|
+
"(record-layer basis) per tag, no invented process setpoint, JSON round-trip")
|
|
1113
|
+
_cli(["telemetry", "--hours", "24", "--seed", "3", "--out", "tm_al.csv"], tmp)
|
|
1114
|
+
r = _cli(["alarms", "--file", "tm_al.csv", "--spec", "template_generic", "--out", "alm"], tmp)
|
|
1115
|
+
body = Path(tmp, "alm", "alarm_event_report.md").read_text() if Path(tmp, "alm", "alarm_event_report.md").exists() else ""
|
|
1116
|
+
kp = json.loads(Path(tmp, "alm", "alarm_event_report.json").read_text())["kpis"] if body else {}
|
|
1117
|
+
ok(r.returncode == 0 and kp.get("n_activations", 0) > 200 and kp.get("flood_10min_bins", 0) >= 1
|
|
1118
|
+
and "exceeds the manageable target" in body and "ISA-18.2" in body and "T_raw_F_S6.HH_RANGE" in body
|
|
1119
|
+
and not forbidden_terms(body),
|
|
1120
|
+
"AA32 CLI alarms on the 24 h export: quality alarms flood (printed against the "
|
|
1121
|
+
"ISA-18.2 targets with the remedy), and the deepest gauge's temperature reads "
|
|
1122
|
+
"above the template datasheet rating - a real over-range alarm on the demo well")
|
|
1123
|
+
|
|
1124
|
+
# (8) model cards
|
|
1125
|
+
from .model_card import build_cards, card_index
|
|
1126
|
+
from .client_reports import model_card_report
|
|
1127
|
+
cards = build_cards(monitor_log_dir=str(Path(tmp, "mon")))
|
|
1128
|
+
ids = [c.model_id for c in cards]
|
|
1129
|
+
ok(ids == ["well_baseline", "gauge_aging_envelope", "strata_property_estimator",
|
|
1130
|
+
"rock_density_inventory", "quality_rules", "well_test_detector"]
|
|
1131
|
+
and all(c.inputs and c.settings and c.calibration_data and c.evaluation and c.limitations and c.components for c in cards),
|
|
1132
|
+
"AA33 model cards: six cards, each with inputs, settings, calibration data, "
|
|
1133
|
+
"evaluation, limitations and component hashes")
|
|
1134
|
+
wb = cards[0]
|
|
1135
|
+
ok(len(wb.refit_history) == 1 and wb.refit_history[0]["status"] == "APPLIED"
|
|
1136
|
+
and wb.refit_history[0]["after"] == 40.66,
|
|
1137
|
+
"AA34 re-fit history: the well-baseline card carries the monitor's APPLIED "
|
|
1138
|
+
"offset correction with before/after")
|
|
1139
|
+
ge = cards[1]
|
|
1140
|
+
ok(any(e["value"] == "NONE ON RECORD" for e in ge.evaluation)
|
|
1141
|
+
and any("without field validation" in x["basis"] for x in ge.settings),
|
|
1142
|
+
"AA35 the aging-envelope card states NONE ON RECORD for field validation and "
|
|
1143
|
+
"labels the lower bound as an unvalidated engineering model")
|
|
1144
|
+
se = cards[2]
|
|
1145
|
+
ok(sum(1 for e in se.evaluation if "NOT_ACCEPTABLE" in e["value"]) == 5
|
|
1146
|
+
and all(d["url"].startswith("http") for d in se.calibration_data if d["records"] != "-"),
|
|
1147
|
+
"AA36 the estimator card prints the five NOT ACCEPTABLE quantities and a URL "
|
|
1148
|
+
"for every calibration dataset")
|
|
1149
|
+
rendered = [render_markdown(model_card_report(c)) for c in cards]
|
|
1150
|
+
ok(all(not forbidden_terms(t) for t in rendered) and all("sha256" in t for t in rendered)
|
|
1151
|
+
and "Telford" in rendered[3] and "primitive" not in rendered[3].lower(),
|
|
1152
|
+
"AA37 every card renders vocabulary-clean with component hashes; the inventory "
|
|
1153
|
+
"card carries the published citations only")
|
|
1154
|
+
r = _cli(["model-cards", "--out", "mc"], tmp)
|
|
1155
|
+
idx = json.loads(Path(tmp, "mc", "model_cards_index.json").read_text()) if Path(tmp, "mc", "model_cards_index.json").exists() else {}
|
|
1156
|
+
ok(r.returncode == 0 and len(idx.get("cards", [])) == 6
|
|
1157
|
+
and all(Path(tmp, "mc", f"model_card_{i}.html").exists() for i in ids),
|
|
1158
|
+
"AA38 CLI model-cards: six HTML/markdown/JSON cards and an index")
|
|
1159
|
+
|
|
1160
|
+
# (9) store-and-forward
|
|
1161
|
+
from .store_forward import simulate, parse_outages, BufferConfig
|
|
1162
|
+
st24 = _ingest_fn(str(Path(tmp, "tm_al.csv")))
|
|
1163
|
+
cat24 = TagCatalogue.from_stream(st24)
|
|
1164
|
+
recs24 = [r for r in records_from_stream(st24, cat24) if r.tag_id.startswith("P_raw")]
|
|
1165
|
+
sim = simulate(recs24, parse_outages(["2026-01-01T06:00:00Z,2026-01-01T09:30:00Z", "2026-01-01T15:00:00Z,2026-01-01T15:20:00Z"]),
|
|
1166
|
+
BufferConfig(cadence_s=60, replay_rate_per_s=5))
|
|
1167
|
+
g1 = sim["gap_report"]["P_raw_psi_S1"]
|
|
1168
|
+
ok(sim["stats"]["buffered"] == 1380 and sim["stats"]["replayed"] == 1380 and sim["stats"]["dropped_over_capacity"] == 0
|
|
1169
|
+
and sim["stats"]["duplicates_suppressed"] == 0 and sim["final_backlog"] == 0
|
|
1170
|
+
and all(v["not_delivered"] == 0 and v["duplicates_in_delivery"] == 0 and v["chronological_replay"] for v in sim["gap_report"].values()),
|
|
1171
|
+
"AA39 store-and-forward: two outages (3.5 h + 20 min) buffer 1,380 samples at the edge "
|
|
1172
|
+
"and replay every one in chronological order at 5/s - none lost, none duplicated")
|
|
1173
|
+
ok(g1["delivered_live"] == 1210 and g1["delivered_by_replay"] == 230 and g1["latency_p50_s"] == 2.0
|
|
1174
|
+
and g1["latency_max_s"] > 12000 and g1["pct_within_2min"] < 90 and g1["source_gaps_in_data"] == 27,
|
|
1175
|
+
"AA40 gap report: replayed samples carry the outage as latency (max > 3.3 h), live "
|
|
1176
|
+
"samples 2 s; the historian's own 27 GAP samples are delivered as gaps, never invented")
|
|
1177
|
+
sim2 = simulate(recs24, parse_outages(["2026-01-01T06:00:00Z,2026-01-01T09:00:00Z"]), BufferConfig(cadence_s=60, capacity_hours=1.0))
|
|
1178
|
+
ok(sim2["stats"]["dropped_over_capacity"] == 720 and sim2["gap_report"]["P_raw_psi_S1"]["not_delivered"] == 120,
|
|
1179
|
+
"AA41 capacity: a 1 h buffer under a 3 h outage drops the oldest 2 h (720 records, 120 per tag), counted and reported")
|
|
1180
|
+
r = _cli(["store-forward", "--file", "tm_al.csv", "--tags", "P_raw", "--outage", "2026-01-01T06:00:00Z,2026-01-01T09:30:00Z",
|
|
1181
|
+
"--replay-rate", "5", "--out", "sf"], tmp)
|
|
1182
|
+
body = Path(tmp, "sf", "data_resilience_report.md").read_text() if Path(tmp, "sf", "data_resilience_report.md").exists() else ""
|
|
1183
|
+
ok(r.returncode == 0 and "ALL SAMPLES DELIVERED, NO DUPLICATES, CHRONOLOGICAL" in body and "SOW 4.2.1.6" in body
|
|
1184
|
+
and "Live delivery only" in body and not forbidden_terms(body),
|
|
1185
|
+
"AA42 CLI store-forward: the Data Resilience report prints latency for all records and "
|
|
1186
|
+
"for live delivery only (SLA 7.0 exclusion), vocabulary clean")
|
|
1187
|
+
|
|
1188
|
+
# (10) configuration versioning, SBOM, SLA measurement, FAT/SAT
|
|
1189
|
+
from .config_versioning import ConfigStore, diff as _cfg_diff
|
|
1190
|
+
cs = ConfigStore(str(Path(tmp, "cfgs")))
|
|
1191
|
+
v1 = cs.commit("well_test_criteria", json.loads(Path(cpath).read_text()), "j.doe", "initial", now=T0)
|
|
1192
|
+
c2 = json.loads(Path(cpath).read_text()); c2["rate_max_cv_pct"] = 2.5
|
|
1193
|
+
v2 = cs.commit("well_test_criteria", c2, "a.smith", "tighten CV", now=T0 + _td(days=4))
|
|
1194
|
+
same = cs.commit("well_test_criteria", c2, "a.smith", "no change", now=T0 + _td(days=4, hours=1))
|
|
1195
|
+
v3 = cs.rollback("well_test_criteria", 1, "a.smith", now=T0 + _td(days=5))
|
|
1196
|
+
ok(v1["version"] == 1 and v2["version"] == 2 and same.get("unchanged") and v3["version"] == 3 and v3["rollback_of"] == 1
|
|
1197
|
+
and v2["diff"]["changed"] == [{"key": "rate_max_cv_pct", "before": 3.0, "after": 2.5}]
|
|
1198
|
+
and cs.get("well_test_criteria") == cs.get("well_test_criteria", 1) and len(cs.history("well_test_criteria")) == 3,
|
|
1199
|
+
"AA43 configuration versioning: commit, key-level diff, unchanged content not re-versioned, "
|
|
1200
|
+
"rollback as a new version equal to v1 with history intact")
|
|
1201
|
+
exp = cs.export("well_test_criteria", str(Path(tmp, "crit_export.json")), 2)
|
|
1202
|
+
ok(json.loads(Path(exp).read_text())["rate_max_cv_pct"] == 2.5 and cs.summary()[0]["rollbacks"] == 1,
|
|
1203
|
+
"AA44 export a named version to a file; the store summary counts the rollback")
|
|
1204
|
+
from .sbom import generate as _sbom_gen, write as _sbom_write
|
|
1205
|
+
sb = _sbom_gen()
|
|
1206
|
+
names = [c["name"] for c in sb["components"]]
|
|
1207
|
+
ok(sb["n_components"] == 5 and names[0] == "Downhole Gauge Monitoring" and "numpy" in names and "Python" in names
|
|
1208
|
+
and all(set(c) >= {"name", "version", "supplier", "licence", "hash", "identifier", "relationship", "generated_utc"} for c in sb["components"])
|
|
1209
|
+
and next(c for c in sb["components"] if c["name"] == "numpy")["version"] not in ("", "not installed"),
|
|
1210
|
+
"AA45 SBOM: five components with all eight fields, versions and licences read from "
|
|
1211
|
+
"installed metadata, optional components listed whether installed or not")
|
|
1212
|
+
pth = _sbom_write(sb, str(Path(tmp, "sbom")))
|
|
1213
|
+
ok(Path(pth["json"]).exists() and len(Path(pth["csv"]).read_text().splitlines()) == 6, "AA46 SBOM written as JSON and CSV (header + 5 rows)")
|
|
1214
|
+
from .sla_report import measure
|
|
1215
|
+
m = measure("2026-10", monitor_log_dir=str(Path(tmp, "mon")), well_test_dir=str(Path(tmp, "wt_rec")), config_dir=str(Path(tmp, "cfgs")))
|
|
1216
|
+
by = {l["metric"]: l for l in m["lines"]}
|
|
1217
|
+
ok(by["Re-fit / redeploy within 10 business days of detection"]["status"] == "MET"
|
|
1218
|
+
and by["Re-fits per station in 2026 (year to date)"]["measured"] == "max 1"
|
|
1219
|
+
and by["Configuration versions committed in the month"]["measured"] == "3"
|
|
1220
|
+
and by["End-to-end latency"]["status"] == "NOT MEASURED"
|
|
1221
|
+
and by["Drift evaluation cadence (days with an evaluation / days due)"]["status"] == "NOT MET",
|
|
1222
|
+
"AA47 monthly SLA: re-fit within 10 bd MET, cap MET, three config versions counted, "
|
|
1223
|
+
"latency NOT MEASURED without a record, and the two-evaluation demo log reads NOT MET on cadence - "
|
|
1224
|
+
"nothing unmeasured is reported as met")
|
|
1225
|
+
r = _cli(["sla-report", "--month", "2026-01", "--store-forward-json", str(Path(tmp, "sf", "data_resilience_report.json")),
|
|
1226
|
+
"--config-store", "cfgs", "--out", "sla"], tmp)
|
|
1227
|
+
body = Path(tmp, "sla", "sla_report_2026-01.md").read_text() if Path(tmp, "sla", "sla_report_2026-01.md").exists() else ""
|
|
1228
|
+
ok(r.returncode == 0 and "Software bill of materials" in body and "Link availability" in body and "NOT MEASURED" in body
|
|
1229
|
+
and not forbidden_terms(body),
|
|
1230
|
+
"AA48 CLI sla-report: the Monthly SLA Report lands with latency, availability, change & "
|
|
1231
|
+
"continuity and the SBOM, vocabulary clean")
|
|
1232
|
+
from . import fat_sat as FS
|
|
1233
|
+
from . import acceptance_tests as _AT
|
|
1234
|
+
snap = (_AT._PASS, list(_AT._FAILS), len(_AT._RESULTS))
|
|
1235
|
+
proto = FS.run_protocol("FAT", sections=["F"])
|
|
1236
|
+
del _AT._RESULTS[snap[2]:]
|
|
1237
|
+
_AT._PASS = snap[0]; _AT._FAILS[:] = snap[1]
|
|
1238
|
+
ok(proto["kind"] == "FAT" and proto["n_steps"] >= 2 and proto["internal_checks_excluded"] == 2 and proto["n_fail"] == 0 and proto["result"] == "ACCEPTED"
|
|
1239
|
+
and all(r_["expected"] == "PASS" and r_["actual"] == "PASS" for r_ in proto["rows"]),
|
|
1240
|
+
"AA49 FAT protocol: section F renders as numbered PASS steps (two internal-register checks excluded, counted) with the result ACCEPTED")
|
|
1241
|
+
|
|
1242
|
+
# (11) the web view
|
|
1243
|
+
from .dashboard import orchestrate, collect
|
|
1244
|
+
r = orchestrate(str(Path(tmp, "dash")),
|
|
1245
|
+
[{"entry": "volve_f12_f14_production_excerpt", "well": "15/9-F-12", "md_ft": 10000.0},
|
|
1246
|
+
{"entry": "volve_f12_f14_production_excerpt", "well": "15/9-F-14", "md_ft": 9500.0}],
|
|
1247
|
+
[{"path": str(Path(tmp, "tm_al.csv"))}], td_ft=10500.0, gauge_spec=GAUGE_SPECS["template_generic"],
|
|
1248
|
+
outages=["2026-01-01T06:00:00Z,2026-01-01T09:30:00Z"], month="2026-01", site_name="acceptance site")
|
|
1249
|
+
page = Path(r["index"]).read_text(encoding="utf-8")
|
|
1250
|
+
d = collect(str(Path(tmp, "dash")))
|
|
1251
|
+
ok(Path(r["index"]).exists() and len(d["wells"]) == 3 and set(d["site"]) >= {"accuracy", "sla", "model_cards", "sbom"}
|
|
1252
|
+
and sorted(r["reports"]["tm_al"]) == ["alarms", "drift", "resilience"]
|
|
1253
|
+
and all(sorted(v) == ["drift", "well_tests"] for k, v in r["reports"].items() if k != "tm_al"),
|
|
1254
|
+
"AA50 dashboard orchestration: three wells (two catalogue, one file) and the site "
|
|
1255
|
+
"reports generated into one tree, index.html built")
|
|
1256
|
+
by = {w["name"]: w for w in d["wells"]}
|
|
1257
|
+
ok(by["15/9-F-12"]["drift"]["detected"] and by["15/9-F-12"]["well_tests"]["accepted"] == 7
|
|
1258
|
+
and by["15/9-F-14"]["drift"]["worst"] == "UNEXPLAINED_OFFSET" and not by["tm_al"]["drift"]["detected"]
|
|
1259
|
+
and by["tm_al"]["alarms"]["n_active"] >= 20 and by["tm_al"]["resilience"]["lost"] == 0,
|
|
1260
|
+
"AA51 dashboard data: every tile figure is read from a report JSON - F-12 drift + 7 tests, "
|
|
1261
|
+
"F-14 offset on thin data, the file well clean with its alarm flood and lossless replay")
|
|
1262
|
+
ok("2 of 3" in page and "MODEL DRIFT DETECTED" in page and "Alarm wall" in page and "Well ranking" in page
|
|
1263
|
+
and page.count('class="tile') >= 8 and "prefers-color-scheme" in page and 'href="wells/' in page
|
|
1264
|
+
and not forbidden_terms(page),
|
|
1265
|
+
"AA52 the page: hero tile reads 2 of 3 wells with drift, ranking and alarm wall present, "
|
|
1266
|
+
"drill-down links relative, light and dark themes, vocabulary clean")
|
|
1267
|
+
ok(all(f'<span class="ic">' in seg for seg in page.split('class="status ')[1:]),
|
|
1268
|
+
"AA53 status is icon plus label on every status element, never colour alone")
|
|
1269
|
+
r = _cli(["dashboard", "--catalog-well", "volve_f12_f14_production_excerpt:15/9-F-12:10000", "--td", "10500",
|
|
1270
|
+
"--name", "cli site", "--out", "dash_cli"], tmp)
|
|
1271
|
+
ok(r.returncode == 0 and Path(tmp, "dash_cli", "index.html").exists() and "site: accuracy" in r.stdout,
|
|
1272
|
+
"AA54 CLI dashboard: one catalogue well to a complete index")
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def main() -> int:
|
|
1276
|
+
print("GEA Downhole Simulator - ACCEPTANCE SUITE (product gate, "
|
|
1277
|
+
"independent of the physics corpus)")
|
|
1278
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1279
|
+
section_a_cli(tmp)
|
|
1280
|
+
section_b_las(tmp)
|
|
1281
|
+
section_c_reconciler(tmp)
|
|
1282
|
+
section_d_catalogue()
|
|
1283
|
+
section_e_operator(tmp)
|
|
1284
|
+
section_f_ports()
|
|
1285
|
+
section_g_gamma()
|
|
1286
|
+
section_h_mixed()
|
|
1287
|
+
section_i_bench()
|
|
1288
|
+
section_j_strata()
|
|
1289
|
+
section_k_measured_tp()
|
|
1290
|
+
section_l_operator_tier()
|
|
1291
|
+
section_m_earth_model()
|
|
1292
|
+
section_n_forward_model()
|
|
1293
|
+
section_p_inverse_engine()
|
|
1294
|
+
section_q_prior_families()
|
|
1295
|
+
section_r_survey_view(tmp)
|
|
1296
|
+
section_s_correlation()
|
|
1297
|
+
section_t_blind_harness()
|
|
1298
|
+
section_u_segy(tmp)
|
|
1299
|
+
section_v_client_shell(tmp)
|
|
1300
|
+
section_x_do_all_three()
|
|
1301
|
+
section_y_survey()
|
|
1302
|
+
section_z_rock_inventory()
|
|
1303
|
+
section_aa_client_reports(tmp)
|
|
1304
|
+
if _FAILS:
|
|
1305
|
+
print(f"[ACCEPTANCE] {len(_FAILS)} FAILURES ({_PASS} passed):")
|
|
1306
|
+
for f in _FAILS:
|
|
1307
|
+
print(" -", f)
|
|
1308
|
+
return 1
|
|
1309
|
+
print(f"[ACCEPTANCE] OK - {_PASS} checks passed. "
|
|
1310
|
+
"The simulator is acceptable as an offline product.")
|
|
1311
|
+
return 0
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
if __name__ == "__main__":
|
|
1315
|
+
sys.exit(main())
|