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 @@
-
+
[](LICENSE)
[](https://nodejs.org/)
[](CONTRIBUTING.md)
@@ -13,7 +13,7 @@
[**Live Demo**](https://openhamclock.up.railway.app) ยท [**Download**](#-installation) ยท [**Documentation**](#-features) ยท [**Contributing**](#-contributing)
-
+
@@ -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';
}