You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

747 lines
21 KiB

/**
* 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}`);
});

Powered by TurnKey Linux.