gea-program 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. gea/BENCH_TEST_PROTOCOL.md +97 -0
  2. gea/IMPORT_RECORD.md +61 -0
  3. gea/__init__.py +160 -0
  4. gea/__main__.py +661 -0
  5. gea/acceptance_tests.py +1315 -0
  6. gea/accuracy_statement.py +181 -0
  7. gea/alarm_engine.py +307 -0
  8. gea/bench.py +144 -0
  9. gea/blind_harness.py +108 -0
  10. gea/case_study.py +229 -0
  11. gea/catalog/acex_lomonosov_age_depth_model.provenance.json +23 -0
  12. gea/catalog/acex_lomonosov_age_depth_model.txt +28 -0
  13. gea/catalog/agassiz77_canada_temperature.csv +68 -0
  14. gea/catalog/agassiz77_canada_temperature.provenance.json +17 -0
  15. gea/catalog/barbados_110_consolidation.provenance.json +24 -0
  16. gea/catalog/barbados_110_consolidation.txt +91 -0
  17. gea/catalog/bengal_u1452_grain_size.provenance.json +20 -0
  18. gea/catalog/bengal_u1452_grain_size.txt +252 -0
  19. gea/catalog/blake_164_methane_isotopes.provenance.json +22 -0
  20. gea/catalog/blake_164_methane_isotopes.txt +68 -0
  21. gea/catalog/chicxulub_m0077a_pwave_velocity.provenance.json +23 -0
  22. gea/catalog/chicxulub_m0077a_pwave_velocity.txt +735 -0
  23. gea/catalog/collingwood_1_28_ks_complete.las +128 -0
  24. gea/catalog/collingwood_1_28_ks_complete.provenance.json +17 -0
  25. gea/catalog/costa_rica_odp_friction_envelope.provenance.json +24 -0
  26. gea/catalog/costa_rica_odp_friction_envelope.txt +57 -0
  27. gea/catalog/dead_sea_5017_debrite_xrf_ms.provenance.json +21 -0
  28. gea/catalog/dead_sea_5017_debrite_xrf_ms.txt +94 -0
  29. gea/catalog/dsdp_504b_physical_properties.provenance.json +24 -0
  30. gea/catalog/dsdp_504b_physical_properties.txt +82 -0
  31. gea/catalog/dsdp_504b_sound_velocity.provenance.json +21 -0
  32. gea/catalog/dsdp_504b_sound_velocity.txt +81 -0
  33. gea/catalog/elgygytgyn_5011_turbidites.provenance.json +20 -0
  34. gea/catalog/elgygytgyn_5011_turbidites.txt +193 -0
  35. gea/catalog/epica_domec_co2_800kyr.provenance.json +21 -0
  36. gea/catalog/epica_domec_co2_800kyr.txt +265 -0
  37. gea/catalog/fram_909_organic_petrography.provenance.json +22 -0
  38. gea/catalog/fram_909_organic_petrography.txt +40 -0
  39. gea/catalog/gbr_325_coral_uth_ages.provenance.json +23 -0
  40. gea/catalog/gbr_325_coral_uth_ages.txt +71 -0
  41. gea/catalog/gisp2_greenland_temperature.csv +599 -0
  42. gea/catalog/gisp2_greenland_temperature.provenance.json +17 -0
  43. gea/catalog/gom_308_t2p_insitu.provenance.json +21 -0
  44. gea/catalog/gom_308_t2p_insitu.txt +40 -0
  45. gea/catalog/guaymas_385_dom_d13c.provenance.json +21 -0
  46. gea/catalog/guaymas_385_dom_d13c.txt +103 -0
  47. gea/catalog/hikurangi_u1520_friction_insitu.provenance.json +26 -0
  48. gea/catalog/hikurangi_u1520_friction_insitu.txt +74 -0
  49. gea/catalog/hydrate_ridge_204_ncr_hydrate.provenance.json +22 -0
  50. gea/catalog/hydrate_ridge_204_ncr_hydrate.txt +60 -0
  51. gea/catalog/iodp_u1324_pore_pressure.provenance.json +22 -0
  52. gea/catalog/iodp_u1324_pore_pressure.txt +42 -0
  53. gea/catalog/jfast_c0019_slow_slip_events.provenance.json +28 -0
  54. gea/catalog/jfast_c0019_slow_slip_events.txt +35 -0
  55. gea/catalog/kennetcook_2_p129_excerpt.las +139 -0
  56. gea/catalog/kennetcook_2_p129_excerpt.provenance.json +17 -0
  57. gea/catalog/ktb_hb_bhgm_density.dat +227 -0
  58. gea/catalog/ktb_hb_bhgm_density.provenance.json +22 -0
  59. gea/catalog/ktb_hb_complog_6020_excerpt.provenance.json +20 -0
  60. gea/catalog/ktb_hb_complog_6020_excerpt.txt +72 -0
  61. gea/catalog/ktb_hb_hlog246_temperature.dat +1636 -0
  62. gea/catalog/ktb_hb_hlog246_temperature.provenance.json +22 -0
  63. gea/catalog/ktb_hb_rockmech_compress.dat +33 -0
  64. gea/catalog/ktb_hb_rockmech_compress.provenance.json +22 -0
  65. gea/catalog/ktb_hb_tvd_0_9080_excerpt.dat +2817 -0
  66. gea/catalog/ktb_hb_tvd_0_9080_excerpt.provenance.json +20 -0
  67. gea/catalog/ktb_vb_rockmech_compress.dat +125 -0
  68. gea/catalog/ktb_vb_rockmech_compress.provenance.json +22 -0
  69. gea/catalog/ktb_vb_vlog251_temperature.dat +1127 -0
  70. gea/catalog/ktb_vb_vlog251_temperature.provenance.json +24 -0
  71. gea/catalog/l06_06_nl_survey.csv +201 -0
  72. gea/catalog/l06_06_nl_survey.provenance.json +17 -0
  73. gea/catalog/l07_01_nl_excerpt.las +90 -0
  74. gea/catalog/l07_01_nl_excerpt.provenance.json +17 -0
  75. gea/catalog/mariana_1200_serpentinite_geochem.provenance.json +21 -0
  76. gea/catalog/mariana_1200_serpentinite_geochem.txt +63 -0
  77. gea/catalog/med_160_sapropels.provenance.json +22 -0
  78. gea/catalog/med_160_sapropels.txt +43 -0
  79. gea/catalog/nankai_megasplay_shear_strength.provenance.json +26 -0
  80. gea/catalog/nankai_megasplay_shear_strength.txt +42 -0
  81. gea/catalog/odp_1027b_thermal_conductivity.provenance.json +21 -0
  82. gea/catalog/odp_1027b_thermal_conductivity.txt +52 -0
  83. gea/catalog/odp_1027c_cork_temperature.provenance.json +20 -0
  84. gea/catalog/odp_1027c_cork_temperature.txt +26 -0
  85. gea/catalog/odp_1165b_thermal_conductivity.provenance.json +22 -0
  86. gea/catalog/odp_1165b_thermal_conductivity.txt +102 -0
  87. gea/catalog/odp_1274a_mantle_peridotite_mad.provenance.json +27 -0
  88. gea/catalog/odp_1274a_mantle_peridotite_mad.txt +44 -0
  89. gea/catalog/odp_504b_dike_elastic_moduli.provenance.json +27 -0
  90. gea/catalog/odp_504b_dike_elastic_moduli.txt +85 -0
  91. gea/catalog/odp_504b_leg137_borehole_fluids.provenance.json +19 -0
  92. gea/catalog/odp_504b_leg137_borehole_fluids.txt +68 -0
  93. gea/catalog/odp_735b_gabbro_elastic_moduli.provenance.json +28 -0
  94. gea/catalog/odp_735b_gabbro_elastic_moduli.txt +127 -0
  95. gea/catalog/peru_201_sulfate_reduction.provenance.json +22 -0
  96. gea/catalog/peru_201_sulfate_reduction.txt +322 -0
  97. gea/catalog/scorpio_e1_sa_excerpt.las +113 -0
  98. gea/catalog/scorpio_e1_sa_excerpt.provenance.json +17 -0
  99. gea/catalog/sumatra_362_cohesion.provenance.json +21 -0
  100. gea/catalog/sumatra_362_cohesion.txt +38 -0
  101. gea/catalog/university_6_17_no1_tx_excerpt.las +119 -0
  102. gea/catalog/university_6_17_no1_tx_excerpt.provenance.json +17 -0
  103. gea/catalog/ursa_308_xrd_mineralogy.provenance.json +21 -0
  104. gea/catalog/ursa_308_xrd_mineralogy.txt +46 -0
  105. gea/catalog/volve_15_9_19_sr_excerpt.las +183 -0
  106. gea/catalog/volve_15_9_19_sr_excerpt.provenance.json +16 -0
  107. gea/catalog/volve_15_9_19a_core_excerpt.csv +88 -0
  108. gea/catalog/volve_15_9_19a_core_excerpt.provenance.json +18 -0
  109. gea/catalog/volve_f12_f14_production_excerpt.csv +167 -0
  110. gea/catalog/volve_f12_f14_production_excerpt.provenance.json +21 -0
  111. gea/catalog/walvis_208_petm_carbonate.provenance.json +21 -0
  112. gea/catalog/walvis_208_petm_carbonate.txt +268 -0
  113. gea/catalog/woodlark_1109_rock_eval.provenance.json +23 -0
  114. gea/catalog/woodlark_1109_rock_eval.txt +30 -0
  115. gea/cli.py +125 -0
  116. gea/client_reports.py +943 -0
  117. gea/config_versioning.py +133 -0
  118. gea/correlation.py +155 -0
  119. gea/dashboard.py +390 -0
  120. gea/deviation.py +70 -0
  121. gea/downhole_engine.py +395 -0
  122. gea/drift_monitor.py +310 -0
  123. gea/earth_model.py +230 -0
  124. gea/example_register_map.json +14 -0
  125. gea/fat_sat.py +68 -0
  126. gea/follower.py +98 -0
  127. gea/forward_model.py +139 -0
  128. gea/gamma.py +176 -0
  129. gea/gauge_specs.py +112 -0
  130. gea/gravity_reference.py +116 -0
  131. gea/inverse_engine.py +215 -0
  132. gea/matplotlib_demo.py +85 -0
  133. gea/modbus.py +229 -0
  134. gea/model_card.py +248 -0
  135. gea/operator_app.py +442 -0
  136. gea/ports.py +340 -0
  137. gea/profile_catalog.py +773 -0
  138. gea/project.py +213 -0
  139. gea/qt6_downhole_app.py +144 -0
  140. gea/quartz_hpht_extension.py +152 -0
  141. gea/reconciler.py +206 -0
  142. gea/rock_inventory.py +404 -0
  143. gea/sample_record.py +430 -0
  144. gea/sample_well_profile.csv +15 -0
  145. gea/sbom.py +116 -0
  146. gea/segy.py +181 -0
  147. gea/service_life.py +173 -0
  148. gea/shell.py +107 -0
  149. gea/sla_report.py +199 -0
  150. gea/store_forward.py +234 -0
  151. gea/strata_join.py +186 -0
  152. gea/survey_cmd.py +264 -0
  153. gea/survey_view.py +138 -0
  154. gea/telemetry.py +306 -0
  155. gea/tool_library.py +260 -0
  156. gea/well_assembler.py +457 -0
  157. gea/well_test_validation.py +369 -0
  158. gea_program-0.1.0.dist-info/METADATA +138 -0
  159. gea_program-0.1.0.dist-info/RECORD +163 -0
  160. gea_program-0.1.0.dist-info/WHEEL +5 -0
  161. gea_program-0.1.0.dist-info/entry_points.txt +2 -0
  162. gea_program-0.1.0.dist-info/licenses/LICENSE +373 -0
  163. gea_program-0.1.0.dist-info/top_level.txt +1 -0
gea/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()