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
@@ -0,0 +1,133 @@
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
+ """config_versioning — version-controlled configuration with export, import
5
+ and rollback (SOW 4.2.1.4 change and continuity; 4.2.24 audit trail).
6
+
7
+ Every configuration the program runs on - the well-test criteria file, the
8
+ alarm definitions, the tag catalogue, the drift-monitor thresholds - is
9
+ committed here as a numbered version with author, note, timestamp, sha256
10
+ and a key-level diff against the previous version. Nothing is ever deleted:
11
+ a rollback is a new version whose content equals an older one, recorded as
12
+ such. `export` writes any version to a file; `import_file` commits a file.
13
+
14
+ Store layout (one directory):
15
+
16
+ <dir>/<name>/v0001.json the content
17
+ <dir>/<name>/history.jsonl one line per version: meta + diff summary
18
+
19
+ Headless-safe: standard library only.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import os
27
+ from datetime import datetime, timezone
28
+ from typing import Any, Dict, List, Optional
29
+
30
+
31
+ def _iso(dt: Optional[datetime] = None) -> str:
32
+ dt = dt or datetime.now(timezone.utc)
33
+ return dt.strftime('%Y-%m-%dT%H:%M:%SZ')
34
+
35
+
36
+ def _sha(obj: Any) -> str:
37
+ return hashlib.sha256(json.dumps(obj, sort_keys=True, default=str).encode('utf-8')).hexdigest()[:16]
38
+
39
+
40
+ def _flatten(obj: Any, prefix: str = '') -> Dict[str, Any]:
41
+ out: Dict[str, Any] = {}
42
+ if isinstance(obj, dict):
43
+ for k, v in obj.items():
44
+ out.update(_flatten(v, f'{prefix}{k}.'))
45
+ elif isinstance(obj, list):
46
+ for i, v in enumerate(obj):
47
+ out.update(_flatten(v, f'{prefix}[{i}].'))
48
+ else:
49
+ out[prefix[:-1]] = obj
50
+ return out
51
+
52
+
53
+ def diff(old: Any, new: Any) -> dict:
54
+ a, b = _flatten(old), _flatten(new)
55
+ added = sorted(k for k in b if k not in a)
56
+ removed = sorted(k for k in a if k not in b)
57
+ changed = sorted(k for k in a if k in b and a[k] != b[k])
58
+ return {'added': added, 'removed': removed,
59
+ 'changed': [{'key': k, 'before': a[k], 'after': b[k]} for k in changed],
60
+ 'n_changes': len(added) + len(removed) + len(changed)}
61
+
62
+
63
+ class ConfigStore:
64
+ def __init__(self, root: str):
65
+ self.root = str(root)
66
+ os.makedirs(self.root, exist_ok=True)
67
+
68
+ def _dir(self, name: str) -> str:
69
+ d = os.path.join(self.root, name)
70
+ os.makedirs(d, exist_ok=True)
71
+ return d
72
+
73
+ def history(self, name: str) -> List[dict]:
74
+ p = os.path.join(self._dir(name), 'history.jsonl')
75
+ if not os.path.exists(p):
76
+ return []
77
+ with open(p, encoding='utf-8') as f:
78
+ return [json.loads(l) for l in f if l.strip()]
79
+
80
+ def latest_version(self, name: str) -> int:
81
+ h = self.history(name)
82
+ return h[-1]['version'] if h else 0
83
+
84
+ def get(self, name: str, version: Optional[int] = None) -> Any:
85
+ v = version or self.latest_version(name)
86
+ if v == 0:
87
+ raise KeyError(f'no versions of {name}')
88
+ with open(os.path.join(self._dir(name), f'v{v:04d}.json'), encoding='utf-8') as f:
89
+ return json.load(f)
90
+
91
+ def commit(self, name: str, content: Any, author: str, note: str = '', now: Optional[datetime] = None,
92
+ rollback_of: Optional[int] = None) -> dict:
93
+ prev_v = self.latest_version(name)
94
+ prev = self.get(name, prev_v) if prev_v else None
95
+ if prev is not None and _sha(prev) == _sha(content) and rollback_of is None:
96
+ return {'name': name, 'version': prev_v, 'unchanged': True, 'sha256': _sha(content)}
97
+ v = prev_v + 1
98
+ with open(os.path.join(self._dir(name), f'v{v:04d}.json'), 'w', encoding='utf-8') as f:
99
+ json.dump(content, f, indent=1, sort_keys=True, default=str)
100
+ meta = {'name': name, 'version': v, 'timestamp_utc': _iso(now), 'author': author, 'note': note,
101
+ 'sha256': _sha(content), 'previous_version': prev_v or None,
102
+ 'diff': diff(prev, content) if prev is not None else {'added': sorted(_flatten(content)), 'removed': [], 'changed': [], 'n_changes': len(_flatten(content))},
103
+ 'rollback_of': rollback_of}
104
+ with open(os.path.join(self._dir(name), 'history.jsonl'), 'a', encoding='utf-8') as f:
105
+ f.write(json.dumps(meta, default=str) + '\n')
106
+ return meta
107
+
108
+ def import_file(self, name: str, path: str, author: str, note: str = '', now: Optional[datetime] = None) -> dict:
109
+ with open(path, encoding='utf-8') as f:
110
+ content = json.load(f)
111
+ return self.commit(name, content, author, note or f'imported from {os.path.basename(path)}', now=now)
112
+
113
+ def export(self, name: str, path: str, version: Optional[int] = None) -> str:
114
+ with open(path, 'w', encoding='utf-8') as f:
115
+ json.dump(self.get(name, version), f, indent=1, sort_keys=True)
116
+ return path
117
+
118
+ def rollback(self, name: str, to_version: int, author: str, note: str = '', now: Optional[datetime] = None) -> dict:
119
+ target = self.get(name, to_version)
120
+ return self.commit(name, target, author, note or f'rollback to v{to_version:04d}', now=now, rollback_of=to_version)
121
+
122
+ def names(self) -> List[str]:
123
+ return sorted(d for d in os.listdir(self.root) if os.path.isdir(os.path.join(self.root, d)))
124
+
125
+ def summary(self) -> List[dict]:
126
+ out = []
127
+ for n in self.names():
128
+ h = self.history(n)
129
+ if h:
130
+ out.append({'name': n, 'versions': len(h), 'latest': h[-1]['version'], 'sha256': h[-1]['sha256'],
131
+ 'last_change_utc': h[-1]['timestamp_utc'], 'last_author': h[-1]['author'],
132
+ 'rollbacks': sum(1 for e in h if e.get('rollback_of'))})
133
+ return out
gea/correlation.py ADDED
@@ -0,0 +1,155 @@
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
+ """correlation - Part 4 of the subsurface surveying tool: WELL-TO-WELL
5
+ CORRELATION (v1.81.0), with the honest finding stated up front.
6
+
7
+ THE FINDING (2026-08-29, and re-verified on every run): with 29 globally
8
+ scattered sites, the library currently supports ZERO cross-site depth-frame
9
+ correlations - every candidate pair (CaCO3 Barbados x Walvis, thermal
10
+ conductivity Hikurangi x Prydz, friction coefficient Barbados x Costa Rica)
11
+ has overlapping depth RANGES but too few coinciding SAMPLES (max 3 common
12
+ bins against a floor of 5). The engine reports that refusal census instead
13
+ of manufacturing correlations, and the machinery stands ready for the data
14
+ that will change the answer (co-located suites, twin holes, denser columns).
15
+
16
+ WHERE THE REAL STRUCTURE LIVES - THE TIME FRAME: four sites carry archive
17
+ age models, and one (the ACEX Lomonosov Ridge age-depth model, 0-55,904 ka)
18
+ is the library's MASTER CHRONOLOGY, whose window overlaps every other
19
+ age-bearing site (Bengal 0.5-193.5 ka; EPICA 611-799 ka; Fram 12,050-17,370
20
+ ka). Time is the Earth Model's fourth axis, and this module registers it.
21
+
22
+ DISTANCE HONESTY (doctrine): a statistical correlation between sites more
23
+ than CONTINUITY_KM apart is NEVER presented as geological continuity -
24
+ the continuity_claim field says 'NONE' with the distance printed. Only
25
+ twin holes (< CONTINUITY_KM) may claim physical continuity, and none
26
+ currently qualify with shared properties.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import collections
32
+ import math
33
+ import re
34
+ import statistics
35
+ from typing import Dict, List
36
+
37
+ from .earth_model import EarthModel, haversine_km
38
+
39
+ CONTINUITY_KM = 5.0 # beyond this, correlation is statistics, not strata
40
+ MIN_COMMON_BINS = 5 # below this, the pair REFUSES (disclosed)
41
+ TARGET_BINS = 8 # default overlap resolution (disclosed)
42
+
43
+ _AGE_WORD = re.compile(r'\bage\b', re.IGNORECASE)
44
+
45
+
46
+ def _family(prop: str) -> str:
47
+ return prop.split(' [')[0].split(' (')[0].strip().lower()
48
+
49
+
50
+ def time_frame(em: EarthModel = None) -> Dict:
51
+ """The library's fourth axis: every archive age model, its window, the
52
+ pairwise epoch overlaps, and the master chronology (the site whose
53
+ window overlaps the most others)."""
54
+ em = em or EarthModel()
55
+ ages = []
56
+ for s in em.sites.values():
57
+ for r in s.records:
58
+ if _AGE_WORD.search(r.property) and 'ka' in (r.unit or '').lower():
59
+ vals = [v for v in r.values if v == v]
60
+ if vals:
61
+ ages.append({'site': s.key, 'entry': r.entry,
62
+ 'property': r.property, 'unit': r.unit,
63
+ 'age_min_ka': min(vals), 'age_max_ka': max(vals)})
64
+ overlaps = []
65
+ for i in range(len(ages)):
66
+ for j in range(i + 1, len(ages)):
67
+ a, b = ages[i], ages[j]
68
+ if a['site'] == b['site']:
69
+ continue
70
+ lo = max(a['age_min_ka'], b['age_min_ka'])
71
+ hi = min(a['age_max_ka'], b['age_max_ka'])
72
+ if hi > lo:
73
+ overlaps.append({'a': a['site'], 'b': b['site'],
74
+ 'epoch_overlap_ka': (lo, hi),
75
+ 'span_ka': round(hi - lo, 3)})
76
+ counts = collections.Counter()
77
+ for o in overlaps:
78
+ counts[o['a']] += 1
79
+ counts[o['b']] += 1
80
+ master = counts.most_common(1)[0] if counts else (None, 0)
81
+ return {'age_bearing_records': ages, 'n_sites': len({a['site'] for a in ages}),
82
+ 'epoch_overlaps': overlaps,
83
+ 'master_chronology': {'site': master[0], 'overlaps_others': master[1]}}
84
+
85
+
86
+ def depth_frame_pairs(em: EarthModel = None, target_bins: int = TARGET_BINS,
87
+ min_common: int = MIN_COMMON_BINS) -> Dict:
88
+ """Every cross-site same-property pair with overlapping z_ref: correlated
89
+ where the samples support it, REFUSED with counts where they do not -
90
+ and today they do not, anywhere. Nothing is interpolated between
91
+ archives; bins carry only real samples."""
92
+ em = em or EarthModel()
93
+ recs = []
94
+ for s in em.sites.values():
95
+ for r in s.records:
96
+ if r.z_ref_m and r.values:
97
+ pts = [(z, v) for z, v in zip(r.z_ref_m, r.values) if v == v]
98
+ if len(pts) >= 5:
99
+ recs.append((s, r, pts))
100
+ fams = collections.defaultdict(list)
101
+ for s, r, pts in recs:
102
+ fams[_family(r.property)].append((s, r, pts))
103
+ ok, refused = [], []
104
+ for fname, group in fams.items():
105
+ for i in range(len(group)):
106
+ for j in range(i + 1, len(group)):
107
+ sa, ra, pa = group[i]
108
+ sb, rb, pb = group[j]
109
+ if sa.key == sb.key:
110
+ continue
111
+ lo = max(min(z for z, _ in pa), min(z for z, _ in pb))
112
+ hi = min(max(z for z, _ in pa), max(z for z, _ in pb))
113
+ if hi <= lo:
114
+ continue
115
+ w = (hi - lo) / target_bins
116
+ def _bins(pts):
117
+ b = collections.defaultdict(list)
118
+ for z, v in pts:
119
+ if lo <= z <= hi:
120
+ b[int((z - lo) // w)].append(v)
121
+ return {k: sum(v) / len(v) for k, v in b.items()}
122
+ ba, bb = _bins(pa), _bins(pb)
123
+ common = sorted(set(ba) & set(bb))
124
+ dist = haversine_km(sa.latitude, sa.longitude,
125
+ sb.latitude, sb.longitude)
126
+ base = {'property_family': fname, 'a': sa.key, 'b': sb.key,
127
+ 'entries': (ra.entry, rb.entry),
128
+ 'z_overlap_m': round(hi - lo, 1),
129
+ 'common_bins': len(common), 'target_bins': target_bins,
130
+ 'distance_km': round(dist, 1),
131
+ 'continuity_claim': (
132
+ 'TWIN_HOLE_ELIGIBLE' if dist < CONTINUITY_KM else
133
+ 'NONE - statistical comparison only (%.0f km '
134
+ 'exceeds the %.0f km continuity threshold)'
135
+ % (dist, CONTINUITY_KM))}
136
+ if len(common) < min_common:
137
+ base['status'] = 'REFUSED_THIN_DATA (min %d)' % min_common
138
+ refused.append(base)
139
+ continue
140
+ xa = [ba[k] for k in common]
141
+ xb = [bb[k] for k in common]
142
+ ma, mb = statistics.mean(xa), statistics.mean(xb)
143
+ den = math.sqrt(sum((x - ma) ** 2 for x in xa)
144
+ * (sum((y - mb) ** 2 for y in xb)))
145
+ base['status'] = 'OK'
146
+ base['pearson_r'] = ((sum((x - ma) * (y - mb)
147
+ for x, y in zip(xa, xb)) / den)
148
+ if den else float('nan'))
149
+ ok.append(base)
150
+ return {'ok': ok, 'refused': refused, 'n_ok': len(ok),
151
+ 'n_refused': len(refused),
152
+ 'finding': ('the library currently supports %d cross-site '
153
+ 'depth-frame correlations; %d candidate pairs '
154
+ 'refused thin - the honest census, not a failure'
155
+ % (len(ok), len(refused)))}