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/operator_app.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
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
|
+
"""Operator surface (v1.44.0) - finish-sequence step 4.
|
|
5
|
+
|
|
6
|
+
The independent evaluation's largest hole: "An operator cannot pick a
|
|
7
|
+
catalogued well, hang a toolstring, run service-life, ingest a LAS, or see
|
|
8
|
+
unexplained offsets without writing Python." This module is that surface,
|
|
9
|
+
split honestly in two:
|
|
10
|
+
|
|
11
|
+
* OperatorSession - a HEADLESS controller carrying every operator action
|
|
12
|
+
(well selection, toolstring + blocking rating check, run, service life,
|
|
13
|
+
case study, ingest, reconcile + alerts, citations). It is fully exercised
|
|
14
|
+
by the fidelity gate on every run - the product logic is tested where no
|
|
15
|
+
display exists.
|
|
16
|
+
* launch_operator_app() - the Qt6 view over that controller (one window:
|
|
17
|
+
well picker, toolstring builder with rating lights, live P/T + drift
|
|
18
|
+
charts, service-life/case-study export, reconcile + undervalued-stream
|
|
19
|
+
alerts, and a citations pane that is ALWAYS visible - Rule 7 in the UI).
|
|
20
|
+
Refuses with the pip hint when PyQt6 is absent, same pattern as the ports.
|
|
21
|
+
|
|
22
|
+
Product-honesty rules carried into the surface:
|
|
23
|
+
- The rating check BLOCKS the run: a tool over its cited rating at its
|
|
24
|
+
station (against the MEASURED profile) stops start_run with the stations
|
|
25
|
+
named. There is no silent override; `acknowledge_over_rating=True` is the
|
|
26
|
+
explicit, logged operator decision.
|
|
27
|
+
- The citations pane never disappears: gauge-spec sources, catalogue
|
|
28
|
+
provenance, and the DERIVED_HYBRID suppression labeling ride with every
|
|
29
|
+
view. The UI does not sell 1.0324 as a derived constant.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import Dict, List, Optional, Tuple
|
|
35
|
+
|
|
36
|
+
from .downhole_engine import (SimulatorConfig, DownholeEngine,
|
|
37
|
+
WellProfile, load_well_profile_csv)
|
|
38
|
+
from .quartz_hpht_extension import canonical_suppression
|
|
39
|
+
from .tool_library import TOOL_LIBRARY, ToolString, rating_check
|
|
40
|
+
from .well_assembler import (BUILTIN_ASSEMBLIES, demo_config,
|
|
41
|
+
production_live_stream)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class OperatorSession:
|
|
45
|
+
"""Headless operator controller - everything the UI can do, scriptable
|
|
46
|
+
and gate-testable. State: one well, one toolstring, one engine."""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self.well_name: Optional[str] = None
|
|
50
|
+
self.config: Optional[SimulatorConfig] = None
|
|
51
|
+
self.toolstring: Optional[ToolString] = None
|
|
52
|
+
self.engine: Optional[DownholeEngine] = None
|
|
53
|
+
self.last_rating: List[dict] = []
|
|
54
|
+
self.last_reconcile: Optional[dict] = None
|
|
55
|
+
self.log: List[str] = []
|
|
56
|
+
|
|
57
|
+
# -- well selection ----------------------------------------------------
|
|
58
|
+
def wells(self) -> Dict[str, dict]:
|
|
59
|
+
out = {}
|
|
60
|
+
for name, maker in sorted(BUILTIN_ASSEMBLIES.items()):
|
|
61
|
+
a = maker()
|
|
62
|
+
out[name] = {
|
|
63
|
+
"site": a.site,
|
|
64
|
+
"components": {r: (c.entry, c.channel, c.coverage())
|
|
65
|
+
for r, c in a.components.items()},
|
|
66
|
+
"engine_ready": "temperature" in a.components,
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
def load_well(self, name: str, n_gauges: int = 6, **kw) -> dict:
|
|
71
|
+
self.config = demo_config(name, n_gauges=n_gauges, **kw)
|
|
72
|
+
self.well_name = name
|
|
73
|
+
self.engine = None
|
|
74
|
+
self.log.append(f"well: {name} ({self.config.profile.name})")
|
|
75
|
+
return {"well": name, "profile": self.config.profile.name,
|
|
76
|
+
"gauges": list(self.config.sensor_depths_ft),
|
|
77
|
+
"window_ft": (self.config.profile.depths_ft[0],
|
|
78
|
+
self.config.profile.depths_ft[-1])}
|
|
79
|
+
|
|
80
|
+
def load_profile_csv(self, path: str, n_gauges: int = 6, **kw) -> dict:
|
|
81
|
+
prof = load_well_profile_csv(path)
|
|
82
|
+
lo, hi = prof.depths_ft[0], prof.depths_ft[-1]
|
|
83
|
+
depths = [lo + (hi - lo) * (i + 1) / (n_gauges + 1)
|
|
84
|
+
for i in range(n_gauges)]
|
|
85
|
+
self.config = SimulatorConfig(td_ft=hi, sensor_depths_ft=depths,
|
|
86
|
+
profile=prof, **kw)
|
|
87
|
+
self.well_name = f"csv:{path}"
|
|
88
|
+
self.engine = None
|
|
89
|
+
self.log.append(f"well: user CSV {path}")
|
|
90
|
+
return {"well": self.well_name, "profile": prof.name,
|
|
91
|
+
"gauges": depths, "window_ft": (lo, hi)}
|
|
92
|
+
|
|
93
|
+
# -- toolstring + BLOCKING rating check --------------------------------
|
|
94
|
+
def tools(self) -> Dict[str, dict]:
|
|
95
|
+
return {n: {"class": t.tool_class, "temp_rating_C": t.temp_rating_C,
|
|
96
|
+
"pressure_rating_psi": t.pressure_rating_psi,
|
|
97
|
+
"source": t.source}
|
|
98
|
+
for n, t in TOOL_LIBRARY.items()}
|
|
99
|
+
|
|
100
|
+
def set_toolstring(self, stations: List[Tuple[float, str]]) -> List[dict]:
|
|
101
|
+
"""Hang tools ((md_ft, tool_name)...) and rate them against the
|
|
102
|
+
CURRENT well's profile. Returns the rating report; run() blocks on
|
|
103
|
+
any failing station."""
|
|
104
|
+
if self.config is None:
|
|
105
|
+
raise NotImplementedError(
|
|
106
|
+
"no well loaded - pick a catalogue well (load_well) or a "
|
|
107
|
+
"profile CSV (load_profile_csv) before hanging tools")
|
|
108
|
+
self.toolstring = ToolString(stations=stations)
|
|
109
|
+
self.last_rating = rating_check(
|
|
110
|
+
self.toolstring, profile=self.config.profile,
|
|
111
|
+
deviation=getattr(self.config, "deviation", None))
|
|
112
|
+
for r in self.last_rating:
|
|
113
|
+
if not r["ok"]:
|
|
114
|
+
self.log.append(
|
|
115
|
+
f"RATING: {r['tool']} at {r['md_ft']:.0f} ft OVER rating "
|
|
116
|
+
f"({r['station_temp_C']} C vs {r['temp_rating_C']} C)")
|
|
117
|
+
return self.last_rating
|
|
118
|
+
|
|
119
|
+
def rating_blocks(self) -> List[dict]:
|
|
120
|
+
return [r for r in self.last_rating if not r["ok"]]
|
|
121
|
+
|
|
122
|
+
# -- run ---------------------------------------------------------------
|
|
123
|
+
def start_run(self, acknowledge_over_rating: bool = False) -> DownholeEngine:
|
|
124
|
+
if self.config is None:
|
|
125
|
+
raise NotImplementedError("no well loaded - nothing to run")
|
|
126
|
+
blocks = self.rating_blocks()
|
|
127
|
+
if blocks and not acknowledge_over_rating:
|
|
128
|
+
names = "; ".join(f"{b['tool']}@{b['md_ft']:.0f}ft "
|
|
129
|
+
f"({b['station_temp_C']}C > {b['temp_rating_C']}C rated)"
|
|
130
|
+
for b in blocks)
|
|
131
|
+
raise RuntimeError(
|
|
132
|
+
f"RUN BLOCKED by the rating check: {names}. The station "
|
|
133
|
+
"conditions come from the MEASURED profile; move the tool, "
|
|
134
|
+
"pick a higher-rated tool, or pass acknowledge_over_rating="
|
|
135
|
+
"True as an explicit logged operator decision.")
|
|
136
|
+
if blocks:
|
|
137
|
+
self.log.append(f"OPERATOR ACKNOWLEDGED over-rating run "
|
|
138
|
+
f"({len(blocks)} station(s))")
|
|
139
|
+
if self.toolstring is not None:
|
|
140
|
+
self.config.toolstring = self.toolstring # v1.47.0 mixed strings
|
|
141
|
+
self.config.acknowledge_over_rating = bool(acknowledge_over_rating)
|
|
142
|
+
self.engine = DownholeEngine(self.config)
|
|
143
|
+
self.log.append("run started")
|
|
144
|
+
return self.engine
|
|
145
|
+
|
|
146
|
+
def step(self, n: int = 1) -> dict:
|
|
147
|
+
if self.engine is None:
|
|
148
|
+
raise NotImplementedError("run not started - start_run() first")
|
|
149
|
+
for _ in range(n):
|
|
150
|
+
self.engine.step()
|
|
151
|
+
return self.engine.comparison_summary()
|
|
152
|
+
|
|
153
|
+
def mixed_report(self) -> dict:
|
|
154
|
+
"""v1.47.0: per-station tool legs from the running engine (twin /
|
|
155
|
+
single / refused, with the aggregate over twin stations only)."""
|
|
156
|
+
if self.engine is None:
|
|
157
|
+
raise NotImplementedError("run not started - start_run() first")
|
|
158
|
+
if self.config.toolstring is None:
|
|
159
|
+
raise NotImplementedError("no toolstring on this run - hang tools "
|
|
160
|
+
"with set_toolstring() before start_run()")
|
|
161
|
+
return self.engine.mixed_summary()
|
|
162
|
+
|
|
163
|
+
# -- exports -----------------------------------------------------------
|
|
164
|
+
def service_life(self, years: float = 5.0, **kw) -> dict:
|
|
165
|
+
"""Twin-leg accumulated error on THIS well: the service-life engine
|
|
166
|
+
evaluates its rates at the loaded (measured) profile's base
|
|
167
|
+
stations, so the divergence summary belongs to the archived well,
|
|
168
|
+
not the template."""
|
|
169
|
+
from .service_life import ServiceLifeConfig, ServiceLifeSimulator
|
|
170
|
+
if self.config is None:
|
|
171
|
+
raise NotImplementedError("no well loaded")
|
|
172
|
+
sim = ServiceLifeSimulator(engine=DownholeEngine(self.config),
|
|
173
|
+
config=ServiceLifeConfig(years=years, **kw))
|
|
174
|
+
summ = sim.run().divergence_summary()
|
|
175
|
+
self.log.append(f"service life {years} yr: {summ.get('years_to_budget_conventional', '?')}")
|
|
176
|
+
return summ
|
|
177
|
+
|
|
178
|
+
def case_study(self, out_path: str, points: int = 12,
|
|
179
|
+
horizon_years: float = 5.0) -> str:
|
|
180
|
+
from .case_study import CaseStudyConfig, case_study, write_markdown
|
|
181
|
+
if self.config is None:
|
|
182
|
+
raise NotImplementedError("no well loaded")
|
|
183
|
+
cfg = CaseStudyConfig(td_ft=self.config.td_ft, n_depth_points=points,
|
|
184
|
+
well_name=self.well_name or "operator well",
|
|
185
|
+
horizon_years=horizon_years,
|
|
186
|
+
profile=self.config.profile)
|
|
187
|
+
write_markdown(case_study(cfg), out_path)
|
|
188
|
+
self.log.append(f"case study -> {out_path}")
|
|
189
|
+
return out_path
|
|
190
|
+
|
|
191
|
+
# -- ingest + reconcile + alerts ---------------------------------------
|
|
192
|
+
def ingest(self, source, port: str = "historian_csv"):
|
|
193
|
+
from . import ingest as _ingest
|
|
194
|
+
return _ingest(source, port=port)
|
|
195
|
+
|
|
196
|
+
def reconcile(self, stream=None, live_catalog: Optional[str] = None,
|
|
197
|
+
live_well: Optional[str] = None,
|
|
198
|
+
station_md_ft: Optional[float] = None) -> dict:
|
|
199
|
+
from .reconciler import Reconciler
|
|
200
|
+
if self.config is None:
|
|
201
|
+
raise NotImplementedError("no well loaded - the closed stream "
|
|
202
|
+
"needs a well configuration")
|
|
203
|
+
station_map = None
|
|
204
|
+
if live_catalog is not None:
|
|
205
|
+
stream, station_map = production_live_stream(
|
|
206
|
+
live_catalog, live_well, station_md_ft)
|
|
207
|
+
if stream is None:
|
|
208
|
+
raise NotImplementedError("reconcile needs a stream or a "
|
|
209
|
+
"live_catalog entry")
|
|
210
|
+
self.last_reconcile = Reconciler(self.config).reconcile(
|
|
211
|
+
stream, station_map=station_map)
|
|
212
|
+
for a in self.alerts():
|
|
213
|
+
self.log.append(f"ALERT: {a['channel']} at {a['md_ft']:.0f} ft "
|
|
214
|
+
f"UNEXPLAINED ({a.get('slope_psi_yr', '?')} psi/yr)")
|
|
215
|
+
return self.last_reconcile
|
|
216
|
+
|
|
217
|
+
def alerts(self) -> List[dict]:
|
|
218
|
+
"""Undervalued-stream alerts from the last reconciliation."""
|
|
219
|
+
if not self.last_reconcile:
|
|
220
|
+
return []
|
|
221
|
+
return [s for s in self.last_reconcile["stations"]
|
|
222
|
+
if s["classification"].startswith("UNEXPLAINED")]
|
|
223
|
+
|
|
224
|
+
# -- Rule 7: the always-visible citations block ------------------------
|
|
225
|
+
def citations(self) -> dict:
|
|
226
|
+
"""Everything the operator is looking at, sourced. The UI renders
|
|
227
|
+
this pane permanently; it is never hidden behind a menu."""
|
|
228
|
+
out = {
|
|
229
|
+
"suppression": ("canonical_suppression() = "
|
|
230
|
+
f"{canonical_suppression():.4f} at unity trims - "
|
|
231
|
+
"DERIVED_HYBRID: industry baseline drift x locked "
|
|
232
|
+
"GEA primitive composition (NOT a derived "
|
|
233
|
+
"constant; bench test = finish-seq step 8)"),
|
|
234
|
+
"gauge_spec": None, "well_provenance": {}, "tools": {}}
|
|
235
|
+
if self.config is not None:
|
|
236
|
+
spec = getattr(self.config, "gauge_spec", None)
|
|
237
|
+
out["gauge_spec"] = (f"{spec.name}: {spec.source}" if spec
|
|
238
|
+
else "template_generic (template class - no vendor claim)")
|
|
239
|
+
if self.well_name in BUILTIN_ASSEMBLIES:
|
|
240
|
+
a = BUILTIN_ASSEMBLIES[self.well_name]()
|
|
241
|
+
for r, c in a.components.items():
|
|
242
|
+
out["well_provenance"][r] = {
|
|
243
|
+
"entry": c.entry,
|
|
244
|
+
"source": c.provenance.get("source_database", "?"),
|
|
245
|
+
"license": c.provenance.get("license", "?")}
|
|
246
|
+
if self.toolstring is not None:
|
|
247
|
+
for _, tn in self.toolstring.stations:
|
|
248
|
+
out["tools"][tn] = TOOL_LIBRARY[tn].source
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# --------------------------------------------------------------------------
|
|
253
|
+
# The Qt6 view. Thin: every button calls one OperatorSession method.
|
|
254
|
+
# --------------------------------------------------------------------------
|
|
255
|
+
def launch_operator_app() -> int:
|
|
256
|
+
"""One operator window over OperatorSession. Refuses without PyQt6
|
|
257
|
+
(pip hint), same pattern as the live-protocol port."""
|
|
258
|
+
try:
|
|
259
|
+
from PyQt6.QtCore import QTimer
|
|
260
|
+
from PyQt6.QtWidgets import (QApplication, QComboBox, QFileDialog,
|
|
261
|
+
QHBoxLayout, QLabel, QListWidget,
|
|
262
|
+
QMainWindow, QPushButton, QSpinBox,
|
|
263
|
+
QTabWidget, QTextEdit, QVBoxLayout,
|
|
264
|
+
QWidget)
|
|
265
|
+
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
|
|
266
|
+
from matplotlib.figure import Figure
|
|
267
|
+
except ImportError:
|
|
268
|
+
raise NotImplementedError(
|
|
269
|
+
"the operator app requires the optional GUI dependencies: "
|
|
270
|
+
"pip install PyQt6 matplotlib (the OperatorSession controller "
|
|
271
|
+
"works headless without them - this is only the view)")
|
|
272
|
+
import sys as _sys
|
|
273
|
+
|
|
274
|
+
session = OperatorSession()
|
|
275
|
+
|
|
276
|
+
class OperatorWindow(QMainWindow):
|
|
277
|
+
def __init__(self):
|
|
278
|
+
super().__init__()
|
|
279
|
+
self.setWindowTitle("GEA Downhole - Operator (v1.44.0)")
|
|
280
|
+
root = QWidget(); self.setCentralWidget(root)
|
|
281
|
+
outer = QVBoxLayout(root)
|
|
282
|
+
body = QHBoxLayout(); outer.addLayout(body, stretch=1)
|
|
283
|
+
|
|
284
|
+
# left: controls
|
|
285
|
+
left = QVBoxLayout(); body.addLayout(left)
|
|
286
|
+
left.addWidget(QLabel("Well (measured catalogue assembly):"))
|
|
287
|
+
self.well_box = QComboBox()
|
|
288
|
+
self.well_box.addItems([n for n, w in session.wells().items()
|
|
289
|
+
if w["engine_ready"]])
|
|
290
|
+
left.addWidget(self.well_box)
|
|
291
|
+
b_load = QPushButton("Load well"); left.addWidget(b_load)
|
|
292
|
+
b_csv = QPushButton("Load profile CSV..."); left.addWidget(b_csv)
|
|
293
|
+
left.addWidget(QLabel("Toolstring:"))
|
|
294
|
+
self.tool_box = QComboBox(); self.tool_box.addItems(TOOL_LIBRARY)
|
|
295
|
+
left.addWidget(self.tool_box)
|
|
296
|
+
self.md_box = QSpinBox(); self.md_box.setRange(0, 40000)
|
|
297
|
+
self.md_box.setValue(26000); left.addWidget(self.md_box)
|
|
298
|
+
b_hang = QPushButton("Hang tool + rating check"); left.addWidget(b_hang)
|
|
299
|
+
self.rating_lbl = QLabel("rating: -"); left.addWidget(self.rating_lbl)
|
|
300
|
+
b_run = QPushButton("Start run"); left.addWidget(b_run)
|
|
301
|
+
b_case = QPushButton("Case study (.md)"); left.addWidget(b_case)
|
|
302
|
+
b_rec = QPushButton("Reconcile Volve F-12 (live catalogue)")
|
|
303
|
+
left.addWidget(b_rec)
|
|
304
|
+
b_ing = QPushButton("Ingest LAS / historian CSV...")
|
|
305
|
+
left.addWidget(b_ing)
|
|
306
|
+
left.addStretch(1)
|
|
307
|
+
|
|
308
|
+
# right: tabs
|
|
309
|
+
tabs = QTabWidget(); body.addWidget(tabs, stretch=1)
|
|
310
|
+
self.fig = Figure(figsize=(6, 4))
|
|
311
|
+
self.canvas = FigureCanvasQTAgg(self.fig)
|
|
312
|
+
tabs.addTab(self.canvas, "Live P/T")
|
|
313
|
+
self.dfig = Figure(figsize=(6, 4))
|
|
314
|
+
self.dcanvas = FigureCanvasQTAgg(self.dfig)
|
|
315
|
+
tabs.addTab(self.dcanvas, "Twin-leg drift")
|
|
316
|
+
self.alerts_list = QListWidget()
|
|
317
|
+
tabs.addTab(self.alerts_list, "Alerts")
|
|
318
|
+
self.log_view = QTextEdit(); self.log_view.setReadOnly(True)
|
|
319
|
+
tabs.addTab(self.log_view, "Log")
|
|
320
|
+
|
|
321
|
+
# bottom: Rule 7 - citations pane, ALWAYS visible
|
|
322
|
+
self.cite = QTextEdit(); self.cite.setReadOnly(True)
|
|
323
|
+
self.cite.setMaximumHeight(140)
|
|
324
|
+
outer.addWidget(QLabel("Citations / provenance (always visible):"))
|
|
325
|
+
outer.addWidget(self.cite)
|
|
326
|
+
|
|
327
|
+
self.stations: List[Tuple[float, str]] = []
|
|
328
|
+
self.timer = QTimer(); self.timer.timeout.connect(self._tick)
|
|
329
|
+
b_load.clicked.connect(self._load)
|
|
330
|
+
b_csv.clicked.connect(self._load_csv)
|
|
331
|
+
b_hang.clicked.connect(self._hang)
|
|
332
|
+
b_run.clicked.connect(self._run)
|
|
333
|
+
b_case.clicked.connect(self._case)
|
|
334
|
+
b_rec.clicked.connect(self._reconcile)
|
|
335
|
+
b_ing.clicked.connect(self._ingest)
|
|
336
|
+
self._refresh_citations()
|
|
337
|
+
|
|
338
|
+
def _refresh_citations(self):
|
|
339
|
+
import json
|
|
340
|
+
self.cite.setPlainText(json.dumps(session.citations(), indent=1))
|
|
341
|
+
self.log_view.setPlainText("\n".join(session.log[-40:]))
|
|
342
|
+
|
|
343
|
+
def _load(self):
|
|
344
|
+
info = session.load_well(self.well_box.currentText())
|
|
345
|
+
lo, hi = info["window_ft"]
|
|
346
|
+
self.md_box.setRange(int(lo), int(hi))
|
|
347
|
+
self.md_box.setValue(int((lo + hi) / 2))
|
|
348
|
+
self.stations = []
|
|
349
|
+
self._refresh_citations()
|
|
350
|
+
|
|
351
|
+
def _load_csv(self):
|
|
352
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
353
|
+
self, "Well profile CSV (depth_ft,pressure_psi,temp_F)")
|
|
354
|
+
if path:
|
|
355
|
+
session.load_profile_csv(path)
|
|
356
|
+
self._refresh_citations()
|
|
357
|
+
|
|
358
|
+
def _hang(self):
|
|
359
|
+
self.stations.append((float(self.md_box.value()),
|
|
360
|
+
self.tool_box.currentText()))
|
|
361
|
+
rep = session.set_toolstring(self.stations)
|
|
362
|
+
bad = [r for r in rep if not r["ok"]]
|
|
363
|
+
self.rating_lbl.setText(
|
|
364
|
+
"rating: BLOCKED - " + "; ".join(
|
|
365
|
+
f"{b['tool']}@{b['md_ft']:.0f}ft" for b in bad)
|
|
366
|
+
if bad else f"rating: OK ({len(rep)} station(s))")
|
|
367
|
+
self._refresh_citations()
|
|
368
|
+
|
|
369
|
+
def _run(self):
|
|
370
|
+
try:
|
|
371
|
+
session.start_run()
|
|
372
|
+
except RuntimeError as e: # rating block surfaces verbatim
|
|
373
|
+
self.rating_lbl.setText(str(e)[:160])
|
|
374
|
+
self._refresh_citations()
|
|
375
|
+
return
|
|
376
|
+
self.timer.start(150)
|
|
377
|
+
|
|
378
|
+
def _tick(self):
|
|
379
|
+
session.step(1)
|
|
380
|
+
eng = session.engine
|
|
381
|
+
self.fig.clear()
|
|
382
|
+
ax = self.fig.add_subplot(111)
|
|
383
|
+
hist = eng.history
|
|
384
|
+
if hist:
|
|
385
|
+
t = [row["time_s"] for row in hist]
|
|
386
|
+
for i in range(len(eng.sensors)):
|
|
387
|
+
ax.plot(t, [row[f"P_S{i+1}"] for row in hist], lw=0.8)
|
|
388
|
+
ax.set_xlabel("t, s"); ax.set_ylabel("P, psi")
|
|
389
|
+
ax.set_title(session.config.profile.name, fontsize=8)
|
|
390
|
+
self.canvas.draw_idle()
|
|
391
|
+
summ = eng.comparison_summary()
|
|
392
|
+
self.dfig.clear()
|
|
393
|
+
dax = self.dfig.add_subplot(111)
|
|
394
|
+
dax.bar([0, 1], [summ["avg_program_drift_pct"],
|
|
395
|
+
summ["avg_conventional_drift_pct"]],
|
|
396
|
+
tick_label=["program-model leg", "conventional leg"])
|
|
397
|
+
dax.set_ylabel("avg drift, %FS/yr")
|
|
398
|
+
dax.set_title(f"twin-leg ratio {summ['measured_ratio_mean']:.4f} "
|
|
399
|
+
"(suppression: DERIVED_HYBRID - see citations)",
|
|
400
|
+
fontsize=8)
|
|
401
|
+
self.dcanvas.draw_idle()
|
|
402
|
+
self._refresh_citations()
|
|
403
|
+
|
|
404
|
+
def _case(self):
|
|
405
|
+
path = session.case_study("operator_case_study.md")
|
|
406
|
+
self.log_view.append(f"case study -> {path}")
|
|
407
|
+
|
|
408
|
+
def _ingest(self):
|
|
409
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
410
|
+
self, "Ingest live-stream file",
|
|
411
|
+
filter="Well data (*.las *.csv);;All files (*)")
|
|
412
|
+
if not path:
|
|
413
|
+
return
|
|
414
|
+
port = "las2" if path.lower().endswith(".las") else "historian_csv"
|
|
415
|
+
try:
|
|
416
|
+
st = session.ingest(path, port=port)
|
|
417
|
+
session.log.append(
|
|
418
|
+
f"ingest[{port}]: {st.name} - {len(st.index)} samples, "
|
|
419
|
+
f"channels: {', '.join(list(st.channels)[:6])}")
|
|
420
|
+
except Exception as e:
|
|
421
|
+
session.log.append(f"ingest REFUSED: {str(e)[:160]}")
|
|
422
|
+
self._refresh_citations()
|
|
423
|
+
|
|
424
|
+
def _reconcile(self):
|
|
425
|
+
try:
|
|
426
|
+
session.reconcile(
|
|
427
|
+
live_catalog="volve_f12_f14_production_excerpt",
|
|
428
|
+
live_well="15/9-F-12", station_md_ft=10000.0)
|
|
429
|
+
except Exception as e:
|
|
430
|
+
self.alerts_list.addItem(str(e)[:200]); return
|
|
431
|
+
self.alerts_list.clear()
|
|
432
|
+
for a in session.alerts():
|
|
433
|
+
self.alerts_list.addItem(
|
|
434
|
+
f"{a['channel']} @ {a['md_ft']:.0f} ft: "
|
|
435
|
+
f"{a['classification']} ({a.get('slope_psi_yr', '?')} psi/yr)")
|
|
436
|
+
if not session.alerts():
|
|
437
|
+
self.alerts_list.addItem("no unexplained streams")
|
|
438
|
+
self._refresh_citations()
|
|
439
|
+
|
|
440
|
+
app = QApplication.instance() or QApplication(_sys.argv)
|
|
441
|
+
win = OperatorWindow(); win.resize(1100, 700); win.show()
|
|
442
|
+
return app.exec()
|