#!/usr/bin/env python3
"""
Monitor de raios - CBMM, Araxá/MG
Dados REAIS: satélite GOES-19 (GLM - Geostationary Lightning Mapper), NOAA,
bucket público noaa-goes19 na AWS (sem login, sem custo, atraso típico < 2 min).

Uso:   python monitor_raios.py            (abre em http://localhost:8080)
Config (variáveis de ambiente, opcionais):
  SITE_LAT, SITE_LON      coordenadas do ponto a proteger (padrão: CBMM Araxá)
  R_ATENCAO, R_ALERTA, R_PERIGO   raios em km (padrão 50 / 30 / 10)
  PORT                    porta do painel (padrão 8080)
  ALERT_WEBHOOK           URL que recebe um POST (JSON) quando o nível muda
"""
import os, csv, json, math, time, threading
import xml.etree.ElementTree as ET
from collections import deque
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import numpy as np
import requests
import netCDF4

SITE_NAME = os.getenv("SITE_NAME", "CBMM - Araxá/MG")
LAT = float(os.getenv("SITE_LAT", "-19.5736"))   # CONFIRME as coordenadas do seu ponto
LON = float(os.getenv("SITE_LON", "-46.9418"))
R_ATENCAO = float(os.getenv("R_ATENCAO", "50"))
R_ALERTA = float(os.getenv("R_ALERTA", "30"))
R_PERIGO = float(os.getenv("R_PERIGO", "10"))
PORT = int(os.getenv("PORT", "8080"))
WEBHOOK = os.getenv("ALERT_WEBHOOK", "")
WINDOW = 30 * 60            # janela de 30 min (regra 30-30)
STALE = 5 * 60              # sem arquivo novo há > 5 min = sem dados
RADAR_KM = 100              # raio máximo guardado
BUCKET = "https://[Log in to view URL]"
J2000 = datetime(2000, 1, 1, 12, tzinfo=timezone.utc).timestamp()
CSV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "raios_registrados.csv")
LEVELS = ["seguro", "atencao", "alerta", "perigo"]

http = requests.Session()
lock = threading.Lock()
strikes = deque(maxlen=20000)   # dicts: t, lat, lon, dist, x, y
seen = {}                       # chave do arquivo -> inicio (epoch)
state = {"last_end": 0.0, "last_ok": 0.0, "error": "", "level": "seguro"}


# ---------- geometria ----------
def geo(lats, lons):
    """distância (km), x (leste, km) e y (norte, km) de cada ponto em relação ao local."""
    la, lo = np.radians(lats), np.radians(lons)
    la0, lo0 = math.radians(LAT), math.radians(LON)
    dla, dlo = la - la0, lo - lo0
    a = np.sin(dla / 2) ** 2 + math.cos(la0) * np.cos(la) * np.sin(dlo / 2) ** 2
    dist = 2 * 6371.0 * np.arcsin(np.sqrt(a))
    y = np.sin(dla) * 6371.0
    x = np.sin(dlo) * np.cos((la + la0) / 2) * 6371.0
    return dist, x, y


# ---------- NOAA / S3 ----------
def list_keys(prefix):
    ns = {"s": "http://[Log in to view URL]"}
    keys, token = [], None
    while True:
        params = {"list-type": "2", "prefix": prefix}
        if token:
            params["continuation-token"] = token
        r = http.get(BUCKET + "/", params=params, timeout=20)
        r.raise_for_status()
        root = ET.fromstring(r.content)
        keys += [c.findtext("s:Key", namespaces=ns) for c in root.findall("s:Contents", ns)]
        if root.findtext("s:IsTruncated", namespaces=ns) == "true":
            token = root.findtext("s:NextContinuationToken", namespaces=ns)
        else:
            return keys


def key_times(key):
    """início e fim (epoch UTC) a partir do nome: ..._sYYYYDDDHHMMSSt_eYYYYDDDHHMMSSt_..."""
    def p(tag):
        s = [x for x in key.split("_") if x.startswith(tag)][0][1:]
        d = datetime.strptime(s[:13], "%Y%j%H%M%S").replace(tzinfo=timezone.utc)
        return d.timestamp() + int(s[13]) / 10
    return p("s"), p("e")


def parse_flashes(data, t0, t1):
    """lista de (epoch, lat, lon) dos flashes de um arquivo GLM-L2-LCFA."""
    ds = netCDF4.Dataset("glm.nc", mode="r", memory=data)
    try:
        fill = lambda v: np.ma.filled(np.ma.asarray(ds.variables[v][:]).astype("float64"), np.nan)
        lat, lon = fill("flash_lat"), fill("flash_lon")
        off = fill("flash_time_offset_of_first_event")
        pt = J2000 + float(ds.variables["product_time"][:])
    finally:
        ds.close()
    t = pt + off
    bad = ~((t >= t0 - 10) & (t <= t1 + 10))      # se o tempo vier fora da janela, usa o início do arquivo
    t = np.where(bad, t0, t)
    ok = np.isfinite(lat) & np.isfinite(lon)
    return list(zip(t[ok], lat[ok], lon[ok]))


def ingest(flashes):
    if not flashes:
        return 0
    arr = np.array(flashes, dtype="float64")
    dist, x, y = geo(arr[:, 1], arr[:, 2])
    sel = dist <= RADAR_KM
    new = []
    for (t, la, lo), d, xx, yy in zip(arr[sel], dist[sel], x[sel], y[sel]):
        new.append({"t": float(t), "lat": float(la), "lon": float(lo),
                    "dist": float(d), "x": float(xx), "y": float(yy)})
    if new:
        with lock:
            strikes.extend(new)
        newfile = not os.path.exists(CSV_PATH)
        with open(CSV_PATH, "a", newline="") as f:
            w = csv.writer(f)
            if newfile:
                w.writerow(["utc", "lat", "lon", "distancia_km"])
            for s in new:
                w.writerow([datetime.fromtimestamp(s["t"], timezone.utc).isoformat(timespec="seconds"),
                            round(s["lat"], 4), round(s["lon"], 4), round(s["dist"], 1)])
    return len(new)


def poll_once():
    now = datetime.now(timezone.utc)
    hours = sorted({(now - timedelta(minutes=m)).replace(minute=0, second=0, microsecond=0) for m in (0, 35)})
    cutoff = now.timestamp() - WINDOW - 300
    for h in hours:
        prefix = f"GLM-L2-LCFA/{h.year}/{h.timetuple().tm_yday:03d}/{h.hour:02d}/"
        for key in list_keys(prefix):
            if key in seen:
                continue
            t0, t1 = key_times(key)
            if t1 < cutoff:
                seen[key] = t0
                continue
            r = http.get(f"{BUCKET}/{key}", timeout=30)
            r.raise_for_status()
            ingest(parse_flashes(r.content, t0, t1))
            seen[key] = t0
            with lock:
                state["last_end"] = max(state["last_end"], t1)
    for k in [k for k, v in seen.items() if v < cutoff - 3600]:
        del seen[k]


def poll_loop():
    while True:
        try:
            poll_once()
            with lock:
                state["last_ok"] = time.time()
                state["error"] = ""
        except Exception as e:
            with lock:
                state["error"] = f"{type(e).__name__}: {e}"
        time.sleep(20)


# ---------- situação ----------
def status():
    now = time.time()
    with lock:
        recent = [s for s in strikes if now - s["t"] <= WINDOW]
        last_end, err, last_ok = state["last_end"], state["error"], state["last_ok"]
    age = now - last_end if last_end else None
    data_ok = age is not None and age <= STALE
    nearest = min((s["dist"] for s in recent), default=None)
    level = "seguro"
    if nearest is not None:
        level = "perigo" if nearest < R_PERIGO else "alerta" if nearest < R_ALERTA else \
                "atencao" if nearest < R_ATENCAO else "seguro"
    inside = [s for s in recent if s["dist"] < R_ALERTA]
    clear_in = None
    if inside:
        clear_in = max(0, math.ceil((max(s["t"] for s in inside) + WINDOW - now) / 60))
    return {
        "site": SITE_NAME, "lat": LAT, "lon": LON, "now": now,
        "radii": [R_PERIGO, R_ALERTA, R_ATENCAO], "radar_km": RADAR_KM,
        "level": level, "data_ok": data_ok, "data_age_s": age, "error": err,
        "nearest": nearest, "n_total": len(recent), "n_alerta": len(inside),
        "clear_in_min": clear_in,
        "strikes": [{"t": s["t"], "x": round(s["x"], 2), "y": round(s["y"], 2), "d": round(s["dist"], 1)}
                    for s in recent[-600:]],
    }


def watch_levels():
    while True:
        time.sleep(10)
        st = status()
        if st["level"] != state["level"]:
            old, state["level"] = state["level"], st["level"]
            if WEBHOOK:
                try:
                    http.post(WEBHOOK, json={"site": SITE_NAME, "de": old, "para": st["level"],
                                             "mais_proximo_km": st["nearest"], "utc": datetime.now(timezone.utc).isoformat()},
                              timeout=10)
                except Exception:
                    pass


# ---------- painel ----------
PAGE = r"""<!DOCTYPE html><html lang="pt-BR"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Monitor de raios</title>
<style>
:root{--bg:#eef0f6;--sf:#fff;--ink:#1b1f33;--mu:#5d6482;--ln:#d5d9e8;--ok:#1f7a54;--at:#a86400;--al:#c24a00;--pe:#c4202f;--st:#4a3fd0;--rd:#f6f7fb}
@media(prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#0e1020;--sf:#171a30;--ink:#e8eaf6;--mu:#9aa1c4;--ln:#2b3052;--ok:#4fd1a0;--at:#f0b040;--al:#ff8a3d;--pe:#ff5468;--st:#d6d2ff;--rd:#0b0d1b}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.5 system-ui,Segoe UI,Roboto,sans-serif}
main{max-width:1000px;margin:auto;padding:16px;display:grid;gap:16px;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr)}
@media(max-width:760px){main{grid-template-columns:1fr}}
h1{grid-column:1/-1;margin:0;font-size:1.5rem}h1 small{display:block;font-size:.9rem;font-weight:400;color:var(--mu)}
section{background:var(--sf);border:1px solid var(--ln);border-radius:10px;padding:16px}
canvas{width:100%;aspect-ratio:1;display:block;border-radius:50%;background:var(--rd);border:1px solid var(--ln)}
#box{border-left:10px solid var(--ok)}#box b{font-size:1.8rem;display:block;line-height:1.2}
.atencao{border-color:var(--at)!important}.alerta{border-color:var(--al)!important}.perigo{border-color:var(--pe)!important}.semdados{border-color:var(--mu)!important}
.st{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;text-align:center}.st div{border:1px solid var(--ln);border-radius:8px;padding:8px 4px}
.st b{display:block;font-size:1.3rem}.st span,.hint{font-size:.85rem;color:var(--mu)}
.col{display:flex;flex-direction:column;gap:16px}button{font:inherit;color:var(--ink);background:var(--sf);border:1px solid var(--ln);border-radius:8px;padding:10px 14px;min-height:44px;cursor:pointer}
button:focus-visible{outline:3px solid var(--st);outline-offset:2px}h2{margin:0 0 10px;font-size:1rem}
</style></head><body><main>
<h1 id="site">Monitor de raios<small>Dados reais: satélite GOES-19 (GLM / NOAA), atualização a cada ~20 s</small></h1>
<section><canvas id="cv" role="img" aria-label="Radar de raios"></canvas>
<p class="hint">Local monitorado no centro. Anéis nos raios de perigo, alerta e atenção. Posição dos raios tem erro típico de alguns km.</p></section>
<div class="col">
<section id="box" aria-live="polite"><b id="lvl">...</b><span id="txt"></span></section>
<section><h2>Últimos 30 minutos</h2><div class="st">
<div><b id="n1">0</b><span>raios (100 km)</span></div><div><b id="n2">0</b><span id="n2l">no raio de alerta</span></div><div><b id="n3">–</b><span>mais próximo</span></div></div></section>
<section><h2>Fonte dos dados</h2><p id="src" class="hint" style="margin:0"></p>
<p style="margin:8px 0 0"><button id="snd">Ativar som de alerta</button></p></section>
</div></main>
<script>
const $=i=>document.getElementById(i),cv=$('cv'),ctx=cv.getContext('2d');let S=null,sound=false,prev='seguro';
const NOME={seguro:'Seguro',atencao:'Atenção',alerta:'Alerta',perigo:'Perigo',semdados:'Sem dados'};
const f=n=>n.toFixed(1).replace('.',',');
function rs(){const r=cv.getBoundingClientRect(),d=devicePixelRatio||1;cv.width=r.width*d;cv.height=r.width*d;ctx.setTransform(d,0,0,d,0,0)}
addEventListener('resize',rs);rs();
const css=n=>getComputedStyle(document.documentElement).getPropertyValue(n).trim();
function beep(n){if(!sound)return;const a=new(window.AudioContext||webkitAudioContext)();for(let i=0;i<n;i++){const o=a.createOscillator(),g=a.createGain();o.connect(g);g.connect(a.destination);o.frequency.value=880;g.gain.value=.2;o.start(a.currentTime+i*.4);o.stop(a.currentTime+i*.4+.25)}}
$('snd').onclick=()=>{sound=true;$('snd').textContent='Som ativado';beep(1)};
async function load(){try{const r=await fetch('/api/status');S=await r.json();S.fetched=Date.now()/1000;render()}catch(e){$('lvl').textContent='Sem conexão com o servidor'}}
function render(){
 const lv=S.data_ok?S.level:'semdados';$('box').className=lv;$('site').firstChild.textContent=S.site+' ';
 $('lvl').textContent=NOME[lv];
 let t;
 if(!S.data_ok)t='Sem dados recentes do satélite'+(S.error?' ('+S.error+')':'')+'. Ausência de dados não significa ausência de raios: use outra fonte.';
 else if(S.nearest===null)t='Nenhum raio a menos de '+S.radar_km+' km nos últimos 30 minutos.';
 else{t='Raio mais próximo a '+f(S.nearest)+' km.';
  if(lv=='perigo')t+=' Abrigue-se agora e suspenda atividades externas.';
  else if(lv=='alerta')t+=' Encerre atividades ao ar livre e vá para abrigo.';
  else if(lv=='atencao')t+=' Tempestade pode se aproximar; acompanhe.';
  if(S.clear_in_min!==null)t+=' Aguarde '+S.clear_in_min+' min sem raios dentro de '+S.radii[1]+' km para liberar.'}
 $('txt').textContent=t;$('n1').textContent=S.n_total;$('n2').textContent=S.n_alerta;$('n2l').textContent='a menos de '+S.radii[1]+' km';
 $('n3').textContent=S.nearest===null?'–':f(S.nearest)+' km';
 const age=S.data_age_s;$('src').textContent='Último arquivo do satélite: '+(age==null?'nenhum ainda':'há '+Math.round(age)+' s')+'. Registro em raios_registrados.csv.';
 if(lv!==prev){const o=LV.indexOf(lv),p=LV.indexOf(prev);if(o>p&&lv!=='semdados')beep(lv=='perigo'?4:2);prev=lv}
}
const LV=['seguro','atencao','alerta','perigo'];
function draw(){
 const w=cv.getBoundingClientRect().width,c=w/2;ctx.clearRect(0,0,w,w);
 if(S){const R=S.radar_km,k=c/R*.94,now=Date.now()/1000;
  ctx.strokeStyle=css('--ln');ctx.fillStyle=css('--mu');ctx.font='11px system-ui';
  [...S.radii,R].forEach(r=>{ctx.beginPath();ctx.arc(c,c,r*k,0,7);ctx.stroke();ctx.fillText(r+' km',c+4,c-r*k+12)});
  ctx.beginPath();ctx.moveTo(c,0);ctx.lineTo(c,w);ctx.moveTo(0,c);ctx.lineTo(w,c);ctx.stroke();ctx.fillText('N',c-4,14);
  const st=css('--st'),pe=css('--pe');
  S.strikes.forEach(s=>{const age=S.now-s.t+(Date.now()/1000-S.fetched);if(age>1800)return;
   ctx.globalAlpha=Math.max(.3,1-age/1800);ctx.fillStyle=s.d<S.radii[0]?pe:st;ctx.beginPath();ctx.arc(c+s.x*k,c-s.y*k,3,0,7);ctx.fill()});
  ctx.globalAlpha=1;ctx.fillStyle=css('--ink');ctx.beginPath();ctx.arc(c,c,5,0,7);ctx.fill()}
 requestAnimationFrame(draw)}
load();setInterval(load,5000);draw();
</script></body></html>"""


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path.startswith("/api/status"):
            body, ctype = json.dumps(status()).encode(), "application/json"
        elif self.path in ("/", "/index.html"):
            body, ctype = PAGE.encode(), "text/html; charset=utf-8"
        else:
            self.send_error(404)
            return
        self.send_response(200)
        self.send_header("Content-Type", ctype)
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *a):
        pass


if __name__ == "__main__":
    threading.Thread(target=poll_loop, daemon=True).start()
    threading.Thread(target=watch_levels, daemon=True).start()
    print(f"Monitorando {SITE_NAME} ({LAT}, {LON}). Painel em http://localhost:{PORT}")
    ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()

Embed on website

To embed this project on your website, copy the following code and paste it into your website's HTML: