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/segy.py ADDED
@@ -0,0 +1,181 @@
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
+ """segy - Part 6 of the subsurface surveying tool: SEISMIC INGEST
5
+ (v1.83.0). SEG-Y is the industry's seismic interchange format; this reader
6
+ brings the first WAVEFIELD modality into the read-only ingestion family.
7
+
8
+ STATUS, honestly: READER_VALIDATED_BY_ROUND_TRIP; AWAITING_FIELD_SEGY.
9
+ No field SEG-Y exists in the catalogue yet, so validation is by exact
10
+ round-trip against a minimal spec-conformant writer (used ONLY for
11
+ validation - the writer never touches archives). The day a licensed field
12
+ volume lands, this reader is its door; until then no seismic claim is made.
13
+
14
+ SCOPE (SEG-Y rev 1, the working subset):
15
+ - 3200-byte textual header (EBCDIC auto-detected and transcoded, or ASCII)
16
+ - 400-byte binary file header: sample interval (bytes 3217-3218),
17
+ samples/trace (3221-3222), data format code (3225-3226)
18
+ - traces: 240-byte trace headers (inline/crossline at the rev1 standard
19
+ byte positions 189/193, source coords at 73/77 with the scalar at 71)
20
+ + samples in format 5 (IEEE float32 big-endian) or format 1 (IBM
21
+ float32, converted exactly)
22
+ - anything else REFUSES with the format code named - no guessing
23
+
24
+ READ-ONLY doctrine unchanged: ingest never writes, never modifies, never
25
+ 'fixes' a volume. Byte-swapped or nonconforming files refuse visibly.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import struct
31
+ from dataclasses import dataclass, field
32
+ from typing import Dict, List
33
+
34
+ SUPPORTED_FORMATS = {1: 'IBM float32', 5: 'IEEE float32'}
35
+
36
+
37
+ def _ibm_to_float(b: bytes) -> float:
38
+ """Exact IBM System/360 hexadecimal float -> Python float."""
39
+ (u,) = struct.unpack('>I', b)
40
+ if u == 0:
41
+ return 0.0
42
+ sign = -1.0 if u >> 31 else 1.0
43
+ exponent = ((u >> 24) & 0x7F) - 64
44
+ mantissa = (u & 0x00FFFFFF) / float(0x1000000)
45
+ return sign * mantissa * (16.0 ** exponent)
46
+
47
+
48
+ def _float_to_ibm(x: float) -> bytes:
49
+ """Exact inverse for the round-trip validator (writer is test-only)."""
50
+ if x == 0.0:
51
+ return b'\x00\x00\x00\x00'
52
+ sign = 0x80000000 if x < 0 else 0
53
+ x = abs(x)
54
+ exponent = 64
55
+ while x >= 1.0:
56
+ x /= 16.0
57
+ exponent += 1
58
+ while x < 0.0625 and exponent > 0:
59
+ x *= 16.0
60
+ exponent -= 1
61
+ mantissa = int(x * 0x1000000) & 0x00FFFFFF
62
+ return struct.pack('>I', sign | (exponent << 24) | mantissa)
63
+
64
+
65
+ @dataclass
66
+ class SegyTrace:
67
+ inline: int
68
+ crossline: int
69
+ source_x: float
70
+ source_y: float
71
+ samples: List[float] = field(default_factory=list)
72
+
73
+
74
+ @dataclass
75
+ class SegyVolume:
76
+ textual_header: str
77
+ encoding: str # 'EBCDIC' | 'ASCII'
78
+ sample_interval_us: int
79
+ n_samples: int
80
+ format_code: int
81
+ format_name: str
82
+ traces: List[SegyTrace] = field(default_factory=list)
83
+ status: str = 'READER_VALIDATED_BY_ROUND_TRIP; AWAITING_FIELD_SEGY'
84
+
85
+ def summary(self) -> Dict:
86
+ return {'n_traces': len(self.traces),
87
+ 'n_samples': self.n_samples,
88
+ 'sample_interval_us': self.sample_interval_us,
89
+ 'trace_length_ms': self.n_samples * self.sample_interval_us / 1000.0,
90
+ 'format': self.format_name, 'encoding': self.encoding,
91
+ 'status': self.status}
92
+
93
+
94
+ def read_segy(path: str) -> SegyVolume:
95
+ """Read a SEG-Y rev1 file (see module scope). Refuses unsupported
96
+ format codes and truncated files by name, never by guess."""
97
+ with open(path, 'rb') as f:
98
+ raw_text = f.read(3200)
99
+ if len(raw_text) < 3200:
100
+ raise ValueError('SEG-Y refused: file shorter than the 3200-byte '
101
+ 'textual header')
102
+ if raw_text[:1] == b'C':
103
+ text, enc = raw_text.decode('ascii', 'replace'), 'ASCII'
104
+ else:
105
+ text, enc = raw_text.decode('cp037', 'replace'), 'EBCDIC'
106
+ bin_hdr = f.read(400)
107
+ if len(bin_hdr) < 400:
108
+ raise ValueError('SEG-Y refused: truncated binary file header')
109
+ (interval,) = struct.unpack('>H', bin_hdr[16:18])
110
+ (n_samples,) = struct.unpack('>H', bin_hdr[20:22])
111
+ (fmt,) = struct.unpack('>H', bin_hdr[24:26])
112
+ if fmt not in SUPPORTED_FORMATS:
113
+ raise ValueError('SEG-Y refused: data format code %d is outside '
114
+ 'the supported subset %s - no guessing'
115
+ % (fmt, sorted(SUPPORTED_FORMATS)))
116
+ if n_samples == 0:
117
+ raise ValueError('SEG-Y refused: binary header declares zero '
118
+ 'samples per trace')
119
+ vol = SegyVolume(textual_header=text, encoding=enc,
120
+ sample_interval_us=interval, n_samples=n_samples,
121
+ format_code=fmt, format_name=SUPPORTED_FORMATS[fmt])
122
+ trace_bytes = n_samples * 4
123
+ while True:
124
+ th = f.read(240)
125
+ if not th:
126
+ break
127
+ if len(th) < 240:
128
+ raise ValueError('SEG-Y refused: truncated trace header at '
129
+ 'trace %d' % (len(vol.traces) + 1))
130
+ data = f.read(trace_bytes)
131
+ if len(data) < trace_bytes:
132
+ raise ValueError('SEG-Y refused: truncated samples at trace '
133
+ '%d' % (len(vol.traces) + 1))
134
+ (scal,) = struct.unpack('>h', th[70:72])
135
+ (sx,) = struct.unpack('>i', th[72:76])
136
+ (sy,) = struct.unpack('>i', th[76:80])
137
+ (il,) = struct.unpack('>i', th[188:192])
138
+ (xl,) = struct.unpack('>i', th[192:196])
139
+ k = (abs(scal) if scal else 1)
140
+ factor = (1.0 / k) if scal < 0 else float(k)
141
+ if fmt == 5:
142
+ samples = list(struct.unpack('>%df' % n_samples, data))
143
+ else:
144
+ samples = [_ibm_to_float(data[i:i + 4])
145
+ for i in range(0, trace_bytes, 4)]
146
+ vol.traces.append(SegyTrace(
147
+ inline=il, crossline=xl,
148
+ source_x=sx * factor, source_y=sy * factor,
149
+ samples=samples))
150
+ return vol
151
+
152
+
153
+ def write_segy_minimal(path: str, traces: List[List[float]],
154
+ sample_interval_us: int = 2000,
155
+ fmt: int = 5) -> None:
156
+ """TEST-ONLY minimal spec-conformant writer: exists solely so the reader
157
+ can be validated by exact round-trip in the absence of licensed field
158
+ data. Never used on archives; never part of ingestion."""
159
+ n_samples = len(traces[0])
160
+ with open(path, 'wb') as f:
161
+ header_line = ('C 1 segy round-trip validation volume - '
162
+ 'TEST ONLY, not field data').ljust(80)
163
+ f.write((header_line + ' ' * (3200 - 80)).encode('ascii')[:3200])
164
+ bin_hdr = bytearray(400)
165
+ bin_hdr[16:18] = struct.pack('>H', sample_interval_us)
166
+ bin_hdr[20:22] = struct.pack('>H', n_samples)
167
+ bin_hdr[24:26] = struct.pack('>H', fmt)
168
+ f.write(bytes(bin_hdr))
169
+ for t_i, samples in enumerate(traces):
170
+ th = bytearray(240)
171
+ th[70:72] = struct.pack('>h', 1)
172
+ th[72:76] = struct.pack('>i', 1000 + t_i)
173
+ th[76:80] = struct.pack('>i', 2000 + t_i)
174
+ th[188:192] = struct.pack('>i', 10 + t_i)
175
+ th[192:196] = struct.pack('>i', 20 + t_i)
176
+ f.write(bytes(th))
177
+ if fmt == 5:
178
+ f.write(struct.pack('>%df' % n_samples, *samples))
179
+ else:
180
+ for s in samples:
181
+ f.write(_float_to_ibm(s))
gea/service_life.py ADDED
@@ -0,0 +1,173 @@
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
+ """service_life — long-horizon drift accumulation (v1.2.0 extension).
5
+
6
+ The v1.0/1.1 layers report drift as an instantaneous RATE (%FS/yr). This module
7
+ integrates that rate over months/years of simulated service, twin-leg at every
8
+ station (GEA-stabilized vs conventional), producing the DIVERGENCE CURVES a
9
+ real bench test or field trial would record: two error traces separating at
10
+ exactly the rate the canonical suppression predicts. Adds optional periodic
11
+ recalibration resets (workover/recal events) and a small random-walk component
12
+ for realism (defaults chosen so the deterministic rate dominates).
13
+
14
+ This is the full simulation instrument for the PAPER_2250 LABORATORY-tier
15
+ quartz bench test (PAPER_2256 sec 5): the separation you'd measure, not just
16
+ the ratio you'd predict.
17
+
18
+ Headless-safe: numpy only, no display imports.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import csv
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import List, Optional
27
+
28
+ import numpy as np
29
+
30
+ from .downhole_engine import DownholeEngine, SimulatorConfig
31
+ from .quartz_hpht_extension import (
32
+ calculate_quartz_transducer_hpht_program,
33
+ canonical_suppression,
34
+ conventional_drift,
35
+ )
36
+
37
+
38
+ @dataclass
39
+ class ServiceLifeConfig:
40
+ years: float = 5.0 # simulated service horizon
41
+ dt_days: float = 7.0 # accumulation step (one reading per week class)
42
+ full_scale_psi: float = 30000.0 # anchor: HPHT quartz-gauge full-scale class (industry)
43
+ error_budget_pct_fs: float = 0.5 # anchor: typical permanent-gauge total-error spec budget
44
+ recalibration_interval_years: Optional[float] = None # None = never recalibrated (permanent install)
45
+ random_walk_pct_fs_per_sqrt_yr: float = 0.002 # small stochastic component (realism; 0 = deterministic)
46
+ seed: Optional[int] = None # rng seed (deterministic runs for tests)
47
+
48
+
49
+ class ServiceLifeSimulator:
50
+ """Accumulates twin-leg gauge error over simulated service years.
51
+
52
+ Rates are evaluated once per sensor at the engine's base station T/P
53
+ (permanent-install conditions); both legs share the same industry baseline
54
+ and stress dressing, differing ONLY by the canonical suppression — so the
55
+ accumulated-error ratio converges to the suppression composition and the
56
+ separation grows linearly at the predicted rate.
57
+ """
58
+
59
+ def __init__(self, engine: DownholeEngine | None = None,
60
+ config: ServiceLifeConfig | None = None):
61
+ self.engine = engine or DownholeEngine(SimulatorConfig())
62
+ self.cfg = config or ServiceLifeConfig()
63
+ spec = getattr(self.engine.cfg, 'gauge_spec', None) # v1.5.0: engine's datasheet spec
64
+ if spec is not None:
65
+ from dataclasses import replace as _replace
66
+ self.cfg = _replace(self.cfg, full_scale_psi=float(spec.full_scale_psi))
67
+ n = len(self.engine.sensors)
68
+ # Per-sensor rates (%FS/yr) at base station conditions
69
+ self.rate = np.zeros(n)
70
+ self.conv_rate = np.zeros(n)
71
+ for i, s in enumerate(self.engine.sensors):
72
+ temp_C = (float(self.engine.base_T[i]) - 32.0) * 5.0 / 9.0
73
+ p_psi = float(self.engine.base_P[i])
74
+ r = calculate_quartz_transducer_hpht_program(
75
+ depth_m=s.depth_ft / 3.28084, temp_c=temp_C, pressure_psi=p_psi,
76
+ k_structural_trim=s.k_structural_trim,
77
+ phi_coupling_trim=s.phi_coupling_trim,
78
+ spec=spec)
79
+ self.rate[i] = r["value"]["drift_pct"]
80
+ self.conv_rate[i] = conventional_drift(temp_C, p_psi, spec=spec)
81
+ self._reset_history()
82
+
83
+ def _reset_history(self) -> None:
84
+ n = len(self.engine.sensors)
85
+ self.time_years: List[float] = [0.0]
86
+ self.err: List[np.ndarray] = [np.zeros(n)] # accumulated error, %FS
87
+ self.conv_err: List[np.ndarray] = [np.zeros(n)]
88
+ self.recal_times: List[float] = []
89
+
90
+ # -- run ------------------------------------------------------------------
91
+ def run(self) -> "ServiceLifeSimulator":
92
+ """Integrate accumulated error over the configured horizon."""
93
+ self._reset_history()
94
+ n = len(self.engine.sensors)
95
+ dt = self.cfg.dt_days / 365.25
96
+ rng = np.random.default_rng(self.cfg.seed)
97
+ rw = self.cfg.random_walk_pct_fs_per_sqrt_yr
98
+ t = 0.0
99
+ uq = np.zeros(n)
100
+ cv = np.zeros(n)
101
+ since_recal = 0.0
102
+ while t < self.cfg.years - 1e-12:
103
+ t += dt
104
+ since_recal += dt
105
+ uq = uq + self.rate * dt + rng.normal(0.0, rw * np.sqrt(dt), n)
106
+ cv = cv + self.conv_rate * dt + rng.normal(0.0, rw * np.sqrt(dt), n)
107
+ if (self.cfg.recalibration_interval_years is not None
108
+ and since_recal >= self.cfg.recalibration_interval_years - 1e-12):
109
+ uq = np.zeros(n) # recal zeroes both legs
110
+ cv = np.zeros(n)
111
+ since_recal = 0.0
112
+ self.recal_times.append(round(t, 6))
113
+ self.time_years.append(round(t, 6))
114
+ self.err.append(uq.copy())
115
+ self.conv_err.append(cv.copy())
116
+ return self
117
+
118
+ # -- reporting --------------------------------------------------------------
119
+ def divergence_summary(self) -> dict:
120
+ """The bench-test report: predicted vs measured separation, ratio check,
121
+ and the service-life arithmetic (time to error budget, extra years)."""
122
+ fs = self.cfg.full_scale_psi
123
+ budget = self.cfg.error_budget_pct_fs
124
+ sep_rate = self.conv_rate - self.rate # %FS/yr
125
+ uq_final = self.err[-1]
126
+ cv_final = self.conv_err[-1]
127
+ with np.errstate(divide='ignore', invalid='ignore'):
128
+ ratio_final = np.where(uq_final > 0, cv_final / uq_final, np.nan)
129
+ t_budget_program = budget / self.rate # yr to spec budget
130
+ t_budget_conv = budget / self.conv_rate
131
+ return {
132
+ 'horizon_years': self.cfg.years,
133
+ 'full_scale_psi': fs,
134
+ 'rate_pct_fs_yr': [round(float(r), 4) for r in self.rate],
135
+ 'conv_rate_pct_fs_yr': [round(float(r), 4) for r in self.conv_rate],
136
+ 'predicted_separation_rate_pct_fs_yr': [round(float(r), 5) for r in sep_rate],
137
+ 'predicted_separation_rate_psi_yr': [round(float(r) * fs / 100.0, 2) for r in sep_rate],
138
+ 'final_separation_psi': [round(float(cv_final[i] - uq_final[i]) * fs / 100.0, 2)
139
+ for i in range(len(sep_rate))],
140
+ 'measured_ratio_final': [round(float(r), 4) for r in ratio_final],
141
+ 'predicted_ratio_suppression': round(canonical_suppression(
142
+ self.engine.cfg.global_k_structural_trim,
143
+ self.engine.cfg.global_phi_coupling_trim), 4),
144
+ 'error_budget_pct_fs': budget,
145
+ 'years_to_budget_program': [round(float(t), 2) for t in t_budget_program],
146
+ 'years_to_budget_conventional': [round(float(t), 2) for t in t_budget_conv],
147
+ 'extra_service_life_years': [round(float(t_budget_program[i] - t_budget_conv[i]), 2)
148
+ for i in range(len(sep_rate))],
149
+ 'recalibrations': self.recal_times,
150
+ }
151
+
152
+ def export_csv(self, path: str | None = None) -> Path:
153
+ """Divergence curves: per-sensor accumulated error (psi) for both legs
154
+ plus the separation, one row per accumulation step."""
155
+ if path is None:
156
+ path = "service_life.csv"
157
+ p = Path(path)
158
+ fs = self.cfg.full_scale_psi
159
+ names = [s.name for s in self.engine.sensors]
160
+ with p.open("w", newline="") as f:
161
+ w = csv.writer(f)
162
+ header = ["time_years"]
163
+ for nm in names:
164
+ header += [f"err_psi_{nm}", f"conv_err_psi_{nm}", f"separation_psi_{nm}"]
165
+ w.writerow(header)
166
+ for i, t in enumerate(self.time_years):
167
+ row = [round(t, 4)]
168
+ for j in range(len(names)):
169
+ uq = float(self.err[i][j]) * fs / 100.0
170
+ cv = float(self.conv_err[i][j]) * fs / 100.0
171
+ row += [round(uq, 3), round(cv, 3), round(cv - uq, 3)]
172
+ w.writerow(row)
173
+ return p
gea/shell.py ADDED
@@ -0,0 +1,107 @@
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
+ """gea.shell - the desktop window (desktop extra): Wells | Reports | Geology.
5
+
6
+ Everything the window shows is the same machinery the command line uses;
7
+ the window never computes anything of its own. Wells browses the catalogue
8
+ with each entry's provenance; Reports runs the report family for a selected
9
+ well into a folder and opens the dashboard; Geology is the operator surface
10
+ from the survey track when its renderers are installed.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import sys
16
+ import webbrowser
17
+
18
+
19
+ def _require_qt() -> bool:
20
+ try:
21
+ from PyQt6 import QtWidgets # noqa: F401
22
+ return True
23
+ except ImportError:
24
+ return False
25
+
26
+
27
+ def main() -> int:
28
+ if not _require_qt():
29
+ print('gea gui needs PyQt6: pip install "gea-program[desktop]"')
30
+ return 3
31
+ from PyQt6.QtWidgets import (QApplication, QMainWindow, QTabWidget, QWidget, QVBoxLayout, QHBoxLayout,
32
+ QListWidget, QTextEdit, QPushButton, QLabel, QFileDialog, QLineEdit)
33
+ from . import __version__
34
+ from .profile_catalog import CATALOG
35
+
36
+ app = QApplication(sys.argv)
37
+ win = QMainWindow()
38
+ win.setWindowTitle(f'GEA {__version__} - downhole gauge monitoring')
39
+ tabs = QTabWidget()
40
+
41
+ # ---- Wells: the catalogue with provenance ----
42
+ wells = QWidget(); wl = QHBoxLayout(wells)
43
+ wlist = QListWidget(); wlist.addItems(sorted(CATALOG))
44
+ wdetail = QTextEdit(); wdetail.setReadOnly(True)
45
+
46
+ def show_well(item):
47
+ e = CATALOG[item.text()]
48
+ wdetail.setPlainText('\n'.join(f'{k}: {v}' for k, v in sorted(e.provenance.items())))
49
+ wlist.itemClicked.connect(show_well)
50
+ wl.addWidget(wlist, 1); wl.addWidget(wdetail, 2)
51
+ tabs.addTab(wells, 'Wells')
52
+
53
+ # ---- Reports: run the family for a well and open the dashboard ----
54
+ rep = QWidget(); rl = QVBoxLayout(rep)
55
+ info = QLabel('Production wells in the catalogue carry downhole pressure; give the well tag and the gauge station depth (ft).')
56
+ entry = QLineEdit('volve_f12_f14_production_excerpt'); entry.setPlaceholderText('catalogue entry')
57
+ tag = QLineEdit('15/9-F-12'); tag.setPlaceholderText('well tag inside the entry')
58
+ md = QLineEdit('10000'); md.setPlaceholderText('station MD, ft (from completion records)')
59
+ td = QLineEdit('10500'); td.setPlaceholderText('total depth, ft')
60
+ hist = QLineEdit(); hist.setPlaceholderText('optional: historian CSV for alarms and resilience')
61
+ pick = QPushButton('Choose historian CSV...')
62
+ run = QPushButton('Generate reports and open the dashboard')
63
+ out = QTextEdit(); out.setReadOnly(True)
64
+
65
+ def choose():
66
+ p, _ = QFileDialog.getOpenFileName(win, 'Historian CSV', '', 'CSV (*.csv)')
67
+ if p:
68
+ hist.setText(p)
69
+
70
+ def do_run():
71
+ from .__main__ import main as sub
72
+ folder = QFileDialog.getExistingDirectory(win, 'Output folder for the reports')
73
+ if not folder:
74
+ return
75
+ args = ['dashboard', '--catalog-well', f'{entry.text()}:{tag.text()}:{md.text()}', '--td', td.text(),
76
+ '--name', tag.text(), '--out', folder]
77
+ if hist.text():
78
+ args += ['--file', hist.text()]
79
+ out.setPlainText('running: gea ' + ' '.join(args)); app.processEvents()
80
+ import io, contextlib
81
+ buf = io.StringIO()
82
+ with contextlib.redirect_stdout(buf):
83
+ rc = sub(args)
84
+ out.setPlainText(buf.getvalue() + f'\nexit {rc}')
85
+ idx = os.path.join(folder, 'index.html')
86
+ if rc == 0 and os.path.exists(idx):
87
+ webbrowser.open('file://' + os.path.abspath(idx))
88
+ pick.clicked.connect(choose); run.clicked.connect(do_run)
89
+ for w in (info, entry, tag, md, td, hist, pick, run, out):
90
+ rl.addWidget(w)
91
+ tabs.addTab(rep, 'Reports')
92
+
93
+ # ---- Geology: the survey-track operator surface, when its renderers exist ----
94
+ try:
95
+ from .project import build_geology_tab
96
+ tabs.addTab(build_geology_tab(), 'Geology')
97
+ except Exception:
98
+ pass
99
+
100
+ win.setCentralWidget(tabs)
101
+ win.resize(1100, 700)
102
+ win.show()
103
+ return app.exec()
104
+
105
+
106
+ if __name__ == '__main__':
107
+ raise SystemExit(main())
gea/sla_report.py ADDED
@@ -0,0 +1,199 @@
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
+ """sla_report — the monthly SLA measurement from the program's own records
5
+ (SLA 2.0 measurement; 5.0 post-implementation; drift and staleness SLA;
6
+ 1.0 latency definitions; SOW 4.2.26 re-fit cap).
7
+
8
+ Inputs are the record files the other components already write; nothing is
9
+ typed in:
10
+ drift monitor log dir evaluations.jsonl, change_log.jsonl
11
+ store-and-forward result the Data Resilience machine JSON (+ records CSV)
12
+ alarm event log JSON lines from the alarm engine
13
+ well-test record dir approvals.jsonl
14
+ accuracy statement JSON the Accuracy Statement machine JSON
15
+ config store versions and rollbacks in the window
16
+
17
+ Every SLA line is measured / target / status; a line whose input is absent
18
+ is NOT MEASURED, never assumed met. Business-day arithmetic matches the
19
+ drift monitor's.
20
+
21
+ Headless-safe: numpy only.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import calendar
27
+ import csv
28
+ import json
29
+ import os
30
+ from datetime import datetime, timedelta, timezone
31
+ from typing import Dict, List, Optional
32
+
33
+ import numpy as np
34
+
35
+ from .sample_record import parse_utc
36
+ from .drift_monitor import business_days_after
37
+
38
+
39
+ def month_window(month: str) -> tuple:
40
+ y, m = int(month[:4]), int(month[5:7])
41
+ start = datetime(y, m, 1, tzinfo=timezone.utc)
42
+ end = datetime(y, m, calendar.monthrange(y, m)[1], 23, 59, 59, tzinfo=timezone.utc)
43
+ return start, end
44
+
45
+
46
+ def _lines(path: Optional[str]) -> List[dict]:
47
+ if not path or not os.path.exists(path):
48
+ return []
49
+ with open(path, encoding='utf-8') as f:
50
+ return [json.loads(l) for l in f if l.strip()]
51
+
52
+
53
+ def _in(ts: str, a: datetime, b: datetime) -> bool:
54
+ try:
55
+ t = parse_utc(ts)
56
+ except Exception:
57
+ return False
58
+ return a <= t <= b
59
+
60
+
61
+ def _bd_between(a: str, b: str) -> int:
62
+ ta, tb = parse_utc(a), parse_utc(b)
63
+ n, d = 0, ta
64
+ while d.date() < tb.date():
65
+ d += timedelta(days=1)
66
+ if d.weekday() < 5:
67
+ n += 1
68
+ return n
69
+
70
+
71
+ def measure(month: str, monitor_log_dir: Optional[str] = None, store_forward_json: Optional[str] = None,
72
+ alarm_log: Optional[str] = None, well_test_dir: Optional[str] = None, accuracy_json: Optional[str] = None,
73
+ config_dir: Optional[str] = None, refit_cap_per_year: int = 4) -> dict:
74
+ a, b = month_window(month)
75
+ days = (b - a).days + 1
76
+ lines: List[dict] = []
77
+
78
+ def line(area, metric, measured, target, status, note=''):
79
+ lines.append({'area': area, 'metric': metric, 'measured': measured, 'target': target, 'status': status, 'note': note})
80
+
81
+ # --- drift and staleness SLA ------------------------------------------------------
82
+ evs = [e for e in _lines(os.path.join(monitor_log_dir, 'evaluations.jsonl')) if _in(e['timestamp_utc'], a, b)] if monitor_log_dir else []
83
+ chg = [e for e in _lines(os.path.join(monitor_log_dir, 'change_log.jsonl'))] if monitor_log_dir else []
84
+ if monitor_log_dir and (evs or chg):
85
+ eval_days = {parse_utc(e['timestamp_utc']).date() for e in evs}
86
+ first_eval = min((parse_utc(e['timestamp_utc']) for e in evs), default=None)
87
+ # adherence counted from the first evaluation in the month (a site commissioned mid-month is not penalised for the days before)
88
+ days_due = (b.date() - first_eval.date()).days + 1 if first_eval else days
89
+ adherence = round(100.0 * len(eval_days) / days_due, 1) if days_due else 0.0
90
+ line('Model drift', 'Drift evaluation cadence (days with an evaluation / days due)', f'{len(eval_days)} / {days_due} ({adherence} %)',
91
+ '>= 1 evaluation every 24 h', 'MET' if adherence >= 100.0 else 'NOT MET')
92
+ detections = sorted({e['evaluation_id'] for e in evs if e.get('drift_detected')})
93
+ line('Model drift', 'Evaluations with drift detected', f'{len(detections)} of {len({e["evaluation_id"] for e in evs})}', 'reported', 'REPORTED')
94
+ proposals = [c for c in chg if c.get('status') == 'PROPOSED' and _in(c['timestamp_utc'], a, b)]
95
+ decided = [c for c in chg if c.get('status') in ('APPLIED', 'REJECTED', 'BLOCKED_ANNUAL_LIMIT') and c.get('decided_utc') and _in(c['decided_utc'], a, b)]
96
+ refits = [c for c in decided if c.get('type') == 'REFIT_OFFSET' and c['status'] == 'APPLIED']
97
+ late = [c for c in refits if _bd_between(c['timestamp_utc'], c['decided_utc']) > 10]
98
+ line('Model drift', 'Re-fit / redeploy within 10 business days of detection', f'{len(refits) - len(late)} of {len(refits)} within',
99
+ '<= 10 business days', ('MET' if not late else 'NOT MET') if refits else 'NOT MEASURED', 'no re-fit in the month' if not refits else '')
100
+ fall = [c for c in chg if c.get('type') == 'FALLBACK' and c.get('decided_utc') and _in(c['decided_utc'], a, b)]
101
+ late_f = [c for c in fall if _bd_between(c['timestamp_utc'], c['decided_utc']) > 2]
102
+ line('Model drift', 'Fallback decided within 2 business days', f'{len(fall) - len(late_f)} of {len(fall)} within', '<= 2 business days',
103
+ ('MET' if not late_f else 'NOT MET') if fall else 'NOT MEASURED', 'no fallback in the month' if not fall else '')
104
+ year_refits: Dict[str, int] = {}
105
+ for c in chg:
106
+ if c.get('type') == 'REFIT_OFFSET' and c.get('status') == 'APPLIED' and c.get('decided_utc') and parse_utc(c['decided_utc']).year == a.year:
107
+ year_refits[c['station']] = year_refits.get(c['station'], 0) + 1
108
+ worst = max(year_refits.values()) if year_refits else 0
109
+ line('Model drift', f'Re-fits per station in {a.year} (year to date)', f'max {worst}', f'<= {refit_cap_per_year} per year', 'MET' if worst <= refit_cap_per_year else 'NOT MET')
110
+ open_props = [c for c in proposals if not any(d.get('supersedes_entry_id') == c.get('entry_id') for d in chg)]
111
+ line('Model drift', 'Proposals awaiting decision at month end', str(len(open_props)), '0', 'MET' if not open_props else 'ATTENTION')
112
+ else:
113
+ line('Model drift', 'Drift evaluation cadence', '-', '>= 1 evaluation every 24 h', 'NOT MEASURED', 'no monitor log supplied')
114
+
115
+ # --- latency and remote-site availability ----------------------------------------------
116
+ if store_forward_json and os.path.exists(store_forward_json):
117
+ sim = json.load(open(store_forward_json, encoding='utf-8')).get('simulation', {})
118
+ gr = sim.get('gap_report', {})
119
+ rec_csv = store_forward_json.replace('.json', '_records.csv')
120
+ lat = []
121
+ if os.path.exists(rec_csv):
122
+ with open(rec_csv, newline='', encoding='utf-8') as f:
123
+ for r in csv.DictReader(f):
124
+ if r.get('ingest_timestamp_utc') and _in(r['timestamp_utc'], a, b):
125
+ lat.append((parse_utc(r['ingest_timestamp_utc']) - parse_utc(r['timestamp_utc'])).total_seconds())
126
+ lat = np.array(lat, dtype=float)
127
+ if len(lat):
128
+ p95, p99 = float(np.percentile(lat, 95)), float(np.percentile(lat, 99))
129
+ w2, w5 = 100.0 * float(np.mean(lat <= 120)), 100.0 * float(np.mean(lat <= 300))
130
+ line('Latency', 'End-to-end latency p95 (field edge to OT lake)', f'{p95:.0f} s', '<= 120 s (95 %)', 'MET' if p95 <= 120 else 'NOT MET',
131
+ 'includes outage replay; SLA 7.0 exclusion applies to documented telecom outages')
132
+ line('Latency', 'End-to-end latency p99', f'{p99:.0f} s', '<= 300 s (99 %)', 'MET' if p99 <= 300 else 'NOT MET')
133
+ line('Latency', 'Records within 2 min / within 5 min', f'{w2:.2f} % / {w5:.2f} %', '95 % / 99 %', 'MET' if (w2 >= 95 and w5 >= 99) else 'NOT MET')
134
+ outs = sim.get('outages', [])
135
+ tot_h = sum(o['duration_h'] for o in outs)
136
+ hours = days * 24.0
137
+ avail = 100.0 * (1 - tot_h / hours)
138
+ line('Remote site', 'Link availability (outage hours / month hours)', f'{avail:.3f} % ({tot_h:.2f} h down)', '>= 99 %', 'MET' if avail >= 99.0 else 'NOT MET')
139
+ st = sim.get('stats', {})
140
+ line('Remote site', 'Store-and-forward: samples lost over capacity', str(st.get('dropped_over_capacity', 0)), '0 (72 h buffer)', 'MET' if not st.get('dropped_over_capacity') else 'NOT MET')
141
+ dup = sum(v.get('duplicates_in_delivery', 0) for v in gr.values())
142
+ line('Remote site', 'Duplicates in delivered stream', str(dup), '0', 'MET' if dup == 0 else 'NOT MET')
143
+ line('Remote site', 'Chronological replay', 'verified' if all(v.get('chronological_replay') for v in gr.values()) else 'VIOLATED', 'required',
144
+ 'MET' if all(v.get('chronological_replay') for v in gr.values()) else 'NOT MET')
145
+ else:
146
+ line('Latency', 'End-to-end latency', '-', '95 % <= 2 min; 99 % <= 5 min', 'NOT MEASURED', 'no store-and-forward record supplied')
147
+ line('Remote site', 'Link availability', '-', '>= 99 %', 'NOT MEASURED')
148
+
149
+ # --- alarms ---------------------------------------------------------------------------------
150
+ al = [e for e in _lines(alarm_log) if _in(e['timestamp_utc'], a, b)] if alarm_log else []
151
+ if al:
152
+ acts = [e for e in al if e['event'] == 'ACTIVATED']
153
+ acked = [e for e in al if e['event'] == 'ACKNOWLEDGED']
154
+ rtn_un = [e for e in al if e['event'] == 'RTN_UNACKED']
155
+ line('Alarms', 'Activations in the month', str(len(acts)), 'reported', 'REPORTED')
156
+ line('Alarms', 'Alarms that returned to normal unacknowledged', f'{len(rtn_un)} of {len(acts)}', '0', 'MET' if not rtn_un else 'ATTENTION')
157
+ p1 = [e for e in acts if e['priority'] == 'P1']
158
+ line('Alarms', 'P1 activations', str(len(p1)), 'each with a documented response', 'REPORTED')
159
+ else:
160
+ line('Alarms', 'Activations in the month', '-', 'reported', 'NOT MEASURED', 'no alarm event log supplied')
161
+
162
+ # --- well tests ------------------------------------------------------------------------------
163
+ ap = [e for e in _lines(os.path.join(well_test_dir, 'approvals.jsonl')) if _in(e['timestamp_utc'], a, b)] if well_test_dir else []
164
+ if ap:
165
+ tests = {e['test_id'] for e in ap}
166
+ line('Well tests', 'Well tests with approval activity', str(len(tests)), 'reported', 'REPORTED')
167
+ line('Well tests', 'Approval decisions recorded', str(len(ap)), 'timestamped, named', 'MET' if all(e.get('approver') for e in ap) else 'NOT MET')
168
+ else:
169
+ line('Well tests', 'Well tests approved', '-', 'reported', 'NOT MEASURED', 'no approval trail supplied')
170
+
171
+ # --- accuracy --------------------------------------------------------------------------------
172
+ if accuracy_json and os.path.exists(accuracy_json):
173
+ bt = json.load(open(accuracy_json, encoding='utf-8')).get('backtest', {})
174
+ bands = bt.get('bands', {})
175
+ n_ok = bt.get('n_ok', 0)
176
+ line('Accuracy', 'Quantities meeting >= 95 % at the conservative 90 % CI end', f"{bands.get('MEETS_TARGET', 0)} of {n_ok}", 'all claimed quantities >= 95 %',
177
+ 'MET' if bands.get('MEETS_TARGET', 0) == n_ok else 'PARTIAL', 'quantities below target are excluded from any claim')
178
+ line('Accuracy', 'Quantities NOT ACCEPTABLE (< 85 %)', str(bands.get('NOT_ACCEPTABLE', 0)), '0 among claimed quantities', 'REPORTED')
179
+ else:
180
+ line('Accuracy', 'Accuracy statement', '-', '>= 95 % at 90 % CI', 'NOT MEASURED', 'no accuracy statement supplied')
181
+
182
+ # --- change and continuity ------------------------------------------------------------------------
183
+ if config_dir and os.path.isdir(config_dir):
184
+ from .config_versioning import ConfigStore
185
+ cs = ConfigStore(config_dir)
186
+ changes = [e for n in cs.names() for e in cs.history(n) if _in(e['timestamp_utc'], a, b)]
187
+ line('Change & continuity', 'Configuration versions committed in the month', str(len(changes)), 'every change versioned with author and diff',
188
+ 'MET' if all(e.get('author') for e in changes) else 'NOT MET')
189
+ line('Change & continuity', 'Rollbacks in the month', str(sum(1 for e in changes if e.get('rollback_of'))), 'reported', 'REPORTED')
190
+ else:
191
+ line('Change & continuity', 'Configuration versions', '-', 'every change versioned', 'NOT MEASURED', 'no config store supplied')
192
+
193
+ status_counts: Dict[str, int] = {}
194
+ for l in lines:
195
+ status_counts[l['status']] = status_counts.get(l['status'], 0) + 1
196
+ return {'month': month, 'window': [a.strftime('%Y-%m-%dT%H:%M:%SZ'), b.strftime('%Y-%m-%dT%H:%M:%SZ')], 'lines': lines,
197
+ 'status_counts': status_counts,
198
+ 'inputs': {'monitor_log_dir': monitor_log_dir, 'store_forward_json': store_forward_json, 'alarm_log': alarm_log,
199
+ 'well_test_dir': well_test_dir, 'accuracy_json': accuracy_json, 'config_dir': config_dir}}