Add VOACAP-style HF propagation predictions

pull/1/head
accius 6 days ago
parent b4e0277fde
commit 6281a1c81e

@ -767,6 +767,205 @@
return date;
};
// ============================================
// PROPAGATION PREDICTION HOOK
// ============================================
const usePropagation = (deLocation, dxLocation) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchPropagation = async () => {
try {
const response = await fetch(
`/api/propagation?deLat=${deLocation.lat}&deLon=${deLocation.lon}&dxLat=${dxLocation.lat}&dxLon=${dxLocation.lon}`
);
if (response.ok) {
const result = await response.json();
setData(result);
}
} catch (err) {
console.error('Propagation fetch error:', err);
} finally {
setLoading(false);
}
};
fetchPropagation();
// Refresh every 15 minutes
const interval = setInterval(fetchPropagation, 900000);
return () => clearInterval(interval);
}, [deLocation.lat, deLocation.lon, dxLocation.lat, dxLocation.lon]);
return { data, loading };
};
// ============================================
// PROPAGATION PANEL COMPONENT
// ============================================
const PropagationPanel = ({ propagation, loading }) => {
if (loading || !propagation) {
return (
<div className="panel">
<div className="panel-header">📡 HF Propagation</div>
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)' }}>
Loading predictions...
</div>
</div>
);
}
const { solarData, distance, currentBands, currentHour, hourlyPredictions } = propagation;
// Get status color
const getStatusColor = (status) => {
switch (status) {
case 'EXCELLENT': return '#00ff88';
case 'GOOD': return '#88ff00';
case 'FAIR': return '#ffcc00';
case 'POOR': return '#ff8800';
case 'CLOSED': return '#ff4444';
default: return 'var(--text-muted)';
}
};
// Get reliability bar color
const getReliabilityColor = (rel) => {
if (rel >= 70) return '#00ff88';
if (rel >= 50) return '#88ff00';
if (rel >= 30) return '#ffcc00';
if (rel >= 15) return '#ff8800';
return '#ff4444';
};
return (
<div className="panel">
<div className="panel-header">
📡 HF Propagation to DX
<span style={{ fontSize: '10px', marginLeft: '8px', color: 'var(--text-muted)' }}>
{Math.round(distance).toLocaleString()} km
</span>
</div>
{/* Solar Conditions Summary */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gap: '8px',
padding: '8px',
marginBottom: '8px',
background: 'var(--bg-tertiary)',
borderRadius: '4px',
fontSize: '11px'
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ color: 'var(--text-muted)' }}>SFI</div>
<div style={{ color: 'var(--accent-primary)', fontWeight: 'bold' }}>{solarData.sfi}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: 'var(--text-muted)' }}>SSN</div>
<div style={{ color: 'var(--accent-secondary)', fontWeight: 'bold' }}>{solarData.ssn}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: 'var(--text-muted)' }}>K</div>
<div style={{
color: solarData.kIndex >= 4 ? '#ff4444' : solarData.kIndex >= 3 ? '#ffcc00' : '#00ff88',
fontWeight: 'bold'
}}>{solarData.kIndex}</div>
</div>
</div>
{/* Band Predictions */}
<div style={{ fontSize: '11px' }}>
<div style={{
display: 'grid',
gridTemplateColumns: '45px 1fr 55px',
gap: '4px',
padding: '4px 0',
borderBottom: '1px solid var(--border-color)',
marginBottom: '4px',
color: 'var(--text-muted)',
fontSize: '10px'
}}>
<span>BAND</span>
<span>RELIABILITY</span>
<span style={{ textAlign: 'right' }}>STATUS</span>
</div>
{currentBands.slice(0, 8).map((band, idx) => (
<div key={band.band} style={{
display: 'grid',
gridTemplateColumns: '45px 1fr 55px',
gap: '4px',
padding: '3px 0',
alignItems: 'center',
borderBottom: idx < 7 ? '1px solid var(--bg-tertiary)' : 'none'
}}>
<span style={{
fontFamily: 'JetBrains Mono, monospace',
color: band.reliability >= 50 ? 'var(--color-primary)' : 'var(--text-muted)'
}}>
{band.band}
</span>
<div style={{ position: 'relative', height: '12px' }}>
<div style={{
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: '100%',
background: 'var(--bg-tertiary)',
borderRadius: '2px'
}} />
<div style={{
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: `${band.reliability}%`,
background: getReliabilityColor(band.reliability),
borderRadius: '2px',
transition: 'width 0.3s ease'
}} />
<span style={{
position: 'absolute',
right: '4px',
top: '50%',
transform: 'translateY(-50%)',
fontSize: '9px',
color: band.reliability > 50 ? '#000' : 'var(--text-muted)'
}}>
{band.reliability}%
</span>
</div>
<span style={{
textAlign: 'right',
fontSize: '9px',
fontWeight: 'bold',
color: getStatusColor(band.status)
}}>
{band.status}
</span>
</div>
))}
</div>
{/* Footer */}
<div style={{
marginTop: '8px',
paddingTop: '8px',
borderTop: '1px solid var(--border-color)',
fontSize: '9px',
color: 'var(--text-muted)',
textAlign: 'center'
}}>
Predictions at {String(currentHour).padStart(2, '0')}:00 UTC • Based on current solar conditions
</div>
</div>
);
};
// ============================================
// LEAFLET MAP COMPONENT
// ============================================
@ -1277,7 +1476,7 @@
const LegacyLayout = ({
config, currentTime, utcTime, utcDate, localTime, localDate,
deGrid, dxGrid, deSunTimes, dxSunTimes, dxLocation, onDXChange,
spaceWeather, bandConditions, potaSpots, dxCluster, contests,
spaceWeather, bandConditions, potaSpots, dxCluster, contests, propagation,
onSettingsClick
}) => {
const bearing = calculateBearing(config.location.lat, config.location.lon, dxLocation.lat, dxLocation.lon);
@ -1488,6 +1687,44 @@
))}
</div>
</div>
{/* Propagation */}
{propagation.data && (
<div style={{ marginTop: '8px' }}>
<div style={{ ...labelStyle, marginBottom: '8px' }}>📡 PROPAGATION</div>
<div style={{ fontSize: '10px' }}>
{propagation.data.currentBands.slice(0, 6).map((b, i) => (
<div key={b.band} style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '2px 0',
borderBottom: i < 5 ? '1px solid rgba(255,255,255,0.05)' : 'none'
}}>
<span style={{ color: b.reliability >= 50 ? 'var(--accent-green)' : 'var(--text-muted)' }}>{b.band}</span>
<div style={{
width: '60px',
height: '8px',
background: 'var(--bg-tertiary)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
width: `${b.reliability}%`,
height: '100%',
background: b.reliability >= 70 ? '#00ff88' : b.reliability >= 50 ? '#88ff00' : b.reliability >= 30 ? '#ffcc00' : '#ff8800',
borderRadius: '2px'
}} />
</div>
<span style={{
fontSize: '9px',
color: b.reliability >= 70 ? '#00ff88' : b.reliability >= 50 ? '#88ff00' : b.reliability >= 30 ? '#ffcc00' : '#ff8800'
}}>{b.status.substring(0, 4)}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* BOTTOM - Footer */}
@ -1847,6 +2084,7 @@
const potaSpots = usePOTASpots();
const dxCluster = useDXCluster();
const contests = useContests();
const propagation = usePropagation(config.location, dxLocation);
const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]);
const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]);
@ -1896,6 +2134,7 @@
potaSpots={potaSpots}
dxCluster={dxCluster}
contests={contests}
propagation={propagation}
onSettingsClick={() => setShowSettings(true)}
/>
<SettingsPanel
@ -1936,33 +2175,7 @@
<BandConditionsPanel bands={bandConditions.data} loading={bandConditions.loading} />
<DXClusterPanel spots={dxCluster.data} loading={dxCluster.loading} />
<POTAPanel activities={potaSpots.data} loading={potaSpots.loading} />
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-color)', borderRadius: '8px', padding: '20px', backdropFilter: 'blur(10px)' }}>
<div style={{ fontSize: '12px', fontWeight: '600', color: 'var(--accent-purple)', letterSpacing: '2px', marginBottom: '16px' }}>
📊 QUICK STATS
</div>
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: '11px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<span style={{ color: 'var(--text-secondary)' }}>Active Contests</span>
<span style={{ color: 'var(--accent-green)', fontWeight: '600' }}>{contests.data.filter(c => c.status === 'active').length}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<span style={{ color: 'var(--text-secondary)' }}>POTA Activators</span>
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{potaSpots.data.length}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<span style={{ color: 'var(--text-secondary)' }}>DX Spots</span>
<span style={{ color: 'var(--accent-cyan)', fontWeight: '600' }}>{dxCluster.data.length}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<span style={{ color: 'var(--text-secondary)' }}>Solar Flux</span>
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{spaceWeather.data?.solarFlux || '--'}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0' }}>
<span style={{ color: 'var(--text-secondary)' }}>Uptime</span>
<span style={{ color: 'var(--text-primary)', fontWeight: '600' }}>{uptime}</span>
</div>
</div>
</div>
<PropagationPanel propagation={propagation.data} loading={propagation.loading} />
</main>
<footer style={{ textAlign: 'center', padding: '20px', color: 'var(--text-muted)', fontSize: '11px', fontFamily: 'JetBrains Mono, monospace' }}>

@ -282,6 +282,188 @@ app.get('/api/dxcluster/spots', async (req, res) => {
res.json([]);
});
// ============================================
// VOACAP / HF PROPAGATION PREDICTION API
// ============================================
app.get('/api/propagation', async (req, res) => {
const { deLat, deLon, dxLat, dxLon } = req.query;
console.log('[Propagation] Calculating for DE:', deLat, deLon, 'to DX:', dxLat, dxLon);
try {
// Get current space weather data for calculations
let sfi = 150, ssn = 100, kIndex = 2; // Defaults
try {
const [fluxRes, kRes] = await Promise.allSettled([
fetch('https://services.swpc.noaa.gov/json/f107_cm_flux.json'),
fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json')
]);
if (fluxRes.status === 'fulfilled' && fluxRes.value.ok) {
const data = await fluxRes.value.json();
if (data?.length) sfi = Math.round(data[data.length - 1].flux || 150);
}
if (kRes.status === 'fulfilled' && kRes.value.ok) {
const data = await kRes.value.json();
if (data?.length > 1) kIndex = parseInt(data[data.length - 1][1]) || 2;
}
// Estimate SSN from SFI: SSN ≈ (SFI - 67) / 0.97
ssn = Math.max(0, Math.round((sfi - 67) / 0.97));
} catch (e) {
console.log('[Propagation] Using default solar values');
}
console.log('[Propagation] Solar data - SFI:', sfi, 'SSN:', ssn, 'K:', kIndex);
// Calculate distance and bearing
const de = { lat: parseFloat(deLat) || 40, lon: parseFloat(deLon) || -75 };
const dx = { lat: parseFloat(dxLat) || 35, lon: parseFloat(dxLon) || 139 };
const distance = calculateDistance(de.lat, de.lon, dx.lat, dx.lon);
const midLat = (de.lat + dx.lat) / 2;
console.log('[Propagation] Distance:', Math.round(distance), 'km, MidLat:', midLat.toFixed(1));
// Calculate propagation for each band at each hour
const bands = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
const bandFreqs = [1.8, 3.5, 7, 10, 14, 18, 21, 24, 28, 50]; // MHz
const currentHour = new Date().getUTCHours();
// Generate 24-hour predictions
const predictions = {};
bands.forEach((band, idx) => {
const freq = bandFreqs[idx];
predictions[band] = [];
for (let hour = 0; hour < 24; hour++) {
const reliability = calculateBandReliability(
freq, distance, midLat, hour, sfi, ssn, kIndex, de, dx
);
predictions[band].push({
hour,
reliability: Math.round(reliability),
snr: calculateSNR(reliability)
});
}
});
// Get current best bands
const currentBands = bands.map((band, idx) => ({
band,
freq: bandFreqs[idx],
reliability: predictions[band][currentHour].reliability,
snr: predictions[band][currentHour].snr,
status: getStatus(predictions[band][currentHour].reliability)
})).sort((a, b) => b.reliability - a.reliability);
res.json({
solarData: { sfi, ssn, kIndex },
distance: Math.round(distance),
currentHour,
currentBands,
hourlyPredictions: predictions
});
} catch (error) {
console.error('[Propagation] Error:', error.message);
res.status(500).json({ error: 'Failed to calculate propagation' });
}
});
// Calculate great circle distance in km
function calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
// Calculate band reliability percentage (simplified VOACAP-style)
function calculateBandReliability(freq, distance, midLat, hour, sfi, ssn, kIndex, de, dx) {
// Maximum Usable Frequency estimation
// MUF ≈ criticalFreq * secant(zenith angle) * sqrt(1 + distance/4000)
// Critical frequency varies with solar activity and time
// foF2 ≈ 0.85 * sqrt(ssn + 12) * (1 + 0.3 * cos(hour * PI / 12))
const hourFactor = 1 + 0.4 * Math.cos((hour - 12) * Math.PI / 12);
const foF2 = 0.9 * Math.sqrt(ssn + 15) * hourFactor;
// Distance factor (longer paths need lower angles, higher MUF)
const distFactor = Math.sqrt(1 + distance / 3500);
// Latitude factor (higher latitudes = more absorption, lower MUF)
const latFactor = 1 - Math.abs(midLat) / 200;
// Estimated MUF
const muf = foF2 * distFactor * latFactor * 3.5;
// Lowest Usable Frequency (absorption limit)
// LUF increases with solar activity and during daytime
const dayNight = isDaytime(hour, (de.lon + dx.lon) / 2) ? 1.5 : 0.5;
const luf = 2 + (sfi / 100) * dayNight + kIndex * 0.5;
// Calculate reliability based on frequency vs MUF/LUF
let reliability = 0;
if (freq > muf) {
// Frequency above MUF - poor propagation
reliability = Math.max(0, 50 - (freq - muf) * 10);
} else if (freq < luf) {
// Frequency below LUF - too much absorption
reliability = Math.max(0, 50 - (luf - freq) * 15);
} else {
// Frequency in usable range
const midFreq = (muf + luf) / 2;
const optimalness = 1 - Math.abs(freq - midFreq) / (muf - luf);
reliability = 50 + optimalness * 45;
}
// K-index degradation (geomagnetic storms)
if (kIndex >= 5) reliability *= 0.3;
else if (kIndex >= 4) reliability *= 0.6;
else if (kIndex >= 3) reliability *= 0.8;
// Distance adjustment - very long paths are harder
if (distance > 15000) reliability *= 0.7;
else if (distance > 10000) reliability *= 0.85;
// High bands need higher solar activity
if (freq >= 21 && sfi < 100) reliability *= (sfi / 100);
if (freq >= 28 && sfi < 120) reliability *= (sfi / 120);
return Math.min(99, Math.max(0, reliability));
}
// Check if it's daytime at given longitude
function isDaytime(utcHour, longitude) {
const localHour = (utcHour + longitude / 15 + 24) % 24;
return localHour >= 6 && localHour <= 18;
}
// Convert reliability to estimated SNR
function calculateSNR(reliability) {
if (reliability >= 80) return '+20dB';
if (reliability >= 60) return '+10dB';
if (reliability >= 40) return '0dB';
if (reliability >= 20) return '-10dB';
return '-20dB';
}
// Get status label from reliability
function getStatus(reliability) {
if (reliability >= 70) return 'EXCELLENT';
if (reliability >= 50) return 'GOOD';
if (reliability >= 30) return 'FAIR';
if (reliability >= 15) return 'POOR';
return 'CLOSED';
}
// QRZ Callsign lookup (requires API key)
app.get('/api/qrz/lookup/:callsign', async (req, res) => {
const { callsign } = req.params;

Loading…
Cancel
Save

Powered by TurnKey Linux.