/** * 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'; // 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, '11m': 27.0, // CB band (26.965-27.405 MHz) '10m': 28.1 // 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 / 1000)).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 }); console.log(`[Parse] Freq ${freq} MHz: SNR=${snr} dB, BCR=${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]); } console.log(`[Parse] Found ${results.frequencies.length} frequency results`); return results; } catch (err) { console.error('[Parse Error]', err.message); return { error: err.message, frequencies: [] }; } } /** * Run ITURHFProp prediction */ async function runPrediction(params) { 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 }); } let execStdout = ''; let execStderr = ''; try { // Generate input file const inputContent = generateInputFile(params); fs.writeFileSync(inputPath, inputContent); console.log(`[ITURHFProp] Running prediction ${id}`); console.log(`[ITURHFProp] TX: ${params.txLat}, ${params.txLon} -> RX: ${params.rxLat}, ${params.rxLon}`); console.log(`[ITURHFProp] Input file:\n${inputContent}`); // Run ITURHFProp const startTime = Date.now(); const cmd = `${ITURHFPROP_PATH} ${inputPath} ${outputPath}`; console.log(`[ITURHFProp] Command: ${cmd}`); try { execStdout = execSync(cmd, { timeout: 30000, // 30 second timeout encoding: 'utf8', env: { ...process.env, LD_LIBRARY_PATH: '/opt/iturhfprop:' + (process.env.LD_LIBRARY_PATH || '') } }); console.log(`[ITURHFProp] stdout: ${execStdout}`); } catch (execError) { execStderr = execError.stderr?.toString() || ''; execStdout = execError.stdout?.toString() || ''; console.error('[ITURHFProp] Execution error!'); console.error('[ITURHFProp] Exit code:', execError.status); console.error('[ITURHFProp] stderr:', execStderr); console.error('[ITURHFProp] stdout:', execStdout); // Don't throw - try to read output anyway } const elapsed = Date.now() - startTime; console.log(`[ITURHFProp] Completed in ${elapsed}ms`); // Check output file if (fs.existsSync(outputPath)) { const rawOutput = fs.readFileSync(outputPath, 'utf8'); const stats = fs.statSync(outputPath); console.log(`[ITURHFProp] Output file exists, size: ${stats.size} bytes`); console.log(`[ITURHFProp] Raw output (first 2000 chars):\n${rawOutput.substring(0, 2000)}`); } else { console.log(`[ITURHFProp] Output file NOT FOUND at ${outputPath}`); // Check if there's a report file in /tmp const tmpFiles = fs.readdirSync('/tmp').filter(f => f.startsWith('RPT') || f.startsWith('PDD')); console.log(`[ITURHFProp] Report files in /tmp: ${tmpFiles.join(', ') || 'none'}`); } // Parse output const results = parseOutputFile(outputPath); results.elapsed = elapsed; results.execStdout = execStdout; results.execStderr = execStderr; results.inputContent = inputContent; results.params = { txLat: params.txLat, txLon: params.txLon, rxLat: params.rxLat, rxLon: params.rxLon, hour: params.hour, month: params.month, ssn: params.ssn }; 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 ? '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', 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 -10.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}`); });