Make environmental feeds band-agnostic; add Environment page

- Remove band_assessment and band_detail from SWPC adapter
- Remove all frequency-specific conclusions (906 MHz, 10m-20m, etc.)
- Store only raw indices: SFI, Kp, R/S/G scales, dM/dz gradients
- Let LLM interpret propagation data based on user's band of interest
- Add full Environment page with feed status, solar indices, and ducting data
- Update Dashboard RF Propagation card to show raw values only
- Update alert messages to be frequency-agnostic

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
zvx-echo6 2026-05-12 14:59:54 -06:00
commit 1158e30c0b
12 changed files with 863 additions and 273 deletions

View file

@ -127,8 +127,6 @@ export interface SWPCStatus {
r_scale?: number r_scale?: number
s_scale?: number s_scale?: number
g_scale?: number g_scale?: number
band_assessment?: string
band_detail?: string
active_warnings?: string[] active_warnings?: string[]
} }
@ -138,7 +136,6 @@ export interface DuctingStatus {
min_gradient?: number min_gradient?: number
duct_thickness_m?: number | null duct_thickness_m?: number | null
duct_base_m?: number | null duct_base_m?: number | null
assessment?: string
last_update?: string last_update?: string
} }
@ -149,15 +146,12 @@ export interface RFPropagation {
r_scale?: number r_scale?: number
s_scale?: number s_scale?: number
g_scale?: number g_scale?: number
band_assessment?: string
band_detail?: string
active_warnings?: string[] active_warnings?: string[]
} }
uhf_ducting: { uhf_ducting: {
condition?: string condition?: string
min_gradient?: number min_gradient?: number
duct_thickness_m?: number | null duct_thickness_m?: number | null
assessment?: string
} }
} }

View file

@ -226,22 +226,6 @@ function RFPropagationCard({ propagation }: { propagation: RFPropagation | null
const hf = propagation.hf const hf = propagation.hf
const ducting = propagation.uhf_ducting const ducting = propagation.uhf_ducting
const getAssessmentColor = (assessment?: string) => {
if (!assessment) return 'text-slate-400'
switch (assessment.toLowerCase()) {
case 'excellent':
return 'text-green-400'
case 'good':
return 'text-green-500'
case 'fair':
return 'text-amber-500'
case 'poor':
return 'text-red-500'
default:
return 'text-slate-400'
}
}
const getDuctingColor = (condition?: string) => { const getDuctingColor = (condition?: string) => {
if (!condition) return 'text-slate-400' if (!condition) return 'text-slate-400'
switch (condition) { switch (condition) {
@ -257,7 +241,7 @@ function RFPropagationCard({ propagation }: { propagation: RFPropagation | null
} }
} }
const hasHF = hf && (hf.band_assessment || hf.sfi || hf.kp_current !== undefined) const hasHF = hf && (hf.sfi || hf.kp_current !== undefined)
const hasDucting = ducting && ducting.condition const hasDucting = ducting && ducting.condition
return ( return (
@ -267,16 +251,16 @@ function RFPropagationCard({ propagation }: { propagation: RFPropagation | null
RF Propagation RF Propagation
</h2> </h2>
{/* HF Section */} {/* Solar/Geomagnetic Indices */}
<div className="mb-4"> <div className="mb-4">
<div className="text-xs text-slate-500 mb-1">HF Bands</div> <div className="text-xs text-slate-500 mb-1">Solar/Geomagnetic</div>
{hasHF ? ( {hasHF ? (
<div className="space-y-1"> <div className="space-y-1">
<div className={`text-sm font-medium ${getAssessmentColor(hf.band_assessment)}`}> <div className="text-sm font-mono text-slate-200">
{hf.band_assessment || 'Unknown'} SFI {hf.sfi?.toFixed(0) || '?'} / Kp {hf.kp_current?.toFixed(1) || '?'}
</div> </div>
<div className="text-xs text-slate-400"> <div className="text-xs text-slate-400">
SFI {hf.sfi?.toFixed(0) || '?'} / Kp {hf.kp_current?.toFixed(1) || '?'} R{hf.r_scale ?? 0} / S{hf.s_scale ?? 0} / G{hf.g_scale ?? 0}
</div> </div>
{hf.r_scale !== undefined && hf.r_scale > 0 && ( {hf.r_scale !== undefined && hf.r_scale > 0 && (
<div className="text-xs text-amber-500"> <div className="text-xs text-amber-500">
@ -285,13 +269,13 @@ function RFPropagationCard({ propagation }: { propagation: RFPropagation | null
)} )}
</div> </div>
) : ( ) : (
<div className="text-sm text-slate-500">No HF data</div> <div className="text-sm text-slate-500">No data</div>
)} )}
</div> </div>
{/* UHF Ducting Section */} {/* Tropospheric Ducting */}
<div> <div>
<div className="text-xs text-slate-500 mb-1">UHF 906 MHz</div> <div className="text-xs text-slate-500 mb-1">Tropospheric</div>
{hasDucting ? ( {hasDucting ? (
<div className="space-y-1"> <div className="space-y-1">
<div className={`text-sm font-medium ${getDuctingColor(ducting.condition)}`}> <div className={`text-sm font-medium ${getDuctingColor(ducting.condition)}`}>
@ -299,14 +283,12 @@ function RFPropagationCard({ propagation }: { propagation: RFPropagation | null
? 'Normal' ? 'Normal'
: ducting.condition?.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())} : ducting.condition?.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
</div> </div>
{ducting.condition !== 'normal' && ducting.min_gradient !== undefined && ( <div className="text-xs text-slate-400 font-mono">
dM/dz: {ducting.min_gradient ?? '?'} M-units/km
</div>
{ducting.duct_thickness_m && (
<div className="text-xs text-slate-400"> <div className="text-xs text-slate-400">
dM/dz: {ducting.min_gradient} M-units/km Duct: ~{ducting.duct_thickness_m}m thick
</div>
)}
{ducting.condition !== 'normal' && (
<div className="text-xs text-blue-400">
Extended range likely
</div> </div>
)} )}
</div> </div>

View file

@ -1,15 +1,452 @@
import { Cloud } from 'lucide-react' import { useEffect, useState } from 'react'
import {
Cloud,
Sun,
AlertTriangle,
AlertCircle,
Info,
CheckCircle,
Activity,
Wind,
} from 'lucide-react'
import {
fetchEnvStatus,
fetchEnvActive,
fetchSWPC,
fetchDucting,
type EnvStatus,
type EnvEvent,
type SWPCStatus,
type DuctingStatus,
} from '@/lib/api'
function FeedStatusCard({ feed }: { feed: { source: string; is_loaded: boolean; last_error: string | null; consecutive_errors: number; event_count: number; last_fetch: number } }) {
const getStatusColor = () => {
if (!feed.is_loaded) return 'bg-red-500'
if (feed.consecutive_errors > 0) return 'bg-amber-500'
return 'bg-green-500'
}
const getStatusText = () => {
if (!feed.is_loaded) return 'Not loaded'
if (feed.consecutive_errors > 0) return `${feed.consecutive_errors} errors`
return 'Healthy'
}
const formatLastFetch = (ts: number) => {
if (!ts) return 'Never'
const date = new Date(ts * 1000)
return date.toLocaleTimeString()
}
export default function Environment() {
return ( return (
<div className="flex flex-col items-center justify-center h-[60vh] text-center"> <div className="bg-bg-hover rounded-lg p-4">
<div className="w-16 h-16 rounded-full bg-bg-card border border-border flex items-center justify-center mb-6"> <div className="flex items-center justify-between mb-2">
<Cloud size={32} className="text-slate-500" /> <div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${getStatusColor()}`} />
<span className="text-sm font-medium text-slate-200 uppercase">
{feed.source}
</span>
</div>
<span className="text-xs text-slate-400">{getStatusText()}</span>
</div>
<div className="text-xs text-slate-500 space-y-1">
<div>Events: {feed.event_count}</div>
<div>Last fetch: {formatLastFetch(feed.last_fetch)}</div>
{feed.last_error && (
<div className="text-amber-500 truncate">{feed.last_error}</div>
)}
</div>
</div>
)
}
function AlertEventCard({ event }: { event: EnvEvent }) {
const getSeverityStyles = (severity: string) => {
switch (severity.toLowerCase()) {
case 'extreme':
case 'severe':
return {
bg: 'bg-red-500/10',
border: 'border-red-500',
icon: AlertCircle,
iconColor: 'text-red-500',
}
case 'moderate':
case 'warning':
return {
bg: 'bg-amber-500/10',
border: 'border-amber-500',
icon: AlertTriangle,
iconColor: 'text-amber-500',
}
case 'minor':
return {
bg: 'bg-yellow-500/10',
border: 'border-yellow-500',
icon: Info,
iconColor: 'text-yellow-500',
}
default:
return {
bg: 'bg-slate-500/10',
border: 'border-slate-500',
icon: Info,
iconColor: 'text-slate-400',
}
}
}
const styles = getSeverityStyles(event.severity)
const Icon = styles.icon
const formatExpires = (ts?: number) => {
if (!ts) return null
const date = new Date(ts * 1000)
return date.toLocaleString()
}
return (
<div className={`p-4 rounded-lg ${styles.bg} border-l-2 ${styles.border}`}>
<div className="flex items-start gap-3">
<Icon size={18} className={styles.iconColor} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-slate-200">
{event.event_type}
</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${styles.bg} ${styles.iconColor}`}>
{event.severity}
</span>
</div>
<div className="text-sm text-slate-300 mb-2">{event.headline}</div>
{event.description && (
<div className="text-xs text-slate-400 mb-2 line-clamp-2">
{event.description}
</div>
)}
<div className="flex items-center gap-4 text-xs text-slate-500">
<span className="uppercase">{event.source}</span>
{event.expires && (
<span>Expires: {formatExpires(event.expires)}</span>
)}
</div>
</div>
</div>
</div>
)
}
function SolarIndicesPanel({ swpc }: { swpc: SWPCStatus | null }) {
if (!swpc || !swpc.enabled) {
return (
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Sun size={14} />
Solar/Geomagnetic Indices
</h2>
<div className="text-slate-500">Data not available</div>
</div>
)
}
const getKpColor = (kp?: number) => {
if (kp === undefined) return 'text-slate-400'
if (kp <= 2) return 'text-green-500'
if (kp <= 4) return 'text-amber-500'
if (kp <= 6) return 'text-orange-500'
return 'text-red-500'
}
const getScaleColor = (scale?: number) => {
if (scale === undefined || scale === 0) return 'text-green-500'
if (scale <= 2) return 'text-amber-500'
if (scale <= 3) return 'text-orange-500'
return 'text-red-500'
}
return (
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Sun size={14} />
Solar/Geomagnetic Indices
</h2>
<div className="grid grid-cols-2 gap-4 mb-4">
{/* SFI */}
<div className="bg-bg-hover rounded-lg p-3">
<div className="text-xs text-slate-500 mb-1">Solar Flux Index</div>
<div className="text-2xl font-mono text-slate-100">
{swpc.sfi?.toFixed(0) ?? '—'}
</div>
<div className="text-xs text-slate-500">SFI (10.7 cm)</div>
</div>
{/* Kp */}
<div className="bg-bg-hover rounded-lg p-3">
<div className="text-xs text-slate-500 mb-1">Planetary K-Index</div>
<div className={`text-2xl font-mono ${getKpColor(swpc.kp_current)}`}>
{swpc.kp_current?.toFixed(1) ?? '—'}
</div>
<div className="text-xs text-slate-500">Kp</div>
</div>
</div>
{/* NOAA Scales */}
<div className="bg-bg-hover rounded-lg p-3 mb-4">
<div className="text-xs text-slate-500 mb-2">NOAA Space Weather Scales</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<span className="text-xs text-slate-400">R:</span>
<span className={`text-sm font-mono ${getScaleColor(swpc.r_scale)}`}>
{swpc.r_scale ?? 0}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-slate-400">S:</span>
<span className={`text-sm font-mono ${getScaleColor(swpc.s_scale)}`}>
{swpc.s_scale ?? 0}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-slate-400">G:</span>
<span className={`text-sm font-mono ${getScaleColor(swpc.g_scale)}`}>
{swpc.g_scale ?? 0}
</span>
</div>
</div>
<div className="text-xs text-slate-500 mt-2">
Radio Blackout / Solar Radiation / Geomagnetic Storm
</div>
</div>
{/* Active Warnings */}
{swpc.active_warnings && swpc.active_warnings.length > 0 && (
<div className="space-y-2">
<div className="text-xs text-slate-500">Active Warnings</div>
{swpc.active_warnings.slice(0, 3).map((warning, i) => (
<div
key={i}
className="text-xs text-amber-400 bg-amber-500/10 rounded p-2"
>
{warning}
</div>
))}
</div>
)}
</div>
)
}
function DuctingPanel({ ducting }: { ducting: DuctingStatus | null }) {
if (!ducting || !ducting.enabled) {
return (
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Wind size={14} />
Tropospheric Ducting
</h2>
<div className="text-slate-500">Data not available</div>
</div>
)
}
const getConditionColor = (condition?: string) => {
switch (condition) {
case 'normal':
return 'text-green-500'
case 'super_refraction':
return 'text-amber-500'
case 'surface_duct':
case 'elevated_duct':
return 'text-blue-400'
default:
return 'text-slate-400'
}
}
const formatCondition = (condition?: string) => {
if (!condition) return 'Unknown'
return condition.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())
}
return (
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Wind size={14} />
Tropospheric Ducting
</h2>
{/* Condition */}
<div className="bg-bg-hover rounded-lg p-4 mb-4">
<div className="text-xs text-slate-500 mb-1">Condition</div>
<div className={`text-xl font-medium ${getConditionColor(ducting.condition)}`}>
{formatCondition(ducting.condition)}
</div>
</div>
{/* Refractivity Gradient */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-bg-hover rounded-lg p-3">
<div className="text-xs text-slate-500 mb-1">Min Gradient</div>
<div className="text-lg font-mono text-slate-100">
{ducting.min_gradient ?? '—'}
</div>
<div className="text-xs text-slate-500">M-units/km</div>
</div>
{ducting.duct_thickness_m && (
<div className="bg-bg-hover rounded-lg p-3">
<div className="text-xs text-slate-500 mb-1">Duct Thickness</div>
<div className="text-lg font-mono text-slate-100">
{ducting.duct_thickness_m}
</div>
<div className="text-xs text-slate-500">meters</div>
</div>
)}
{ducting.duct_base_m && (
<div className="bg-bg-hover rounded-lg p-3">
<div className="text-xs text-slate-500 mb-1">Duct Base</div>
<div className="text-lg font-mono text-slate-100">
{ducting.duct_base_m}
</div>
<div className="text-xs text-slate-500">meters AGL</div>
</div>
)}
</div>
{/* Reference */}
<div className="text-xs text-slate-500 bg-bg-hover rounded p-2">
<div>dM/dz reference:</div>
<div className="mt-1 space-y-0.5">
<div>&gt;79: Normal propagation</div>
<div>079: Super-refraction</div>
<div>&lt;0: Ducting (trapping layer)</div>
</div>
</div>
{ducting.last_update && (
<div className="text-xs text-slate-500 mt-3">
Last update: {ducting.last_update}
</div>
)}
</div>
)
}
export default function Environment() {
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null)
const [events, setEvents] = useState<EnvEvent[]>([])
const [swpc, setSWPC] = useState<SWPCStatus | null>(null)
const [ducting, setDucting] = useState<DuctingStatus | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([
fetchEnvStatus().catch(() => null),
fetchEnvActive().catch(() => []),
fetchSWPC().catch(() => null),
fetchDucting().catch(() => null),
])
.then(([status, active, swpcData, ductingData]) => {
setEnvStatus(status)
setEvents(active)
setSWPC(swpcData)
setDucting(ductingData)
setLoading(false)
})
.catch((err) => {
setError(err.message)
setLoading(false)
})
}, [])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading environmental data...</div>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Error: {error}</div>
</div>
)
}
if (!envStatus?.enabled) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
<div className="w-16 h-16 rounded-full bg-bg-card border border-border flex items-center justify-center mb-6">
<Cloud size={32} className="text-slate-500" />
</div>
<h2 className="text-xl font-semibold text-slate-300 mb-2">
Environmental Feeds Disabled
</h2>
<p className="text-slate-500 max-w-md">
Enable environmental feeds in config.yaml to see weather alerts,
space weather indices, and tropospheric ducting data.
</p>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-slate-200">Environment</h1>
<div className="text-xs text-slate-500">
{events.length} active event{events.length !== 1 ? 's' : ''}
</div>
</div>
{/* Feed Status */}
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Activity size={14} />
Feed Status
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{envStatus.feeds.map((feed) => (
<FeedStatusCard key={feed.source} feed={feed} />
))}
</div>
</div>
{/* Main content grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Solar Indices */}
<SolarIndicesPanel swpc={swpc} />
{/* Tropospheric Ducting */}
<DuctingPanel ducting={ducting} />
</div>
{/* Active Events */}
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<AlertTriangle size={14} />
Active Events ({events.length})
</h2>
{events.length > 0 ? (
<div className="space-y-3">
{events.map((event) => (
<AlertEventCard key={event.event_id} event={event} />
))}
</div>
) : (
<div className="flex items-center gap-2 text-slate-500 py-4">
<CheckCircle size={16} className="text-green-500" />
<span>No active environmental events</span>
</div>
)}
</div> </div>
<h2 className="text-xl font-semibold text-slate-300 mb-2">Environment</h2>
<p className="text-slate-500 max-w-md">
Environmental feeds and space weather detail coming soon
</p>
</div> </div>
) )
} }

View file

@ -641,20 +641,22 @@ class AlertEngine:
"is_critical": r_scale >= 4, "is_critical": r_scale >= 4,
}) })
# UHF ducting (informational -- not critical but operators want to know) # Tropospheric ducting (informational -- not critical but operators want to know)
ducting = env_store.get_ducting_status() ducting = env_store.get_ducting_status()
if ducting and ducting.get("condition") in ("surface_duct", "elevated_duct"): if ducting and ducting.get("condition") in ("surface_duct", "elevated_duct"):
key = "env_ducting_active" key = "env_ducting_active"
state = self._get_state(key) state = self._get_state(key)
if state.should_fire(now): if state.should_fire(now):
state.fire(now) state.fire(now)
condition = ducting.get("condition", "ducting").replace("_", " ")
gradient = ducting.get("min_gradient", "?")
alerts.append({ alerts.append({
"type": "uhf_ducting", "type": "tropospheric_ducting",
"message": "UHF ducting detected -- 906 MHz range may be extended, expect distant nodes", "message": f"Tropospheric {condition} detected (dM/dz {gradient} M-units/km)",
"severity": "info", "severity": "info",
"node_num": None, "node_num": None,
"node_name": "Ducting", "node_name": "Ducting",
"node_short": "UHF", "node_short": "TROPO",
"region": "", "region": "",
"scope_type": "mesh", "scope_type": "mesh",
"scope_value": None, "scope_value": None,

View file

@ -20,44 +20,36 @@ class SolarCommand(CommandHandler):
lines = [] lines = []
# HF section # Space weather indices (raw data - no band conclusions)
s = self._env_store.get_swpc_status() s = self._env_store.get_swpc_status()
if s: if s:
assessment = s.get("band_assessment", "Unknown")
kp = s.get("kp_current", "?") kp = s.get("kp_current", "?")
sfi = s.get("sfi", "?") sfi = s.get("sfi", "?")
r = s.get("r_scale", 0) r = s.get("r_scale", 0)
s_sc = s.get("s_scale", 0) s_sc = s.get("s_scale", 0)
g = s.get("g_scale", 0) g = s.get("g_scale", 0)
lines.append(f"HF: {assessment} -- SFI {sfi}, Kp {kp}") lines.append(f"Solar: SFI {sfi}, Kp {kp}")
lines.append(f" R{r}/S{s_sc}/G{g} scales") lines.append(f" R{r}/S{s_sc}/G{g} scales")
if assessment in ("Excellent", "Good"):
lines.append(" 10m-20m open, solid DX")
elif assessment == "Fair":
lines.append(" 20m-40m usable, upper bands marginal")
else:
lines.append(" Degraded -- lower bands only")
warnings = s.get("active_warnings", []) warnings = s.get("active_warnings", [])
for w in warnings[:2]: for w in warnings[:2]:
lines.append(f" Warning: {w[:100]}") lines.append(f" Warning: {w[:100]}")
else: else:
lines.append("HF: Data not available") lines.append("Solar: Data not available")
# UHF ducting section # Tropospheric ducting (raw data - no frequency conclusions)
d = self._env_store.get_ducting_status() d = self._env_store.get_ducting_status()
if d: if d:
cond = d.get("condition", "unknown") cond = d.get("condition", "unknown")
gradient = d.get("min_gradient", "?")
if cond == "normal": if cond == "normal":
lines.append("UHF: Normal propagation (906 MHz)") lines.append(f"Ducting: Normal (dM/dz {gradient})")
else: else:
gradient = d.get("min_gradient", "?") thickness = d.get("duct_thickness_m", "?")
lines.append(f"UHF: {cond.replace('_', ' ').title()} (906 MHz)") lines.append(f"Ducting: {cond.replace('_', ' ').title()}")
lines.append(f" dM/dz: {gradient} M-units/km") lines.append(f" dM/dz: {gradient} M-units/km, ~{thickness}m thick")
lines.append(" Extended range -- expect distant nodes")
else: else:
lines.append("UHF: Ducting data not available") lines.append("Ducting: Data not available")
return "\n".join(lines) return "\n".join(lines)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,17 +1,17 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="dark"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MeshAI Dashboard</title> <title>MeshAI Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-CELmCk_K.js"></script> <script type="module" crossorigin src="/assets/index-Hvb4qZ75.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DKYlTqQ1.css"> <link rel="stylesheet" crossorigin href="/assets/index-B5wp_1Dg.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>

20
meshai/env/store.py vendored
View file

@ -134,32 +134,32 @@ class EnvironmentalStore:
else: else:
lines.append("NWS: No active alerts for mesh area.") lines.append("NWS: No active alerts for mesh area.")
# HF # Space weather indices (raw - LLM interprets)
s = self._swpc_status s = self._swpc_status
if s: if s:
kp = s.get("kp_current", "?") kp = s.get("kp_current", "?")
sfi = s.get("sfi", "?") sfi = s.get("sfi", "?")
assessment = s.get("band_assessment", "Unknown") r = s.get("r_scale", 0)
lines.append(f"HF: {assessment} -- SFI {sfi}, Kp {kp}") g = s.get("g_scale", 0)
lines.append(f"Space Weather: SFI {sfi}, Kp {kp}, R{r}/G{g}")
warnings = s.get("active_warnings", []) warnings = s.get("active_warnings", [])
if warnings: if warnings:
for w in warnings[:2]: for w in warnings[:2]:
lines.append(f" Warning: {w}") lines.append(f" Warning: {w}")
else: else:
lines.append("HF: Space weather data not available.") lines.append("Space Weather: Data not available.")
# UHF ducting # Tropospheric ducting (raw - LLM interprets)
d = self._ducting_status d = self._ducting_status
if d: if d:
condition = d.get("condition", "unknown") condition = d.get("condition", "unknown")
gradient = d.get("min_gradient", "?")
if condition == "normal": if condition == "normal":
lines.append("UHF Ducting: Normal propagation, no ducting detected.") lines.append(f"Tropospheric: Normal (dM/dz {gradient} M-units/km)")
elif condition in ("super_refraction", "ducting", "surface_duct", "elevated_duct"): else:
gradient = d.get("min_gradient", "?")
thickness = d.get("duct_thickness_m", "?") thickness = d.get("duct_thickness_m", "?")
lines.append(f"UHF Ducting: {condition.replace('_', ' ').title()} detected") lines.append(f"Tropospheric: {condition.replace('_', ' ').title()}")
lines.append(f" dM/dz: {gradient} M-units/km, duct ~{thickness}m thick") lines.append(f" dM/dz: {gradient} M-units/km, duct ~{thickness}m thick")
lines.append(" Extended range likely on 906 MHz -- expect distant nodes")
return "\n".join(lines) return "\n".join(lines)

30
meshai/env/swpc.py vendored
View file

@ -52,7 +52,7 @@ class SWPCAdapter:
changed = True changed = True
if changed: if changed:
self._update_assessment() self._update_events()
return changed return changed
@ -197,29 +197,9 @@ class SWPCAdapter:
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
def _update_assessment(self): def _update_events(self):
"""Compute band assessment from SFI and Kp.""" """Generate events for significant space weather conditions."""
sfi = self._status.get("sfi", 0) # Generate events for R-scale >= 3 (radio blackout)
kp = self._status.get("kp_current", 0)
# Band assessment formula
if sfi > 150 and kp <= 1:
assessment = "Excellent"
detail = "Upper HF bands (10m-20m) open, solid DX conditions"
elif sfi >= 100 and kp <= 3:
assessment = "Good"
detail = "Upper HF bands (10m-20m) open, solid DX conditions"
elif sfi >= 80 and kp <= 4:
assessment = "Fair"
detail = "Mid HF bands (20m-40m) usable, upper bands marginal"
else:
assessment = "Poor"
detail = "HF conditions degraded, stick to lower bands (40m-80m)"
self._status["band_assessment"] = assessment
self._status["band_detail"] = detail
# Generate events for R-scale >= 3
self._events = [] self._events = []
r_scale = self._status.get("r_scale", 0) r_scale = self._status.get("r_scale", 0)
if r_scale >= 3: if r_scale >= 3:
@ -228,7 +208,7 @@ class SWPCAdapter:
"event_id": f"swpc_r{r_scale}_{int(time.time())}", "event_id": f"swpc_r{r_scale}_{int(time.time())}",
"event_type": f"R{r_scale} Radio Blackout", "event_type": f"R{r_scale} Radio Blackout",
"severity": "warning" if r_scale >= 3 else "advisory", "severity": "warning" if r_scale >= 3 else "advisory",
"headline": f"R{r_scale} HF Radio Blackout -- HF comms degraded", "headline": f"R{r_scale} Radio Blackout in progress",
"expires": time.time() + 3600, # 1hr TTL "expires": time.time() + 3600, # 1hr TTL
"areas": [], "areas": [],
"fetched_at": time.time(), "fetched_at": time.time(),