File size: 15,816 Bytes
3560ab3
 
ee70ae1
3560ab3
 
 
 
 
 
 
 
 
19e92d3
1a44fdb
4e65ab0
 
19e92d3
3560ab3
 
1a44fdb
19e92d3
1a44fdb
ee70ae1
 
 
232f6e8
19e92d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22283cf
 
 
 
 
 
 
 
 
 
 
 
 
1a44fdb
19e92d3
 
 
 
1a44fdb
22283cf
19e92d3
 
 
 
22283cf
19e92d3
 
22283cf
19e92d3
 
 
 
 
 
 
 
 
22283cf
19e92d3
 
 
 
 
 
 
 
 
 
22283cf
19e92d3
1a44fdb
19e92d3
 
 
 
 
1a44fdb
22283cf
19e92d3
 
 
 
 
 
 
 
2541927
19e92d3
 
 
 
22283cf
 
19e92d3
 
 
 
 
 
 
 
 
 
 
22283cf
19e92d3
 
 
22283cf
 
19e92d3
22283cf
19e92d3
22283cf
 
 
 
 
19e92d3
22283cf
 
 
 
19e92d3
 
 
22283cf
 
 
 
 
 
19e92d3
 
 
 
 
22283cf
 
19e92d3
22283cf
 
 
19e92d3
22283cf
 
 
 
 
 
 
 
19e92d3
 
 
22283cf
19e92d3
 
 
 
 
 
 
 
 
22283cf
19e92d3
 
 
 
22283cf
19e92d3
22283cf
19e92d3
 
 
 
 
 
22283cf
19e92d3
 
22283cf
19e92d3
22283cf
19e92d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22283cf
19e92d3
 
 
 
 
22283cf
19e92d3
 
 
 
 
22283cf
19e92d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee70ae1
3560ab3
 
19e92d3
ee70ae1
19e92d3
232f6e8
eac0077
 
19e92d3
 
ee70ae1
 
19e92d3
ee70ae1
 
 
 
 
19e92d3
ee70ae1
19e92d3
ee70ae1
eac0077
 
19e92d3
 
3560ab3
 
19e92d3
ee70ae1
3560ab3
ee70ae1
 
eac0077
19e92d3
 
 
22283cf
19e92d3
 
3560ab3
19e92d3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
import gradio as gr
import obspy
from obspy import Stream, Trace
from obspy.clients.seedlink.easyseedlink import create_client
from seisbench.models import PhaseNet
from groq import Groq
import threading
import numpy as np
import collections
import pandas as pd
import os
import time
from datetime import datetime
import requests
import logging

logging.getLogger("seisbench").setLevel(logging.ERROR)

GROQ_API_KEY = os.environ.get("api_groq")
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")
TELEGRAM_CHAT_ID = "-5515143628"

SERVIDOR_CHILE_CSN = "eew.csn.uchile.cl" 
SERVIDOR_ASIA_IRIS = "rtserve.iris.washington.edu"

ESTACIONES_CHILE = [
    # --- NORTE GRANDE (Arica, Tarapacá, Antofagasta) ---
    {"net": "C1", "sta": "GO01", "loc": "Iquique (Tarapacá - Costa) *Latencia ~6h"},  # 
    {"net": "CX", "sta": "PB01", "loc": "Pica (Tarapacá - Interior)"},               # 
    {"net": "CX", "sta": "PB02", "loc": "Pozo Almonte (Tarapacá)"},                  # 
    {"net": "C1", "sta": "LMEL", "loc": "María Elena (Antofagasta - Interior)"},      # 
    {"net": "CX", "sta": "PATCX","loc": "Pampa Alta (Antofagasta) *Latencia ~3h"},    # 
    {"net": "CX", "sta": "PB10", "loc": "Antofagasta (Norte Interior)"},              # 

    # --- NORTE CHICO & CENTRAL (Atacama, Coquimbo, Valparaíso, RM) ---
    {"net": "C1", "sta": "GO02", "loc": "Copiapó (Atacama)"},                         # 
    {"net": "C1", "sta": "GO03", "loc": "Vallenar / Huasco (Atacama)"},               # 
    {"net": "IU", "sta": "LCO",  "loc": "Las Campanas (Observatorio - Coquimbo)"},    # 
    {"net": "C1", "sta": "GO06", "loc": "La Serena / Coquimbo (Costa)"},              # 
    {"net": "C1", "sta": "MT01", "loc": "Santiago / Farellones (Metropolitana)"},     # 
    {"net": "C1", "sta": "MT02", "loc": "Santiago / San José de Maipo (RM)"},         # 
    {"net": "C1", "sta": "ROC1", "loc": "Rancagua / El Teniente (O'Higgins)"},        # 

    # --- ZONA SUR & AUSTRAL (Biobío, La Araucanía, Los Lagos, Magallanes) ---
    {"net": "C1", "sta": "CO01", "loc": "Concepción / Talcahuano (Biobío)"},          # 
    {"net": "C1", "sta": "CO03", "loc": "Chillán / Ñuble (Interior)"},                # 
    {"net": "C1", "sta": "TA01", "loc": "Temuco / Araucanía"},                        # 
    {"net": "C1", "sta": "TA02", "loc": "Valdivia / Los Ríos"},                       # 
    {"net": "C1", "sta": "TA03", "loc": "Puerto Montt / Los Lagos"},                  # 
    {"net": "G",  "sta": "COYC", "loc": "Coyhaique (Aysén)"}                          # 
]

REDES_ASIA = [
    {"net": "IU", "sta": "MAJO", "pais": "Japón", "loc": "Matsushiro"},
    {"net": "II", "sta": "ERM",  "pais": "Japón", "loc": "Erimo (Sanriku)"},
    {"net": "IU", "sta": "PAB",  "pais": "Filipinas", "loc": "San Pablo (Luzón)"},
    {"net": "II", "sta": "DAV",  "pais": "Filipinas", "loc": "Dávao (Mindanao)"},
    {"net": "GE", "sta": "PSI",  "pais": "Indonesia", "loc": "Prapat (Sumatra)"},
    {"net": "GE", "sta": "JCJI", "pais": "Indonesia", "loc": "Jeti (Java)"}
]

MUESTRAS_VENTANA = 3000
buffers_globales = {}

print("[SISTEMA] Cargando e inicializando PhaseNet...")
model = PhaseNet.from_pretrained("original")
model.eval()
print("[SISTEMA] PhaseNet configurado y listo para producción.")

def analizar_paquete_global(trace):
    net = trace.stats.network
    estacion = trace.stats.station
    channel = trace.stats.channel
    componente = channel[-1] if channel else None

    if componente not in ['Z', 'N', 'E']:
        return

    if estacion not in buffers_globales:
        match_cl = next((e for e in ESTACIONES_CHILE if e["sta"] == estacion), None)
        if match_cl:
            pais = "Chile"
            loc = match_cl["loc"]
        else:
            match_as = next((e for e in REDES_ASIA if e["sta"] == estacion), None)
            pais = match_as["pais"] if match_as else "Internacional"
            loc = match_as["loc"] if match_as else "Ubicación Remota"

        buffers_globales[estacion] = {
            'Z': collections.deque(maxlen=MUESTRAS_VENTANA),
            'N': collections.deque(maxlen=MUESTRAS_VENTANA),
            'E': collections.deque(maxlen=MUESTRAS_VENTANA),
            'net': net,
            'channel': channel[:-1],
            'sampling_rate': trace.stats.sampling_rate,
            'pais': pais,
            'loc': loc
        }

    buffers_globales[estacion][componente].extend(trace.data)

    if (len(buffers_globales[estacion]['Z']) == MUESTRAS_VENTANA and 
        len(buffers_globales[estacion]['N']) == MUESTRAS_VENTANA and 
        len(buffers_globales[estacion]['E']) == MUESTRAS_VENTANA):
        
        evaluar_ruptura(estacion)

def evaluar_ruptura(estacion):
    meta = buffers_globales[estacion]
    sr = meta['sampling_rate']
    
    st = Stream()
    amplitudes_maximas = []
    
    for comp in ['Z', 'N', 'E']:
        data_array = np.array(meta[comp], dtype=np.float32)
        
        # --- FILTRO 1: ANTI-GLITCH ELECTRÓNICO ---
        # Si hay un pico de energía plano absurdo (típico error de sensor muerto), descartamos.
        amp_pico = np.max(np.abs(data_array))
        if amp_pico > 1e7:  # Umbral de tolerancia eléctrica estándar para cuentas digitales masivas
            return
            
        data_array = data_array - np.mean(data_array)
        amplitudes_maximas.append(amp_pico)
        
        header = {
            'network': meta['net'],
            'station': estacion,
            'channel': f"{meta['channel']}{comp}",
            'sampling_rate': sr,
            'starttime': obspy.UTCDateTime()
        }
        st.append(Trace(data=data_array, header=header))

    try:
        # --- FILTRO 2: BANDPASS FILTER (1.0 Hz - 15.0 Hz) ---
        # Remueve ruidos ambientales mecánicos y frecuencias parásitas antes de PhaseNet
        st.detrend("linear")
        st.taper(max_percentage=0.05, type="cosine")
        st.filter("bandpass", fmin=1.0, fmax=15.0, corners=4, zerophase=True)
        
        # Normalización estricta por desviación estándar post-filtrado
        for trace in st:
            std = np.std(trace.data)
            if std > 0: 
                trace.data /= std

        # Pasamos la traza limpia por el modelo de IA sismológica
        annotations = model.annotate(st)
        prob_P = np.max(annotations[0].data)
        prob_S = np.max(annotations[1].data)
        
        # --- MODIFICACIÓN: Ajuste de estrictez ---
        # Bajamos P a 0.70 y S a 0.60. Mantiene precisión exigiendo ambas, pero es menos estricto con la dispersión real de la señal.
        if prob_P > 0.70 and prob_S > 0.60:
            idx_P = np.argmax(annotations[0].data)
            idx_S = np.argmax(annotations[1].data)
            
            if idx_S > idx_P:
                delta_t = (idx_S - idx_P) / float(sr)
                
                # --- VALIDACIÓN FÍSICA SÍSMICA ---
                # Un tiempo P-S menor a 0.5s o mayor a 120s en redes regionales suele ser ruido o anomalía.
                if delta_t < 0.5 or delta_t > 120.0:
                    return
                    
                pais = meta['pais']
                ubicacion = meta['loc']
                
                distancia_km = delta_t * 8.2
                amplitud_maxima_total = np.max(amplitudes_maximas)
                
                # Cálculo calibrado de magnitud local
                if amplitud_maxima_total > 0 and distancia_km > 0:
                    magnitud_estimada = np.log10(amplitud_maxima_total) + 1.6 * np.log10(distancia_km) - 0.15
                    magnitud_estimada = round(max(1.0, min(9.5, magnitud_estimada)), 1)
                else:
                    magnitud_estimada = 0.0
                
                disparar_alerta_ia_global(estacion, ubicacion, pais, delta_t, prob_P, prob_S, magnitud_estimada)
                
                for c in ['Z', 'N', 'E']: meta[c].clear()
    except:
        pass

import asyncio
import aiohttp
import time

def enviar_a_telegram_background(url, payload):
    """
    Punto de entrada compatible con hilos que inicializa y ejecuta 
    el bucle asíncrono dedicado para saltarse el estrangulamiento de CPU.
    """
    try:
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        loop.run_until_complete(despachar_hacia_gateway_async(url, payload))
        loop.close()
    except Exception as e:
        print(f"⚠️ [SISTEMA] Error crítico al inicializar bucle asíncrono: {e}")

async def despachar_hacia_gateway_async(url, payload):
    """Despacha la alerta usando sockets asíncronos no bloqueantes"""
    intentos_maximos = 3
    timeout_estricto = aiohttp.ClientTimeout(total=25)
    
    await asyncio.sleep(0.5)

    async with aiohttp.ClientSession(trust_env=False, timeout=timeout_estricto) as session:
        for intento in range(1, intentos_maximos + 1):
            try:
                print(f"🔄 [ASYNC GATEWAY] Intentando envío (Intento {intento}/{intentos_maximos})...", flush=True)
                
                headers = {
                    "Connection": "close",
                    "Content-Type": "application/json"
                }
                
                async with session.post(url, json=payload, headers=headers) as response:
                    status = response.status
                    text_response = await response.text()
                    
                    print(f"📥 [ASYNC GATEWAY] Status Code recibido: {status}")
                    
                    if status == 200:
                        print(f"✅ [TELEGRAM] ¡Alerta sísmica enviada con éxito mediante Cloudflare Workers!")
                        return True
                    elif status == 429:
                        print("⏳ [ASYNC GATEWAY] Rate-limit activo en la API. Esperando reintento...")
                        await asyncio.sleep(4)
                    else:
                        print(f"❌ [ASYNC GATEWAY] Error devuelto por endpoint: {text_response}")
                        return False
                        
            except asyncio.TimeoutError:
                print(f"⚠️ [ASYNC GATEWAY] Timeout en intento {intento}. CPU saturada por SeisBench, reintentando...")
                await asyncio.sleep(2)
            except Exception as e:
                print(f"⚠️ [ASYNC GATEWAY] Error de conexión en socket asíncrono: {e}")
                await asyncio.sleep(1)
                
    print("❌ [TELEGRAM] Envío cancelado. No se pudo liberar el socket tras 3 intentos.")
    return False
    
def disparar_alerta_ia_global(estacion, ubicacion, pais, delta_t, p_prob, s_prob, magnitud):
    distancia_epicentro_km = round(delta_t * 8.2, 1)
    
    str_magnitud = f"{magnitud} Ml" if magnitud > 0 else "En cálculo preliminar"
    reporte_final = f"⚠️ <b>[ALERTA SÍSMICA AUTOMÁTICA]</b><br><br>• <b>País:</b> {pais}<br>• <b>Estación:</b> {estacion} ({ubicacion})<br>• <b>Tiempo P-S:</b> {delta_t}s<br>• <b>Magnitud Estimada:</b> {str_magnitud}<br>• <b>Epicentro estimado:</b> a ~{distancia_epicentro_km} km del sensor."

    if GROQ_API_KEY:
        client_groq = Groq(api_key=GROQ_API_KEY)
        
        prompt = f"""[DETECCIÓN PRELIMINAR AUTOMÁTICA INSTRUMENTAL - MONITOREO SEISNET]
        El sistema automático ha filtrado y verificado una posible señal sísmica en la corteza:
        - Región / País: {pais}
        - Ubicación técnica: {ubicacion} (Sensor: {estacion})
        - Tiempo transcurrido entre Onda P y Onda S: {delta_t} segundos.
        - Radio estimado al epicentro: {distancia_epicentro_km} km.
        - Certeza de Fase: Onda P ({round(p_prob*100,1)}%), Onda S ({round(s_prob*100,1)}%).
        - Magnitud Local Estimada: {str_magnitud}.
        
        Instrucciones de formato de salida OBLIGATORIAS:
        1. Encabeza OBLIGATORIAMENTE el mensaje con el título: "⚠️ DETECCIÓN PRELIMINAR INSTRUMENTAL AUTOMÁTICA (EN PROCESO DE VALIDACIÓN)" en mayúsculas destacadas con emojis formales.
        2. Redacta el informe en ESPAÑOL de manera clara, objetiva y breve para lectura móvil.
        3. NO uses asteriscos para negrita. Si quieres usar negrita utiliza la etiqueta HTML <b>texto</b> y para cursiva <i>texto</i>.
        4. Explica explícitamente que es un cálculo de software automático en tiempo real procesado por el equipo de SeisNet y que NO reemplaza bajo ningún motivo el reporte oficial ni la opinión experta del Centro Sismológico Nacional de Chile (CSN).
        5. Llama a mantener la calma e infórmale a las zonas cercanas el tiempo estimado de viaje que tienen las ondas secundarias basado en el radio calculado.
        6. Muestra la Magnitud Local Estimada de forma clara en su sección."""

        try:
            completion = client_groq.chat.completions.create(
                model="llama-3.1-8b-instant",
                messages=[{"role": "user", "content": prompt}]
            )
            reporte_final = completion.choices[0].message.content
            reporte_final = reporte_final.replace("\n", "<br>")
        except Exception as e:
            print(f"Error en Groq API: {e}. Se enviará reporte técnico base.")

    print(f"\n🚨 [ALERTA GENERADA] - Despachando a cola de Telegram... 🚨\n")

    if TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID:
        url_gateway = f"https://seisnet.bleondubos.workers.dev/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
        
        texto_telegram = reporte_final.replace("<br>", "\n").replace("<br/>", "\n")
        texto_telegram = texto_telegram.replace("<tbd>", "TBD").replace("<TBD>", "TBD")
        texto_telegram = texto_telegram.replace("</tbd>", "")
        
        payload = {
            "chat_id": str(TELEGRAM_CHAT_ID).strip(), 
            "text": texto_telegram,
        }
        
        threading.Thread(target=enviar_a_telegram_background, args=(url_gateway, payload), daemon=True).start()
    else:
        print(f"⚠️ [TELEGRAM] Envío omitido: Faltan credenciales (Token: {'OK' if TELEGRAM_BOT_TOKEN else 'FALTA'}, ChatID: {'OK' if TELEGRAM_CHAT_ID else 'FALTA'})")
        
def conectar_chile_csn():
    while True:
        try:
            print(f"[CHILE CSN] Abriendo canal explícito con {SERVIDOR_CHILE_CSN}...")
            client = create_client(SERVIDOR_CHILE_CSN, on_data=analizar_paquete_global)
            
            for est in ESTACIONES_CHILE:
                try:
                    client.select_stream(est["net"], est["sta"], "?H?")
                except:
                    continue
            client.run()
        except Exception as e:
            print(f"[RECONEXIÓN CHILE] Reintentando en 15 segundos... Info: {e}")
            time.sleep(15)

def conectar_asia_iris():
    while True:
        try:
            print(f"[ASIA IRIS] Abriendo canal con {SERVIDOR_ASIA_IRIS}...")
            client = create_client(SERVIDOR_ASIA_IRIS, on_data=analizar_paquete_global)
            
            for est in REDES_ASIA:
                try:
                    client.select_stream(est["net"], est["sta"], "BH?")
                except:
                    continue
            client.run()
        except Exception as e:
            print(f"[RECONEXIÓN ASIA] Reintentando en 15 segundos... Info: {e}")
            time.sleep(15)

threading.Thread(target=conectar_chile_csn, daemon=True).start()
threading.Thread(target=conectar_asia_iris, daemon=True).start()

with gr.Blocks() as demo:
    gr.Markdown("# 🌍 Consola de Vigilancia Sísmica Total - Cobertura País Chile")
    gr.Markdown("Escucha activa multipunto en el servidor del CSN sin bloqueos de comodines.")
    
    with gr.Row():
        gr.DataFrame(pd.DataFrame(ESTACIONES_CHILE), label="Malla de Sensores Chilenos en Monitoreo Directo")

demo.launch()