RFTSystems commited on
Commit
28358f3
·
verified ·
1 Parent(s): 711443e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +554 -107
app.py CHANGED
@@ -1,10 +1,18 @@
1
-
2
  # ===============================================================
3
  # Rendered Frame Theory — Live Prediction Console (Open Method)
4
  # Domains: Atmospheric / Seismic / Magnetic / Solar
 
5
  # ===============================================================
6
 
7
  import math
 
 
 
 
 
 
 
 
8
  from datetime import datetime, timezone, timedelta
9
 
10
  import gradio as gr
@@ -13,8 +21,10 @@ import numpy as np
13
  import pandas as pd
14
 
15
  APP_NAME = "Rendered Frame Theory — Live Prediction Console (Open Method)"
 
16
  UA = {"User-Agent": "RFTSystems/LivePredictionConsole"}
17
 
 
18
  T_EARTH = 365.2422 * 24 * 3600.0
19
  OMEGA_OBS = 2.0 * math.pi / T_EARTH
20
  K_TAU = 1.38
@@ -33,8 +43,13 @@ RING_OF_FIRE_BBOXES = [
33
  ]
34
 
35
 
 
 
 
 
 
36
  def utc_now_iso() -> str:
37
- return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
38
 
39
 
40
  def clamp(x: float, a: float, b: float) -> float:
@@ -56,15 +71,124 @@ def index_from_tau(tau: float) -> float:
56
  return float(OMEGA_OBS * float(tau) * ALPHA_R)
57
 
58
 
59
- def geocode_location(q: str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  q = (q or "").strip()
61
  if not q:
62
  return None, None, "Empty location"
63
  url = "https://geocoding-api.open-meteo.com/v1/search"
64
  params = {"name": q, "count": 1, "language": "en", "format": "json"}
65
- r = httpx.get(url, params=params, headers=UA, timeout=12)
66
- r.raise_for_status()
67
- js = r.json()
68
  results = js.get("results") or []
69
  if not results:
70
  return None, None, f"Could not geocode '{q}'"
@@ -75,7 +199,7 @@ def geocode_location(q: str):
75
  return lat, lon, display
76
 
77
 
78
- def fetch_openmeteo_hourly(lat: float, lon: float, past_days: int = 1):
79
  url = "https://api.open-meteo.com/v1/forecast"
80
  params = {
81
  "latitude": lat,
@@ -85,9 +209,7 @@ def fetch_openmeteo_hourly(lat: float, lon: float, past_days: int = 1):
85
  "forecast_days": 1,
86
  "timezone": "UTC",
87
  }
88
- r = httpx.get(url, params=params, headers=UA, timeout=18)
89
- r.raise_for_status()
90
- js = r.json()
91
  hourly = js.get("hourly") or {}
92
  return {
93
  "time": hourly.get("time") or [],
@@ -95,14 +217,13 @@ def fetch_openmeteo_hourly(lat: float, lon: float, past_days: int = 1):
95
  "rh": hourly.get("relative_humidity_2m") or [],
96
  "p": hourly.get("pressure_msl") or [],
97
  "wind": hourly.get("wind_speed_10m") or [],
 
98
  }
99
 
100
 
101
- def fetch_kp_last_24h():
102
  url = "https://services.swpc.noaa.gov/json/planetary_k_index_1m.json"
103
- r = httpx.get(url, headers=UA, timeout=15)
104
- r.raise_for_status()
105
- js = r.json()
106
  if not isinstance(js, list) or not js:
107
  return []
108
  vals = []
@@ -117,11 +238,9 @@ def fetch_kp_last_24h():
117
  return vals[-1440:]
118
 
119
 
120
- def fetch_goes_xray_1day():
121
  url = "https://services.swpc.noaa.gov/json/goes/primary/xrays-1-day.json"
122
- r = httpx.get(url, headers=UA, timeout=15)
123
- r.raise_for_status()
124
- js = r.json()
125
  if not isinstance(js, list) or not js:
126
  return []
127
  out = []
@@ -136,14 +255,25 @@ def fetch_goes_xray_1day():
136
  return out
137
 
138
 
139
- def fetch_usgs_quakes(hours: int, minmag: float, bbox=None, center=None, radius_km=None):
 
 
 
 
 
 
 
 
140
  url = "https://earthquake.usgs.gov/fdsnws/event/1/query"
141
- end = datetime.now(timezone.utc)
142
  start = end - timedelta(hours=int(hours))
143
- params = {
 
 
 
144
  "format": "geojson",
145
- "starttime": start.isoformat().replace("+00:00", "Z"),
146
- "endtime": end.isoformat().replace("+00:00", "Z"),
147
  "minmagnitude": str(float(minmag)),
148
  "orderby": "time",
149
  }
@@ -169,12 +299,11 @@ def fetch_usgs_quakes(hours: int, minmag: float, bbox=None, center=None, radius_
169
  }
170
  )
171
 
172
- r = httpx.get(url, params=params, headers=UA, timeout=22)
173
- r.raise_for_status()
174
- js = r.json()
175
  feats = js.get("features") if isinstance(js, dict) else None
176
  if not feats:
177
- return []
 
178
  out = []
179
  for f in feats:
180
  props = f.get("properties") or {}
@@ -186,10 +315,18 @@ def fetch_usgs_quakes(hours: int, minmag: float, bbox=None, center=None, radius_
186
  "time": props.get("time"),
187
  }
188
  )
189
- return out
190
-
191
-
192
- def build_verification_links(lat: float, lon: float, seismic_mode: str, seismic_region: str, radius_km: float) -> str:
 
 
 
 
 
 
 
 
193
  swpc_kp_page = "https://www.swpc.noaa.gov/products/planetary-k-index"
194
  swpc_kp_json = "https://services.swpc.noaa.gov/json/planetary_k_index_1m.json"
195
 
@@ -203,16 +340,26 @@ def build_verification_links(lat: float, lon: float, seismic_mode: str, seismic_
203
  )
204
 
205
  usgs_map = "https://earthquake.usgs.gov/earthquakes/map/"
206
- if seismic_mode == "Local radius":
207
- usgs_query = (
208
- "https://earthquake.usgs.gov/fdsnws/event/1/query"
209
- f"?format=geojson&starttime=&endtime=&minmagnitude=2.5"
210
- f"&latitude={lat:.5f}&longitude={lon:.5f}&maxradiuskm={float(radius_km):.0f}"
211
- )
212
- scope = f"Local radius query ({int(radius_km)} km around your location)"
213
- else:
214
- scope = f"Region mode ({seismic_region})"
215
- usgs_query = "https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&starttime=&endtime=&minmagnitude=2.5"
 
 
 
 
 
 
 
 
 
 
216
 
217
  return (
218
  "### Verify instantly (official sources)\n"
@@ -227,8 +374,9 @@ def build_verification_links(lat: float, lon: float, seismic_mode: str, seismic_
227
  )
228
 
229
 
230
- def magnetic_agent():
231
- kp = fetch_kp_last_24h()
 
232
  if len(kp) < 30:
233
  return {"enabled": False, "reason": "NOAA Kp feed too short"}
234
  last = float(kp[-1])
@@ -273,8 +421,8 @@ def magnetic_agent():
273
  }
274
 
275
 
276
- def solar_agent():
277
- flux = fetch_goes_xray_1day()
278
  if len(flux) < 50:
279
  return {"enabled": False, "reason": "GOES X-ray feed too short"}
280
  tail = flux[-120:] if len(flux) >= 120 else flux[-60:]
@@ -319,8 +467,8 @@ def solar_agent():
319
  }
320
 
321
 
322
- def atmospheric_agent(lat: float, lon: float, display: str):
323
- wx = fetch_openmeteo_hourly(lat, lon, past_days=1)
324
  temp = wx["temp"]
325
  p = wx["p"]
326
  wind = wx["wind"]
@@ -386,29 +534,34 @@ def atmospheric_agent(lat: float, lon: float, display: str):
386
  }
387
 
388
 
389
- def seismic_agent_region(region: str):
390
  if region == "RingOfFire":
391
  seen = set()
392
  eqs = []
 
393
  for bb in RING_OF_FIRE_BBOXES:
394
- chunk = fetch_usgs_quakes(hours=24, minmag=2.5, bbox=bb)
395
- for e in chunk:
 
396
  eid = e.get("id")
397
  if eid and eid not in seen:
398
  seen.add(eid)
399
  eqs.append(e)
 
400
  else:
401
  bbox = REGION_BBOX.get(region, None)
402
- eqs = fetch_usgs_quakes(hours=24, minmag=2.5, bbox=bbox)
403
- return eqs, f"Region={region}"
 
 
404
 
405
 
406
- def seismic_agent_local(lat: float, lon: float, radius_km: float):
407
- eqs = fetch_usgs_quakes(hours=24, minmag=2.5, center=(lat, lon), radius_km=radius_km)
408
- return eqs, f"Local radius={int(radius_km)}km"
409
 
410
 
411
- def seismic_score(eqs):
412
  N = int(len(eqs))
413
  mags = []
414
  for e in eqs:
@@ -443,15 +596,15 @@ def seismic_score(eqs):
443
  return pred, rule, z, tau, idx, N, Mmax
444
 
445
 
446
- def seismic_agent(mode: str, region: str, lat: float, lon: float, radius_km: float):
447
  if mode == "Local radius":
448
- eqs, scope = seismic_agent_local(lat, lon, radius_km)
449
  location_effect = "Location changes Seismic in Local radius mode."
450
  do = "Use to monitor seismic activity within the selected radius around your typed location."
451
  dont = "Do not treat as time/epicenter prediction."
452
  truth_scope = f"USGS events within {int(radius_km)} km"
453
  else:
454
- eqs, scope = seismic_agent_region(region)
455
  location_effect = "Location does not change Seismic in Region mode. Region selector does."
456
  do = "Use as a regional seismic stress monitor."
457
  dont = "Do not treat as time/epicenter prediction."
@@ -472,50 +625,130 @@ def seismic_agent(mode: str, region: str, lat: float, lon: float, radius_km: flo
472
  "index": float(idx),
473
  "live_status": live,
474
  "truth_source": f"USGS FDSN event feed ({truth_scope})",
475
- "inputs_used": {"count_24h": N, "max_mag_24h": Mmax, "mode": mode, "region": region, "radius_km": float(radius_km), "lat": float(lat), "lon": float(lon)},
 
 
 
 
 
 
 
 
476
  "location_effect": location_effect,
477
  "do": do,
478
  "dont": dont,
479
  "what_it_is_not": "Not an earthquake time predictor. Not a rupture location predictor.",
480
  "why": "z_seis compresses activity density and severity into a bounded stress coordinate; τ_eff rises as ln(1+z).",
481
  "how": "Fetch USGS → count + max magnitude → z_seis → τ_eff → Index → label via fixed thresholds.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  }
483
 
484
 
485
- def run_forecast(location_text: str, seismic_mode: str, seismic_region: str, radius_km: float):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  try:
487
- lat, lon, display = geocode_location(location_text)
488
  except Exception as e:
489
  df = pd.DataFrame([{"Domain": "Error", "RFT Prediction": "DISABLED", "Live Status": f"Geocode error: {e}"}])
490
  empty = {"enabled": False, "reason": f"Geocode error: {e}"}
491
- return f"❌ Geocode error: {e}", df, "", empty, empty, empty, empty
 
 
492
 
493
  if lat is None:
494
  df = pd.DataFrame([{"Domain": "Error", "RFT Prediction": "DISABLED", "Live Status": display}])
495
  empty = {"enabled": False, "reason": display}
496
- return f" {display}", df, "", empty, empty, empty, empty
 
 
497
 
 
498
  try:
499
- atm = atmospheric_agent(lat, lon, display)
500
  except Exception as e:
501
  atm = {"enabled": False, "reason": f"atmos error: {e}"}
502
 
503
  try:
504
- sei = seismic_agent(seismic_mode, seismic_region, lat, lon, radius_km)
505
  except Exception as e:
506
  sei = {"enabled": False, "reason": f"seismic error: {e}"}
507
 
508
  try:
509
- mag = magnetic_agent()
510
  except Exception as e:
511
  mag = {"enabled": False, "reason": f"magnetic error: {e}"}
512
 
513
  try:
514
- sol = solar_agent()
515
  except Exception as e:
516
  sol = {"enabled": False, "reason": f"solar error: {e}"}
517
 
518
- def fmt_row(domain: str, out: dict):
519
  if not out.get("enabled"):
520
  return {"Domain": domain, "RFT Prediction": "DISABLED", "Live Status": out.get("reason", "missing inputs")}
521
  idx = out.get("index", None)
@@ -539,13 +772,221 @@ def run_forecast(location_text: str, seismic_mode: str, seismic_region: str, rad
539
  ]
540
  )
541
 
542
- ts = utc_now_iso()
543
- header = f"**Location:** {display} (lat {lat:.3f}, lon {lon:.3f}) | **UTC:** {ts}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
544
 
545
- verify_md = build_verification_links(lat, lon, seismic_mode, seismic_region, radius_km)
546
- return header, df, verify_md, atm, sei, mag, sol
 
 
547
 
548
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
  INSTRUCTIONS_MD = """
550
  ## Use and interpretation
551
 
@@ -561,11 +1002,15 @@ INSTRUCTIONS_MD = """
561
  **Run Forecast**
562
  - Pulls live data and recomputes from scratch.
563
  - No auto-refresh. No memory. No smoothing.
564
-
565
- **Reading the table**
566
- - RFT Prediction shows model state + index + z + τ_eff.
567
- - Live Status shows the raw physical measurements used.
568
- - DISABLED means missing/insufficient live data; no guessing is performed.
 
 
 
 
569
  """
570
 
571
  METHOD_MD = f"""
@@ -587,53 +1032,33 @@ Decision thresholds are shown per-domain in the agent output as `rule_fired`.
587
  """
588
 
589
 
 
590
  with gr.Blocks(title=APP_NAME) as demo:
591
  gr.Markdown(f"# {APP_NAME}")
 
592
 
593
  with gr.Tab("Live Forecast"):
594
  loc = gr.Textbox(label="Location", value="London")
595
- gr.Markdown(
596
- "**Location input**\n\n"
597
- "- Used for Atmospheric.\n"
598
- "- Used for Seismic only in **Local radius** mode.\n"
599
- "- Not used for Solar or Magnetic (global signals).\n\n"
600
- "If a location cannot be resolved, predictions are disabled instead of guessed."
601
- )
602
 
603
  seismic_mode = gr.Radio(
604
  choices=["Region", "Local radius"],
605
  value="Local radius",
606
  label="Seismic Mode"
607
  )
608
- gr.Markdown(
609
- "**Seismic Mode**\n\n"
610
- "- **Region:** counts earthquakes in a large region.\n"
611
- "- **Local radius:** counts earthquakes within a radius (km) around your typed location.\n\n"
612
- "This is a stress monitor, not a time/epicenter prediction system."
613
- )
614
 
615
  with gr.Row():
616
  region = gr.Dropdown(["Global", "EMEA", "AMER", "APAC", "RingOfFire"], value="EMEA", label="Seismic Region (used in Region mode)")
617
  radius = gr.Slider(50, 2000, value=500, step=50, label="Seismic Radius km (used in Local radius mode)")
618
 
619
- btn = gr.Button("Run Forecast", variant="primary")
620
- gr.Markdown(
621
- "**Run Forecast**\n\n"
622
- "- Pulls live data and recomputes from scratch.\n"
623
- "- No auto-refresh.\n"
624
- "- No stored memory.\n"
625
- "- No guessing when data is missing."
626
  )
627
 
 
 
628
  header_md = gr.Markdown()
629
- gr.Markdown(
630
- "**How to read the table**\n\n"
631
- "- RFT Prediction shows model state + index + z + τ_eff.\n"
632
- "- Live Status shows the raw physical measurements used.\n"
633
- "- DISABLED means missing/insufficient live data; no guessing is performed."
634
- )
635
  table = gr.Dataframe(headers=["Domain", "RFT Prediction", "Live Status"], interactive=False)
636
-
637
  verify_md = gr.Markdown()
638
 
639
  with gr.Accordion("Atmospheric details", open=False):
@@ -645,11 +1070,33 @@ with gr.Blocks(title=APP_NAME) as demo:
645
  with gr.Accordion("Solar details", open=False):
646
  sol_json = gr.JSON(label="Solar agent output")
647
 
 
 
 
 
 
 
 
 
 
648
  btn.click(
649
  run_forecast,
650
- inputs=[loc, seismic_mode, region, radius],
651
- outputs=[header_md, table, verify_md, atm_json, sei_json, mag_json, sol_json],
 
 
 
 
 
 
 
 
652
  )
 
 
 
 
 
653
 
654
  with gr.Tab("Method (Open)"):
655
  gr.Markdown(INSTRUCTIONS_MD)
 
 
1
  # ===============================================================
2
  # Rendered Frame Theory — Live Prediction Console (Open Method)
3
  # Domains: Atmospheric / Seismic / Magnetic / Solar
4
+ # Adds: Verifiable "Forecast Receipt" export + Receipt Upload Verification
5
  # ===============================================================
6
 
7
  import math
8
+ import os
9
+ import sys
10
+ import json
11
+ import uuid
12
+ import base64
13
+ import hashlib
14
+ import platform
15
+ from typing import Optional, Dict, Any, List, Tuple
16
  from datetime import datetime, timezone, timedelta
17
 
18
  import gradio as gr
 
21
  import pandas as pd
22
 
23
  APP_NAME = "Rendered Frame Theory — Live Prediction Console (Open Method)"
24
+ APP_VERSION = "v1.1-receipts+verify"
25
  UA = {"User-Agent": "RFTSystems/LivePredictionConsole"}
26
 
27
+ # ---------- Constants --------------------------------------------------------
28
  T_EARTH = 365.2422 * 24 * 3600.0
29
  OMEGA_OBS = 2.0 * math.pi / T_EARTH
30
  K_TAU = 1.38
 
43
  ]
44
 
45
 
46
+ # ---------- Core Helpers -----------------------------------------------------
47
+ def utc_now() -> datetime:
48
+ return datetime.now(timezone.utc)
49
+
50
+
51
  def utc_now_iso() -> str:
52
+ return utc_now().isoformat().replace("+00:00", "Z")
53
 
54
 
55
  def clamp(x: float, a: float, b: float) -> float:
 
71
  return float(OMEGA_OBS * float(tau) * ALPHA_R)
72
 
73
 
74
+ def sha256_hex(b: bytes) -> str:
75
+ return hashlib.sha256(b).hexdigest()
76
+
77
+
78
+ def safe_json_dumps(obj: Any) -> str:
79
+ return json.dumps(obj, ensure_ascii=False, indent=2, sort_keys=True, default=str)
80
+
81
+
82
+ def env_snapshot() -> Dict[str, Any]:
83
+ return {
84
+ "app_name": APP_NAME,
85
+ "app_version": APP_VERSION,
86
+ "python": sys.version,
87
+ "platform": platform.platform(),
88
+ "packages": {
89
+ "gradio": getattr(gr, "__version__", "unknown"),
90
+ "httpx": getattr(httpx, "__version__", "unknown"),
91
+ "numpy": getattr(np, "__version__", "unknown"),
92
+ "pandas": getattr(pd, "__version__", "unknown"),
93
+ },
94
+ "constants": {
95
+ "T_EARTH": T_EARTH,
96
+ "OMEGA_OBS": OMEGA_OBS,
97
+ "K_TAU": K_TAU,
98
+ "ALPHA_R": ALPHA_R,
99
+ },
100
+ # Optional: set as HF Space secret if you want deterministic provenance for code versioning
101
+ "git_commit": os.environ.get("RFT_GIT_COMMIT", ""),
102
+ }
103
+
104
+
105
+ # ---------- Provenance / Fetch Logging --------------------------------------
106
+ def record_fetch(
107
+ prov_list: List[Dict[str, Any]],
108
+ name: str,
109
+ url: str,
110
+ params: Optional[Dict[str, Any]],
111
+ status_code: Optional[int],
112
+ content_type: Optional[str],
113
+ body_bytes: Optional[bytes],
114
+ include_raw_payloads: bool,
115
+ fetched_at_utc: str,
116
+ error: Optional[str] = None,
117
+ request_url: Optional[str] = None,
118
+ ) -> None:
119
+ body_bytes = body_bytes or b""
120
+ item: Dict[str, Any] = {
121
+ "name": name,
122
+ "fetched_at_utc": fetched_at_utc,
123
+ "url": url,
124
+ "params": params or {},
125
+ "request_url": request_url or "",
126
+ "status_code": status_code,
127
+ "content_type": content_type or "",
128
+ "bytes_len": int(len(body_bytes)),
129
+ "sha256": sha256_hex(body_bytes) if body_bytes else "",
130
+ "error": error or "",
131
+ }
132
+ if include_raw_payloads and body_bytes:
133
+ item["raw_b64"] = base64.b64encode(body_bytes).decode("ascii")
134
+ item["raw_encoding"] = "base64"
135
+ prov_list.append(item)
136
+
137
+
138
+ def http_get_json(
139
+ name: str,
140
+ url: str,
141
+ params: Optional[Dict[str, Any]],
142
+ prov_list: List[Dict[str, Any]],
143
+ include_raw_payloads: bool,
144
+ timeout: float,
145
+ ) -> Any:
146
+ fetched_at = utc_now_iso()
147
+ try:
148
+ r = httpx.get(url, params=params, headers=UA, timeout=timeout)
149
+ body = r.content
150
+ ct = r.headers.get("content-type", "")
151
+ req_url = str(r.request.url) if r.request else ""
152
+ record_fetch(
153
+ prov_list=prov_list,
154
+ name=name,
155
+ url=url,
156
+ params=params,
157
+ status_code=r.status_code,
158
+ content_type=ct,
159
+ body_bytes=body,
160
+ include_raw_payloads=include_raw_payloads,
161
+ fetched_at_utc=fetched_at,
162
+ error=None,
163
+ request_url=req_url,
164
+ )
165
+ r.raise_for_status()
166
+ return r.json()
167
+ except Exception as e:
168
+ record_fetch(
169
+ prov_list=prov_list,
170
+ name=name,
171
+ url=url,
172
+ params=params,
173
+ status_code=None,
174
+ content_type=None,
175
+ body_bytes=None,
176
+ include_raw_payloads=include_raw_payloads,
177
+ fetched_at_utc=fetched_at,
178
+ error=str(e),
179
+ request_url=None,
180
+ )
181
+ raise
182
+
183
+
184
+ # ---------- Data Adapters ----------------------------------------------------
185
+ def geocode_location(q: str, prov_list: List[Dict[str, Any]], include_raw_payloads: bool):
186
  q = (q or "").strip()
187
  if not q:
188
  return None, None, "Empty location"
189
  url = "https://geocoding-api.open-meteo.com/v1/search"
190
  params = {"name": q, "count": 1, "language": "en", "format": "json"}
191
+ js = http_get_json("GEOCODE_OPENMETEO", url, params, prov_list, include_raw_payloads, timeout=12)
 
 
192
  results = js.get("results") or []
193
  if not results:
194
  return None, None, f"Could not geocode '{q}'"
 
199
  return lat, lon, display
200
 
201
 
202
+ def fetch_openmeteo_hourly(lat: float, lon: float, prov_list: List[Dict[str, Any]], include_raw_payloads: bool, past_days: int = 1):
203
  url = "https://api.open-meteo.com/v1/forecast"
204
  params = {
205
  "latitude": lat,
 
209
  "forecast_days": 1,
210
  "timezone": "UTC",
211
  }
212
+ js = http_get_json("OPENMETEO_HOURLY", url, params, prov_list, include_raw_payloads, timeout=18)
 
 
213
  hourly = js.get("hourly") or {}
214
  return {
215
  "time": hourly.get("time") or [],
 
217
  "rh": hourly.get("relative_humidity_2m") or [],
218
  "p": hourly.get("pressure_msl") or [],
219
  "wind": hourly.get("wind_speed_10m") or [],
220
+ "meta": {"source": "Open-Meteo", "url": url, "params": params},
221
  }
222
 
223
 
224
+ def fetch_kp_last_24h(prov_list: List[Dict[str, Any]], include_raw_payloads: bool):
225
  url = "https://services.swpc.noaa.gov/json/planetary_k_index_1m.json"
226
+ js = http_get_json("NOAA_SWPC_KP_1M", url, None, prov_list, include_raw_payloads, timeout=15)
 
 
227
  if not isinstance(js, list) or not js:
228
  return []
229
  vals = []
 
238
  return vals[-1440:]
239
 
240
 
241
+ def fetch_goes_xray_1day(prov_list: List[Dict[str, Any]], include_raw_payloads: bool):
242
  url = "https://services.swpc.noaa.gov/json/goes/primary/xrays-1-day.json"
243
+ js = http_get_json("NOAA_SWPC_GOES_XRAY_1D", url, None, prov_list, include_raw_payloads, timeout=15)
 
 
244
  if not isinstance(js, list) or not js:
245
  return []
246
  out = []
 
255
  return out
256
 
257
 
258
+ def fetch_usgs_quakes(
259
+ hours: int,
260
+ minmag: float,
261
+ prov_list: List[Dict[str, Any]],
262
+ include_raw_payloads: bool,
263
+ bbox: Optional[Tuple[float, float, float, float]] = None,
264
+ center: Optional[Tuple[float, float]] = None,
265
+ radius_km: Optional[float] = None,
266
+ ) -> Dict[str, Any]:
267
  url = "https://earthquake.usgs.gov/fdsnws/event/1/query"
268
+ end = utc_now()
269
  start = end - timedelta(hours=int(hours))
270
+ start_iso = start.isoformat().replace("+00:00", "Z")
271
+ end_iso = end.isoformat().replace("+00:00", "Z")
272
+
273
+ params: Dict[str, Any] = {
274
  "format": "geojson",
275
+ "starttime": start_iso,
276
+ "endtime": end_iso,
277
  "minmagnitude": str(float(minmag)),
278
  "orderby": "time",
279
  }
 
299
  }
300
  )
301
 
302
+ js = http_get_json("USGS_FDSN_EVENTS", url, params, prov_list, include_raw_payloads, timeout=22)
 
 
303
  feats = js.get("features") if isinstance(js, dict) else None
304
  if not feats:
305
+ return {"events": [], "start": start_iso, "end": end_iso, "url": url, "params": params}
306
+
307
  out = []
308
  for f in feats:
309
  props = f.get("properties") or {}
 
315
  "time": props.get("time"),
316
  }
317
  )
318
+ return {"events": out, "start": start_iso, "end": end_iso, "url": url, "params": params}
319
+
320
+
321
+ # ---------- Verification Links (user-facing) --------------------------------
322
+ def build_verification_links(
323
+ lat: float,
324
+ lon: float,
325
+ seismic_mode: str,
326
+ seismic_region: str,
327
+ radius_km: float,
328
+ usgs_meta: Optional[Dict[str, Any]],
329
+ ) -> str:
330
  swpc_kp_page = "https://www.swpc.noaa.gov/products/planetary-k-index"
331
  swpc_kp_json = "https://services.swpc.noaa.gov/json/planetary_k_index_1m.json"
332
 
 
340
  )
341
 
342
  usgs_map = "https://earthquake.usgs.gov/earthquakes/map/"
343
+ scope = "Unknown"
344
+ usgs_query = "https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson"
345
+
346
+ def build_q(meta: Dict[str, Any]) -> str:
347
+ base = meta.get("url", "https://earthquake.usgs.gov/fdsnws/event/1/query")
348
+ params = meta.get("params", {})
349
+ pairs = [f"{k}={v}" for k, v in params.items()]
350
+ return base + "?" + "&".join(pairs)
351
+
352
+ if usgs_meta:
353
+ # RingOfFire can be multi-request
354
+ if "requests" in usgs_meta and isinstance(usgs_meta["requests"], list) and usgs_meta["requests"]:
355
+ usgs_query = build_q(usgs_meta["requests"][0])
356
+ scope = f"Multi-request (RingOfFire): showing first of {len(usgs_meta['requests'])}"
357
+ else:
358
+ usgs_query = build_q(usgs_meta)
359
+ if seismic_mode == "Local radius":
360
+ scope = f"Local radius query ({int(radius_km)} km around your location)"
361
+ else:
362
+ scope = f"Region mode ({seismic_region})"
363
 
364
  return (
365
  "### Verify instantly (official sources)\n"
 
374
  )
375
 
376
 
377
+ # ---------- Agents -----------------------------------------------------------
378
+ def magnetic_agent(prov_list: List[Dict[str, Any]], include_raw_payloads: bool) -> Dict[str, Any]:
379
+ kp = fetch_kp_last_24h(prov_list, include_raw_payloads)
380
  if len(kp) < 30:
381
  return {"enabled": False, "reason": "NOAA Kp feed too short"}
382
  last = float(kp[-1])
 
421
  }
422
 
423
 
424
+ def solar_agent(prov_list: List[Dict[str, Any]], include_raw_payloads: bool) -> Dict[str, Any]:
425
+ flux = fetch_goes_xray_1day(prov_list, include_raw_payloads)
426
  if len(flux) < 50:
427
  return {"enabled": False, "reason": "GOES X-ray feed too short"}
428
  tail = flux[-120:] if len(flux) >= 120 else flux[-60:]
 
467
  }
468
 
469
 
470
+ def atmospheric_agent(lat: float, lon: float, display: str, prov_list: List[Dict[str, Any]], include_raw_payloads: bool) -> Dict[str, Any]:
471
+ wx = fetch_openmeteo_hourly(lat, lon, prov_list, include_raw_payloads, past_days=1)
472
  temp = wx["temp"]
473
  p = wx["p"]
474
  wind = wx["wind"]
 
534
  }
535
 
536
 
537
+ def seismic_agent_region(region: str, prov_list: List[Dict[str, Any]], include_raw_payloads: bool):
538
  if region == "RingOfFire":
539
  seen = set()
540
  eqs = []
541
+ metas = []
542
  for bb in RING_OF_FIRE_BBOXES:
543
+ res = fetch_usgs_quakes(hours=24, minmag=2.5, bbox=bb, prov_list=prov_list, include_raw_payloads=include_raw_payloads)
544
+ metas.append({"url": res["url"], "params": res["params"], "start": res["start"], "end": res["end"]})
545
+ for e in res["events"]:
546
  eid = e.get("id")
547
  if eid and eid not in seen:
548
  seen.add(eid)
549
  eqs.append(e)
550
+ meta = {"mode": "RingOfFireMultiBBox", "requests": metas}
551
  else:
552
  bbox = REGION_BBOX.get(region, None)
553
+ res = fetch_usgs_quakes(hours=24, minmag=2.5, bbox=bbox, prov_list=prov_list, include_raw_payloads=include_raw_payloads)
554
+ eqs = res["events"]
555
+ meta = {"url": res["url"], "params": res["params"], "start": res["start"], "end": res["end"]}
556
+ return eqs, f"Region={region}", meta
557
 
558
 
559
+ def seismic_agent_local(lat: float, lon: float, radius_km: float, prov_list: List[Dict[str, Any]], include_raw_payloads: bool):
560
+ res = fetch_usgs_quakes(hours=24, minmag=2.5, center=(lat, lon), radius_km=radius_km, prov_list=prov_list, include_raw_payloads=include_raw_payloads)
561
+ return res["events"], f"Local radius={int(radius_km)}km", {"url": res["url"], "params": res["params"], "start": res["start"], "end": res["end"]}
562
 
563
 
564
+ def seismic_score(eqs: List[Dict[str, Any]]):
565
  N = int(len(eqs))
566
  mags = []
567
  for e in eqs:
 
596
  return pred, rule, z, tau, idx, N, Mmax
597
 
598
 
599
+ def seismic_agent(mode: str, region: str, lat: float, lon: float, radius_km: float, prov_list: List[Dict[str, Any]], include_raw_payloads: bool) -> Dict[str, Any]:
600
  if mode == "Local radius":
601
+ eqs, scope, usgs_meta = seismic_agent_local(lat, lon, radius_km, prov_list, include_raw_payloads)
602
  location_effect = "Location changes Seismic in Local radius mode."
603
  do = "Use to monitor seismic activity within the selected radius around your typed location."
604
  dont = "Do not treat as time/epicenter prediction."
605
  truth_scope = f"USGS events within {int(radius_km)} km"
606
  else:
607
+ eqs, scope, usgs_meta = seismic_agent_region(region, prov_list, include_raw_payloads)
608
  location_effect = "Location does not change Seismic in Region mode. Region selector does."
609
  do = "Use as a regional seismic stress monitor."
610
  dont = "Do not treat as time/epicenter prediction."
 
625
  "index": float(idx),
626
  "live_status": live,
627
  "truth_source": f"USGS FDSN event feed ({truth_scope})",
628
+ "inputs_used": {
629
+ "count_24h": N,
630
+ "max_mag_24h": Mmax,
631
+ "mode": mode,
632
+ "region": region,
633
+ "radius_km": float(radius_km),
634
+ "lat": float(lat),
635
+ "lon": float(lon),
636
+ },
637
  "location_effect": location_effect,
638
  "do": do,
639
  "dont": dont,
640
  "what_it_is_not": "Not an earthquake time predictor. Not a rupture location predictor.",
641
  "why": "z_seis compresses activity density and severity into a bounded stress coordinate; τ_eff rises as ln(1+z).",
642
  "how": "Fetch USGS → count + max magnitude → z_seis → τ_eff → Index → label via fixed thresholds.",
643
+ "usgs_meta": usgs_meta,
644
+ }
645
+
646
+
647
+ # ---------- Receipt Build/Save ----------------------------------------------
648
+ def build_receipt(
649
+ run_id: str,
650
+ run_started_utc: str,
651
+ run_finished_utc: str,
652
+ location_text: str,
653
+ lat: float,
654
+ lon: float,
655
+ display: str,
656
+ seismic_mode: str,
657
+ seismic_region: str,
658
+ radius_km: float,
659
+ df: pd.DataFrame,
660
+ atm: Dict[str, Any],
661
+ sei: Dict[str, Any],
662
+ mag: Dict[str, Any],
663
+ sol: Dict[str, Any],
664
+ prov_list: List[Dict[str, Any]],
665
+ include_raw_payloads: bool,
666
+ ) -> Dict[str, Any]:
667
+ return {
668
+ "receipt_version": 1,
669
+ "run_id": run_id,
670
+ "run_started_utc": run_started_utc,
671
+ "run_finished_utc": run_finished_utc,
672
+ "settings": {
673
+ "location_text": location_text,
674
+ "geocode_result": {"display": display, "lat": lat, "lon": lon},
675
+ "seismic_mode": seismic_mode,
676
+ "seismic_region": seismic_region,
677
+ "radius_km": float(radius_km),
678
+ "include_raw_payloads": bool(include_raw_payloads),
679
+ },
680
+ "outputs": {
681
+ "table_rows": df.to_dict(orient="records"),
682
+ "agents": {
683
+ "atmospheric": atm,
684
+ "seismic": sei,
685
+ "magnetic": mag,
686
+ "solar": sol,
687
+ },
688
+ },
689
+ "provenance": {"fetches": prov_list},
690
+ "environment": env_snapshot(),
691
+ "verification_note": (
692
+ "Receipt is tamper-evident via sha256 for each upstream payload. "
693
+ "If raw payloads are embedded (raw_b64), integrity + offline verification is strong. "
694
+ "If not embedded, you can still compare provider payloads later, but providers may revise feeds."
695
+ ),
696
  }
697
 
698
 
699
+ def write_receipt_to_file(receipt: Dict[str, Any]) -> str:
700
+ run_id = receipt.get("run_id", "run")
701
+ path = f"/tmp/rft_forecast_receipt_{run_id}.json"
702
+ with open(path, "w", encoding="utf-8") as f:
703
+ f.write(safe_json_dumps(receipt))
704
+ return path
705
+
706
+
707
+ # ---------- Forecast Runner --------------------------------------------------
708
+ def run_forecast(location_text: str, seismic_mode: str, seismic_region: str, radius_km: float, include_raw_payloads: bool):
709
+ run_started = utc_now_iso()
710
+ run_id = uuid.uuid4().hex[:12]
711
+ prov: List[Dict[str, Any]] = []
712
+
713
+ # Geocode
714
  try:
715
+ lat, lon, display = geocode_location(location_text, prov, include_raw_payloads)
716
  except Exception as e:
717
  df = pd.DataFrame([{"Domain": "Error", "RFT Prediction": "DISABLED", "Live Status": f"Geocode error: {e}"}])
718
  empty = {"enabled": False, "reason": f"Geocode error: {e}"}
719
+ receipt = build_receipt(run_id, run_started, utc_now_iso(), location_text, float("nan"), float("nan"), "", seismic_mode, seismic_region, radius_km, df, empty, empty, empty, empty, prov, include_raw_payloads)
720
+ receipt_path = write_receipt_to_file(receipt)
721
+ return f"❌ Geocode error: {e}", df, "", empty, empty, empty, empty, receipt, receipt_path
722
 
723
  if lat is None:
724
  df = pd.DataFrame([{"Domain": "Error", "RFT Prediction": "DISABLED", "Live Status": display}])
725
  empty = {"enabled": False, "reason": display}
726
+ receipt = build_receipt(run_id, run_started, utc_now_iso(), location_text, float("nan"), float("nan"), display, seismic_mode, seismic_region, radius_km, df, empty, empty, empty, empty, prov, include_raw_payloads)
727
+ receipt_path = write_receipt_to_file(receipt)
728
+ return f"❌ {display}", df, "", empty, empty, empty, empty, receipt, receipt_path
729
 
730
+ # Agents
731
  try:
732
+ atm = atmospheric_agent(lat, lon, display, prov, include_raw_payloads)
733
  except Exception as e:
734
  atm = {"enabled": False, "reason": f"atmos error: {e}"}
735
 
736
  try:
737
+ sei = seismic_agent(seismic_mode, seismic_region, lat, lon, radius_km, prov, include_raw_payloads)
738
  except Exception as e:
739
  sei = {"enabled": False, "reason": f"seismic error: {e}"}
740
 
741
  try:
742
+ mag = magnetic_agent(prov, include_raw_payloads)
743
  except Exception as e:
744
  mag = {"enabled": False, "reason": f"magnetic error: {e}"}
745
 
746
  try:
747
+ sol = solar_agent(prov, include_raw_payloads)
748
  except Exception as e:
749
  sol = {"enabled": False, "reason": f"solar error: {e}"}
750
 
751
+ def fmt_row(domain: str, out: Dict[str, Any]):
752
  if not out.get("enabled"):
753
  return {"Domain": domain, "RFT Prediction": "DISABLED", "Live Status": out.get("reason", "missing inputs")}
754
  idx = out.get("index", None)
 
772
  ]
773
  )
774
 
775
+ run_finished = utc_now_iso()
776
+ header = f"**Location:** {display} (lat {lat:.3f}, lon {lon:.3f}) | **UTC:** {run_finished} | **Run ID:** `{run_id}`"
777
+
778
+ usgs_meta = None
779
+ if isinstance(sei, dict):
780
+ usgs_meta = sei.get("usgs_meta", None)
781
+
782
+ verify_md = build_verification_links(lat, lon, seismic_mode, seismic_region, radius_km, usgs_meta)
783
+
784
+ receipt = build_receipt(
785
+ run_id=run_id,
786
+ run_started_utc=run_started,
787
+ run_finished_utc=run_finished,
788
+ location_text=location_text,
789
+ lat=lat,
790
+ lon=lon,
791
+ display=display,
792
+ seismic_mode=seismic_mode,
793
+ seismic_region=seismic_region,
794
+ radius_km=radius_km,
795
+ df=df,
796
+ atm=atm,
797
+ sei=sei,
798
+ mag=mag,
799
+ sol=sol,
800
+ prov_list=prov,
801
+ include_raw_payloads=include_raw_payloads,
802
+ )
803
+ receipt_path = write_receipt_to_file(receipt)
804
+
805
+ return header, df, verify_md, atm, sei, mag, sol, receipt, receipt_path
806
+
807
+
808
+ # ---------- Receipt Verification --------------------------------------------
809
+ def _safe_float(x):
810
+ try:
811
+ if x is None:
812
+ return None
813
+ return float(x)
814
+ except Exception:
815
+ return None
816
+
817
 
818
+ def _close(a, b, tol=1e-9):
819
+ if a is None or b is None:
820
+ return False
821
+ return abs(float(a) - float(b)) <= tol * max(1.0, abs(float(a)), abs(float(b)))
822
 
823
 
824
+ def _verify_payload_hashes(fetches: List[Dict[str, Any]]):
825
+ rows = []
826
+ ok_all = True
827
+ for f in (fetches or []):
828
+ name = f.get("name", "")
829
+ h = f.get("sha256", "")
830
+ raw_b64 = f.get("raw_b64", None)
831
+
832
+ if not raw_b64:
833
+ rows.append({"Check": f"payload:{name}", "Status": "SKIP", "Detail": "No raw_b64 embedded"})
834
+ continue
835
+
836
+ try:
837
+ raw = base64.b64decode(raw_b64.encode("ascii"))
838
+ h2 = sha256_hex(raw)
839
+ ok = (h2 == h)
840
+ ok_all = ok_all and ok
841
+ rows.append({"Check": f"payload:{name}", "Status": "PASS" if ok else "FAIL", "Detail": f"sha256(receipt)={h} sha256(decoded)={h2}"})
842
+ except Exception as e:
843
+ ok_all = False
844
+ rows.append({"Check": f"payload:{name}", "Status": "FAIL", "Detail": f"Decode/hash error: {e}"})
845
+
846
+ return ok_all, rows
847
+
848
+
849
+ def _recompute_domain(domain: str, agent: Dict[str, Any]):
850
+ if not agent or not agent.get("enabled"):
851
+ return {"enabled": False}
852
+
853
+ iu = agent.get("inputs_used") or {}
854
+ dom = (domain or "").strip().lower()
855
+
856
+ if dom == "atmospheric":
857
+ dT = _safe_float(iu.get("dT_12h"))
858
+ dp = _safe_float(iu.get("dP_12h"))
859
+ if dT is None:
860
+ return {"error": "Missing inputs_used.dT_12h"}
861
+ z_dt = clamp(dT / 10.0, 0.0, 2.0)
862
+ z_dp = clamp((abs(dp) / 12.0) if dp is not None else 0.0, 0.0, 1.5)
863
+ z = clamp(z_dt + z_dp, 0.0, 3.0)
864
+ if dT >= 10.0 or (dp is not None and dp <= -10.0):
865
+ pred = "storm risk"
866
+ elif dT >= 7.0 or (dp is not None and dp <= -6.0):
867
+ pred = "swing"
868
+ elif dT >= 4.0:
869
+ pred = "mild swing"
870
+ else:
871
+ pred = "stable"
872
+
873
+ elif dom == "seismic":
874
+ N = _safe_float(iu.get("count_24h"))
875
+ Mmax = _safe_float(iu.get("max_mag_24h"))
876
+ if N is None or Mmax is None:
877
+ return {"error": "Missing inputs_used.count_24h or inputs_used.max_mag_24h"}
878
+ z_count = clamp(N / 60.0, 0.0, 1.5)
879
+ z_mag = clamp(max(0.0, Mmax - 4.0) / 2.5, 0.0, 1.5)
880
+ z = clamp(z_count + z_mag, 0.0, 3.0)
881
+ if Mmax >= 6.5 or z >= 2.2:
882
+ pred = "alert"
883
+ elif Mmax >= 5.5 or z >= 1.5:
884
+ pred = "watch"
885
+ elif N >= 25 or z >= 1.0:
886
+ pred = "monitor"
887
+ else:
888
+ pred = "quiet"
889
+
890
+ elif dom == "magnetic":
891
+ last = _safe_float(iu.get("kp_last"))
892
+ drift = _safe_float(iu.get("kp_drift"))
893
+ slope = _safe_float(iu.get("kp_slope"))
894
+ if last is None or drift is None or slope is None:
895
+ return {"error": "Missing inputs_used.kp_last/kp_drift/kp_slope"}
896
+ z = clamp((last / 9.0) + (drift / 2.0) + 2.0 * abs(slope), 0.0, 3.0)
897
+ if last >= 7.0 or z >= 2.0:
898
+ pred = "warning"
899
+ elif last >= 5.0 or z >= 1.2:
900
+ pred = "watch"
901
+ elif last >= 4.0 or z >= 0.8:
902
+ pred = "monitor"
903
+ else:
904
+ pred = "hold"
905
+
906
+ elif dom == "solar":
907
+ f_mean = _safe_float(iu.get("flux_mean"))
908
+ f_peak = _safe_float(iu.get("flux_peak"))
909
+ if f_mean is None or f_peak is None:
910
+ return {"error": "Missing inputs_used.flux_mean/flux_peak"}
911
+ lr = stable_log_ratio(f_mean, 1e-8)
912
+ z = clamp(lr / 10.0, 0.0, 3.0)
913
+ if f_peak >= 1e-4 or z >= 2.2:
914
+ pred = "flare likely"
915
+ elif f_peak >= 1e-5 or z >= 1.5:
916
+ pred = "flare watch"
917
+ elif f_mean >= 1e-6 or z >= 0.9:
918
+ pred = "monitor"
919
+ else:
920
+ pred = "hold"
921
+
922
+ else:
923
+ return {"error": f"Unknown domain '{domain}'"}
924
+
925
+ tau = tau_eff_from_z(z)
926
+ idx = index_from_tau(tau)
927
+ return {"z": z, "tau_eff": tau, "index": idx, "prediction": pred}
928
+
929
+
930
+ def verify_receipt(uploaded_file):
931
+ if uploaded_file is None:
932
+ return "❌ Upload a receipt JSON first.", pd.DataFrame([])
933
+
934
+ try:
935
+ content = uploaded_file.read()
936
+ receipt = json.loads(content.decode("utf-8"))
937
+ except Exception as e:
938
+ return f"❌ Could not read JSON: {e}", pd.DataFrame([])
939
+
940
+ checks: List[Dict[str, str]] = []
941
+ ok = True
942
+
943
+ for key in ["run_id", "settings", "outputs", "provenance", "environment"]:
944
+ if key not in receipt:
945
+ ok = False
946
+ checks.append({"Check": f"has:{key}", "Status": "FAIL", "Detail": "Missing key"})
947
+ else:
948
+ checks.append({"Check": f"has:{key}", "Status": "PASS", "Detail": ""})
949
+
950
+ fetches = (receipt.get("provenance") or {}).get("fetches") or []
951
+ ok_payloads, rows = _verify_payload_hashes(fetches)
952
+ checks.extend(rows)
953
+ ok = ok and ok_payloads
954
+
955
+ agents = ((receipt.get("outputs") or {}).get("agents") or {})
956
+ mapping = {
957
+ "Atmospheric": agents.get("atmospheric"),
958
+ "Seismic": agents.get("seismic"),
959
+ "Magnetic": agents.get("magnetic"),
960
+ "Solar": agents.get("solar"),
961
+ }
962
+
963
+ for dom, agent in mapping.items():
964
+ if not agent or not agent.get("enabled"):
965
+ checks.append({"Check": f"recompute:{dom}", "Status": "SKIP", "Detail": "Agent disabled"})
966
+ continue
967
+
968
+ rec = _recompute_domain(dom, agent)
969
+ if rec.get("error"):
970
+ ok = False
971
+ checks.append({"Check": f"recompute:{dom}", "Status": "FAIL", "Detail": rec["error"]})
972
+ continue
973
+
974
+ z_ok = _close(rec["z"], agent.get("z"), tol=1e-6)
975
+ t_ok = _close(rec["tau_eff"], agent.get("tau_eff"), tol=1e-6)
976
+ i_ok = _close(rec["index"], agent.get("index"), tol=1e-6)
977
+ p_ok = (str(rec["prediction"]).strip().lower() == str(agent.get("prediction")).strip().lower())
978
+
979
+ ok = ok and z_ok and t_ok and i_ok and p_ok
980
+ checks.append({"Check": f"{dom}:z", "Status": "PASS" if z_ok else "FAIL", "Detail": f"expected={agent.get('z')} recomputed={rec['z']}"})
981
+ checks.append({"Check": f"{dom}:tau", "Status": "PASS" if t_ok else "FAIL", "Detail": f"expected={agent.get('tau_eff')} recomputed={rec['tau_eff']}"})
982
+ checks.append({"Check": f"{dom}:index", "Status": "PASS" if i_ok else "FAIL", "Detail": f"expected={agent.get('index')} recomputed={rec['index']}"})
983
+ checks.append({"Check": f"{dom}:label", "Status": "PASS" if p_ok else "FAIL", "Detail": f"expected={agent.get('prediction')} recomputed={rec['prediction']}"})
984
+
985
+ status = "✅ Receipt verification PASS" if ok else "⚠️ Receipt verification FAIL (see checks)"
986
+ return status, pd.DataFrame(checks)
987
+
988
+
989
+ # ---------- Markdown Tabs ----------------------------------------------------
990
  INSTRUCTIONS_MD = """
991
  ## Use and interpretation
992
 
 
1002
  **Run Forecast**
1003
  - Pulls live data and recomputes from scratch.
1004
  - No auto-refresh. No memory. No smoothing.
1005
+ - No guessing when data is missing (DISABLED instead).
1006
+
1007
+ **Forecast Receipts**
1008
+ - Each run generates a downloadable receipt JSON that includes:
1009
+ - source URLs + params + timestamps
1010
+ - sha256 hashes of upstream payloads
1011
+ - computed intermediates + label rule fired
1012
+ - environment snapshot (versions + constants)
1013
+ - Optional: embed raw upstream payloads for stronger offline verification.
1014
  """
1015
 
1016
  METHOD_MD = f"""
 
1032
  """
1033
 
1034
 
1035
+ # ---------- UI ---------------------------------------------------------------
1036
  with gr.Blocks(title=APP_NAME) as demo:
1037
  gr.Markdown(f"# {APP_NAME}")
1038
+ gr.Markdown(f"**Build:** `{APP_VERSION}`")
1039
 
1040
  with gr.Tab("Live Forecast"):
1041
  loc = gr.Textbox(label="Location", value="London")
 
 
 
 
 
 
 
1042
 
1043
  seismic_mode = gr.Radio(
1044
  choices=["Region", "Local radius"],
1045
  value="Local radius",
1046
  label="Seismic Mode"
1047
  )
 
 
 
 
 
 
1048
 
1049
  with gr.Row():
1050
  region = gr.Dropdown(["Global", "EMEA", "AMER", "APAC", "RingOfFire"], value="EMEA", label="Seismic Region (used in Region mode)")
1051
  radius = gr.Slider(50, 2000, value=500, step=50, label="Seismic Radius km (used in Local radius mode)")
1052
 
1053
+ include_raw = gr.Checkbox(
1054
+ value=False,
1055
+ label="Receipt durability: embed raw upstream payloads (larger download, stronger verification)"
 
 
 
 
1056
  )
1057
 
1058
+ btn = gr.Button("Run Forecast", variant="primary")
1059
+
1060
  header_md = gr.Markdown()
 
 
 
 
 
 
1061
  table = gr.Dataframe(headers=["Domain", "RFT Prediction", "Live Status"], interactive=False)
 
1062
  verify_md = gr.Markdown()
1063
 
1064
  with gr.Accordion("Atmospheric details", open=False):
 
1070
  with gr.Accordion("Solar details", open=False):
1071
  sol_json = gr.JSON(label="Solar agent output")
1072
 
1073
+ with gr.Accordion("Forecast Receipt (verifiable history)", open=True):
1074
+ gr.Markdown(
1075
+ "- Download the receipt to freeze this run.\n"
1076
+ "- If you enabled raw payloads, payload-hash verification is offline.\n"
1077
+ "- If not enabled, you still get URLs/params/timestamps + sha256 for audit trails."
1078
+ )
1079
+ receipt_json = gr.JSON(label="Receipt JSON")
1080
+ receipt_file = gr.File(label="Download receipt (.json)")
1081
+
1082
  btn.click(
1083
  run_forecast,
1084
+ inputs=[loc, seismic_mode, region, radius, include_raw],
1085
+ outputs=[header_md, table, verify_md, atm_json, sei_json, mag_json, sol_json, receipt_json, receipt_file],
1086
+ )
1087
+
1088
+ with gr.Tab("Verify Receipt"):
1089
+ gr.Markdown(
1090
+ "Upload a previously downloaded Forecast Receipt JSON to verify:\n\n"
1091
+ "- Structural integrity\n"
1092
+ "- Embedded payload hash checks (if raw payloads were included)\n"
1093
+ "- Recomputed z / τ_eff / index and label against stored intermediates\n"
1094
  )
1095
+ up = gr.File(label="Upload receipt (.json)", file_types=[".json"])
1096
+ vbtn = gr.Button("Verify", variant="primary")
1097
+ vstatus = gr.Markdown()
1098
+ vtable = gr.Dataframe(headers=["Check", "Status", "Detail"], interactive=False)
1099
+ vbtn.click(verify_receipt, inputs=[up], outputs=[vstatus, vtable])
1100
 
1101
  with gr.Tab("Method (Open)"):
1102
  gr.Markdown(INSTRUCTIONS_MD)