Spaces:
Sleeping
Sleeping
| """ | |
| GNSS Guardian — GPS Jamming & Spoofing Detection | |
| Detect -> Locate -> Retrieve Similar Cases -> Generate Threat Report -> Alert | |
| """ | |
| import os | |
| import json | |
| import urllib.request | |
| import urllib.parse | |
| import urllib.error | |
| import socket | |
| from datetime import datetime, timezone | |
| import gradio as gr | |
| import numpy as np | |
| import pandas as pd | |
| import matplotlib.pyplot as plt | |
| import matplotlib.patches as mpatches | |
| import tensorflow as tf | |
| # ── Load model & scaler ────────────────────────────────────────── | |
| model = tf.keras.models.load_model('gnss_cnn_model.keras') | |
| with open('gnss_scaler_meta.json') as f: | |
| meta = json.load(f) | |
| MEAN = np.array(meta['mean_'], dtype=np.float32) | |
| SCALE = np.array(meta['scale_'], dtype=np.float32) | |
| WINDOW = meta['window_size'] | |
| CLASSES = {0: 'Normal', 1: 'Jamming', 2: 'Spoofing'} | |
| COLORS = {0: '#2ecc71', 1: '#e74c3c', 2: '#3498db'} | |
| REQUIRED_COLS = ['cn0_mean', 'cn0_std', 'agc_level', | |
| 'sv_count', 'pos_variance', 'delta_pseudorange'] | |
| AGC_THRESHOLD = meta['agc_jamming_threshold'] | |
| SV_IDX = meta['sv_count_feature_idx'] | |
| AGC_IDX = meta['agc_feature_idx'] | |
| EXAMPLES = [ | |
| [42.0, 1.5, -85.0, 10.0, 0.5, 0.1], | |
| [12.0, 6.5, -61.0, 1.0, 9.0, 3.5], | |
| [49.5, 2.0, -83.5, 8.0, 18.0, 9.2], | |
| ] | |
| # ── Space theme CSS ────────────────────────────────────────────── | |
| SPACE_CSS = """ | |
| .gradio-container { | |
| background: | |
| radial-gradient(1px 1px at 25px 45px, #fff, transparent), | |
| radial-gradient(1px 1px at 150px 105px, #cfe3ff, transparent), | |
| radial-gradient(2px 2px at 290px 60px, #fff, transparent), | |
| radial-gradient(1px 1px at 420px 160px, #aac6ff, transparent), | |
| radial-gradient(1.5px 1.5px at 560px 30px, #fff, transparent), | |
| radial-gradient(1px 1px at 640px 130px, #e0ecff, transparent), | |
| radial-gradient(2px 2px at 780px 80px, #fff, transparent), | |
| linear-gradient(180deg, #05060f 0%, #0a0f24 45%, #10173a 100%) !important; | |
| background-repeat: repeat !important; | |
| background-size: 800px 200px, 800px 200px, 800px 200px, 800px 200px, | |
| 800px 200px, 800px 200px, 800px 200px, 100% 100% !important; | |
| } | |
| #guardian-header { | |
| text-align: center; | |
| padding: 18px 0 4px 0; | |
| } | |
| #guardian-header h1 { | |
| background: linear-gradient(90deg, #6ec3ff, #a78bfa, #6ec3ff); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| font-size: 2.2em; | |
| letter-spacing: 1px; | |
| } | |
| .tab-nav button { font-weight: 600 !important; } | |
| /* ── Readability: force light text on the dark space background ── */ | |
| .gradio-container, .gradio-container * { | |
| color: #e8ecf8; | |
| } | |
| .gradio-container .prose, .gradio-container .prose *, | |
| .gradio-container .md, .gradio-container .md * { | |
| color: #e8ecf8 !important; | |
| } | |
| .gradio-container label, .gradio-container .label-wrap, | |
| .gradio-container span, .gradio-container p, | |
| .gradio-container h1, .gradio-container h2, | |
| .gradio-container h3, .gradio-container h4 { | |
| color: #e8ecf8 !important; | |
| } | |
| .gradio-container table, .gradio-container th, .gradio-container td { | |
| color: #e8ecf8 !important; | |
| border-color: #3a4468 !important; | |
| } | |
| .gradio-container input, .gradio-container textarea, .gradio-container select { | |
| color: #f2f5ff !important; | |
| background: #141b38 !important; | |
| } | |
| .gradio-container .tab-nav button { | |
| color: #c7d2f0 !important; | |
| } | |
| .gradio-container .tab-nav button.selected { | |
| color: #ffffff !important; | |
| } | |
| /* inline code chips: dark chip + bright text (was light-on-light) */ | |
| .gradio-container code, | |
| .gradio-container .prose code, | |
| .gradio-container .md code { | |
| background: #1e2a52 !important; | |
| color: #7ec8ff !important; | |
| border: 1px solid #35427a !important; | |
| border-radius: 4px !important; | |
| padding: 1px 6px !important; | |
| } | |
| .gradio-container pre, .gradio-container pre code { | |
| background: #10173a !important; | |
| color: #d7e3ff !important; | |
| } | |
| /* tab buttons: bright enough to read when unselected */ | |
| .gradio-container .tab-nav button, | |
| .gradio-container button[role="tab"] { | |
| color: #aebcf0 !important; | |
| opacity: 1 !important; | |
| } | |
| .gradio-container .tab-nav button.selected, | |
| .gradio-container button[role="tab"][aria-selected="true"] { | |
| color: #ffffff !important; | |
| border-color: #7ec8ff !important; | |
| } | |
| /* file upload component: readable filename + labels */ | |
| .gradio-container .file-preview, .gradio-container .file-preview *, | |
| .gradio-container .wrap, .gradio-container .or, | |
| .gradio-container .icon-wrap svg { | |
| color: #dbe4ff !important; | |
| } | |
| .gradio-container a, .gradio-container a * { | |
| color: #7ec8ff !important; | |
| } | |
| /* secondary/dim text bumped up */ | |
| .gradio-container .secondary-wrap, .gradio-container .info, | |
| .gradio-container small, .gradio-container .text-gray-500 { | |
| color: #b8c4e8 !important; | |
| } | |
| /* ── Kill ALL light backgrounds: dark bg + light text everywhere ── */ | |
| /* Examples table (Quick Starters) — rows were white with light text */ | |
| .gradio-container table, | |
| .gradio-container thead, .gradio-container tbody, | |
| .gradio-container tr, .gradio-container th, .gradio-container td { | |
| background: #141b38 !important; | |
| color: #e8ecf8 !important; | |
| border-color: #35427a !important; | |
| } | |
| .gradio-container thead th, .gradio-container tr:hover td { | |
| background: #1e2a52 !important; | |
| color: #ffffff !important; | |
| } | |
| /* Dataframe component cells */ | |
| .gradio-container .table-wrap, .gradio-container .cell-wrap, | |
| .gradio-container .dataframe, .gradio-container .dataframe * { | |
| background: #141b38 !important; | |
| color: #e8ecf8 !important; | |
| } | |
| /* Component labels / chips ("Confidence Score", "Feature Profile", "Examples") */ | |
| .gradio-container .label, .gradio-container label > span, | |
| .gradio-container .plot-container label, | |
| .gradio-container div[data-testid="block-label"], | |
| .gradio-container div[data-testid="block-label"] * { | |
| background: #1e2a52 !important; | |
| color: #dbe4ff !important; | |
| border-color: #35427a !important; | |
| } | |
| /* Slider number boxes + all inputs */ | |
| .gradio-container input[type="number"], | |
| .gradio-container input[type="text"], | |
| .gradio-container textarea { | |
| background: #141b38 !important; | |
| color: #ffffff !important; | |
| border-color: #35427a !important; | |
| } | |
| /* Secondary buttons (Download: Normal, etc.) */ | |
| .gradio-container button.secondary, | |
| .gradio-container .secondary { | |
| background: #1e2a52 !important; | |
| color: #dbe4ff !important; | |
| border-color: #35427a !important; | |
| } | |
| /* File upload drop-zone */ | |
| .gradio-container .upload-container, .gradio-container .file-preview, | |
| .gradio-container div[data-testid="file"] { | |
| background: #10173a !important; | |
| color: #dbe4ff !important; | |
| } | |
| /* Checkbox row */ | |
| .gradio-container input[type="checkbox"] + span, | |
| .gradio-container .checkbox-label { | |
| color: #e8ecf8 !important; | |
| } | |
| /* Make the checkbox itself clearly visible + show checked state */ | |
| .gradio-container input[type="checkbox"] { | |
| appearance: none !important; | |
| -webkit-appearance: none !important; | |
| width: 20px !important; height: 20px !important; | |
| border: 2px solid #6ec3ff !important; | |
| border-radius: 5px !important; | |
| background: #0d1230 !important; | |
| cursor: pointer !important; | |
| position: relative !important; | |
| vertical-align: middle !important; | |
| flex: none !important; | |
| } | |
| .gradio-container input[type="checkbox"]:checked { | |
| background: #6ec3ff !important; | |
| border-color: #6ec3ff !important; | |
| } | |
| .gradio-container input[type="checkbox"]:checked::after { | |
| content: '✓' !important; | |
| position: absolute !important; | |
| top: -2px !important; left: 3px !important; | |
| color: #08101f !important; | |
| font-size: 15px !important; | |
| font-weight: 900 !important; | |
| } | |
| /* Any leftover white/light utility backgrounds */ | |
| .gradio-container .bg-white, .gradio-container [class*="bg-gray-1"], | |
| .gradio-container [class*="bg-gray-2"], .gradio-container [class*="bg-slate-1"] { | |
| background: #141b38 !important; | |
| } | |
| /* keep gradient title working */ | |
| #guardian-header h1 { | |
| -webkit-text-fill-color: transparent !important; | |
| } | |
| /* blocks/panels: translucent dark cards so plots and controls stay readable */ | |
| .gradio-container .block, .gradio-container .form, | |
| .gradio-container .panel { | |
| background: rgba(13, 18, 42, 0.72) !important; | |
| border-color: #2c3660 !important; | |
| } | |
| /* ═══════════════ DEEP SPACE SCENE ═══════════════ */ | |
| @keyframes twinkle { | |
| 0%, 100% { opacity: 0.9; } | |
| 50% { opacity: 0.25; } | |
| } | |
| .space-decor { position: fixed; pointer-events: none; z-index: 0; } | |
| .star-layer-a, .star-layer-b { | |
| top: 0; left: 0; width: 100%; height: 100%; | |
| } | |
| .star-layer-a { | |
| background: | |
| radial-gradient(1.5px 1.5px at 12% 22%, #fff, transparent), | |
| radial-gradient(2px 2px at 33% 68%, #dbe9ff, transparent), | |
| radial-gradient(1px 1px at 55% 15%, #fff, transparent), | |
| radial-gradient(2px 2px at 71% 43%, #ffe9c4, transparent), | |
| radial-gradient(1.5px 1.5px at 88% 76%, #fff, transparent), | |
| radial-gradient(1px 1px at 44% 89%, #cfe3ff, transparent); | |
| animation: twinkle 3.5s ease-in-out infinite; | |
| } | |
| .star-layer-b { | |
| background: | |
| radial-gradient(1px 1px at 8% 55%, #fff, transparent), | |
| radial-gradient(2px 2px at 25% 35%, #ffd9e8, transparent), | |
| radial-gradient(1.5px 1.5px at 62% 82%, #fff, transparent), | |
| radial-gradient(1px 1px at 79% 12%, #d0f0ff, transparent), | |
| radial-gradient(2px 2px at 93% 48%, #fff, transparent); | |
| animation: twinkle 5s ease-in-out 1.2s infinite; | |
| } | |
| /* ringed gas giant — top right */ | |
| .planet-saturn { | |
| top: 90px; right: 4%; | |
| width: 110px; height: 110px; border-radius: 50%; | |
| background: radial-gradient(circle at 32% 30%, | |
| #ffd9a0 0%, #e8a960 35%, #a96b35 70%, #5c3418 100%); | |
| box-shadow: 0 0 45px rgba(255, 190, 110, 0.35), | |
| inset -18px -14px 40px rgba(0,0,0,0.55); | |
| animation: planet-drift 26s ease-in-out infinite; | |
| } | |
| .planet-saturn::before { | |
| content: ''; | |
| position: absolute; top: 50%; left: 50%; | |
| width: 210px; height: 54px; | |
| border: 7px solid rgba(230, 195, 140, 0.55); | |
| border-radius: 50%; | |
| transform: translate(-50%, -50%) rotateX(72deg) rotate(-16deg); | |
| box-shadow: 0 0 18px rgba(255, 210, 150, 0.25); | |
| } | |
| @keyframes planet-drift { | |
| 0%, 100% { transform: translateY(0px); } | |
| 50% { transform: translateY(-18px); } | |
| } | |
| /* blue ice planet — mid left */ | |
| .planet-blue { | |
| top: 46%; left: 2.5%; | |
| width: 70px; height: 70px; border-radius: 50%; | |
| background: radial-gradient(circle at 35% 30%, | |
| #bfe6ff 0%, #55a8e8 40%, #1d5fa8 75%, #0a2c5c 100%); | |
| box-shadow: 0 0 34px rgba(90, 170, 255, 0.4), | |
| inset -12px -10px 26px rgba(0,0,0,0.5); | |
| animation: planet-drift 20s ease-in-out 3s infinite; | |
| } | |
| /* small red planet — bottom right */ | |
| .planet-red { | |
| bottom: 12%; right: 7%; | |
| width: 44px; height: 44px; border-radius: 50%; | |
| background: radial-gradient(circle at 38% 32%, | |
| #ffb59a 0%, #e06a45 45%, #93321c 80%, #4a1408 100%); | |
| box-shadow: 0 0 22px rgba(255, 120, 80, 0.35), | |
| inset -8px -7px 16px rgba(0,0,0,0.55); | |
| animation: planet-drift 17s ease-in-out 1.5s infinite; | |
| } | |
| /* Earth glimpse — bottom left horizon */ | |
| .planet-earth { | |
| bottom: -130px; left: -110px; | |
| width: 320px; height: 320px; border-radius: 50%; | |
| background: radial-gradient(circle at 42% 30%, | |
| #9fd8ff 0%, #3f96d8 30%, #1a6ab8 55%, #0b3a78 78%, #041c40 100%); | |
| box-shadow: 0 0 90px rgba(80, 160, 255, 0.45), | |
| inset -30px -25px 80px rgba(0,0,0,0.6); | |
| opacity: 0.85; | |
| } | |
| .planet-earth::after { | |
| content: ''; | |
| position: absolute; inset: -14px; | |
| border-radius: 50%; | |
| border: 3px solid rgba(140, 210, 255, 0.28); | |
| filter: blur(3px); | |
| } | |
| /* orbiting satellite crossing the screen */ | |
| @keyframes sat-orbit { | |
| 0% { transform: translate(-8vw, 22vh) rotate(-18deg); } | |
| 45% { transform: translate(48vw, 6vh) rotate(8deg); } | |
| 100% { transform: translate(108vw, 18vh) rotate(24deg); } | |
| } | |
| .flying-sat { | |
| top: 0; left: 0; | |
| font-size: 34px; | |
| filter: drop-shadow(0 0 10px rgba(140, 200, 255, 0.8)); | |
| animation: sat-orbit 38s linear infinite; | |
| } | |
| /* second, smaller satellite the other way */ | |
| @keyframes sat-orbit-2 { | |
| 0% { transform: translate(105vw, 62vh) rotate(200deg) scale(0.6); } | |
| 100% { transform: translate(-10vw, 40vh) rotate(140deg) scale(0.6); } | |
| } | |
| .flying-sat-2 { | |
| top: 0; left: 0; | |
| font-size: 30px; | |
| opacity: 0.75; | |
| animation: sat-orbit-2 55s linear 8s infinite; | |
| } | |
| /* shooting star */ | |
| @keyframes shoot { | |
| 0% { transform: translate(110vw, -40px) rotate(-35deg); opacity: 0; } | |
| 3% { opacity: 1; } | |
| 12% { transform: translate(55vw, 32vh) rotate(-35deg); opacity: 0; } | |
| 100% { opacity: 0; } | |
| } | |
| .shooting-star { | |
| top: 0; left: 0; | |
| width: 130px; height: 2px; | |
| background: linear-gradient(90deg, transparent, #fff 60%, #aee3ff); | |
| border-radius: 2px; | |
| animation: shoot 14s ease-in 4s infinite; | |
| } | |
| /* content sits above the scenery */ | |
| .gradio-container > * { position: relative; z-index: 1; } | |
| """ | |
| SPACE_DECOR_HTML = """ | |
| <div class="space-decor star-layer-a"></div> | |
| <div class="space-decor star-layer-b"></div> | |
| <div class="space-decor planet-saturn"></div> | |
| <div class="space-decor planet-blue"></div> | |
| <div class="space-decor planet-red"></div> | |
| <div class="space-decor planet-earth"></div> | |
| <div class="space-decor flying-sat">🛰️</div> | |
| <div class="space-decor flying-sat-2">🛰️</div> | |
| <div class="space-decor shooting-star"></div> | |
| """ | |
| HEADER_MD = """ | |
| <div id="guardian-header"> | |
| # 🛰️ GNSS GUARDIAN | |
| **GPS Jamming & Spoofing Detection · Direction Finding · AI Threat Reports** | |
| *Detect → Locate → Retrieve Similar Cases → Report → Alert · Powered by 1D-CNN + Edge AI* | |
| </div> | |
| """ | |
| # ── Core helpers ───────────────────────────────────────────────── | |
| def scale_features(raw, calib_offset=None): | |
| adjusted = raw - calib_offset if calib_offset is not None else raw | |
| return (adjusted - MEAN) / SCALE | |
| def emergency_fallback(sv_count, agc): | |
| if sv_count == 0.0: | |
| if agc > AGC_THRESHOLD: | |
| return 1, 'Jamming [FALLBACK]', 1.0 | |
| return -1, 'Unknown [Zero-Sat]', 1.0 | |
| return None, None, None | |
| # Normal-class reference: the feature centroid the model considers "Normal". | |
| # Auto-calibration maps a device's clean baseline onto THIS point (not the | |
| # global scaler mean, which is a blend of all 3 classes and leans toward | |
| # attacks). Loaded from the demo_normal sample of the training distribution. | |
| try: | |
| NORMAL_MEAN = pd.read_csv('demo_normal.csv')[REQUIRED_COLS].mean().values.astype(np.float32) | |
| except Exception: | |
| NORMAL_MEAN = MEAN.copy() | |
| def compute_calibration(df: pd.DataFrame, n_baseline: int = 30): | |
| """First n_baseline rows = the device's clean baseline. We shift them onto | |
| the Normal-class centroid so real clean data lands in the Normal region and | |
| only genuine deviations move toward the attack clusters.""" | |
| baseline = df[REQUIRED_COLS].head(n_baseline).mean().values.astype(np.float32) | |
| offset = baseline - NORMAL_MEAN | |
| return offset, baseline | |
| # ── Known-episode library (Similar-Case Retrieval) ─────────────── | |
| # Each episode: representative feature vector + human description. | |
| # NOTE: feature-space retrieval for now; will be swapped for the | |
| # text-embedding model (MiniLM / bge / e5) once Part 3 evaluation picks a winner. | |
| EPISODE_LIBRARY = [ | |
| # label, features [cn0_mean, cn0_std, agc, sv, pos_var, d_pr], description | |
| (0, [42.5, 1.2, -85.2, 11, 0.4, 0.1], | |
| "Open-sky pedestrian walk, clear conditions. Strong stable C/N0 around 42 dB-Hz, " | |
| "11 satellites tracked, quiet AGC noise floor. Textbook clean GNSS environment."), | |
| (0, [38.0, 2.8, -84.5, 8, 1.2, 0.4], | |
| "Urban canyon drive. Mild multipath raises C/N0 variance and drops 2-3 satellites " | |
| "behind buildings, but AGC stays at thermal noise floor - no interference present."), | |
| (0, [35.5, 3.5, -84.0, 6, 2.0, 0.6], | |
| "Dense foliage hiking trail. Attenuated signals and reduced satellite count from " | |
| "canopy blockage. Benign degradation - AGC unchanged, no coherent anomaly."), | |
| (1, [15.0, 6.0, -60.0, 2, 8.0, 3.0], | |
| "Wideband chirp jammer near a logistics depot. AGC climbed 25 dB as the receiver " | |
| "fought rising noise floor; C/N0 collapsed below 18 dB-Hz and lock lost on most SVs."), | |
| (1, [8.0, 7.5, -55.0, 0, 0.0, 0.0], | |
| "High-power barrage jamming - total denial. Zero satellites tracked, AGC saturated " | |
| "30 dB above baseline. Classic truck-mounted PPD (personal privacy device) signature."), | |
| (1, [22.0, 5.0, -68.0, 4, 6.0, 2.2], | |
| "Intermittent pulsed jammer at airport perimeter. Partial degradation: half the " | |
| "constellation lost, AGC oscillating, C/N0 dips synchronized with pulse duty cycle."), | |
| (2, [49.0, 2.2, -83.0, 9, 16.0, 8.5], | |
| "Meaconing/replay spoofing at a harbor. Counterfeit signals arrive stronger than " | |
| "authentic ones (C/N0 ~49 dB-Hz - suspiciously high), position solution dragged " | |
| "800 m off the pier while AGC stayed near normal."), | |
| (2, [47.5, 3.0, -82.5, 8, 22.0, 11.0], | |
| "Drone-hijack spoofing attack. Elevated uniform C/N0 across all channels, sudden " | |
| "pseudorange jumps of ~10 m, and position variance spike during the capture phase."), | |
| (2, [50.5, 1.8, -84.0, 10, 12.0, 6.0], | |
| "GPS time-spoofing against a power-grid substation receiver. Signals slightly " | |
| "over-powered and abnormally uniform; position stable but clock solution walked off."), | |
| ] | |
| _LIB_FEATS = np.array([e[1] for e in EPISODE_LIBRARY], dtype=np.float32) | |
| _LIB_SCALED = (_LIB_FEATS - MEAN) / SCALE | |
| def retrieve_similar_cases(raw_features, top_k=3): | |
| """Cosine similarity in standardized feature space -> top-k known episodes.""" | |
| q = (np.asarray(raw_features, dtype=np.float32) - MEAN) / SCALE | |
| qn = q / (np.linalg.norm(q) + 1e-9) | |
| ln = _LIB_SCALED / (np.linalg.norm(_LIB_SCALED, axis=1, keepdims=True) + 1e-9) | |
| sims = ln @ qn | |
| order = np.argsort(sims)[::-1][:top_k] | |
| results = [] | |
| for idx in order: | |
| label, feats, desc = EPISODE_LIBRARY[idx] | |
| results.append({'label': CLASSES[label], 'similarity': float(sims[idx]), | |
| 'description': desc}) | |
| return results | |
| # ── Threat report generator (GenAI part - template engine now, | |
| # small HF LLM slot-in later) ────────────────────────────────── | |
| MITIGATIONS = { | |
| 0: ["No action required. Continue routine monitoring.", | |
| "Keep logging enabled to maintain a healthy device baseline."], | |
| 1: ["Switch to a multi-constellation / multi-frequency receiver if available (L5/E5a is harder to jam).", | |
| "Fall back to inertial navigation (IMU dead-reckoning) until AGC returns to baseline.", | |
| "Report the interference location and time to the national spectrum regulator.", | |
| "If direction estimate is available, physically survey that bearing for a jamming device."], | |
| 2: ["Cross-check position against an independent source (Wi-Fi/cell positioning, IMU).", | |
| "Reject satellites with abnormally uniform or elevated C/N0 from the PVT solution.", | |
| "Enable OSNMA / authentication-capable signals if the receiver supports them.", | |
| "Treat current position/time outputs as untrusted until signals renormalize."], | |
| } | |
| def generate_threat_report(results_df, calib_note="", direction_info=None, | |
| similar_cases=None): | |
| n_total = len(results_df) | |
| counts = results_df['predicted_class'].value_counts() | |
| n_jam = int(counts.get(1, 0)) | |
| n_spoof = int(counts.get(2, 0)) | |
| n_norm = int(counts.get(0, 0)) | |
| n_attack = n_jam + n_spoof | |
| # A real attack is sustained. Require it to cover a meaningful share of the | |
| # trace before declaring an attack verdict, so a few scattered false | |
| # positives on a clean signal still read as NO THREAT. | |
| attack_rate = n_attack / n_total if n_total else 0.0 | |
| if attack_rate < 0.35: | |
| verdict, dominant = "NO THREAT DETECTED", 0 | |
| elif n_jam >= n_spoof: | |
| verdict, dominant = "JAMMING ATTACK", 1 | |
| else: | |
| verdict, dominant = "SPOOFING ATTACK", 2 | |
| attacks = results_df[results_df['predicted_class'] != 0] | |
| first_idx = int(attacks['sample_idx'].iloc[0]) if n_attack else None | |
| avg_conf = float(results_df['confidence'].mean()) | |
| atk_conf = float(attacks['confidence'].mean()) if n_attack else None | |
| agc_series = results_df['agc_level'].values | |
| cn0_series = results_df['cn0_mean'].values | |
| agc_delta = float(agc_series.max() - np.median(agc_series[:min(30, n_total)])) | |
| cn0_delta = float(np.median(cn0_series[:min(30, n_total)]) - cn0_series.min()) | |
| now = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC') | |
| sev = {0: '🟢 LOW', 1: '🔴 CRITICAL', 2: '🟠 HIGH'}[dominant] | |
| lines = [ | |
| f"# 📋 GNSS THREAT REPORT", | |
| f"**Generated:** {now} | **Severity:** {sev}", | |
| f"", | |
| f"## Verdict: {verdict}", | |
| f"", | |
| f"## 1. What Happened", | |
| f"Analyzed **{n_total} signal epochs**: {n_norm} normal, " | |
| f"{n_jam} jamming, {n_spoof} spoofing.", | |
| ] | |
| if n_attack: | |
| lines += [ | |
| f"First anomaly at epoch **{first_idx}** " | |
| f"(~{first_idx} seconds into the recording). " | |
| f"Attack persisted across **{n_attack/n_total*100:.0f}%** of the trace.", | |
| ] | |
| lines += [ | |
| f"", | |
| f"## 2. Confidence", | |
| f"Mean model confidence: **{avg_conf*100:.1f}%**" | |
| + (f" (attack epochs: **{atk_conf*100:.1f}%**)" if n_attack else ""), | |
| f"", | |
| f"## 3. Anomaly Explanation", | |
| ] | |
| if dominant == 1: | |
| lines += [ | |
| f"- AGC rose **{agc_delta:+.1f} dB** above baseline — the receiver amplified " | |
| f"against a rising noise floor, the physical fingerprint of jamming.", | |
| f"- C/N0 dropped **{cn0_delta:.1f} dB-Hz** from baseline as noise buried the satellites.", | |
| f"- Satellite count minimum: **{int(results_df['sv_count'].min())}**.", | |
| ] | |
| elif dominant == 2: | |
| lines += [ | |
| f"- C/N0 became abnormally strong/uniform — counterfeit signals typically " | |
| f"overpower authentic ones (peak {cn0_series.max():.1f} dB-Hz).", | |
| f"- AGC stayed near baseline ({agc_delta:+.1f} dB) — spoofing adds structured " | |
| f"signal, not noise, so the noise floor barely moves.", | |
| f"- Position/pseudorange discontinuities indicate a capture-and-drag phase.", | |
| ] | |
| else: | |
| lines += [ | |
| f"- AGC stable ({agc_delta:+.1f} dB from baseline), C/N0 within normal spread.", | |
| f"- No coherent interference signature found.", | |
| ] | |
| if direction_info: | |
| lines += [f"", f"## 4. Interference Direction", | |
| f"Estimated bearing to source: **{direction_info['bearing']:.0f}° " | |
| f"({direction_info['compass']})** " | |
| f"(directionality strength: {direction_info['strength']:.0%})."] | |
| if similar_cases: | |
| lines += [f"", f"## 5. Most Similar Known Episodes"] | |
| for i, c in enumerate(similar_cases, 1): | |
| lines += [f"{i}. **[{c['label']}]** (similarity {c['similarity']:.2f}) — " | |
| f"{c['description']}"] | |
| lines += [f"", f"## 6. Recommended Actions"] | |
| lines += [f"- {m}" for m in MITIGATIONS[dominant]] | |
| if calib_note: | |
| lines += [f"", f"---", f"*Auto-calibration was applied to this analysis.*"] | |
| return "\n".join(lines), verdict, dominant | |
| # ── Telegram alert (chat-app bonus) ────────────────────────────── | |
| # HF Spaces (and many cloud hosts) have broken IPv6: Python tries the AAAA | |
| # record first, the TLS handshake hangs, and you get an SSL timeout. Forcing | |
| # getaddrinfo to return only IPv4 addresses fixes the outbound connection. | |
| _orig_getaddrinfo = socket.getaddrinfo | |
| def _ipv4_only_getaddrinfo(*args, **kwargs): | |
| responses = _orig_getaddrinfo(*args, **kwargs) | |
| return [r for r in responses if r[0] == socket.AF_INET] or responses | |
| def _telegram_share_link(report_text): | |
| """Browser-side delivery: a t.me link the USER clicks. Works even when the | |
| Space itself cannot reach Telegram (blocked outbound) because it uses the | |
| user's own device/network. Opens Telegram with the report ready to send.""" | |
| plain = report_text.replace('#', '').replace('**', '').replace('`', '').strip() | |
| plain = plain[:1500] # keep the URL within safe length limits | |
| text = urllib.parse.quote(plain) | |
| return f"https://t.me/share/url?url=%20&text={text}" | |
| def send_telegram_alert(report_text): | |
| # Guard: no report yet -> nothing to send | |
| if not report_text or not report_text.strip(): | |
| return ("⚠️ No report to send yet. Go to the **CSV Analysis** tab, run an " | |
| "analysis first, then come back and send — the report is generated there.") | |
| # Free HF Spaces block outbound connections to Telegram, so a server-side | |
| # send just hangs for ~20s and fails. We skip it entirely and hand back an | |
| # instant browser link — it uses the user's own device to deliver, and it's | |
| # immediate (no network call from the Space). | |
| link = _telegram_share_link(report_text) | |
| return ( | |
| f"### 👉 [📲 Send the report via Telegram]({link})\n\n" | |
| f"Click the link — Telegram opens on **your** device with the report " | |
| f"ready. Pick a chat (your bot, a group, or Saved Messages) and hit send." | |
| ) | |
| # ── GNSSLogger parser ───────────────────────────────────────────── | |
| def parse_gnsslogger(filepath: str) -> pd.DataFrame: | |
| """Convert a Google GNSSLogger .txt file into the model's CSV format.""" | |
| epoch_data = {} | |
| with open(filepath, 'r', errors='replace') as f: | |
| lines = f.readlines() | |
| col_map = {} | |
| header_idx = None | |
| for i, line in enumerate(lines): | |
| stripped = line.lstrip('# ').strip() | |
| if stripped.startswith('Raw,') and 'Svid' in stripped: | |
| header_parts = stripped.split(',') | |
| col_map = {name.strip(): idx for idx, name in enumerate(header_parts)} | |
| header_idx = i | |
| break | |
| if header_idx is None or not col_map: | |
| raise ValueError( | |
| "This does not look like a GNSSLogger file.\n" | |
| "Make sure you export from the GNSSLogger app as a .txt file\n" | |
| "and that it contains 'Raw,' measurement lines." | |
| ) | |
| required_gnss_cols = ['Svid', 'Cn0DbHz', 'TimeNanos', 'ReceivedSvTimeNanos'] | |
| missing = [c for c in required_gnss_cols if c not in col_map] | |
| if missing: | |
| raise ValueError(f"GNSSLogger file missing expected columns: {missing}") | |
| agc_col = 'AgcDb' if 'AgcDb' in col_map else None | |
| # Doppler range-rate: the receiver's own clean range-rate estimate. | |
| # We build delta_pseudorange from its temporal jitter (see below). | |
| prr_col = ('PseudorangeRateMetersPerSecond' | |
| if 'PseudorangeRateMetersPerSecond' in col_map else None) | |
| for line in lines: | |
| if not line.startswith('Raw,'): | |
| continue | |
| parts = line.strip().split(',') | |
| try: | |
| svid = int(parts[col_map['Svid']]) | |
| cn0 = float(parts[col_map['Cn0DbHz']]) | |
| time_ns = int(parts[col_map['TimeNanos']]) | |
| pr_ns = int(parts[col_map['ReceivedSvTimeNanos']]) | |
| agc = float(parts[col_map[agc_col]]) if agc_col and parts[col_map[agc_col]] else -85.0 | |
| prr = (float(parts[col_map[prr_col]]) | |
| if prr_col and parts[col_map[prr_col]] else None) | |
| except (ValueError, IndexError): | |
| continue | |
| epoch_key = time_ns // 1_000_000_000 | |
| if epoch_key not in epoch_data: | |
| epoch_data[epoch_key] = {'cn0': [], 'agc': [], 'pr': {}, 'prr': {}} | |
| epoch_data[epoch_key]['cn0'].append(cn0) | |
| epoch_data[epoch_key]['agc'].append(agc) | |
| epoch_data[epoch_key]['pr'][svid] = pr_ns | |
| if prr is not None: | |
| epoch_data[epoch_key]['prr'][svid] = prr | |
| if not epoch_data: | |
| raise ValueError("No valid Raw measurement lines found in the file.") | |
| rows = [] | |
| prev_prr = {} # PseudorangeRate one epoch back | |
| prev2_prr = {} # two epochs back | |
| DPR_CLIP = 20.0 # m/s — matches synthetic training range | |
| POSVAR_CLIP = 40.0 | |
| for key in sorted(epoch_data.keys()): | |
| ep = epoch_data[key] | |
| cn0_vals = ep['cn0'] | |
| agc_vals = [a for a in ep['agc'] if a != -85.0] | |
| cn0_mean = float(np.mean(cn0_vals)) if cn0_vals else 0.0 | |
| cn0_std = float(np.std(cn0_vals)) if len(cn0_vals) > 1 else 0.0 | |
| agc_mean = float(np.mean(agc_vals)) if agc_vals else -85.0 | |
| sv_count = len(cn0_vals) | |
| # delta_pseudorange = temporal JITTER of the Doppler range-rate. | |
| # Raw pseudorange changes hundreds of m/s every second just from normal | |
| # satellite motion, so differencing it directly is useless. Instead we | |
| # take the second difference of PseudorangeRateMetersPerSecond per | |
| # satellite: |prr[t] - 2*prr[t-1] + prr[t-2]|. Smooth orbital motion | |
| # cancels out -> ~0 for a clean signal, spikes on the sudden jumps that | |
| # a spoofing capture produces. Averaged over satellites, clipped for | |
| # satellite appear/disappear edge cases. | |
| jitters = [] | |
| for svid, prr in ep['prr'].items(): | |
| if svid in prev_prr and svid in prev2_prr: | |
| second_diff = abs(prr - 2.0 * prev_prr[svid] + prev2_prr[svid]) | |
| jitters.append(min(second_diff, DPR_CLIP)) | |
| prev2_prr = prev_prr | |
| prev_prr = ep['prr'].copy() | |
| delta_pr = float(np.mean(jitters)) if jitters else 0.0 | |
| delta_pr = min(delta_pr, DPR_CLIP) | |
| pos_var = min(cn0_std * delta_pr * 0.10, POSVAR_CLIP) if delta_pr > 0 else 0.0 | |
| rows.append({ | |
| 'cn0_mean' : round(cn0_mean, 3), | |
| 'cn0_std' : round(cn0_std, 3), | |
| 'agc_level' : round(agc_mean, 3), | |
| 'sv_count' : float(sv_count), | |
| 'pos_variance' : round(pos_var, 3), | |
| 'delta_pseudorange' : round(delta_pr, 3), | |
| }) | |
| return pd.DataFrame(rows) | |
| # ── Direction Finder (per-satellite azimuth analysis) ───────────── | |
| def parse_gnss_status(filepath: str): | |
| """ | |
| Parse GNSSLogger 'Status' lines -> per-epoch {svid: (azimuth, cn0)}. | |
| Status lines carry each satellite's azimuth/elevation + C/N0, which lets | |
| us estimate the interference bearing: satellites in the jammer's direction | |
| lose more signal. | |
| """ | |
| with open(filepath, 'r', errors='replace') as f: | |
| lines = f.readlines() | |
| col_map = {} | |
| for line in lines: | |
| stripped = line.lstrip('# ').strip() | |
| if stripped.startswith('Status,') and 'AzimuthDegrees' in stripped: | |
| col_map = {n.strip(): i for i, n in enumerate(stripped.split(','))} | |
| break | |
| if not col_map: | |
| return None | |
| cn0_key = 'Cn0DbHz' if 'Cn0DbHz' in col_map else None | |
| if cn0_key is None or 'AzimuthDegrees' not in col_map or 'Svid' not in col_map: | |
| return None | |
| time_key = ('UnixTimeMillis' if 'UnixTimeMillis' in col_map | |
| else 'ElapsedRealtimeMillis' if 'ElapsedRealtimeMillis' in col_map | |
| else None) | |
| epochs = {} | |
| for line in lines: | |
| if not line.startswith('Status,'): | |
| continue | |
| parts = line.strip().split(',') | |
| try: | |
| svid = int(parts[col_map['Svid']]) | |
| az = float(parts[col_map['AzimuthDegrees']]) | |
| cn0 = float(parts[col_map[cn0_key]]) | |
| t = int(parts[col_map[time_key]]) // 1000 if time_key else 0 | |
| except (ValueError, IndexError): | |
| continue | |
| if cn0 <= 0: | |
| continue | |
| epochs.setdefault(t, {})[svid] = (az, cn0) | |
| return epochs if epochs else None | |
| def estimate_jammer_bearing(status_epochs, n_baseline=30): | |
| """ | |
| Weighted circular mean of per-satellite C/N0 degradation. | |
| Baseline = first n_baseline epochs. drop_i = baseline_cn0_i - current_cn0_i. | |
| Bearing vector = sum(drop_i * [sin(az_i), cos(az_i)]). | |
| Returns (bearing_deg, strength 0-1, per-azimuth drop profile). | |
| """ | |
| keys = sorted(status_epochs.keys()) | |
| if len(keys) < n_baseline + 5: | |
| return None | |
| baseline = {} | |
| for k in keys[:n_baseline]: | |
| for svid, (az, cn0) in status_epochs[k].items(): | |
| baseline.setdefault(svid, []).append(cn0) | |
| baseline = {s: float(np.mean(v)) for s, v in baseline.items()} | |
| x_sum, y_sum, drop_total, n_obs = 0.0, 0.0, 0.0, 0 | |
| az_bins = np.zeros(12) # 30-degree sectors for the polar profile | |
| bin_cnt = np.zeros(12) | |
| for k in keys[n_baseline:]: | |
| for svid, (az, cn0) in status_epochs[k].items(): | |
| if svid not in baseline: | |
| continue | |
| drop = baseline[svid] - cn0 | |
| if drop <= 0: | |
| drop = 0.0 | |
| rad = np.deg2rad(az) | |
| x_sum += drop * np.sin(rad) | |
| y_sum += drop * np.cos(rad) | |
| drop_total += drop | |
| n_obs += 1 | |
| b = int(az // 30) % 12 | |
| az_bins[b] += drop | |
| bin_cnt[b] += 1 | |
| if n_obs == 0 or drop_total < 1.0: | |
| return None | |
| bearing = float(np.rad2deg(np.arctan2(x_sum, y_sum))) % 360 | |
| # strength: resultant length / total drop -> 1.0 = perfectly directional | |
| strength = float(np.hypot(x_sum, y_sum) / (drop_total + 1e-9)) | |
| profile = np.divide(az_bins, np.maximum(bin_cnt, 1)) | |
| return bearing, strength, profile | |
| COMPASS_PTS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', | |
| 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] | |
| def bearing_to_compass(deg): | |
| return COMPASS_PTS[int((deg + 11.25) // 22.5) % 16] | |
| def make_compass_plot(bearing, strength, profile): | |
| fig = plt.figure(figsize=(7, 7)) | |
| fig.patch.set_facecolor('#0a0f24') | |
| ax = fig.add_subplot(111, projection='polar') | |
| ax.set_facecolor('#0d0d1a') | |
| ax.set_theta_zero_location('N') | |
| ax.set_theta_direction(-1) | |
| theta = np.deg2rad(np.arange(15, 375, 30)) | |
| norm = profile / (profile.max() + 1e-9) | |
| bars = ax.bar(theta, norm, width=np.deg2rad(28), bottom=0.0, | |
| color='#e67e22', alpha=0.55, edgecolor='#f39c12') | |
| ax.annotate('', xy=(np.deg2rad(bearing), 1.05), xytext=(0, 0), | |
| arrowprops=dict(facecolor='#e74c3c', edgecolor='white', | |
| width=4, headwidth=14)) | |
| ax.set_ylim(0, 1.15) | |
| ax.set_yticklabels([]) | |
| ax.set_xticks(np.deg2rad([0, 45, 90, 135, 180, 225, 270, 315])) | |
| ax.set_xticklabels(['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'], | |
| color='white', fontsize=12) | |
| ax.set_title(f'Estimated Interference Bearing: {bearing:.0f}° ' | |
| f'({bearing_to_compass(bearing)})\n' | |
| f'Directionality strength: {strength:.0%}', | |
| color='white', fontsize=13, fontweight='bold', pad=20) | |
| ax.grid(color='#333', alpha=0.5) | |
| return fig | |
| def find_direction(file): | |
| """Direction Finder tab handler — needs the original GNSSLogger .txt.""" | |
| if file is None: | |
| return "Upload a GNSSLogger .txt file first.", None | |
| try: | |
| status_epochs = parse_gnss_status(file.name) | |
| except Exception as e: | |
| return f"Could not read file: {e}", None | |
| if status_epochs is None: | |
| return ("No usable `Status` lines found in this file.\n\n" | |
| "In GNSSLogger, enable **GnssStatus** logging (Settings tab) " | |
| "before recording — Status lines carry each satellite's " | |
| "azimuth, which is what direction-finding needs."), None | |
| result = estimate_jammer_bearing(status_epochs) | |
| if result is None: | |
| return ("**No directional interference detected.** ✅\n\n" | |
| "Per-satellite signal drops are negligible or uniformly spread — " | |
| "there is no consistent bearing pointing at an interference source. " | |
| "This is expected for clean recordings."), None | |
| bearing, strength, profile = result | |
| compass = bearing_to_compass(bearing) | |
| verdict = "strong directional signature" if strength > 0.5 else \ | |
| "moderate directional signature" if strength > 0.25 else \ | |
| "weak directional signature (interpret with caution)" | |
| msg = (f"### 🧭 Interference source estimated at **{bearing:.0f}° ({compass})**\n\n" | |
| f"- Directionality strength: **{strength:.0%}** — {verdict}\n" | |
| f"- Method: satellites located toward the interference source lose more C/N0 " | |
| f"than satellites away from it. We compute a degradation-weighted circular " | |
| f"mean over all satellite azimuths (baseline = first 30 s).\n" | |
| f"- ⚠️ Single-receiver bearing is approximate (±30°). For a fix, take a second " | |
| f"recording from a different location and triangulate.") | |
| return msg, make_compass_plot(bearing, strength, profile) | |
| # ── Demo CSV generators ─────────────────────────────────────────── | |
| def _make_demo(scenario: str) -> str: | |
| """Serve the real demo CSVs (sampled from the exact training distribution, | |
| so they classify correctly with the deployed model). Falls back gracefully | |
| if a bundled file is missing. `mixed` is built by stitching real segments.""" | |
| demo_files = { | |
| 'normal' : 'demo_normal.csv', | |
| 'jamming' : 'demo_jamming.csv', | |
| 'spoofing': 'demo_spoofing.csv', | |
| } | |
| def _load(name): | |
| return pd.read_csv(demo_files[name])[REQUIRED_COLS] | |
| if scenario in demo_files and os.path.exists(demo_files[scenario]): | |
| df = _load(scenario) | |
| elif scenario == 'mixed': | |
| # Normal -> Jamming -> Normal -> Spoofing, from real rows | |
| segs = [] | |
| for name in ['normal', 'jamming', 'normal', 'spoofing']: | |
| if os.path.exists(demo_files[name]): | |
| segs.append(_load(name).sample(frac=1, random_state=7).head(30)) | |
| df = (pd.concat(segs, ignore_index=True) if segs | |
| else pd.DataFrame(columns=REQUIRED_COLS)) | |
| else: | |
| # Should not happen if the demo CSVs are uploaded alongside app.py. | |
| df = pd.DataFrame(columns=REQUIRED_COLS) | |
| path = f'/tmp/demo_{scenario}.csv' | |
| df.to_csv(path, index=False) | |
| return path | |
| def demo_normal(): return _make_demo('normal') | |
| def demo_jamming(): return _make_demo('jamming') | |
| def demo_spoofing(): return _make_demo('spoofing') | |
| def demo_mixed(): return _make_demo('mixed') | |
| # ── GNSSLogger upload handler ───────────────────────────────────── | |
| def convert_gnsslogger(file): | |
| if file is None: | |
| return "No file uploaded.", None | |
| try: | |
| fpath = file.name if hasattr(file, 'name') else str(file) | |
| df = parse_gnsslogger(fpath) | |
| except ValueError as e: | |
| return f"❌ {e}", None | |
| except Exception as e: | |
| import traceback | |
| return (f"❌ Unexpected error: `{type(e).__name__}: {e}`\n\n" | |
| f"```\n{traceback.format_exc()[-800:]}\n```"), None | |
| if len(df) < WINDOW: | |
| return (f"File parsed OK ({len(df)} epochs) but need at least " | |
| f"{WINDOW} for inference. Walk around longer next time.", None) | |
| path = '/tmp/gnsslogger_converted.csv' | |
| df.to_csv(path, index=False) | |
| msg = (f"Converted {len(df)} epochs from GNSSLogger file.\n\n" | |
| f"Avg C/N0={df['cn0_mean'].mean():.1f} dB-Hz | " | |
| f"Avg AGC={df['agc_level'].mean():.1f} dBm | " | |
| f"Avg SV={df['sv_count'].mean():.1f}\n\n" | |
| f"File ready — click **Analyze CSV** in the CSV Analysis tab.") | |
| return msg, path | |
| # ── Manual mode plots ───────────────────────────────────────────── | |
| def make_gauge(class_id, confidence, label): | |
| fig, ax = plt.subplots(figsize=(7, 3.2)) | |
| fig.patch.set_facecolor('#1a1a2e') | |
| ax.set_facecolor('#1a1a2e') | |
| heights = [0.05, 0.05, 0.05] | |
| if 0 <= class_id <= 2: | |
| heights[class_id] = confidence | |
| bars = ax.bar(['Normal', 'Jamming', 'Spoofing'], heights, | |
| color=[COLORS[i] for i in range(3)], | |
| width=0.5, edgecolor='white', linewidth=0.8) | |
| if 0 <= class_id <= 2: | |
| bars[class_id].set_linewidth(2.5) | |
| ax.text(class_id, confidence + 0.04, f'{confidence*100:.1f}%', | |
| ha='center', color='white', fontsize=13, fontweight='bold') | |
| ax.set_ylim(0, 1.15) | |
| ax.set_ylabel('Confidence', color='white', fontsize=12) | |
| ax.set_title(f'Prediction: {label}', color='white', fontsize=14, fontweight='bold') | |
| ax.tick_params(colors='white') | |
| for sp in ax.spines.values(): | |
| sp.set_edgecolor('#444') | |
| plt.tight_layout() | |
| return fig | |
| def make_feature_bar(raw): | |
| features = ['C/N0 mean', 'C/N0 std', 'AGC', 'SV count', 'Pos var', 'Delta PR'] | |
| ranges = [(0, 55), (0, 10), (-95, -55), (0, 14), (0, 40), (0, 20)] | |
| normed = np.clip([(raw[i] - ranges[i][0]) / (ranges[i][1] - ranges[i][0]) | |
| for i in range(6)], 0, 1) | |
| fig, ax = plt.subplots(figsize=(7, 3.2)) | |
| fig.patch.set_facecolor('#1a1a2e') | |
| ax.set_facecolor('#1a1a2e') | |
| ax.bar(range(6), normed, color='#3498db', alpha=0.8, edgecolor='white', linewidth=0.7) | |
| ax.set_xticks(range(6)) | |
| ax.set_xticklabels(features, color='white', fontsize=9) | |
| ax.set_ylim(0, 1.2) | |
| ax.set_ylabel('Normalised Value', color='white', fontsize=11) | |
| ax.set_title('Input Feature Profile', color='white', fontsize=13, fontweight='bold') | |
| ax.tick_params(colors='white') | |
| for sp in ax.spines.values(): | |
| sp.set_edgecolor('#444') | |
| plt.tight_layout() | |
| return fig | |
| def predict_manual(cn0_mean, cn0_std, agc_level, sv_count, pos_var, delta_pr): | |
| raw = np.array([cn0_mean, cn0_std, agc_level, sv_count, | |
| pos_var, delta_pr], dtype=np.float32) | |
| fb_id, fb_label, fb_conf = emergency_fallback(sv_count, agc_level) | |
| if fb_label: | |
| disp = 1 if fb_id == 1 else 0 | |
| return (fb_label, make_gauge(disp, fb_conf, fb_label), | |
| make_feature_bar(raw), | |
| f"**Emergency Fallback triggered** - SV=0, AGC={agc_level:.1f} dBm", | |
| _similar_cases_md(raw)) | |
| scaled = scale_features(raw) | |
| window = np.tile(scaled, (WINDOW, 1))[np.newaxis] | |
| probs = model.predict(window, verbose=0)[0] | |
| cls_id = int(np.argmax(probs)) | |
| conf = float(probs[cls_id]) | |
| label = CLASSES[cls_id] | |
| details = { | |
| 0: f"Signal clean. C/N0={cn0_mean:.1f} dB-Hz, AGC={agc_level:.1f} dBm, {int(sv_count)} satellites.", | |
| 1: f"Jamming detected. AGC={agc_level:.1f} dBm elevated. SV count={int(sv_count)}.", | |
| 2: f"Spoofing detected. C/N0={cn0_mean:.1f} dB-Hz abnormal. delta_PR={delta_pr:.1f} m jump.", | |
| } | |
| return (label, make_gauge(cls_id, conf, label), make_feature_bar(raw), | |
| details.get(cls_id, ''), _similar_cases_md(raw)) | |
| def _similar_cases_md(raw): | |
| cases = retrieve_similar_cases(raw) | |
| md = ["### 🔍 Most similar known episodes"] | |
| for i, c in enumerate(cases, 1): | |
| md.append(f"{i}. **[{c['label']}]** (sim {c['similarity']:.2f}) — {c['description']}") | |
| return "\n\n".join(md) | |
| # ── Timeline plot ───────────────────────────────────────────────── | |
| def make_timeline(results_df): | |
| color_map = {0: '#2ecc71', 1: '#e74c3c', 2: '#3498db'} | |
| label_map = {0: 'Normal', 1: 'Jamming', 2: 'Spoofing'} | |
| ids = results_df['predicted_class'].values | |
| confs = results_df['confidence'].values | |
| x = np.arange(len(results_df)) | |
| fig, axes = plt.subplots(3, 1, figsize=(14, 9), sharex=True) | |
| fig.patch.set_facecolor('#1a1a2e') | |
| ax1 = axes[0] | |
| ax1.set_facecolor('#0d0d1a') | |
| ax1.plot(x, results_df['cn0_mean'].values, color='#f1c40f', lw=1.5, label='C/N0 mean') | |
| for i in x: | |
| ax1.axvspan(i-0.5, i+0.5, alpha=0.15, color=color_map.get(int(ids[i]), 'gray')) | |
| ax1.set_ylabel('C/N0 (dB-Hz)', color='white') | |
| ax1.set_title('GNSS Signal Timeline - CSV Analysis', | |
| color='white', fontsize=14, fontweight='bold') | |
| ax1.tick_params(colors='white') | |
| ax1.legend(loc='upper right', fontsize=9) | |
| for sp in ax1.spines.values(): sp.set_edgecolor('#444') | |
| ax2 = axes[1] | |
| ax2.set_facecolor('#0d0d1a') | |
| ax2.plot(x, results_df['agc_level'].values, color='#e67e22', lw=1.5, label='AGC level') | |
| ax2.axhline(AGC_THRESHOLD, color='#e74c3c', linestyle='--', lw=1.2, | |
| label=f'Threshold ({AGC_THRESHOLD} dBm)') | |
| for i in x: | |
| ax2.axvspan(i-0.5, i+0.5, alpha=0.15, color=color_map.get(int(ids[i]), 'gray')) | |
| ax2.set_ylabel('AGC (dBm)', color='white') | |
| ax2.tick_params(colors='white') | |
| ax2.legend(loc='upper right', fontsize=9) | |
| for sp in ax2.spines.values(): sp.set_edgecolor('#444') | |
| ax3 = axes[2] | |
| ax3.set_facecolor('#0d0d1a') | |
| ax3.bar(x, confs, color=[color_map.get(int(c), 'gray') for c in ids], | |
| alpha=0.85, width=0.8) | |
| ax3.set_ylim(0, 1.1) | |
| ax3.set_ylabel('Confidence', color='white') | |
| ax3.set_xlabel('Sample Index', color='white') | |
| ax3.tick_params(colors='white') | |
| patches = [mpatches.Patch(color=c, label=label_map[k]) for k, c in color_map.items()] | |
| ax3.legend(handles=patches, loc='upper right', fontsize=9) | |
| for sp in ax3.spines.values(): sp.set_edgecolor('#444') | |
| plt.tight_layout(h_pad=0.8) | |
| return fig | |
| # ── CSV inference engine ────────────────────────────────────────── | |
| def run_inference_on_df(df: pd.DataFrame, calib_offset=None): | |
| raw_matrix = df[REQUIRED_COLS].values.astype(np.float32) | |
| predictions = [] | |
| for i in range(len(raw_matrix)): | |
| start = max(0, i - WINDOW + 1) | |
| window = raw_matrix[start: i + 1] | |
| if len(window) < WINDOW: | |
| pad = np.tile(window[0], (WINDOW - len(window), 1)) | |
| window = np.vstack([pad, window]) | |
| raw_row = raw_matrix[i] | |
| fb_id, fb_label, fb_conf = emergency_fallback(raw_row[SV_IDX], raw_row[AGC_IDX]) | |
| if fb_label: | |
| cls_id, conf, tier = (1 if fb_id == 1 else 0), fb_conf, 'FALLBACK' | |
| else: | |
| scaled = scale_features(window, calib_offset) | |
| probs = model.predict(scaled[np.newaxis], verbose=0)[0] | |
| cls_id = int(np.argmax(probs)) | |
| conf = float(probs[cls_id]) | |
| tier = 'CNN' | |
| predictions.append({ | |
| 'sample_idx' : i, | |
| 'cn0_mean' : round(float(raw_row[0]), 2), | |
| 'agc_level' : round(float(raw_row[2]), 2), | |
| 'sv_count' : int(raw_row[3]), | |
| 'predicted_class': cls_id, | |
| 'label' : CLASSES[cls_id], | |
| 'confidence' : round(conf, 4), | |
| 'tier' : tier, | |
| }) | |
| return _smooth_predictions(pd.DataFrame(predictions)) | |
| def _smooth_predictions(pred_df): | |
| """Temporal smoothing: a genuine attack persists for many seconds, so an | |
| isolated epoch that disagrees with its neighbours is a false alarm. Replace | |
| each label with the majority vote over a small centred window.""" | |
| if len(pred_df) >= 5: | |
| cls = pred_df['predicted_class'].tolist() | |
| smoothed = cls.copy() | |
| half = 3 # 7-wide window | |
| for i in range(len(cls)): | |
| lo, hi = max(0, i - half), min(len(cls), i + half + 1) | |
| votes = cls[lo:hi] | |
| smoothed[i] = max(set(votes), key=votes.count) | |
| pred_df['predicted_class'] = smoothed | |
| pred_df['label'] = pred_df['predicted_class'].map(CLASSES) | |
| return pred_df | |
| def run_physics_detector(df: pd.DataFrame): | |
| """Robust detector for REAL device data. The CNN is trained on synthetic | |
| data and generalises poorly to a real phone, so for real recordings we | |
| classify by physical deviation from the device's own clean baseline (the | |
| first 30 rows). This is scale-independent and far more reliable on real GPS. | |
| - Jamming: AGC rises sharply above baseline AND C/N0 drops (or satellites lost) | |
| - Spoofing: pseudorange jitter / position variance spikes well above baseline | |
| - Normal: no significant physical deviation | |
| """ | |
| raw = df[REQUIRED_COLS].values.astype(np.float32) | |
| b = raw[:30] | |
| b_cn0, b_agc = b[:, 0].mean(), b[:, 2].mean() | |
| b_pv, b_dpr = b[:, 4].mean(), b[:, 5].mean() | |
| s_cn0 = max(b[:, 0].std(), 1.0) | |
| s_agc = max(b[:, 2].std(), 1.0) | |
| s_pv = max(b[:, 4].std(), 0.3) | |
| s_dpr = max(b[:, 5].std(), 0.3) | |
| rows = [] | |
| for i, r in enumerate(raw): | |
| cn0, cn0_std, agc, sv, pv, dpr = r | |
| agc_rise = agc - b_agc | |
| jam = (agc_rise > max(6.0, 3 * s_agc)) and \ | |
| (cn0 < b_cn0 - max(4.0, 3 * s_cn0) or sv <= 2) | |
| spoof = (dpr > b_dpr + max(6.0, 6 * s_dpr)) or \ | |
| (pv > b_pv + max(6.0, 6 * s_pv)) | |
| cls_id = 1 if jam else (2 if spoof else 0) | |
| # confidence ~ how far past the trigger, capped | |
| if cls_id == 1: | |
| conf = min(1.0, 0.6 + agc_rise / 30.0) | |
| elif cls_id == 2: | |
| conf = min(1.0, 0.6 + (dpr - b_dpr) / 15.0) | |
| else: | |
| conf = min(1.0, 0.75 + (b_agc + 6 - agc) / 40.0) | |
| rows.append({ | |
| 'sample_idx': i, 'cn0_mean': round(float(cn0), 2), | |
| 'agc_level': round(float(agc), 2), 'sv_count': int(sv), | |
| 'predicted_class': cls_id, 'label': CLASSES[cls_id], | |
| 'confidence': round(float(max(0.5, conf)), 4), 'tier': 'PHYSICS', | |
| }) | |
| return _smooth_predictions(pd.DataFrame(rows)) | |
| def build_outputs(results_df): | |
| n_total = len(results_df) | |
| attacks = results_df[results_df['predicted_class'] != 0] | |
| n_attack = len(attacks) | |
| if n_attack == 0: | |
| status = f"**No attacks detected** across {n_total} samples. Signal is clean." | |
| else: | |
| jam = (results_df['predicted_class'] == 1).sum() | |
| spoof = (results_df['predicted_class'] == 2).sum() | |
| first = attacks['sample_idx'].iloc[0] | |
| status = (f"**{n_attack} attack samples** out of {n_total} total\n\n" | |
| f"- Jamming: **{jam}** | Spoofing: **{spoof}**\n" | |
| f"- First attack at sample index: **{first}**\n" | |
| f"- Attack rate: **{n_attack/n_total*100:.1f}%**") | |
| label_map = {0: 'Normal', 1: 'Jamming', 2: 'Spoofing'} | |
| counts = results_df['predicted_class'].value_counts().rename(label_map) | |
| summary = pd.DataFrame({ | |
| 'Class' : counts.index, | |
| 'Count' : counts.values, | |
| 'Percentage': (counts.values / n_total * 100).round(1) | |
| }).reset_index(drop=True) | |
| out_path = '/tmp/gnss_results.csv' | |
| results_df.to_csv(out_path, index=False) | |
| return status, make_timeline(results_df), summary, out_path | |
| def process_csv(file, use_calibration=True): | |
| """Full pipeline: classify -> retrieve similar cases -> generate report.""" | |
| empty = (None,) * 6 | |
| if file is None: | |
| return ("No file uploaded.",) + empty[:5] | |
| try: | |
| df = pd.read_csv(file.name) | |
| except Exception as e: | |
| return (f"Could not read file: {e}",) + empty[:5] | |
| missing = [c for c in REQUIRED_COLS if c not in df.columns] | |
| if missing: | |
| return ((f"CSV missing columns: {missing}\n\n" | |
| f"If this is a GNSSLogger .txt file, use the **GNSSLogger** tab first " | |
| f"to convert it."),) + empty[:5] | |
| df = df[REQUIRED_COLS].dropna().reset_index(drop=True) | |
| if len(df) < WINDOW: | |
| return (f"Need at least {WINDOW} rows. File has {len(df)}.",) + empty[:5] | |
| calib_note = "" | |
| if use_calibration and len(df) >= 30: | |
| # REAL-DEVICE MODE: the CNN is trained on synthetic data and generalises | |
| # poorly to a real phone, so we use the baseline-relative physics | |
| # detector, which reads real deviations from the device's own clean start. | |
| baseline = df[REQUIRED_COLS].head(30).mean().values | |
| results_df = run_physics_detector(df) | |
| calib_note = ( | |
| f"\n\n---\n**Real-device mode** — classified by physical deviation from " | |
| f"your device's own baseline (first 30 rows), not the synthetic model.\n\n" | |
| f"Device baseline: C/N0 **{baseline[0]:.1f}** dB-Hz · " | |
| f"AGC **{baseline[2]:.1f}** dBm · SV **{baseline[3]:.0f}**. " | |
| f"Jamming = AGC spikes above this; Spoofing = pseudorange jumps above this." | |
| ) | |
| else: | |
| # DEMO MODE: synthetic CSVs are already at the training scale -> use CNN. | |
| results_df = run_inference_on_df(df, None) | |
| status, timeline, summary, out_path = build_outputs(results_df) | |
| # Retrieve similar cases using the mean attack profile (or overall mean) | |
| attacks = results_df[results_df['predicted_class'] != 0] | |
| if len(attacks): | |
| ref_rows = df.iloc[attacks['sample_idx'].values] | |
| else: | |
| ref_rows = df | |
| mean_profile = ref_rows[REQUIRED_COLS].mean().values | |
| similar = retrieve_similar_cases(mean_profile) | |
| report_md, verdict, _ = generate_threat_report( | |
| results_df, calib_note, direction_info=None, similar_cases=similar) | |
| return status + calib_note, timeline, summary, out_path, report_md, report_md | |
| # ── Gradio UI ──────────────────────────────────────────────────── | |
| with gr.Blocks(theme=gr.themes.Base(primary_hue='indigo', neutral_hue='slate'), | |
| css=SPACE_CSS, title='GNSS Guardian') as demo: | |
| gr.HTML(SPACE_DECOR_HTML) | |
| gr.Markdown(HEADER_MD) | |
| # holds the latest generated report for the Telegram button | |
| report_state = gr.State("") | |
| with gr.Tabs(): | |
| # ── TAB 1: Manual ──────────────────────────────────────── | |
| with gr.TabItem('🎛️ Manual Mode'): | |
| gr.Markdown("Enter GNSS receiver measurements and click **Detect Attack**.") | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| cn0_mean = gr.Slider(0, 60, value=42.0, step=0.5, label='C/N0 Mean (dB-Hz)') | |
| cn0_std = gr.Slider(0, 10, value=1.5, step=0.1, label='C/N0 Std (dB-Hz)') | |
| agc_level = gr.Slider(-95, -55, value=-85.0, step=0.5, label='AGC Level (dBm)') | |
| sv_count = gr.Slider(0, 14, value=10, step=1, label='Satellite Count') | |
| pos_var = gr.Slider(0, 40, value=0.5, step=0.5, label='Position Variance (m^2)') | |
| delta_pr = gr.Slider(0, 20, value=0.1, step=0.1, label='Delta Pseudorange (m)') | |
| btn_manual = gr.Button('🚨 Detect Attack', variant='primary', size='lg') | |
| with gr.Column(scale=2): | |
| label_out = gr.Textbox(label='Classification', interactive=False) | |
| gauge_plot = gr.Plot(label='Confidence Score') | |
| radar_plot = gr.Plot(label='Feature Profile') | |
| explain = gr.Markdown() | |
| similar_md = gr.Markdown() | |
| gr.Markdown("### ⚡ Quick Starters — click to load an example") | |
| gr.Examples( | |
| examples=EXAMPLES, | |
| inputs=[cn0_mean, cn0_std, agc_level, sv_count, pos_var, delta_pr], | |
| outputs=[label_out, gauge_plot, radar_plot, explain, similar_md], | |
| fn=predict_manual, run_on_click=True, | |
| ) | |
| btn_manual.click( | |
| fn=predict_manual, | |
| inputs=[cn0_mean, cn0_std, agc_level, sv_count, pos_var, delta_pr], | |
| outputs=[label_out, gauge_plot, radar_plot, explain, similar_md] | |
| ) | |
| # ── TAB 2: Demo CSV Scenarios ──────────────────────────── | |
| with gr.TabItem('🧪 Demo Scenarios'): | |
| gr.Markdown(""" | |
| ### Pre-built demo scenarios | |
| Download any scenario as a CSV, then switch to **CSV Analysis** to run it. | |
| | Scenario | Description | | |
| |----------|-------------| | |
| | Normal | 100 clean signal samples | | |
| | Jamming | 30 normal → 70 jamming with AGC ramp | | |
| | Spoofing | 30 normal → 70 spoofing with C/N0 elevation | | |
| | Mixed | Normal → Jamming → Normal → Spoofing | | |
| """) | |
| with gr.Row(): | |
| btn_norm = gr.Button('Download: Normal', variant='secondary') | |
| btn_jam = gr.Button('Download: Jamming', variant='secondary') | |
| btn_spoof = gr.Button('Download: Spoofing', variant='secondary') | |
| btn_mix = gr.Button('Download: Mixed', variant='secondary') | |
| demo_file = gr.File(label='Downloaded Demo CSV — upload this in CSV Analysis tab', | |
| interactive=False) | |
| btn_norm.click(fn=demo_normal, outputs=demo_file) | |
| btn_jam.click( fn=demo_jamming, outputs=demo_file) | |
| btn_spoof.click(fn=demo_spoofing, outputs=demo_file) | |
| btn_mix.click( fn=demo_mixed, outputs=demo_file) | |
| # ── TAB 3: GNSSLogger (real data) ──────────────────────── | |
| with gr.TabItem('📱 GNSSLogger (Real GPS)'): | |
| gr.Markdown(""" | |
| ### Upload real GPS data from your phone | |
| **How to record real GPS data:** | |
| 1. Install **GNSSLogger** from Google on your Android phone | |
| (search "GNSSLogger" on Play Store — by Google LLC) | |
| 2. Go outside, press **Start Logging**, walk for 2+ minutes | |
| 3. Press **Stop**, share the **.txt file** to your computer | |
| 4. Upload it here — the app converts it automatically | |
| **What GNSSLogger records:** | |
| - `Cn0DbHz` — signal quality per satellite (our C/N0) | |
| - `AgcDb` — hardware noise floor (our AGC) | |
| - `ReceivedSvTimeNanos` — timing for pseudorange calculation | |
| """) | |
| gnss_file = gr.File(label='Upload GNSSLogger .txt file', | |
| file_types=['.txt', '.log']) | |
| convert_btn = gr.Button('Convert to CSV', variant='primary') | |
| with gr.Row(): | |
| convert_status = gr.Markdown() | |
| converted_file = gr.File( | |
| label='Converted CSV — upload this in CSV Analysis tab', | |
| interactive=False | |
| ) | |
| convert_btn.click( | |
| fn=convert_gnsslogger, | |
| inputs=gnss_file, | |
| outputs=[convert_status, converted_file] | |
| ) | |
| # ── TAB 4: CSV Analysis ────────────────────────────────── | |
| with gr.TabItem('📊 CSV Analysis'): | |
| gr.Markdown(""" | |
| ### Analyze any CSV file | |
| Upload a CSV from the **Demo Scenarios** tab or the **GNSSLogger** tab. | |
| Analysis now also generates a full **Threat Report** (see Threat Report tab). | |
| **Required columns:** | |
| `cn0_mean` | `cn0_std` | `agc_level` | `sv_count` | `pos_variance` | `delta_pseudorange` | |
| """) | |
| csv_input = gr.File(label='Upload CSV', file_types=['.csv']) | |
| calib_check = gr.Checkbox( | |
| value=False, | |
| label='Auto-Calibrate — turn ON for real GNSSLogger recordings', | |
| info='Maps the first 30 rows (assumed clean) onto the Normal profile, so real ' | |
| 'phone data classifies correctly. Leave OFF for the demo CSVs (they are ' | |
| 'already at the training scale).' | |
| ) | |
| analyze_btn = gr.Button('🔬 Analyze', variant='primary', size='lg') | |
| status_out = gr.Markdown() | |
| timeline_plot = gr.Plot(label='Signal Timeline') | |
| with gr.Row(): | |
| summary_tbl = gr.Dataframe( | |
| label='Attack Summary', | |
| headers=['Class', 'Count', 'Percentage'], | |
| interactive=False | |
| ) | |
| results_dl = gr.File(label='Download Full Results CSV') | |
| # ── TAB 5: Direction Finder ────────────────────────────── | |
| with gr.TabItem('🧭 Direction Finder'): | |
| gr.Markdown(""" | |
| ### Where is the interference coming from? | |
| Upload the **original GNSSLogger .txt file** (not the converted CSV — we need | |
| each satellite's sky position). | |
| **How it works:** every satellite sits at a known azimuth in the sky. | |
| A ground-based jammer degrades satellites **in its direction** more than | |
| satellites behind you. We measure each satellite's C/N0 drop relative to the | |
| first 30 seconds, then compute a degradation-weighted compass bearing. | |
| ⚠️ Requires **GnssStatus** logging enabled in the GNSSLogger app settings. | |
| """) | |
| dir_file = gr.File(label='Upload GNSSLogger .txt file', | |
| file_types=['.txt', '.log']) | |
| dir_btn = gr.Button('🧭 Estimate Direction', variant='primary', size='lg') | |
| dir_out = gr.Markdown() | |
| dir_plot = gr.Plot(label='Interference Compass') | |
| dir_btn.click(fn=find_direction, inputs=dir_file, | |
| outputs=[dir_out, dir_plot]) | |
| # ── TAB 6: Threat Report + Telegram ────────────────────── | |
| with gr.TabItem('📋 Threat Report'): | |
| gr.Markdown(""" | |
| ### AI-generated incident report | |
| Run an analysis in the **CSV Analysis** tab first — the report is generated | |
| automatically and appears here: verdict, confidence, anomaly explanation, | |
| similar known episodes, and recommended mitigations. | |
| """) | |
| report_out = gr.Markdown(value="*No report yet — analyze a CSV first.*") | |
| gr.Markdown("---") | |
| gr.Markdown("### 📱 Send alert to Telegram") | |
| tg_btn = gr.Button('🚀 Send Report to Telegram', variant='secondary') | |
| tg_status = gr.Markdown() | |
| tg_btn.click(fn=send_telegram_alert, inputs=report_state, | |
| outputs=tg_status) | |
| # CSV Analysis wiring (after all components exist) | |
| analyze_btn.click( | |
| fn=process_csv, | |
| inputs=[csv_input, calib_check], | |
| outputs=[status_out, timeline_plot, summary_tbl, results_dl, | |
| report_out, report_state] | |
| ) | |
| gr.Markdown( | |
| "<div style='text-align:center; opacity:0.6; padding:10px'>" | |
| "🛰️ GNSS Guardian · Introduction to Data Science Final Project · " | |
| "1D-CNN + Edge AI + Auto-Calibration</div>" | |
| ) | |
| demo.launch() | |