diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e65090..fc0683b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - WebSocket DX cluster connection - Azimuthal equidistant projection option +## [3.8.0] - 2026-01-31 + +### Added +- **DX Cluster Paths on Map** - Visual lines connecting spotters to DX stations + - Band-specific colors: 160m (red), 80m (orange), 40m (yellow), 20m (green), 15m (cyan), 10m (purple), 6m (magenta) + - Toggle visibility with button in DX Cluster panel + - Click paths to see spot details +- **Hover Highlighting** - Hover over spots in DX list to highlight path on map + - Path turns white and thickens when hovered + - Circle markers pulse on hover +- **Grid Square Extraction** - Parse grid squares from DX cluster comments + - Supports "Grid: XX00xx" format in spot comments + - Shows grid in spot popups on map +- **Callsign Labels on Map** - Optional labels for DX stations and spotters + - Toggle with label button in DX Cluster panel +- **Moon Tracking** - Real-time sublunar point on map + - Shows current moon phase emoji + - Updates position and phase in real-time + +### Changed +- Improved DX path rendering with antimeridian crossing support +- Better popup formatting with grid square display +- Enhanced spot filtering works on map paths too + +## [3.7.0] - 2026-01-31 + +### Added +- **DX Spider Proxy Service** - Dedicated server for DX cluster data + - Real-time Telnet connection to DX Spider nodes + - WebSocket distribution to multiple clients + - Grid square parsing from spot comments + - Fallback to HTTP APIs when Telnet unavailable +- **Spotter Location Mapping** - Show where spots originate from + - Circle markers for spotters with callsign popups + - Lines connecting spotter to DX station +- **Map Layer Controls** - Toggle various map overlays + - POTA activators toggle + - DX cluster paths toggle + - Satellite footprints toggle (placeholder) + +### Technical +- New `/api/dxcluster-paths` endpoint returns enriched spot data +- Grid-to-coordinate conversion for spotter locations +- Improved caching for DX cluster data + ## [3.6.0] - 2026-01-31 ### Added @@ -119,7 +164,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CORS issues with external APIs now handled by server proxy - Map projection accuracy improved -## [2.0.0] - 2024-01-29 +## [2.0.0] - 2026-01-29 ### Added - Live API integrations for NOAA space weather @@ -134,7 +179,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved space weather display with color coding - Better visual hierarchy in panels -## [1.0.0] - 2024-01-29 +## [1.0.0] - 2026-01-29 ### Added - Initial release @@ -161,12 +206,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | Version | Date | Highlights | |---------|------|------------| +| 3.8.0 | 2026-01-31 | DX paths on map, hover highlights, moon tracking | +| 3.7.0 | 2026-01-31 | DX Spider proxy, spotter locations, map toggles | +| 3.6.0 | 2026-01-31 | Real-time ionosonde data, ITU-R P.533 propagation | | 3.3.0 | 2026-01-30 | Contest calendar, classic layout, themes | | 3.2.0 | 2026-01-30 | Theme system (dark/light/legacy) | | 3.1.0 | 2026-01-30 | User settings, DX cluster fixes | | 3.0.0 | 2026-01-30 | Real maps, Electron, Docker, Railway | -| 2.0.0 | 2024-01-29 | Live APIs, improved map | -| 1.0.0 | 2024-01-29 | Initial release | +| 2.0.0 | 2026-01-29 | Live APIs, improved map | +| 1.0.0 | 2026-01-29 | Initial release | --- diff --git a/README.md b/README.md index 02c72ee..ee2b333 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
-![OpenHamClock Banner](https://img.shields.io/badge/OpenHamClock-v3.0.0-orange?style=for-the-badge) +![OpenHamClock Banner](https://img.shields.io/badge/OpenHamClock-v3.8.0-orange?style=for-the-badge) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg?style=for-the-badge)](LICENSE) [![Node.js](https://img.shields.io/badge/Node.js-18+-brightgreen?style=for-the-badge&logo=node.js)](https://nodejs.org/) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge)](CONTRIBUTING.md) @@ -13,7 +13,7 @@ [**Live Demo**](https://openhamclock.up.railway.app) ยท [**Download**](#-installation) ยท [**Documentation**](#-features) ยท [**Contributing**](#-contributing) -![OpenHamClock Screenshot](https://via.placeholder.com/800x450/0a0e14/ffb432?text=OpenHamClock+Screenshot) +![OpenHamClock Screenshot](screenshot.png)
@@ -41,19 +41,37 @@ OpenHamClock is a spiritual successor to the beloved HamClock application create - **Real-time day/night terminator** (gray line) - **Great circle paths** between DE and DX - **Click anywhere** to set DX location -- **POTA activators** displayed on map +- **POTA activators** displayed on map with callsigns +- **DX cluster paths** - Lines connecting spotters to DX stations with band colors +- **Moon tracking** - Real-time sublunar point with phase display - **Zoom and pan** with full interactivity +### ๐Ÿ“ก Propagation Prediction +- **Real-time ionosonde data** from KC2G/GIRO network (~100 stations) +- **ITU-R P.533-based** MUF/LUF calculations +- **Visual heat map** showing band conditions to DX +- **24-hour propagation chart** with hourly predictions +- **Solar flux, K-index, and sunspot** integration + ### ๐Ÿ“Š Live Data Integration | Source | Data | Update Rate | |--------|------|-------------| | NOAA SWPC | Solar Flux, K-Index, Sunspots | 5 min | +| KC2G/GIRO | Ionosonde foF2, MUF data | 10 min | | POTA | Parks on the Air spots | 1 min | | SOTA | Summits on the Air spots | 1 min | | DX Cluster | Real-time DX spots | 30 sec | | HamQSL | Band conditions | 5 min | +### ๐Ÿ” DX Cluster +- **Real-time spots** from DX Spider network +- **Visual paths on map** with band-specific colors +- **Hover highlighting** - Mouse over spots to highlight on map +- **Grid square display** - Parsed from spot comments +- **Filtering** by band, mode, continent, and search +- **Spotter locations** shown on map + ### ๐Ÿ• Station Information - **UTC and Local time** with date - **Maidenhead grid square** (6 character) @@ -219,6 +237,10 @@ openhamclock/ โ”‚ โ””โ”€โ”€ icons/ # App icons โ”œโ”€โ”€ electron/ # Electron main process โ”‚ โ””โ”€โ”€ main.js # Desktop app entry +โ”œโ”€โ”€ dxspider-proxy/ # DX Cluster proxy service +โ”‚ โ”œโ”€โ”€ server.js # Telnet-to-WebSocket proxy +โ”‚ โ”œโ”€โ”€ package.json # Proxy dependencies +โ”‚ โ””โ”€โ”€ README.md # Proxy documentation โ”œโ”€โ”€ scripts/ # Setup scripts โ”‚ โ”œโ”€โ”€ setup-pi.sh # Raspberry Pi setup โ”‚ โ”œโ”€โ”€ setup-linux.sh @@ -238,11 +260,11 @@ We welcome contributions from the amateur radio community! See [CONTRIBUTING.md] ### Priority Areas 1. **Satellite Tracking** - TLE parsing and pass predictions -2. **Contest Calendar** - Integration with contest databases -3. **Rotator Control** - Hamlib integration -4. **Additional APIs** - QRZ, LoTW, ClubLog -5. **Accessibility** - Screen reader support, high contrast modes -6. **Translations** - Internationalization +2. **Rotator Control** - Hamlib integration +3. **Additional APIs** - QRZ, LoTW, ClubLog +4. **Accessibility** - Screen reader support, high contrast modes +5. **Translations** - Internationalization +6. **WebSocket DX Cluster** - Direct connection to DX Spider nodes ### How to Contribute diff --git a/package.json b/package.json index 381fe69..69a052e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openhamclock", - "version": "3.6.0", + "version": "3.8.0", "description": "Open-source amateur radio dashboard with real-time space weather, band conditions, DX cluster, and interactive world map", "main": "server.js", "scripts": { diff --git a/public/index.html b/public/index.html index b947736..0b037fd 100644 --- a/public/index.html +++ b/public/index.html @@ -1725,9 +1725,9 @@ if (loading || !propagation) { return (
-
๐Ÿ“ก ITU-R P.533
+
๐Ÿ“ก VOACAP
- Calculating predictions... + Loading predictions...
); @@ -1780,8 +1780,8 @@
- {viewMode === 'bands' ? '๐Ÿ“Š BAND CONDITIONS' : '๐Ÿ“ก ITU-R P.533'} - {hasRealData && viewMode !== 'bands' && โ—} + {viewMode === 'bands' ? '๐Ÿ“Š BAND CONDITIONS' : '๐Ÿ“ก VOACAP'} + {hasRealData && viewMode !== 'bands' && โ—} {viewModeLabels[viewMode]} โ€ข click to toggle @@ -1818,7 +1818,7 @@
) : ( <> - {/* MUF/LUF/OWF and Data Source Info */} + {/* MUF/LUF and Data Source Info */}
-
+
MUF - {propagation.frequencies?.muf || muf || '?'} - - - OWF - {propagation.frequencies?.owf || (muf ? (muf * 0.85).toFixed(1) : '?')} + {muf || '?'} + MHz LUF - {propagation.frequencies?.luf || luf || '?'} + {luf || '?'} + MHz
{hasRealData - ? `๐Ÿ“ก ${ionospheric?.source || 'ionosonde'}${ionospheric?.distance ? ` ${ionospheric.distance}km` : ''}` + ? `๐Ÿ“ก ${ionospheric?.source || 'ionosonde'}${ionospheric?.distance ? ` (${ionospheric.distance}km)` : ''}` : ionospheric?.nearestDistance - ? `โšก model (${ionospheric.nearestStation}: ${ionospheric.nearestDistance}km)` - : 'โšก model' + ? `โšก estimated (nearest: ${ionospheric.nearestStation}, ${ionospheric.nearestDistance}km - too far)` + : 'โšก estimated' }
- {/* Path info for multi-hop */} - {propagation.pathGeometry?.hops > 1 && ( -
- {propagation.pathGeometry.hops}-hop path - โ€ข - ~{propagation.pathGeometry.hopLength}km/hop - โ€ข - {propagation.pathGeometry.takeoffAngle}ยฐ takeoff -
- )} - {viewMode === 'chart' ? ( /* VOACAP Heat Map Chart View */
@@ -1940,16 +1919,16 @@ fontSize: '11px' }}>
- BCR: -
-
-
-
-
-
+ REL: +
+
+
+
+
+
- {Math.round(propagation.pathGeometry?.distance || distance)}km โ€ข {ionospheric?.foF2 ? `foF2 ${ionospheric.foF2}MHz` : `Rโ‚โ‚‚=${solarData.ssn}`} + {Math.round(distance)}km โ€ข {ionospheric?.foF2 ? `foF2=${ionospheric.foF2}` : `SSN=${solarData.ssn}`}
@@ -1970,7 +1949,7 @@ {ionospheric?.foF2 ? ( foF2 {ionospheric.foF2} ) : ( - Rโ‚โ‚‚ {solarData.ssn} + SSN {solarData.ssn} )} K = 4 ? '#ff4444' : '#00ff88' }}>{solarData.kIndex}
@@ -1978,7 +1957,7 @@ {currentBands.slice(0, 8).map((band, idx) => (
{band.reliability}% - - {band.sUnits || ''} -
))}
diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..5dd845c Binary files /dev/null and b/screenshot.png differ diff --git a/server.js b/server.js index 0677b3a..20c23e1 100644 --- a/server.js +++ b/server.js @@ -1,20 +1,10 @@ /** - * OpenHamClock Server v3.9.0 + * OpenHamClock Server * * Express server that: * 1. Serves the static web application * 2. Proxies API requests to avoid CORS issues - * 3. Provides ITU-R P.533-14 HF propagation predictions - * 4. Integrates real-time ionosonde data from KC2G/GIRO network - * 5. Provides WebSocket support for future real-time features - * - * Propagation Model: ITU-R P.533-14 - * - Method for prediction of HF circuit performance - * - Real-time ionosonde foF2/MUF data integration - * - BCR (Basic Circuit Reliability) calculations - * - Signal strength in dBW and S-units - * - Multi-hop path analysis for long-distance circuits - * - Sporadic-E prediction for 6m/10m + * 3. Provides WebSocket support for future real-time features * * Usage: * node server.js @@ -1777,24 +1767,22 @@ function interpolateFoF2(lat, lon, stations) { } // ============================================ -// ITU-R P.533-14 COMPLIANT PROPAGATION PREDICTION API -// Based on: "Method for the prediction of the performance of HF circuits" +// ENHANCED PROPAGATION PREDICTION API (ITU-R P.533 based) // ============================================ app.get('/api/propagation', async (req, res) => { - const { deLat, deLon, dxLat, dxLon, txPower = 100, mode = 'SSB' } = req.query; + const { deLat, deLon, dxLat, dxLon } = req.query; - console.log('[ITU-R P.533] Prediction for DE:', deLat, deLon, 'to DX:', dxLat, dxLon); + console.log('[Propagation] Enhanced calculation for DE:', deLat, deLon, 'to DX:', dxLat, dxLon); try { - // ========== STEP 1: Get Solar/Geomagnetic Indices ========== - let sfi = 150, ssn = 100, kIndex = 2, aIndex = 10; + // Get current space weather data + let sfi = 150, ssn = 100, kIndex = 2; try { - const [fluxRes, kRes, ssnRes] = await Promise.allSettled([ + 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'), - fetch('https://services.swpc.noaa.gov/json/solar-cycle/predicted-solar-cycle.json') + fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json') ]); if (fluxRes.status === 'fulfilled' && fluxRes.value.ok) { @@ -1803,245 +1791,123 @@ app.get('/api/propagation', async (req, res) => { } 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; - // Approximate A-index from K-index - aIndex = Math.round(Math.pow(kIndex, 2.5) * 2); - } + if (data?.length > 1) kIndex = parseInt(data[data.length - 1][1]) || 2; } - // ITU-R P.533 uses R12 (smoothed sunspot number) - // Approximate from SFI: R12 โ‰ˆ (SFI - 67) / 0.97 ssn = Math.max(0, Math.round((sfi - 67) / 0.97)); } catch (e) { - console.log('[ITU-R P.533] Using default solar values'); + console.log('[Propagation] Using default solar values'); } - // ========== STEP 2: Get Real Ionosonde Data ========== + // Get real ionosonde data const ionosondeStations = await fetchIonosondeData(); - // ========== STEP 3: Path Geometry Calculations ========== + // Calculate path geometry const de = { lat: parseFloat(deLat) || 40, lon: parseFloat(deLon) || -75 }; const dx = { lat: parseFloat(dxLat) || 35, lon: parseFloat(dxLon) || 139 }; const distance = haversineDistance(de.lat, de.lon, dx.lat, dx.lon); + const midLat = (de.lat + dx.lat) / 2; + let midLon = (de.lon + dx.lon) / 2; - // Calculate control points along the path (ITU-R uses multiple points for long paths) - const pathGeometry = calculatePathGeometry(distance); - const controlPoints = getControlPoints(de, dx, pathGeometry.hops); - - // Get ionospheric data at each control point - const ionoDataPoints = controlPoints.map(cp => - interpolateFoF2(cp.lat, cp.lon, ionosondeStations) - ); - - // Use worst-case foF2 along path (limiting factor) - const validIonoPoints = ionoDataPoints.filter(d => d && d.method !== 'no-coverage' && d.foF2); - const hasValidIonoData = validIonoPoints.length > 0; - - // For MUF, use minimum foF2 along path (weakest link) - let effectiveIonoData = null; - if (hasValidIonoData) { - effectiveIonoData = validIonoPoints.reduce((min, curr) => - (curr.foF2 < min.foF2) ? curr : min, validIonoPoints[0]); + // Handle antimeridian crossing + if (Math.abs(de.lon - dx.lon) > 180) { + midLon = (de.lon + dx.lon + 360) / 2; + if (midLon > 180) midLon -= 360; } - const midLat = controlPoints[Math.floor(controlPoints.length / 2)].lat; - const midLon = controlPoints[Math.floor(controlPoints.length / 2)].lon; + // Get ionospheric data at path midpoint + const ionoData = interpolateFoF2(midLat, midLon, ionosondeStations); - console.log('[ITU-R P.533] Distance:', Math.round(distance), 'km, Hops:', pathGeometry.hops); - console.log('[ITU-R P.533] Solar: SFI', sfi, 'SSN(R12)', ssn, 'K', kIndex, 'A', aIndex); + // Check if we have valid ionosonde coverage + const hasValidIonoData = ionoData && ionoData.method !== 'no-coverage' && ionoData.foF2; + + console.log('[Propagation] Distance:', Math.round(distance), 'km'); + console.log('[Propagation] Solar: SFI', sfi, 'SSN', ssn, 'K', kIndex); if (hasValidIonoData) { - console.log('[ITU-R P.533] Real foF2:', effectiveIonoData.foF2?.toFixed(2), 'MHz from', - effectiveIonoData.nearestStation || effectiveIonoData.source); + console.log('[Propagation] Real foF2:', ionoData.foF2?.toFixed(2), 'MHz from', ionoData.nearestStation || ionoData.source, '(', ionoData.nearestDistance, 'km away)'); + } else if (ionoData?.method === 'no-coverage') { + console.log('[Propagation] No ionosonde coverage -', ionoData.reason); } - // ========== STEP 4: Mode-specific SNR requirements ========== - const modeParams = { - 'CW': { bandwidth: 500, requiredSnr: 0 }, - 'SSB': { bandwidth: 3000, requiredSnr: 15 }, - 'FT8': { bandwidth: 50, requiredSnr: -21 }, - 'FT4': { bandwidth: 80, requiredSnr: -17 }, - 'WSPR': { bandwidth: 6, requiredSnr: -29 }, - 'RTTY': { bandwidth: 300, requiredSnr: 5 }, - 'AM': { bandwidth: 6000, requiredSnr: 20 } - }; - const currentMode = modeParams[mode] || modeParams['SSB']; - - // ========== STEP 5: Calculate predictions for all bands ========== 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]; const currentHour = new Date().getUTCHours(); - const currentMonth = new Date().getMonth() + 1; + // Generate 24-hour predictions (use null for ionoData if no valid coverage) + const effectiveIonoData = hasValidIonoData ? ionoData : null; const predictions = {}; - const power = parseFloat(txPower) || 100; bands.forEach((band, idx) => { const freq = bandFreqs[idx]; predictions[band] = []; for (let hour = 0; hour < 24; hour++) { - // Calculate MUF and LUF for this hour - const hourIonoData = getHourlyIonoData(effectiveIonoData, hour, currentHour); - const muf = calculateMUF(distance, midLat, midLon, hour, sfi, ssn, hourIonoData); - const luf = calculateLUF(distance, midLat, hour, sfi, kIndex); - - // Calculate signal strength (ITU-R P.533 methodology) - const signalDbw = calculateSignalStrength(freq, distance, muf, luf, sfi, kIndex, power); - const sUnits = dbwToSMeter(signalDbw); - - // Calculate BCR (Basic Circuit Reliability) - let bcr = calculateBCR(freq, muf, luf, signalDbw, currentMode.requiredSnr); - - // Add Sporadic-E contribution for 6m/10m - if (freq >= 28) { - const localHour = (hour + midLon / 15 + 24) % 24; - const esReliability = calculateEsReliability(freq, distance, currentMonth, localHour, midLat); - bcr = Math.min(99, bcr + esReliability * (1 - bcr / 100)); - } - - // Apply geomagnetic storm degradation - if (kIndex >= 5) bcr *= 0.3; - else if (kIndex >= 4) bcr *= 0.5; - else if (kIndex >= 3) bcr *= 0.75; - + const reliability = calculateEnhancedReliability( + freq, distance, midLat, midLon, hour, sfi, ssn, kIndex, de, dx, effectiveIonoData, currentHour + ); predictions[band].push({ hour, - reliability: Math.round(bcr), - snr: calculateSNR(bcr), - sUnits: sUnits, - signalDbw: Math.round(signalDbw) + reliability: Math.round(reliability), + snr: calculateSNR(reliability) }); } }); - // ========== STEP 6: Current band status ========== - const currentBands = bands.map((band, idx) => { - const pred = predictions[band][currentHour]; - return { - band, - freq: bandFreqs[idx], - reliability: pred.reliability, - snr: pred.snr, - sUnits: pred.sUnits, - status: getStatus(pred.reliability) - }; - }).sort((a, b) => b.reliability - a.reliability); + // 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); - // Calculate current MUF/LUF/OWF + // Calculate current MUF and LUF const currentMuf = calculateMUF(distance, midLat, midLon, currentHour, sfi, ssn, effectiveIonoData); const currentLuf = calculateLUF(distance, midLat, currentHour, sfi, kIndex); - const owf = currentMuf * 0.85; // Optimum Working Frequency - // ========== STEP 7: Build response ========== + // Build ionospheric response let ionosphericResponse; if (hasValidIonoData) { ionosphericResponse = { - foF2: effectiveIonoData.foF2?.toFixed(2), - mufd: effectiveIonoData.mufd?.toFixed(1), - hmF2: effectiveIonoData.hmF2?.toFixed(0), - source: effectiveIonoData.nearestStation || effectiveIonoData.source, - distance: effectiveIonoData.nearestDistance, - method: effectiveIonoData.method, - stationsUsed: validIonoPoints.length, - controlPoints: controlPoints.length + foF2: ionoData.foF2?.toFixed(2), + mufd: ionoData.mufd?.toFixed(1), + hmF2: ionoData.hmF2?.toFixed(0), + source: ionoData.nearestStation || ionoData.source, + distance: ionoData.nearestDistance, + method: ionoData.method, + stationsUsed: ionoData.stationsUsed || 1 }; - } else { - ionosphericResponse = { - source: 'ITU-R P.533 model', - method: 'estimated', - note: 'Using solar indices - ionosonde data unavailable for path' + } else if (ionoData?.method === 'no-coverage') { + ionosphericResponse = { + source: 'No ionosonde coverage', + reason: ionoData.reason, + nearestStation: ionoData.nearestStation, + nearestDistance: ionoData.nearestDistance, + method: 'estimated' }; + } else { + ionosphericResponse = { source: 'model', method: 'estimated' }; } res.json({ - model: 'ITU-R P.533-14', - solarData: { sfi, ssn, kIndex, aIndex }, + solarData: { sfi, ssn, kIndex }, ionospheric: ionosphericResponse, - pathGeometry: { - distance: Math.round(distance), - hops: pathGeometry.hops, - takeoffAngle: Math.round(pathGeometry.takeoffAngle * 10) / 10, - hopLength: Math.round(pathGeometry.hopLength) - }, - frequencies: { - muf: Math.round(currentMuf * 10) / 10, - luf: Math.round(currentLuf * 10) / 10, - owf: Math.round(owf * 10) / 10 - }, - mode: { name: mode, ...currentMode }, + muf: Math.round(currentMuf * 10) / 10, + luf: Math.round(currentLuf * 10) / 10, + distance: Math.round(distance), currentHour, currentBands, hourlyPredictions: predictions, - dataSource: hasValidIonoData ? - 'Real-time ionosonde (KC2G/GIRO) + ITU-R P.533' : - 'ITU-R P.533 model predictions' + dataSource: hasValidIonoData ? 'KC2G/GIRO Ionosonde Network' : 'Estimated from solar indices' }); } catch (error) { - console.error('[ITU-R P.533] Error:', error.message); + console.error('[Propagation] Error:', error.message); res.status(500).json({ error: 'Failed to calculate propagation' }); } }); -// Get control points along the great circle path -function getControlPoints(de, dx, numHops) { - const points = []; - const numPoints = Math.max(3, numHops + 1); - - for (let i = 0; i <= numPoints; i++) { - const fraction = i / numPoints; - const point = interpolateGreatCircle(de, dx, fraction); - points.push(point); - } - - return points; -} - -// Interpolate position along great circle -function interpolateGreatCircle(start, end, fraction) { - const lat1 = start.lat * Math.PI / 180; - const lon1 = start.lon * Math.PI / 180; - const lat2 = end.lat * Math.PI / 180; - const lon2 = end.lon * Math.PI / 180; - - const d = 2 * Math.asin(Math.sqrt( - Math.pow(Math.sin((lat2 - lat1) / 2), 2) + - Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin((lon2 - lon1) / 2), 2) - )); - - if (d === 0) return { lat: start.lat, lon: start.lon }; - - const A = Math.sin((1 - fraction) * d) / Math.sin(d); - const B = Math.sin(fraction * d) / Math.sin(d); - - const x = A * Math.cos(lat1) * Math.cos(lon1) + B * Math.cos(lat2) * Math.cos(lon2); - const y = A * Math.cos(lat1) * Math.sin(lon1) + B * Math.cos(lat2) * Math.sin(lon2); - const z = A * Math.sin(lat1) + B * Math.sin(lat2); - - const lat = Math.atan2(z, Math.sqrt(x * x + y * y)) * 180 / Math.PI; - const lon = Math.atan2(y, x) * 180 / Math.PI; - - return { lat, lon }; -} - -// Estimate ionospheric data for different hours based on current measurement -function getHourlyIonoData(ionoData, targetHour, currentHour) { - if (!ionoData || !ionoData.foF2) return null; - - // foF2 follows a diurnal pattern - peak around 14:00 local - // Typical day/night ratio is 2-3x - const currentHourFactor = 1 + 0.5 * Math.cos((currentHour - 14) * Math.PI / 12); - const targetHourFactor = 1 + 0.5 * Math.cos((targetHour - 14) * Math.PI / 12); - const scaleFactor = targetHourFactor / currentHourFactor; - - return { - ...ionoData, - foF2: ionoData.foF2 * scaleFactor, - mufd: ionoData.mufd ? ionoData.mufd * scaleFactor : null - }; -} - // Calculate MUF using real ionosonde data or model function calculateMUF(distance, midLat, midLon, hour, sfi, ssn, ionoData) { // If we have real MUF(3000) data, scale it for actual distance @@ -2216,235 +2082,21 @@ function calculateEnhancedReliability(freq, distance, midLat, midLon, hour, sfi, return Math.min(99, Math.max(0, reliability)); } -// ============================================ -// ITU-R P.533-14 PROPAGATION CALCULATIONS -// Enhanced HF prediction based on ITU methodology -// ============================================ - -// Calculate signal strength in dBW (ITU-R P.533 style) -function calculateSignalStrength(freq, distance, muf, luf, sfi, kIndex, txPower = 100) { - // Basic path loss (free space + ionospheric) - const freqMHz = freq; - const distKm = distance; - - // Free space path loss (dB) at reference 1000km - const fspl = 32.4 + 20 * Math.log10(distKm) + 20 * Math.log10(freqMHz); - - // Ionospheric absorption loss (D-layer) - // Higher at lower frequencies, higher during daytime - const absorptionBase = 10 * Math.pow(10 / freqMHz, 1.5); - - // MUF margin loss - signal degrades as we approach MUF - let mufLoss = 0; - if (freq > muf * 0.9) { - mufLoss = 20 * Math.pow((freq - muf * 0.9) / (muf * 0.1), 2); - } - - // LUF margin loss - absorption increases below LUF - let lufLoss = 0; - if (freq < luf * 1.2) { - lufLoss = 15 * Math.pow((luf * 1.2 - freq) / (luf * 0.2), 1.5); - } - - // Geomagnetic storm loss - const kLoss = kIndex >= 5 ? 20 : kIndex >= 4 ? 12 : kIndex >= 3 ? 6 : kIndex >= 2 ? 3 : 0; - - // Multi-hop additional loss (about 3dB per hop) - const hops = Math.max(1, Math.ceil(distKm / 3500)); - const hopLoss = (hops - 1) * 3; - - // TX power in dBW (100W = 20dBW) - const txPowerDbw = 10 * Math.log10(txPower); - - // Typical amateur antenna gain (dipole ~2dBi TX, ~2dBi RX) - const antennaGain = 4; // Combined TX+RX - - // Calculate received power - const totalLoss = fspl + absorptionBase + mufLoss + lufLoss + kLoss + hopLoss - 60; // -60 for ionospheric reflection gain - const rxPower = txPowerDbw + antennaGain - totalLoss; - - return rxPower; -} - -// Convert dBW to S-meter reading (IARU Region 1 standard: S9 = -73dBm = -103dBW) -function dbwToSMeter(dbw) { - const dbm = dbw + 30; // dBW to dBm - - // S9 = -73dBm, each S-unit = 6dB - const sUnitsFromS9 = (dbm + 73) / 6; - const sReading = 9 + sUnitsFromS9; - - if (sReading >= 9) { - const over = Math.round((sReading - 9) * 6); - if (over > 0) return `S9+${over}dB`; - return 'S9'; - } else if (sReading >= 1) { - return `S${Math.max(1, Math.round(sReading))}`; - } else { - return 'S0'; - } -} - -// Calculate Basic Circuit Reliability (BCR) - ITU-R P.533 style -function calculateBCR(freq, muf, luf, signalDbw, requiredSnr = 0) { - // BCR is the probability that the circuit will support the required SNR - // Based on the variability of the ionosphere and signal levels - - // Noise floor assumption (typical rural): -150 dBW/Hz at 3MHz, scales with freq - const noiseFloor = -150 + 20 * Math.log10(freq / 3); - - // SNR at receiver - const snr = signalDbw - noiseFloor; - - // SNR margin over required - const snrMargin = snr - requiredSnr; - - // Standard deviation of day-to-day variability (typically 5-10 dB) - const sigma = 7; - - // Calculate probability using normal distribution - // BCR = probability that actual SNR > required SNR - const zScore = snrMargin / sigma; - - // Approximate normal CDF - const bcr = 0.5 * (1 + erf(zScore / Math.sqrt(2))); - - // Apply MUF/LUF penalties - let mufPenalty = 1.0; - if (freq > muf) { - mufPenalty = Math.exp(-2 * Math.pow((freq - muf) / muf, 2)); - } - - let lufPenalty = 1.0; - if (freq < luf) { - lufPenalty = Math.exp(-2 * Math.pow((luf - freq) / luf, 2)); - } - - return Math.min(99, Math.max(0, bcr * mufPenalty * lufPenalty * 100)); -} - -// Error function approximation for normal distribution -function erf(x) { - const a1 = 0.254829592; - const a2 = -0.284496736; - const a3 = 1.421413741; - const a4 = -1.453152027; - const a5 = 1.061405429; - const p = 0.3275911; - - const sign = x >= 0 ? 1 : -1; - x = Math.abs(x); - - const t = 1.0 / (1.0 + p * x); - const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x); - - return sign * y; -} - -// Calculate path geometry for multi-hop propagation -function calculatePathGeometry(distance) { - // ITU-R P.533 uses standard hop lengths - // F2 layer: max single hop ~3500-4000km - // E layer: max single hop ~2000km - - const maxF2Hop = 3500; // km - const hops = Math.ceil(distance / maxF2Hop); - - // Calculate take-off angle (elevation) - // For single hop at distance d with F2 layer height ~300km - const earthRadius = 6371; // km - const ionoHeight = 300; // F2 layer height - - if (hops === 1) { - // Single hop geometry - const hopDistance = distance / 2; // Ground distance to reflection point - const takeoffAngle = Math.atan2(ionoHeight, hopDistance) * 180 / Math.PI; - return { hops, takeoffAngle: Math.max(3, takeoffAngle), hopLength: distance }; - } else { - // Multi-hop - const hopLength = distance / hops; - const takeoffAngle = Math.atan2(ionoHeight, hopLength / 2) * 180 / Math.PI; - return { hops, takeoffAngle: Math.max(3, takeoffAngle), hopLength }; - } -} - -// Sporadic-E (Es) propagation prediction for 6m/10m -function calculateEsReliability(freq, distance, month, localHour, midLat) { - // Es is most common: - // - Summer months (May-August in Northern Hemisphere) - // - Mid-latitudes (30-50ยฐ) - // - Daytime (10:00-22:00 local) - // - 6m band (50 MHz) most common, 10m less so - - if (freq < 28) return 0; // Es mainly affects 10m and up - - // Season factor (Northern Hemisphere summer peak) - const summerMonths = [5, 6, 7, 8]; // May-August - const winterMonths = [11, 12, 1, 2]; - let seasonFactor = 0.3; // Base - - if (midLat >= 0) { - // Northern hemisphere - if (summerMonths.includes(month)) seasonFactor = 1.0; - else if (winterMonths.includes(month)) seasonFactor = 0.2; - else seasonFactor = 0.5; - } else { - // Southern hemisphere - opposite seasons - if (winterMonths.includes(month)) seasonFactor = 1.0; - else if (summerMonths.includes(month)) seasonFactor = 0.2; - else seasonFactor = 0.5; - } - - // Time of day factor - let timeFactor = 0.3; - if (localHour >= 10 && localHour <= 14) timeFactor = 0.8; - else if (localHour >= 14 && localHour <= 20) timeFactor = 1.0; - else if (localHour >= 20 && localHour <= 22) timeFactor = 0.7; - - // Latitude factor - Es favors mid-latitudes - let latFactor = 0.5; - const absLat = Math.abs(midLat); - if (absLat >= 30 && absLat <= 50) latFactor = 1.0; - else if (absLat >= 20 && absLat <= 60) latFactor = 0.7; - - // Frequency factor - 6m is most likely, 10m less so - let freqFactor = 0.5; - if (freq >= 50) freqFactor = 0.8; // 6m - else if (freq >= 28) freqFactor = 0.4; // 10m - - // Distance factor - Es works best 500-2300km - let distFactor = 0.3; - if (distance >= 500 && distance <= 2300) distFactor = 1.0; - else if (distance >= 300 && distance <= 3000) distFactor = 0.5; - - // Es is unpredictable - max reliability around 40% - const esReliability = 40 * seasonFactor * timeFactor * latFactor * freqFactor * distFactor; - - return esReliability; -} - -// Convert reliability to estimated SNR (ITU-R style) +// Convert reliability to estimated SNR function calculateSNR(reliability) { - // Map reliability to approximate SNR margin - if (reliability >= 90) return '+30dB'; if (reliability >= 80) return '+20dB'; - if (reliability >= 70) return '+15dB'; if (reliability >= 60) return '+10dB'; - if (reliability >= 50) return '+5dB'; if (reliability >= 40) return '0dB'; - if (reliability >= 30) return '-5dB'; if (reliability >= 20) return '-10dB'; - if (reliability >= 10) return '-15dB'; return '-20dB'; } -// Get status label from reliability (BCR-based) +// Get status label from reliability function getStatus(reliability) { - // Based on ITU-R P.533 BCR thresholds - if (reliability >= 80) return 'EXCELLENT'; - if (reliability >= 60) return 'GOOD'; - if (reliability >= 40) return 'FAIR'; - if (reliability >= 20) return 'POOR'; + if (reliability >= 70) return 'EXCELLENT'; + if (reliability >= 50) return 'GOOD'; + if (reliability >= 30) return 'FAIR'; + if (reliability >= 15) return 'POOR'; return 'CLOSED'; }