#!/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()
To embed this project on your website, copy the following code and paste it into your website's HTML: