/** * ITURHFProp Service * * REST API wrapper for the ITURHFProp HF propagation prediction engine * Implements ITU-R P.533-14 "Method for the prediction of the performance of HF circuits" * * Endpoints: * GET /api/predict - Single point prediction * GET /api/predict/hourly - 24-hour prediction * GET /api/health - Health check */ const express = require('express'); const cors = require('cors'); const { execSync, exec } = require('child_process'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const app = express(); const PORT = process.env.PORT || 3000; // Paths to ITURHFProp - DATA points to root which contains Data/ and IonMap/ subdirs const ITURHFPROP_PATH = process.env.ITURHFPROP_PATH || '/opt/iturhfprop/ITURHFProp'; const ITURHFPROP_DATA = process.env.ITURHFPROP_DATA || '/opt/iturhfprop'; // Temp directory for input/output files const TEMP_DIR = '/tmp/iturhfprop'; // === CACHING AND CIRCUIT BREAKER === const predictionCache = new Map(); const CACHE_TTL = 5 * 60 * 1000; // 5 minutes const CACHE_MAX_SIZE = 100; // Circuit breaker state let circuitBreakerOpen = false; let consecutiveFailures = 0; let lastFailureTime = 0; let lastLogTime = 0; const FAILURE_THRESHOLD = 3; // Open after 3 failures const CIRCUIT_RESET_TIME = 2 * 60 * 1000; // 2 minutes before retry const LOG_INTERVAL = 30000; // Only log every 30 seconds function getCacheKey(params) { // Round coordinates to 1 decimal place for better cache hits const key = `${params.txLat.toFixed(1)},${params.txLon.toFixed(1)}-${params.rxLat.toFixed(1)},${params.rxLon.toFixed(1)}-${params.month}-${params.hour}-${params.ssn}`; return key; } function getFromCache(key) { const cached = predictionCache.get(key); if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) { return cached.data; } predictionCache.delete(key); return null; } function setCache(key, data) { // Enforce max cache size if (predictionCache.size >= CACHE_MAX_SIZE) { const oldestKey = predictionCache.keys().next().value; predictionCache.delete(oldestKey); } predictionCache.set(key, { data, timestamp: Date.now() }); } function shouldLog() { const now = Date.now(); if (now - lastLogTime > LOG_INTERVAL) { lastLogTime = now; return true; } return false; } function checkCircuitBreaker() { if (!circuitBreakerOpen) return false; // Check if enough time has passed to try again if (Date.now() - lastFailureTime > CIRCUIT_RESET_TIME) { circuitBreakerOpen = false; consecutiveFailures = 0; if (shouldLog()) { console.log('[Circuit Breaker] Reset - allowing requests'); } return false; } return true; } function recordFailure() { consecutiveFailures++; lastFailureTime = Date.now(); if (consecutiveFailures >= FAILURE_THRESHOLD && !circuitBreakerOpen) { circuitBreakerOpen = true; if (shouldLog()) { console.log(`[Circuit Breaker] OPEN after ${consecutiveFailures} failures - pausing for ${CIRCUIT_RESET_TIME/1000}s`); } } } function recordSuccess() { consecutiveFailures = 0; circuitBreakerOpen = false; } // Middleware app.use(cors()); app.use(express.json()); // Ensure temp directory exists if (!fs.existsSync(TEMP_DIR)) { fs.mkdirSync(TEMP_DIR, { recursive: true }); } // HF band frequencies (MHz) - P.533 valid range is 2-30 MHz const HF_BANDS = { '160m': 2.0, // Adjusted from 1.9 to meet P.533 minimum of 2 MHz '80m': 3.5, '60m': 5.3, '40m': 7.1, '30m': 10.1, '20m': 14.1, '17m': 18.1, '15m': 21.1, '12m': 24.9, '10m': 28.1 // Note: 11m (27 MHz) excluded - too close to P.533 upper limit, use built-in calculation // Note: 6m (50 MHz) excluded - outside P.533 HF range (2-30 MHz) }; /** * Generate ITURHFProp input file */ function generateInputFile(params) { const { txLat, txLon, rxLat, rxLon, year, month, hour, ssn = 100, txPower = 100, // Watts txGain = 0, // dBi rxGain = 0, // dBi frequencies = Object.values(HF_BANDS), manMadeNoise = 'RESIDENTIAL', // CITY, RESIDENTIAL, RURAL, QUIET requiredReliability = 90, requiredSNR = 15 // dB for SSB } = params; // Convert coordinates to ITURHFProp format (decimal degrees) const txLatStr = txLat >= 0 ? `${txLat.toFixed(2)} N` : `${Math.abs(txLat).toFixed(2)} S`; const txLonStr = txLon >= 0 ? `${txLon.toFixed(2)} E` : `${Math.abs(txLon).toFixed(2)} W`; const rxLatStr = rxLat >= 0 ? `${rxLat.toFixed(2)} N` : `${Math.abs(rxLat).toFixed(2)} S`; const rxLonStr = rxLon >= 0 ? `${rxLon.toFixed(2)} E` : `${Math.abs(rxLon).toFixed(2)} W`; // Format frequencies - comma-separated per ITURHFProp docs const freqList = frequencies.map(f => f.toFixed(3)).join(', '); // ITURHFProp input file format - complete version with all required fields const input = `PathName "OpenHamClock" PathTXName "TX" Path.L_tx.lat ${txLat.toFixed(4)} Path.L_tx.lng ${txLon.toFixed(4)} TXAntFilePath "ISOTROPIC" TXGOS 0.0 PathRXName "RX" Path.L_rx.lat ${rxLat.toFixed(4)} Path.L_rx.lng ${rxLon.toFixed(4)} RXAntFilePath "ISOTROPIC" RXGOS 0.0 AntennaOrientation "TX2RX" Path.year ${year} Path.month ${month} Path.hour ${isNaN(hour) ? 12 : (hour === 0 ? 24 : hour)} Path.SSN ${ssn} Path.frequency ${freqList} Path.txpower ${(10 * Math.log10(txPower)).toFixed(1)} Path.BW 3000 Path.SNRr ${requiredSNR} Path.SNRXXp ${requiredReliability} Path.ManMadeNoise "${manMadeNoise}" Path.Modulation ANALOG Path.SorL SHORTPATH LL.lat ${rxLat.toFixed(4)} LL.lng ${rxLon.toFixed(4)} LR.lat ${rxLat.toFixed(4)} LR.lng ${rxLon.toFixed(4)} UL.lat ${rxLat.toFixed(4)} UL.lng ${rxLon.toFixed(4)} UR.lat ${rxLat.toFixed(4)} UR.lng ${rxLon.toFixed(4)} DataFilePath "${ITURHFPROP_DATA}/Data/" RptFilePath "/tmp/" RptFileFormat "RPT_PR | RPT_SNR | RPT_BCR" `; return input; } /** * Parse ITURHFProp output file */ function parseOutputFile(outputPath) { try { const output = fs.readFileSync(outputPath, 'utf8'); const lines = output.split('\n'); const results = { frequencies: [], raw: output.substring(0, 3000) // Include raw for debugging }; let inDataSection = false; for (const line of lines) { const trimmed = line.trim(); // Look for "Calculated Parameters" section if (trimmed.includes('Calculated Parameters') && !trimmed.includes('End')) { inDataSection = true; continue; } // Stop at end of data if (trimmed.includes('End Calculated') || trimmed.includes('*****')) { if (inDataSection && results.frequencies.length > 0) { break; } } // Parse data lines: "02, 05, 2.000,-120.29, -16.04, 0.00" // Format: Month, Hour, Freq, Pr, SNR, BCR if (inDataSection && trimmed && !trimmed.startsWith('*') && !trimmed.startsWith('-')) { const parts = trimmed.split(',').map(p => p.trim()); if (parts.length >= 6) { const freq = parseFloat(parts[2]); const pr = parseFloat(parts[3]); const snr = parseFloat(parts[4]); const bcr = parseFloat(parts[5]); if (!isNaN(freq) && freq > 0) { results.frequencies.push({ freq: freq, sdbw: pr, snr: snr, reliability: bcr }); } } } } // Extract MUF from header section const mufMatch = output.match(/(?:BMUF|MUF|Operational MUF)\s*[:=]?\s*([\d.]+)/i); if (mufMatch) { results.muf = parseFloat(mufMatch[1]); } return results; } catch (err) { // File doesn't exist - this is expected when ITURHFProp fails return { error: err.message, frequencies: [] }; } } /** * Run ITURHFProp prediction */ async function runPrediction(params) { // Check circuit breaker first - return immediately if open if (checkCircuitBreaker()) { return { error: 'Circuit breaker open', frequencies: [], circuitBreakerOpen: true }; } // Check cache const cacheKey = getCacheKey(params); const cached = getFromCache(cacheKey); if (cached) { return { ...cached, fromCache: true }; } const id = crypto.randomBytes(8).toString('hex'); const inputPath = path.join(TEMP_DIR, `input_${id}.txt`); const outputPath = path.join(TEMP_DIR, `output_${id}.txt`); // Ensure temp dir exists if (!fs.existsSync(TEMP_DIR)) { fs.mkdirSync(TEMP_DIR, { recursive: true }); } try { // Generate input file const inputContent = generateInputFile(params); fs.writeFileSync(inputPath, inputContent); // Run ITURHFProp const startTime = Date.now(); const cmd = `${ITURHFPROP_PATH} ${inputPath} ${outputPath}`; let execStdout = ''; try { execStdout = execSync(cmd, { timeout: 30000, encoding: 'utf8', env: { ...process.env, LD_LIBRARY_PATH: '/opt/iturhfprop:' + (process.env.LD_LIBRARY_PATH || '') } }); } catch (execError) { recordFailure(); // Only log occasionally if (shouldLog()) { console.error(`[ITURHFProp] Failed (exit ${execError.status}), failures: ${consecutiveFailures}, circuit: ${circuitBreakerOpen ? 'OPEN' : 'closed'}`); } // Cache the error to prevent repeated attempts const errorResult = { error: `Exit code ${execError.status}`, frequencies: [] }; setCache(cacheKey, errorResult); return errorResult; } const elapsed = Date.now() - startTime; // Parse output const results = parseOutputFile(outputPath); if (results.frequencies.length === 0) { recordFailure(); const errorResult = { error: 'No frequency results', frequencies: [], elapsed }; setCache(cacheKey, errorResult); return errorResult; } // Success! recordSuccess(); results.elapsed = elapsed; results.params = { txLat: params.txLat, txLon: params.txLon, rxLat: params.rxLat, rxLon: params.rxLon, hour: params.hour, month: params.month, ssn: params.ssn }; // Cache successful result setCache(cacheKey, results); if (shouldLog()) { console.log(`[ITURHFProp] Success: ${results.frequencies.length} freqs, MUF=${results.muf || 'N/A'}`); } return results; } finally { // Cleanup temp files try { if (fs.existsSync(inputPath)) fs.unlinkSync(inputPath); if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); } catch (e) { // Ignore cleanup errors } } } // ============================================ // API ENDPOINTS // ============================================ /** * Health check */ app.get('/api/health', (req, res) => { const binaryExists = fs.existsSync(ITURHFPROP_PATH); const dataExists = fs.existsSync(ITURHFPROP_DATA); const dataSubExists = fs.existsSync(ITURHFPROP_DATA + '/Data'); // Check for shared libraries const libp533Exists = fs.existsSync('/opt/iturhfprop/libp533.so'); const libp372Exists = fs.existsSync('/opt/iturhfprop/libp372.so'); // Check for ionospheric data (ionos12.bin in Data folder) const ionosDataExists = fs.existsSync(ITURHFPROP_DATA + '/Data/ionos12.bin'); res.json({ status: binaryExists && dataSubExists && libp533Exists && ionosDataExists && !circuitBreakerOpen ? 'healthy' : 'degraded', service: 'iturhfprop', version: '1.0.0', engine: 'ITURHFProp (ITU-R P.533-14)', binary: binaryExists ? 'found' : 'missing', libp533: libp533Exists ? 'found' : 'missing', libp372: libp372Exists ? 'found' : 'missing', dataDir: dataSubExists ? 'found' : 'missing', ionosData: ionosDataExists ? 'found' : 'missing', circuitBreaker: { open: circuitBreakerOpen, consecutiveFailures, cacheSize: predictionCache.size }, paths: { binary: ITURHFPROP_PATH, data: ITURHFPROP_DATA }, timestamp: new Date().toISOString() }); }); /** * Diagnostic endpoint - test binary execution */ app.get('/api/diag', async (req, res) => { const results = { binary: {}, libraries: {}, data: {}, testRun: {} }; // Check binary try { const stats = fs.statSync(ITURHFPROP_PATH); results.binary = { exists: true, size: stats.size, mode: stats.mode.toString(8), path: ITURHFPROP_PATH }; } catch (e) { results.binary = { exists: false, error: e.message }; } // Check libraries try { const libs = fs.readdirSync('/opt/iturhfprop').filter(f => f.endsWith('.so')); results.libraries = { found: libs }; } catch (e) { results.libraries = { error: e.message }; } // Check data files try { const dataFiles = fs.readdirSync(ITURHFPROP_DATA + '/Data').slice(0, 15); results.data.dataDir = dataFiles; } catch (e) { results.data.dataDir = { error: e.message }; } // Check for ionospheric data try { const ionosExists = fs.existsSync(ITURHFPROP_DATA + '/Data/ionos12.bin'); results.data.ionosData = ionosExists ? 'found' : 'missing'; } catch (e) { results.data.ionosData = { error: e.message }; } // Try running ldd on the binary try { const { execSync } = require('child_process'); const lddOutput = execSync(`ldd ${ITURHFPROP_PATH} 2>&1`, { encoding: 'utf8' }); results.testRun.ldd = lddOutput.split('\n').slice(0, 10); } catch (e) { results.testRun.ldd = { error: e.message }; } // Try running the binary with no args to see usage try { const { execSync } = require('child_process'); const output = execSync(`${ITURHFPROP_PATH} 2>&1 || true`, { encoding: 'utf8', env: { ...process.env, LD_LIBRARY_PATH: '/opt/iturhfprop' } }); results.testRun.usage = output.split('\n').slice(0, 10); } catch (e) { results.testRun.usage = { error: e.message, stderr: e.stderr?.toString(), stdout: e.stdout?.toString() }; } // List ALL files in Data directory try { const allDataFiles = fs.readdirSync(ITURHFPROP_DATA + '/Data'); results.data.allFiles = allDataFiles; results.data.hasIonos = allDataFiles.some(f => f.includes('ionos')); results.data.hasAnt = allDataFiles.some(f => f.endsWith('.ant')); results.data.fileCount = allDataFiles.length; } catch (e) { results.data.allFiles = { error: e.message }; } // Create a minimal test input file and try to run try { const { execSync } = require('child_process'); const testInput = `PathName "Test" PathTXName "TX" Path.L_tx.lat 40.0 Path.L_tx.lng -75.0 TXAntFilePath "ISOTROPIC" TXGOS 0.0 PathRXName "RX" Path.L_rx.lat 51.0 Path.L_rx.lng 0.0 RXAntFilePath "ISOTROPIC" RXGOS 0.0 AntennaOrientation "TX2RX" Path.year 2025 Path.month 6 Path.hour 12 Path.SSN 100 Path.frequency 14.0 Path.txpower 20.0 Path.BW 3000 Path.SNRr 15 Path.SNRXXp 90 Path.ManMadeNoise "RESIDENTIAL" Path.Modulation ANALOG Path.SorL SHORTPATH LL.lat 51.0 LL.lng 0.0 LR.lat 51.0 LR.lng 0.0 UL.lat 51.0 UL.lng 0.0 UR.lat 51.0 UR.lng 0.0 DataFilePath "${ITURHFPROP_DATA}/Data/" RptFilePath "/tmp/" RptFileFormat "RPT_PR | RPT_SNR | RPT_BCR" `; fs.writeFileSync('/tmp/test_input.txt', testInput); results.testRun.inputFile = testInput.split('\n'); const testOutput = execSync(`${ITURHFPROP_PATH} /tmp/test_input.txt /tmp/test_output.txt 2>&1 || echo "Exit code: $?"`, { encoding: 'utf8', env: { ...process.env, LD_LIBRARY_PATH: '/opt/iturhfprop' } }); results.testRun.testExec = testOutput.split('\n').slice(0, 20); // Check if output was created if (fs.existsSync('/tmp/test_output.txt')) { const output = fs.readFileSync('/tmp/test_output.txt', 'utf8'); results.testRun.testOutput = output.split('\n').slice(0, 20); } else { results.testRun.testOutput = 'No output file created'; } } catch (e) { results.testRun.testExec = { error: e.message, stderr: e.stderr?.toString(), stdout: e.stdout?.toString() }; } res.json(results); }); /** * Single point prediction * * GET /api/predict?txLat=40&txLon=-74&rxLat=51&rxLon=0&month=1&hour=12&ssn=100 */ app.get('/api/predict', async (req, res) => { try { const { txLat, txLon, rxLat, rxLon, month, hour, ssn, year = new Date().getFullYear(), txPower, frequencies } = req.query; // Validate required params if (!txLat || !txLon || !rxLat || !rxLon) { return res.status(400).json({ error: 'Missing required coordinates (txLat, txLon, rxLat, rxLon)' }); } const params = { txLat: parseFloat(txLat), txLon: parseFloat(txLon), rxLat: parseFloat(rxLat), rxLon: parseFloat(rxLon), year: parseInt(year), month: parseInt(month) || new Date().getMonth() + 1, hour: parseInt(hour) || new Date().getUTCHours() || 12, ssn: parseInt(ssn) || 100, txPower: parseInt(txPower) || 100 }; if (frequencies) { params.frequencies = frequencies.split(',').map(f => parseFloat(f)); } const results = await runPrediction(params); res.json({ model: 'ITU-R P.533-14', engine: 'ITURHFProp', ...results }); } catch (err) { console.error('[API Error]', err); res.status(500).json({ error: err.message }); } }); /** * 24-hour prediction * * GET /api/predict/hourly?txLat=40&txLon=-74&rxLat=51&rxLon=0&month=1&ssn=100 */ app.get('/api/predict/hourly', async (req, res) => { try { const { txLat, txLon, rxLat, rxLon, month, ssn, year = new Date().getFullYear() } = req.query; // Validate required params if (!txLat || !txLon || !rxLat || !rxLon) { return res.status(400).json({ error: 'Missing required coordinates (txLat, txLon, rxLat, rxLon)' }); } const baseParams = { txLat: parseFloat(txLat), txLon: parseFloat(txLon), rxLat: parseFloat(rxLat), rxLon: parseFloat(rxLon), year: parseInt(year), month: parseInt(month) || new Date().getMonth() + 1, ssn: parseInt(ssn) || 100 }; // Run predictions for each hour (0-23 UTC) const hourlyResults = []; for (let hour = 0; hour < 24; hour++) { const params = { ...baseParams, hour }; try { const result = await runPrediction(params); hourlyResults.push({ hour, muf: result.muf, frequencies: result.frequencies }); } catch (err) { hourlyResults.push({ hour, error: err.message }); } } res.json({ model: 'ITU-R P.533-14', engine: 'ITURHFProp', path: { tx: { lat: baseParams.txLat, lon: baseParams.txLon }, rx: { lat: baseParams.rxLat, lon: baseParams.rxLon } }, month: baseParams.month, year: baseParams.year, ssn: baseParams.ssn, hourly: hourlyResults }); } catch (err) { console.error('[API Error]', err); res.status(500).json({ error: err.message }); } }); /** * Band conditions (simplified format for OpenHamClock) * * GET /api/bands?txLat=40&txLon=-74&rxLat=51&rxLon=0 */ app.get('/api/bands', async (req, res) => { try { const { txLat, txLon, rxLat, rxLon, month, hour, ssn } = req.query; if (!txLat || !txLon || !rxLat || !rxLon) { return res.status(400).json({ error: 'Missing required coordinates' }); } const params = { txLat: parseFloat(txLat), txLon: parseFloat(txLon), rxLat: parseFloat(rxLat), rxLon: parseFloat(rxLon), year: new Date().getFullYear(), month: parseInt(month) || new Date().getMonth() + 1, hour: parseInt(hour) || new Date().getUTCHours() || 12, ssn: parseInt(ssn) || 100, frequencies: Object.values(HF_BANDS) }; const results = await runPrediction(params); // Map to band names const bands = {}; const bandFreqs = Object.entries(HF_BANDS); for (const freqResult of results.frequencies) { const bandEntry = bandFreqs.find(([name, freq]) => Math.abs(freq - freqResult.freq) < 1 ); if (bandEntry) { const [bandName] = bandEntry; bands[bandName] = { freq: freqResult.freq, reliability: freqResult.reliability, snr: freqResult.snr, sdbw: freqResult.sdbw, status: freqResult.reliability >= 70 ? 'GOOD' : freqResult.reliability >= 40 ? 'FAIR' : 'POOR' }; } } res.json({ model: 'ITU-R P.533-14', muf: results.muf, bands, debug: { rawOutput: results.raw, freqCount: results.frequencies.length, parsedFreqs: results.frequencies, execStdout: results.execStdout, execStderr: results.execStderr, inputContent: results.inputContent?.substring(0, 1000) }, timestamp: new Date().toISOString() }); } catch (err) { console.error('[API Error]', err); res.status(500).json({ error: err.message }); } }); // Start server app.listen(PORT, () => { console.log(`ITURHFProp Service running on port ${PORT}`); console.log(`Binary: ${ITURHFPROP_PATH}`); console.log(`Data: ${ITURHFPROP_DATA}`); });