RFTSystems commited on
Commit
8499ecf
·
verified ·
1 Parent(s): 959fced

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +618 -228
app.py CHANGED
@@ -1,272 +1,662 @@
 
1
  # ===============================================================
2
- # Rendered Frame Theory — Forecast Lab Console (Instant Falsifiability Links)
 
 
 
3
  # ===============================================================
4
 
 
 
 
5
  import gradio as gr
6
- from datetime import datetime, timezone
7
- import pandas as pd, numpy as np, httpx, io, csv
8
-
9
- from rft_core.geocode import geocode_location
10
- from data_adapters.noaa_geomag import fetch_k_index
11
- from data_adapters.goes_xray import fetch_goes_flux
12
- from data_adapters.metar_weather import fetch_meteo
13
- from data_adapters.usgs_quakes import fetch_usgs_recent
14
-
15
- from scoreboard_core import load_entries
16
- from scoreboard_jobs import run_daily_cycle
17
-
18
- APP_NAME = "Rendered Frame Theory — Forecast Lab Console"
19
-
20
- # ---------- Helpers -----------------------------------------------------------
21
- def classify_kp(kp: float) -> str:
22
- if kp >= 7: return f"Kp={kp} (severe storm)"
23
- if kp >= 5: return f"Kp={kp} (storm)"
24
- if kp >= 4: return f"Kp={kp} (active)"
25
- if kp >= 3: return f"Kp={kp} (unsettled)"
26
- return f"Kp={kp} (quiet)"
27
-
28
- def classify_flux(flux: float) -> str:
29
- # GOES 1–8 Å classes
30
- if flux >= 1e-4: return f"{flux:.2e} (X-class)"
31
- if flux >= 1e-5: return f"{flux:.2e} (M-class)"
32
- if flux >= 1e-6: return f"{flux:.2e} (C-class)"
33
- if flux >= 1e-7: return f"{flux:.2e} (B-class)"
34
- return f"{flux:.2e} (A-class)"
35
-
36
- def build_verification_links(lat: float, lon: float, region: str) -> str:
37
- """
38
- Returns a Markdown block with direct verification links so users can falsify instantly.
39
- """
40
- # Magnetic (Kp)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  swpc_kp_page = "https://www.swpc.noaa.gov/products/planetary-k-index"
42
- swpc_kp_json = "https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json"
43
 
44
- # Solar (GOES X-ray)
45
  goes_plot_page = "https://www.swpc.noaa.gov/products/goes-x-ray-flux"
46
- goes_primary_csv = "https://services.swpc.noaa.gov/text/goes-xray-flux-primary.csv"
47
- goes_secondary_csv = "https://services.swpc.noaa.gov/text/goes-xray-flux-secondary.csv"
48
 
49
- # Atmospheric (Open-Meteo)
50
- # This is a direct URL to a forecast call for that lat/lon that anyone can open.
51
  open_meteo_link = (
52
  "https://api.open-meteo.com/v1/forecast"
53
  f"?latitude={lat:.5f}&longitude={lon:.5f}"
54
- "&hourly=temperature_2m&past_days=1&forecast_days=1&timezone=UTC"
55
  )
56
 
57
- # Seismic (USGS)
58
- # Past day, minimum magnitude 2.5, global feed.
59
  usgs_map = "https://earthquake.usgs.gov/earthquakes/map/"
60
- usgs_query = (
61
- "https://earthquake.usgs.gov/fdsnws/event/1/query"
62
- "?format=geojson&starttime=&endtime=&minmagnitude=2.5"
63
- )
64
- usgs_past_day_geojson = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson"
 
 
 
 
 
65
 
66
  return (
67
- "### Verify these results instantly (official sources)\n"
68
  f"- **Magnetic (Kp):** {swpc_kp_page} \n"
69
- f" Live feed JSON: {swpc_kp_json}\n"
70
  f"- **Solar (GOES X-ray):** {goes_plot_page} \n"
71
- f" Live CSV: {goes_primary_csv} \n"
72
- f" Backup CSV: {goes_secondary_csv}\n"
73
- f"- **Atmospheric (Temp history near your location):** {open_meteo_link}\n"
74
  f"- **Seismic (USGS map):** {usgs_map} \n"
75
- f" Past-day M≥2.5 GeoJSON: {usgs_past_day_geojson}\n"
76
- "\n"
77
- "**Note:** In the current build, the seismic summary is global (past 24h), so it won’t change with location. "
78
- "If you want it to be location-aware, we filter USGS by radius around your lat/lon."
79
  )
80
 
81
- # ---------- Live domain logic -------------------------------------------------
82
- def magnetic_block():
83
- """
84
- Primary: SWPC JSON feed; fallback: Kyoto WDC mirror (open endpoint).
85
- """
86
- try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  try:
88
- data = fetch_k_index(24)
89
  except Exception:
90
- url = "https://wdc.kugi.kyoto-u.ac.jp/kp/kp.json"
91
- r = httpx.get(url, timeout=10)
92
- j = r.json()
93
- data = [{"kp": float(v)} for v in j[-24:]] if isinstance(j, list) else []
94
- if not data:
95
- return ("hold", "no data")
96
- last_kp = float(data[-1]["kp"])
97
- drift = np.std([d["kp"] for d in data[-6:]]) if len(data) >= 6 else 0.0
98
- rft_pred = "storm soon" if last_kp >= 5 or drift > 1.2 else "quiet"
99
- live = classify_kp(round(last_kp, 1))
100
- return (rft_pred, live)
101
- except Exception:
102
- return ("hold", "fetch error")
103
-
104
- def solar_block():
105
- """
106
- Primary: GOES flux CSV; fallback: secondary mirror if blocked.
107
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  try:
109
- try:
110
- data = fetch_goes_flux(24)
111
- except Exception:
112
- url = "https://services.swpc.noaa.gov/text/goes-xray-flux-secondary.csv"
113
- r = httpx.get(url, timeout=10)
114
- reader = csv.DictReader(io.StringIO(r.text))
115
- data = [{"flux": float(row["flux"])} for row in reader if row.get("flux")]
116
- if not data:
117
- return ("hold", "no data")
118
- tail = data[-30:] if len(data) >= 30 else data
119
- flux_mean = float(np.mean([d["flux"] for d in tail]))
120
- rft_pred = "flare watch" if flux_mean > 1e-6 else "calm"
121
- live = classify_flux(flux_mean)
122
- return (rft_pred, live)
123
- except Exception:
124
- return ("hold", "fetch error")
125
-
126
- def atmos_block(lat: float, lon: float):
127
  try:
128
- data = fetch_meteo(lat, lon)
129
- if not data:
130
- return ("hold", "no data")
131
- temps = [d["temp"] for d in data[-12:]] if len(data) >= 12 else [d["temp"] for d in data]
132
- dT = (max(temps) - min(temps)) if temps else 0.0
133
- rft_pred = "swing" if dT >= 6 else "stable"
134
- live = f"ΔT(12 h) = {dT:.1f} °C"
135
- return (rft_pred, live)
136
- except Exception:
137
- return ("hold", "fetch error")
138
-
139
- def seismic_block(region: str):
140
  try:
141
- eqs = fetch_usgs_recent(24, 2.5)
142
- if not eqs:
143
- return ("quiet", "0 events (M≥2.5)")
144
- count = len(eqs)
145
- mags = [e["mag"] for e in eqs if e.get("mag") is not None]
146
- max_mag = max(mags) if mags else 0.0
147
- rft_pred = "watch" if max_mag >= 5.0 or count >= 20 else "quiet"
148
- live = f"{count} events, max M{max_mag:.1f}"
149
- return (rft_pred, live)
150
- except Exception:
151
- return ("hold", "fetch error")
152
-
153
- # ---------- Orchestrator ------------------------------------------------------
154
- def run_live_dashboard(loc_name: str, region: str):
155
- lat, lon, display = geocode_location(loc_name)
156
- if lat is None:
157
- return (
158
- f"❌ Could not find location '{loc_name}'",
159
- pd.DataFrame(columns=["Domain","RFT Prediction","Live Status"]),
160
- ""
161
- )
162
 
163
- mag_pred, mag_live = magnetic_block()
164
- sol_pred, sol_live = solar_block()
165
- atm_pred, atm_live = atmos_block(lat, lon)
166
- sei_pred, sei_live = seismic_block(region)
167
-
168
- rows = [
169
- {"Domain": "Magnetic", "RFT Prediction": mag_pred, "Live Status": mag_live},
170
- {"Domain": "Solar", "RFT Prediction": sol_pred, "Live Status": sol_live},
171
- {"Domain": "Atmospheric", "RFT Prediction": atm_pred, "Live Status": atm_live},
172
- {"Domain": "Seismic", "RFT Prediction": sei_pred, "Live Status": sei_live},
173
- ]
174
- df = pd.DataFrame(rows)
175
- ts = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
176
- header = f"**Location:** {display} | **UTC:** {ts}"
177
- links_md = build_verification_links(lat, lon, region)
178
- return header, df, links_md
179
-
180
- # ---------- Scoreboard helpers -----------------------------------------------
181
- def run_daily_cycle_ui() -> str:
182
- records = run_daily_cycle()
183
- if not records:
184
- return "No records written (check scoreboard_jobs wiring)."
185
- win = records[0]["window"]
186
- return (
187
- f"Logged {len(records)} scoreboard entries for window "
188
- f"{win['start']} {win['end']} (magnetic, solar, METAR)."
 
 
 
 
 
 
189
  )
190
 
191
- def build_scoreboard_df() -> pd.DataFrame:
192
- entries = load_entries(limit=200)
193
- if not entries:
194
- return pd.DataFrame(
195
- columns=[
196
- "timestamp", "domain", "window_start", "window_end",
197
- "event", "baseline_p", "rft_p",
198
- "brier_baseline", "brier_rft"
199
- ]
200
- )
201
- rows = []
202
- for e in entries:
203
- ts = datetime.fromtimestamp(e["timestamp"], tz=timezone.utc).isoformat().replace("+00:00", "Z")
204
- win = e.get("window", {})
205
- rows.append({
206
- "timestamp": ts,
207
- "domain": e.get("domain", ""),
208
- "window_start": win.get("start", ""),
209
- "window_end": win.get("end", ""),
210
- "event": e.get("event"),
211
- "baseline_p": e.get("baseline_p"),
212
- "rft_p": e.get("rft_p"),
213
- "brier_baseline": e.get("brier_baseline"),
214
- "brier_rft": e.get("brier_rft"),
215
- })
216
- df = pd.DataFrame(rows)
217
- df.sort_values("timestamp", ascending=False, inplace=True)
218
- return df
219
-
220
- # ---------- UI ---------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  with gr.Blocks(title=APP_NAME) as demo:
222
- gr.Markdown(f"# {APP_NAME}\nLive multi-domain forecast — simple, clear, always live.")
223
 
224
  with gr.Tab("Live Forecast"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  with gr.Row():
226
- loc_name = gr.Textbox(label="Location (city or region)", value="London")
227
- region = gr.Dropdown(
228
- ["Global","EMEA","AMER","APAC","RingOfFire"],
229
- value="EMEA", label="Seismic Region"
230
- )
 
 
 
 
 
 
231
 
232
- run_btn = gr.Button("Run Forecast", variant="primary")
233
  header_md = gr.Markdown()
234
- table = gr.Dataframe(headers=["Domain","RFT Prediction","Live Status"], interactive=False)
 
 
 
 
 
 
 
235
  verify_md = gr.Markdown()
236
 
237
- run_btn.click(run_live_dashboard, [loc_name, region], [header_md, table, verify_md])
 
 
 
 
 
 
 
238
 
239
- with gr.Tab("Scoreboard"):
240
- gr.Markdown(
241
- "### RFT Falsifiability Scoreboard\n"
242
- "This tab logs **synthetic but structured** daily tests for:\n"
243
- "- Geomagnetic Kp storms\n"
244
- "- GOES X-ray flux pulses\n"
245
- "- METAR temperature swings\n\n"
246
- "Each row: event flag, baseline probability, RFT probability, and Brier scores."
247
  )
248
- cycle_btn = gr.Button("Run daily scoring cycle (yesterday UTC)")
249
- cycle_status = gr.Markdown()
250
- cycle_btn.click(run_daily_cycle_ui, inputs=None, outputs=cycle_status)
251
 
252
- refresh_btn = gr.Button("Refresh scoreboard view")
253
- board_df = gr.Dataframe(interactive=False)
254
- refresh_btn.click(build_scoreboard_df, inputs=None, outputs=board_df)
255
 
256
- with gr.Tab("Notes"):
257
- gr.Markdown(
258
- "## What this Space is doing\n"
259
- "- Pulls public live feeds on-demand when you press **Run Forecast**.\n"
260
- "- Computes simple, inspectable signals and prints both **RFT Prediction** and **Live Status**.\n"
261
- "- Provides direct links for instant falsifiability (shown under the table after each run).\n\n"
262
- "## What this Space is NOT doing\n"
263
- "- It is not a guaranteed prediction engine.\n"
264
- "- It is not hiding logic behind symbols.\n"
265
- "- It is not auto-refreshing.\n\n"
266
- "## Why some values look the same across locations\n"
267
- "- The current seismic summary is global (past 24h). Location-aware seismic requires filtering by radius "
268
- "around your typed location.\n"
269
- )
270
 
271
  if __name__ == "__main__":
272
  demo.launch()
 
1
+ # app.py
2
  # ===============================================================
3
+ # Rendered Frame Theory — Live Prediction Console (Open Method)
4
+ # Domains: Atmospheric / Seismic / Magnetic / Solar
5
+ # Adds: Seismic LOCAL radius mode (so location changes seismic)
6
+ # Single-file. No placeholders.
7
  # ===============================================================
8
 
9
+ import math
10
+ from datetime import datetime, timezone, timedelta
11
+
12
  import gradio as gr
13
+ import httpx
14
+ import numpy as np
15
+ import pandas as pd
16
+
17
+ APP_NAME = "Rendered Frame Theory — Live Prediction Console (Open Method)"
18
+ UA = {"User-Agent": "RFTSystems/LivePredictionConsole"}
19
+
20
+ T_EARTH = 365.2422 * 24 * 3600.0
21
+ OMEGA_OBS = 2.0 * math.pi / T_EARTH
22
+ K_TAU = 1.38
23
+ ALPHA_R = 1.02
24
+
25
+ REGION_BBOX = {
26
+ "Global": None,
27
+ "EMEA": (-35.0, -20.0, 70.0, 60.0),
28
+ "AMER": (-60.0, -170.0, 72.0, -30.0),
29
+ "APAC": (-50.0, 60.0, 60.0, 180.0),
30
+ }
31
+ RING_OF_FIRE_BBOXES = [
32
+ (-60.0, 120.0, 60.0, 180.0),
33
+ (-60.0, -180.0, 60.0, -100.0),
34
+ (10.0, -90.0, 60.0, -60.0),
35
+ ]
36
+
37
+
38
+ def utc_now_iso() -> str:
39
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
40
+
41
+
42
+ def clamp(x: float, a: float, b: float) -> float:
43
+ return max(a, min(b, x))
44
+
45
+
46
+ def tau_eff_from_z(z: float) -> float:
47
+ z = max(0.0, float(z))
48
+ return K_TAU * math.log(1.0 + z)
49
+
50
+
51
+ def stable_log_ratio(x: float, x0: float) -> float:
52
+ x = max(float(x), 1e-30)
53
+ x0 = max(float(x0), 1e-30)
54
+ return math.log(x / x0)
55
+
56
+
57
+ def index_from_tau(tau: float) -> float:
58
+ return float(OMEGA_OBS * float(tau) * ALPHA_R)
59
+
60
+
61
+ def geocode_location(q: str):
62
+ q = (q or "").strip()
63
+ if not q:
64
+ return None, None, "Empty location"
65
+ url = "https://geocoding-api.open-meteo.com/v1/search"
66
+ params = {"name": q, "count": 1, "language": "en", "format": "json"}
67
+ r = httpx.get(url, params=params, headers=UA, timeout=12)
68
+ r.raise_for_status()
69
+ js = r.json()
70
+ results = js.get("results") or []
71
+ if not results:
72
+ return None, None, f"Could not geocode '{q}'"
73
+ top = results[0]
74
+ lat = float(top["latitude"])
75
+ lon = float(top["longitude"])
76
+ display = f"{top.get('name','')}, {top.get('country_code','')}".strip().strip(",")
77
+ return lat, lon, display
78
+
79
+
80
+ def fetch_openmeteo_hourly(lat: float, lon: float, past_days: int = 1):
81
+ url = "https://api.open-meteo.com/v1/forecast"
82
+ params = {
83
+ "latitude": lat,
84
+ "longitude": lon,
85
+ "hourly": "temperature_2m,relative_humidity_2m,pressure_msl,wind_speed_10m",
86
+ "past_days": past_days,
87
+ "forecast_days": 1,
88
+ "timezone": "UTC",
89
+ }
90
+ r = httpx.get(url, params=params, headers=UA, timeout=18)
91
+ r.raise_for_status()
92
+ js = r.json()
93
+ hourly = js.get("hourly") or {}
94
+ return {
95
+ "time": hourly.get("time") or [],
96
+ "temp": hourly.get("temperature_2m") or [],
97
+ "rh": hourly.get("relative_humidity_2m") or [],
98
+ "p": hourly.get("pressure_msl") or [],
99
+ "wind": hourly.get("wind_speed_10m") or [],
100
+ }
101
+
102
+
103
+ def fetch_kp_last_24h():
104
+ url = "https://services.swpc.noaa.gov/json/planetary_k_index_1m.json"
105
+ r = httpx.get(url, headers=UA, timeout=15)
106
+ r.raise_for_status()
107
+ js = r.json()
108
+ if not isinstance(js, list) or not js:
109
+ return []
110
+ vals = []
111
+ for row in js:
112
+ kp = row.get("kp_index")
113
+ if kp is None:
114
+ continue
115
+ try:
116
+ vals.append(float(kp))
117
+ except Exception:
118
+ pass
119
+ return vals[-1440:]
120
+
121
+
122
+ def fetch_goes_xray_1day():
123
+ url = "https://services.swpc.noaa.gov/json/goes/primary/xrays-1-day.json"
124
+ r = httpx.get(url, headers=UA, timeout=15)
125
+ r.raise_for_status()
126
+ js = r.json()
127
+ if not isinstance(js, list) or not js:
128
+ return []
129
+ out = []
130
+ for row in js:
131
+ f = row.get("flux")
132
+ if f is None:
133
+ continue
134
+ try:
135
+ out.append(float(f))
136
+ except Exception:
137
+ pass
138
+ return out
139
+
140
+
141
+ def fetch_usgs_quakes(hours: int, minmag: float, bbox=None, center=None, radius_km=None):
142
+ url = "https://earthquake.usgs.gov/fdsnws/event/1/query"
143
+ end = datetime.now(timezone.utc)
144
+ start = end - timedelta(hours=int(hours))
145
+ params = {
146
+ "format": "geojson",
147
+ "starttime": start.isoformat().replace("+00:00", "Z"),
148
+ "endtime": end.isoformat().replace("+00:00", "Z"),
149
+ "minmagnitude": str(float(minmag)),
150
+ "orderby": "time",
151
+ }
152
+
153
+ if bbox is not None:
154
+ minlat, minlon, maxlat, maxlon = bbox
155
+ params.update(
156
+ {
157
+ "minlatitude": str(minlat),
158
+ "minlongitude": str(minlon),
159
+ "maxlatitude": str(maxlat),
160
+ "maxlongitude": str(maxlon),
161
+ }
162
+ )
163
+
164
+ if center is not None and radius_km is not None:
165
+ lat, lon = center
166
+ params.update(
167
+ {
168
+ "latitude": str(float(lat)),
169
+ "longitude": str(float(lon)),
170
+ "maxradiuskm": str(float(radius_km)),
171
+ }
172
+ )
173
+
174
+ r = httpx.get(url, params=params, headers=UA, timeout=22)
175
+ r.raise_for_status()
176
+ js = r.json()
177
+ feats = js.get("features") if isinstance(js, dict) else None
178
+ if not feats:
179
+ return []
180
+ out = []
181
+ for f in feats:
182
+ props = f.get("properties") or {}
183
+ out.append(
184
+ {
185
+ "id": f.get("id"),
186
+ "mag": props.get("mag"),
187
+ "place": props.get("place"),
188
+ "time": props.get("time"),
189
+ }
190
+ )
191
+ return out
192
+
193
+
194
+ def build_verification_links(lat: float, lon: float, seismic_mode: str, seismic_region: str, radius_km: float) -> str:
195
  swpc_kp_page = "https://www.swpc.noaa.gov/products/planetary-k-index"
196
+ swpc_kp_json = "https://services.swpc.noaa.gov/json/planetary_k_index_1m.json"
197
 
 
198
  goes_plot_page = "https://www.swpc.noaa.gov/products/goes-x-ray-flux"
199
+ goes_xray_json = "https://services.swpc.noaa.gov/json/goes/primary/xrays-1-day.json"
 
200
 
 
 
201
  open_meteo_link = (
202
  "https://api.open-meteo.com/v1/forecast"
203
  f"?latitude={lat:.5f}&longitude={lon:.5f}"
204
+ "&hourly=temperature_2m,pressure_msl,wind_speed_10m&past_days=1&forecast_days=1&timezone=UTC"
205
  )
206
 
 
 
207
  usgs_map = "https://earthquake.usgs.gov/earthquakes/map/"
208
+ if seismic_mode == "Local radius":
209
+ usgs_query = (
210
+ "https://earthquake.usgs.gov/fdsnws/event/1/query"
211
+ f"?format=geojson&starttime=&endtime=&minmagnitude=2.5"
212
+ f"&latitude={lat:.5f}&longitude={lon:.5f}&maxradiuskm={float(radius_km):.0f}"
213
+ )
214
+ scope = f"Local radius query ({int(radius_km)} km around your location)"
215
+ else:
216
+ scope = f"Region mode ({seismic_region})"
217
+ usgs_query = "https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&starttime=&endtime=&minmagnitude=2.5"
218
 
219
  return (
220
+ "### Verify instantly (official sources)\n"
221
  f"- **Magnetic (Kp):** {swpc_kp_page} \n"
222
+ f" Live JSON: {swpc_kp_json}\n"
223
  f"- **Solar (GOES X-ray):** {goes_plot_page} \n"
224
+ f" Live JSON: {goes_xray_json}\n"
225
+ f"- **Atmospheric (Open-Meteo API for this location):** {open_meteo_link}\n"
 
226
  f"- **Seismic (USGS map):** {usgs_map} \n"
227
+ f" **USGS query used:** {usgs_query} \n"
228
+ f" Scope: {scope}\n"
 
 
229
  )
230
 
231
+
232
+ def magnetic_agent():
233
+ kp = fetch_kp_last_24h()
234
+ if len(kp) < 30:
235
+ return {"enabled": False, "reason": "NOAA Kp feed too short"}
236
+ last = float(kp[-1])
237
+ tail = kp[-360:] if len(kp) >= 360 else kp
238
+ drift = float(np.std(tail)) if len(tail) >= 10 else 0.0
239
+ slope = float((tail[-1] - tail[0]) / max(1, len(tail) - 1))
240
+
241
+ z = clamp((last / 9.0) + (drift / 2.0) + 2.0 * abs(slope), 0.0, 3.0)
242
+ tau = tau_eff_from_z(z)
243
+ idx = index_from_tau(tau)
244
+
245
+ if last >= 7.0 or z >= 2.0:
246
+ pred = "warning"
247
+ rule = "Kp>=7 OR z>=2.0"
248
+ elif last >= 5.0 or z >= 1.2:
249
+ pred = "watch"
250
+ rule = "Kp>=5 OR z>=1.2"
251
+ elif last >= 4.0 or z >= 0.8:
252
+ pred = "monitor"
253
+ rule = "Kp>=4 OR z>=0.8"
254
+ else:
255
+ pred = "hold"
256
+ rule = "else"
257
+
258
+ live = f"Global Kp={last:.1f} | drift={drift:.2f} | slope={slope:.4f}"
259
+ return {
260
+ "enabled": True,
261
+ "domain": "Magnetic",
262
+ "prediction": pred,
263
+ "rule_fired": rule,
264
+ "z": float(z),
265
+ "tau_eff": float(tau),
266
+ "omega_obs": float(OMEGA_OBS),
267
+ "alpha_r": float(ALPHA_R),
268
+ "index": float(idx),
269
+ "live_status": live,
270
+ "truth_source": "NOAA SWPC planetary_k_index_1m (global)",
271
+ "inputs_used": {"kp_last": last, "kp_drift": drift, "kp_slope": slope, "tail_len": len(tail)},
272
+ "location_effect": "Location does not change Magnetic. Kp is global.",
273
+ "do": "Use to track global geomagnetic regime shifts.",
274
+ "dont": "Do not treat as a city magnetometer.",
275
+ }
276
+
277
+
278
+ def solar_agent():
279
+ flux = fetch_goes_xray_1day()
280
+ if len(flux) < 50:
281
+ return {"enabled": False, "reason": "GOES X-ray feed too short"}
282
+ tail = flux[-120:] if len(flux) >= 120 else flux[-60:]
283
+ f_mean = float(np.mean(tail))
284
+ f_peak = float(np.max(tail))
285
+
286
+ lr = stable_log_ratio(f_mean, 1e-8)
287
+ z = clamp(lr / 10.0, 0.0, 3.0)
288
+ tau = tau_eff_from_z(z)
289
+ idx = index_from_tau(tau)
290
+
291
+ if f_peak >= 1e-4 or z >= 2.2:
292
+ pred = "flare likely"
293
+ rule = "peak>=1e-4 OR z>=2.2"
294
+ elif f_peak >= 1e-5 or z >= 1.5:
295
+ pred = "flare watch"
296
+ rule = "peak>=1e-5 OR z>=1.5"
297
+ elif f_mean >= 1e-6 or z >= 0.9:
298
+ pred = "monitor"
299
+ rule = "mean>=1e-6 OR z>=0.9"
300
+ else:
301
+ pred = "hold"
302
+ rule = "else"
303
+
304
+ live = f"Global GOES mean={f_mean:.2e} | peak={f_peak:.2e}"
305
+ return {
306
+ "enabled": True,
307
+ "domain": "Solar",
308
+ "prediction": pred,
309
+ "rule_fired": rule,
310
+ "z": float(z),
311
+ "tau_eff": float(tau),
312
+ "omega_obs": float(OMEGA_OBS),
313
+ "alpha_r": float(ALPHA_R),
314
+ "index": float(idx),
315
+ "live_status": live,
316
+ "truth_source": "NOAA SWPC GOES xrays-1-day (global)",
317
+ "inputs_used": {"flux_mean": f_mean, "flux_peak": f_peak, "tail_len": len(tail)},
318
+ "location_effect": "Location does not change Solar. GOES flux is global.",
319
+ "do": "Use to track global solar radiative regime shifts.",
320
+ "dont": "Do not treat as flare timing or CME arrival prediction.",
321
+ }
322
+
323
+
324
+ def atmospheric_agent(lat: float, lon: float, display: str):
325
+ wx = fetch_openmeteo_hourly(lat, lon, past_days=1)
326
+ temp = wx["temp"]
327
+ p = wx["p"]
328
+ wind = wx["wind"]
329
+
330
+ if len(temp) < 13:
331
+ return {"enabled": False, "reason": "Open-Meteo hourly series too short"}
332
+
333
+ t12 = [float(x) for x in temp[-13:]]
334
+ dT = float(max(t12) - min(t12))
335
+
336
+ dp = None
337
+ if len(p) >= 13:
338
+ p12 = [float(x) for x in p[-13:]]
339
+ dp = float(p12[-1] - p12[0])
340
+
341
+ w_mean = None
342
+ if len(wind) >= 13:
343
+ w12 = [float(x) for x in wind[-13:]]
344
+ w_mean = float(np.mean(w12))
345
+
346
+ z_dt = clamp(dT / 10.0, 0.0, 2.0)
347
+ z_dp = clamp((abs(dp) / 12.0) if dp is not None else 0.0, 0.0, 1.5)
348
+ z = clamp(z_dt + z_dp, 0.0, 3.0)
349
+ tau = tau_eff_from_z(z)
350
+ idx = index_from_tau(tau)
351
+
352
+ if dT >= 10.0 or (dp is not None and dp <= -10.0):
353
+ pred = "storm risk"
354
+ rule = "ΔT>=10 OR ΔP<=-10"
355
+ elif dT >= 7.0 or (dp is not None and dp <= -6.0):
356
+ pred = "swing"
357
+ rule = "ΔT>=7 OR ΔP<=-6"
358
+ elif dT >= 4.0:
359
+ pred = "mild swing"
360
+ rule = "ΔT>=4"
361
+ else:
362
+ pred = "stable"
363
+ rule = "else"
364
+
365
+ parts = [f"{display} ΔT(12h)={dT:.1f}°C"]
366
+ if dp is not None:
367
+ parts.append(f"ΔP(12h)={dp:.1f} hPa")
368
+ if w_mean is not None:
369
+ parts.append(f"wind≈{w_mean:.1f} m/s")
370
+ live = " | ".join(parts)
371
+
372
+ return {
373
+ "enabled": True,
374
+ "domain": "Atmospheric",
375
+ "prediction": pred,
376
+ "rule_fired": rule,
377
+ "z": float(z),
378
+ "tau_eff": float(tau),
379
+ "omega_obs": float(OMEGA_OBS),
380
+ "alpha_r": float(ALPHA_R),
381
+ "index": float(idx),
382
+ "live_status": live,
383
+ "truth_source": "Open-Meteo hourly (location-based)",
384
+ "inputs_used": {"dT_12h": dT, "dP_12h": dp, "wind_mean": w_mean, "lat": lat, "lon": lon},
385
+ "location_effect": "Location changes Atmospheric.",
386
+ "do": "Use as a short-term stability detector from ΔT and ΔP.",
387
+ "dont": "Do not treat as precipitation probability or full NWP forecast.",
388
+ }
389
+
390
+
391
+ def seismic_agent_region(region: str):
392
+ if region == "RingOfFire":
393
+ seen = set()
394
+ eqs = []
395
+ for bb in RING_OF_FIRE_BBOXES:
396
+ chunk = fetch_usgs_quakes(hours=24, minmag=2.5, bbox=bb)
397
+ for e in chunk:
398
+ eid = e.get("id")
399
+ if eid and eid not in seen:
400
+ seen.add(eid)
401
+ eqs.append(e)
402
+ else:
403
+ bbox = REGION_BBOX.get(region, None)
404
+ eqs = fetch_usgs_quakes(hours=24, minmag=2.5, bbox=bbox)
405
+ return eqs, f"Region={region}"
406
+
407
+
408
+ def seismic_agent_local(lat: float, lon: float, radius_km: float):
409
+ eqs = fetch_usgs_quakes(hours=24, minmag=2.5, center=(lat, lon), radius_km=radius_km)
410
+ return eqs, f"Local radius={int(radius_km)}km"
411
+
412
+
413
+ def seismic_score(eqs):
414
+ N = int(len(eqs))
415
+ mags = []
416
+ for e in eqs:
417
+ m = e.get("mag")
418
+ if m is None:
419
+ continue
420
  try:
421
+ mags.append(float(m))
422
  except Exception:
423
+ pass
424
+ Mmax = float(max(mags)) if mags else 0.0
425
+
426
+ z_count = clamp(N / 60.0, 0.0, 1.5)
427
+ z_mag = clamp(max(0.0, Mmax - 4.0) / 2.5, 0.0, 1.5)
428
+ z = clamp(z_count + z_mag, 0.0, 3.0)
429
+ tau = tau_eff_from_z(z)
430
+ idx = index_from_tau(tau)
431
+
432
+ if Mmax >= 6.5 or z >= 2.2:
433
+ pred = "alert"
434
+ rule = "Mmax>=6.5 OR z>=2.2"
435
+ elif Mmax >= 5.5 or z >= 1.5:
436
+ pred = "watch"
437
+ rule = "Mmax>=5.5 OR z>=1.5"
438
+ elif N >= 25 or z >= 1.0:
439
+ pred = "monitor"
440
+ rule = "N>=25 OR z>=1.0"
441
+ else:
442
+ pred = "quiet"
443
+ rule = "else"
444
+
445
+ return pred, rule, z, tau, idx, N, Mmax
446
+
447
+
448
+ def seismic_agent(mode: str, region: str, lat: float, lon: float, radius_km: float):
449
+ if mode == "Local radius":
450
+ eqs, scope = seismic_agent_local(lat, lon, radius_km)
451
+ location_effect = "Location changes Seismic in Local radius mode."
452
+ do = "Use to monitor seismic activity within the selected radius around your typed location."
453
+ dont = "Do not treat as time/epicenter prediction."
454
+ truth_scope = f"USGS events within {int(radius_km)} km"
455
+ else:
456
+ eqs, scope = seismic_agent_region(region)
457
+ location_effect = "Location does not change Seismic in Region mode. Region selector does."
458
+ do = "Use as a regional seismic stress monitor."
459
+ dont = "Do not treat as time/epicenter prediction."
460
+ truth_scope = f"USGS events filtered by region={region}"
461
+
462
+ pred, rule, z, tau, idx, N, Mmax = seismic_score(eqs)
463
+ live = f"{scope} | quakes(24h,M≥2.5)={N} | max M{Mmax:.1f}"
464
+
465
+ return {
466
+ "enabled": True,
467
+ "domain": "Seismic",
468
+ "prediction": pred,
469
+ "rule_fired": rule,
470
+ "z": float(z),
471
+ "tau_eff": float(tau),
472
+ "omega_obs": float(OMEGA_OBS),
473
+ "alpha_r": float(ALPHA_R),
474
+ "index": float(idx),
475
+ "live_status": live,
476
+ "truth_source": f"USGS FDSN event feed ({truth_scope})",
477
+ "inputs_used": {"count_24h": N, "max_mag_24h": Mmax, "mode": mode, "region": region, "radius_km": float(radius_km), "lat": float(lat), "lon": float(lon)},
478
+ "location_effect": location_effect,
479
+ "do": do,
480
+ "dont": dont,
481
+ "what_it_is_not": "Not an earthquake time predictor. Not a rupture location predictor.",
482
+ "why": "z_seis compresses activity density and severity into a bounded stress coordinate; τ_eff rises as ln(1+z).",
483
+ "how": "Fetch USGS → count + max magnitude → z_seis → τ_eff → Index → label via fixed thresholds.",
484
+ }
485
+
486
+
487
+ def run_forecast(location_text: str, seismic_mode: str, seismic_region: str, radius_km: float):
488
  try:
489
+ lat, lon, display = geocode_location(location_text)
490
+ except Exception as e:
491
+ df = pd.DataFrame([{"Domain": "Error", "RFT Prediction": "DISABLED", "Live Status": f"Geocode error: {e}"}])
492
+ empty = {"enabled": False, "reason": f"Geocode error: {e}"}
493
+ return f"❌ Geocode error: {e}", df, "", empty, empty, empty, empty
494
+
495
+ if lat is None:
496
+ df = pd.DataFrame([{"Domain": "Error", "RFT Prediction": "DISABLED", "Live Status": display}])
497
+ empty = {"enabled": False, "reason": display}
498
+ return f"❌ {display}", df, "", empty, empty, empty, empty
499
+
 
 
 
 
 
 
 
500
  try:
501
+ atm = atmospheric_agent(lat, lon, display)
502
+ except Exception as e:
503
+ atm = {"enabled": False, "reason": f"atmos error: {e}"}
504
+
 
 
 
 
 
 
 
 
505
  try:
506
+ sei = seismic_agent(seismic_mode, seismic_region, lat, lon, radius_km)
507
+ except Exception as e:
508
+ sei = {"enabled": False, "reason": f"seismic error: {e}"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
 
510
+ try:
511
+ mag = magnetic_agent()
512
+ except Exception as e:
513
+ mag = {"enabled": False, "reason": f"magnetic error: {e}"}
514
+
515
+ try:
516
+ sol = solar_agent()
517
+ except Exception as e:
518
+ sol = {"enabled": False, "reason": f"solar error: {e}"}
519
+
520
+ def fmt_row(domain: str, out: dict):
521
+ if not out.get("enabled"):
522
+ return {"Domain": domain, "RFT Prediction": "DISABLED", "Live Status": out.get("reason", "missing inputs")}
523
+ idx = out.get("index", None)
524
+ z = out.get("z", None)
525
+ tau = out.get("tau_eff", None)
526
+ idx_s = f"{float(idx):.3e}" if isinstance(idx, (int, float)) else "n/a"
527
+ z_s = f"{float(z):.2f}" if isinstance(z, (int, float)) else "n/a"
528
+ t_s = f"{float(tau):.3f}" if isinstance(tau, (int, float)) else "n/a"
529
+ return {
530
+ "Domain": domain,
531
+ "RFT Prediction": f"{out.get('prediction','hold')} | idx={idx_s} | z={z_s} | τ={t_s}",
532
+ "Live Status": out.get("live_status", ""),
533
+ }
534
+
535
+ df = pd.DataFrame(
536
+ [
537
+ fmt_row("Atmospheric", atm),
538
+ fmt_row("Seismic", sei),
539
+ fmt_row("Magnetic", mag),
540
+ fmt_row("Solar", sol),
541
+ ]
542
  )
543
 
544
+ ts = utc_now_iso()
545
+ header = f"**Location:** {display} (lat {lat:.3f}, lon {lon:.3f}) | **UTC:** {ts}"
546
+
547
+ verify_md = build_verification_links(lat, lon, seismic_mode, seismic_region, radius_km)
548
+ return header, df, verify_md, atm, sei, mag, sol
549
+
550
+
551
+ INSTRUCTIONS_MD = """
552
+ ## Use and interpretation
553
+
554
+ **Location input**
555
+ - Used for Atmospheric.
556
+ - Used for Seismic only if Seismic Mode is set to Local radius.
557
+ - Not used for Solar or Magnetic (global signals).
558
+
559
+ **Seismic Mode**
560
+ - Region mode: counts quakes in large region (EMEA/AMER/APAC/RingOfFire/Global).
561
+ - Local radius mode: counts quakes within a radius (km) around your typed location.
562
+
563
+ **Run Forecast**
564
+ - Pulls live data and recomputes from scratch.
565
+ - No auto-refresh. No memory. No smoothing.
566
+
567
+ **Reading the table**
568
+ - RFT Prediction shows model state + index + z + τ_eff.
569
+ - Live Status shows the raw physical measurements used.
570
+ - DISABLED means missing/insufficient live data; no guessing is performed.
571
+ """
572
+
573
+ METHOD_MD = f"""
574
+ ## Open method equations
575
+
576
+ Shared core:
577
+ - τ_eff = {K_TAU} · ln(1 + z)
578
+ - Ω_obs = 2π / T_earth = {OMEGA_OBS:.6e}
579
+ - α_R = {ALPHA_R}
580
+ - Index = Ω_obs · τ_eff · α_R
581
+
582
+ z definitions:
583
+ - Atmospheric: z_atm = clamp( clamp(ΔT/10,0..2) + clamp(|ΔP|/12,0..1.5), 0..3 )
584
+ - Seismic: z_seis = clamp( clamp(N/60,0..1.5) + clamp(max(0,Mmax-4)/2.5,0..1.5), 0..3 )
585
+ - Magnetic: z_mag = clamp( (Kp_last/9) + (drift/2) + 2·|slope|, 0..3 )
586
+ - Solar: z_solar= clamp( ln(F_mean/1e-8)/10, 0..3 )
587
+
588
+ Decision thresholds are shown per-domain in the agent output as `rule_fired`.
589
+ """
590
+
591
+
592
  with gr.Blocks(title=APP_NAME) as demo:
593
+ gr.Markdown(f"# {APP_NAME}")
594
 
595
  with gr.Tab("Live Forecast"):
596
+ loc = gr.Textbox(label="Location", value="London, UK")
597
+ gr.Markdown(
598
+ "**Location input**\n\n"
599
+ "- Used for Atmospheric.\n"
600
+ "- Used for Seismic only in **Local radius** mode.\n"
601
+ "- Not used for Solar or Magnetic (global signals).\n\n"
602
+ "If a location cannot be resolved, predictions are disabled instead of guessed."
603
+ )
604
+
605
+ seismic_mode = gr.Radio(
606
+ choices=["Region", "Local radius"],
607
+ value="Local radius",
608
+ label="Seismic Mode"
609
+ )
610
+ gr.Markdown(
611
+ "**Seismic Mode**\n\n"
612
+ "- **Region:** counts earthquakes in a large region.\n"
613
+ "- **Local radius:** counts earthquakes within a radius (km) around your typed location.\n\n"
614
+ "This is a stress monitor, not a time/epicenter prediction system."
615
+ )
616
+
617
  with gr.Row():
618
+ region = gr.Dropdown(["Global", "EMEA", "AMER", "APAC", "RingOfFire"], value="EMEA", label="Seismic Region (used in Region mode)")
619
+ radius = gr.Slider(50, 2000, value=500, step=50, label="Seismic Radius km (used in Local radius mode)")
620
+
621
+ btn = gr.Button("Run Forecast", variant="primary")
622
+ gr.Markdown(
623
+ "**Run Forecast**\n\n"
624
+ "- Pulls live data and recomputes from scratch.\n"
625
+ "- No auto-refresh.\n"
626
+ "- No stored memory.\n"
627
+ "- No guessing when data is missing."
628
+ )
629
 
 
630
  header_md = gr.Markdown()
631
+ gr.Markdown(
632
+ "**How to read the table**\n\n"
633
+ "- RFT Prediction shows model state + index + z + τ_eff.\n"
634
+ "- Live Status shows the raw physical measurements used.\n"
635
+ "- DISABLED means missing/insufficient live data; no guessing is performed."
636
+ )
637
+ table = gr.Dataframe(headers=["Domain", "RFT Prediction", "Live Status"], interactive=False)
638
+
639
  verify_md = gr.Markdown()
640
 
641
+ with gr.Accordion("Atmospheric details", open=False):
642
+ atm_json = gr.JSON(label="Atmospheric agent output")
643
+ with gr.Accordion("Seismic details", open=False):
644
+ sei_json = gr.JSON(label="Seismic agent output")
645
+ with gr.Accordion("Magnetic details", open=False):
646
+ mag_json = gr.JSON(label="Magnetic agent output")
647
+ with gr.Accordion("Solar details", open=False):
648
+ sol_json = gr.JSON(label="Solar agent output")
649
 
650
+ btn.click(
651
+ run_forecast,
652
+ inputs=[loc, seismic_mode, region, radius],
653
+ outputs=[header_md, table, verify_md, atm_json, sei_json, mag_json, sol_json],
 
 
 
 
654
  )
 
 
 
655
 
656
+ with gr.Tab("Method (Open)"):
657
+ gr.Markdown(INSTRUCTIONS_MD)
658
+ gr.Markdown(METHOD_MD)
659
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
660
 
661
  if __name__ == "__main__":
662
  demo.launch()