Source code for dvoacap.voacap_parser

"""
VOACAP Binary File Parser

This module provides functionality to parse VOACAP binary coefficient data files.
These files contain Fourier coefficients for ionospheric models used in HF radio
propagation predictions.

Binary file formats:
- Coeff##.dat: Monthly ionospheric coefficient data (38,480 bytes)
- FOF2CCIR##.dat: F2 critical frequency data (7,904 bytes)

Author: Port from Pascal implementation by Alex Shovkoplyas, VE3NEA
License: Mozilla Public License Version 1.1
"""

import numpy as np
from pathlib import Path
from enum import IntEnum
from typing import Any


[docs] class VarMapKind(IntEnum): """Types of variable ionospheric maps""" VM_ES_U = 0 # foEs upper decile VM_ES_M = 1 # foEs median VM_ES_L = 2 # foEs lower decile VM_F2 = 3 # foF2 VM_FM3 = 4 # M3000 VM_ER = 5 # foE
[docs] class FixedMapKind(IntEnum): """Types of fixed ionospheric maps""" FM_NOISE1 = 0 FM_NOISE2 = 1 FM_NOISE3 = 2 FM_NOISE4 = 3 FM_NOISE5 = 4 FM_NOISE6 = 5 FM_LAND_MASS = 6 FM_YM_F2 = 7
[docs] class FixedCoeff: """ Fixed coefficient data structure. This corresponds to the TFixedCoeff record in FrMaps.pas. Contains arrays used for computing fixed ionospheric maps. """
[docs] def __init__(self): # P: array[TFixedMapKind, 0..15, 0..28] of Single # This is structured as FAKP (6x16x29) + FAKMAP (16x29) + HMYM (16x29) self.P = np.zeros((8, 16, 29), dtype=np.float32) # ABP: array[TFixedMapKind, 0..1] of Single # This is structured as FAKABP (6x2) + ABMAP (3x2) self.ABP = np.zeros((8, 2), dtype=np.float32)
[docs] def load_from_arrays(self, fakp: np.ndarray, fakmap: np.ndarray, hmym: np.ndarray, fakabp: np.ndarray, abmap: np.ndarray) -> None: """Load coefficients from separate arrays""" # Fill P array: first 6 are from FAKP self.P[0:6, :, :] = fakp # Index 6 is FAKMAP (land mass) self.P[6, :, :] = fakmap # Index 7 is HMYM (YmF2) self.P[7, :, :] = hmym # Fill ABP array: first 6 are from FAKABP self.ABP[0:6, :] = fakabp # Last 2 rows are from ABMAP (but ABMAP is 3x2) self.ABP[6:8, :] = abmap[0:2, :]
[docs] class CoeffData: """ Complete coefficient data for one month. This corresponds to the data loaded by TFourierMaps.LoadCoeffResource() from the Coeff##.dat files. """
[docs] def __init__(self): # Integer array indices for variable maps self.ikim = np.zeros((6, 10), dtype=np.int32) # System parameters self.dud = np.zeros((5, 12, 5), dtype=np.float32) self.fam = np.zeros((12, 2, 7), dtype=np.float32) self.sys = np.zeros((6, 16, 9), dtype=np.float32) # Fixed coefficients self.fixed_coeff = FixedCoeff() # Variable map coefficients (for SSN interpolation) self.xfm3cf = np.zeros((2, 49, 9), dtype=np.float32) self.anew = np.zeros(3, dtype=np.float32) self.bnew = np.zeros(3, dtype=np.float32) self.achi = np.zeros(2, dtype=np.float32) self.bchi = np.zeros(2, dtype=np.float32) self.f2d = np.zeros((6, 6, 16), dtype=np.float32) self.perr = np.zeros((6, 4, 9), dtype=np.float32) self.xesmcf = np.zeros((2, 61, 7), dtype=np.float32) self.xpmap = np.zeros((2, 16, 29), dtype=np.float32) self.xeslcf = np.zeros((2, 55, 5), dtype=np.float32) self.xesucf = np.zeros((2, 55, 5), dtype=np.float32) self.xercof = np.zeros((2, 22, 9), dtype=np.float32)
[docs] class F2Data: """ F2 critical frequency coefficient data. This corresponds to the data loaded from FOF2CCIR##.dat files. """
[docs] def __init__(self): # XF2COF: array[0..1, 0..75, 0..12] of Single self.xf2cof = np.zeros((2, 76, 13), dtype=np.float32)
[docs] class VoacapParser: """ Parser for VOACAP binary coefficient data files. This class provides methods to read and parse the binary coefficient files used by VOACAP for ionospheric propagation predictions. """
[docs] @staticmethod def parse_coeff_file(filepath: Path) -> CoeffData: """ Parse a Coeff##.dat binary file. Args: filepath: Path to the Coeff##.dat file Returns: CoeffData object containing all coefficient arrays Raises: FileNotFoundError: If the file doesn't exist ValueError: If the file size is incorrect """ if not filepath.exists(): raise FileNotFoundError(f"Coefficient file not found: {filepath}") # Expected file size: 60 integers + 9560 floats = 38,480 bytes expected_size = 60 * 4 + 9560 * 4 actual_size = filepath.stat().st_size if actual_size != expected_size: raise ValueError( f"Invalid Coeff file size: expected {expected_size} bytes, " f"got {actual_size} bytes" ) data = CoeffData() with open(filepath, 'rb') as f: # Read in the exact order specified in FrMaps.pas LoadCoeffResource() # 1. IKIM: array[TVarMapKind, 0..9] of integer (6x10) data.ikim = np.fromfile(f, dtype=np.int32, count=60).reshape((6, 10)) # 2. DUD: array[0..4, 0..11, 0..4] of Single (5x12x5) data.dud = np.fromfile(f, dtype=np.float32, count=300).reshape((5, 12, 5)) # 3. FAM: array[0..11, 0..1, 0..6] of Single (12x2x7) data.fam = np.fromfile(f, dtype=np.float32, count=168).reshape((12, 2, 7)) # 4. SYS: array[0..5, 0..15, 0..8] of Single (6x16x9) data.sys = np.fromfile(f, dtype=np.float32, count=864).reshape((6, 16, 9)) # 5. FAKP: array[0..5, 0..15, 0..28] of Single (6x16x29) fakp = np.fromfile(f, dtype=np.float32, count=2784).reshape((6, 16, 29)) # 6. FAKABP: array[0..5, 0..1] of Single (6x2) fakabp = np.fromfile(f, dtype=np.float32, count=12).reshape((6, 2)) # 7. XFM3CF: array[0..1, 0..48, 0..8] of Single (2x49x9) data.xfm3cf = np.fromfile(f, dtype=np.float32, count=882).reshape((2, 49, 9)) # 8. ANEW: array[0..2] of Single (3) data.anew = np.fromfile(f, dtype=np.float32, count=3) # 9. BNEW: array[0..2] of Single (3) data.bnew = np.fromfile(f, dtype=np.float32, count=3) # 10. ACHI: array[0..1] of Single (2) data.achi = np.fromfile(f, dtype=np.float32, count=2) # 11. BCHI: array[0..1] of Single (2) data.bchi = np.fromfile(f, dtype=np.float32, count=2) # 12. FAKMAP: array[0..15, 0..28] of Single (16x29) fakmap = np.fromfile(f, dtype=np.float32, count=464).reshape((16, 29)) # 13. ABMAP: array[0..2, 0..1] of Single (3x2) abmap = np.fromfile(f, dtype=np.float32, count=6).reshape((3, 2)) # 14. F2D: array[0..5, 0..5, 0..15] of Single (6x6x16) data.f2d = np.fromfile(f, dtype=np.float32, count=576).reshape((6, 6, 16)) # 15. PERR: array[0..5, 0..3, 0..8] of Single (6x4x9) data.perr = np.fromfile(f, dtype=np.float32, count=216).reshape((6, 4, 9)) # 16. XESMCF: array[0..1, 0..60, 0..6] of Single (2x61x7) data.xesmcf = np.fromfile(f, dtype=np.float32, count=854).reshape((2, 61, 7)) # 17. XPMAP: array[0..1, 0..15, 0..28] of Single (2x16x29) data.xpmap = np.fromfile(f, dtype=np.float32, count=928).reshape((2, 16, 29)) # 18. XESLCF: array[0..1, 0..54, 0..4] of Single (2x55x5) data.xeslcf = np.fromfile(f, dtype=np.float32, count=550).reshape((2, 55, 5)) # 19. XESUCF: array[0..1, 0..54, 0..4] of Single (2x55x5) data.xesucf = np.fromfile(f, dtype=np.float32, count=550).reshape((2, 55, 5)) # 20. XERCOF: array[0..1, 0..21, 0..8] of Single (2x22x9) data.xercof = np.fromfile(f, dtype=np.float32, count=396).reshape((2, 22, 9)) # 21. HMYM is actually loaded as XPMAP initially (see line 180 vs 225-227) # It gets computed later during SSN interpolation, but we need to read it # Actually, looking more carefully, HMYM is stored in CoefF but computed # from XPMAP during interpolation. The file only contains XPMAP. hmym = np.zeros((16, 29), dtype=np.float32) # Will be computed later # Load the fixed coefficients data.fixed_coeff.load_from_arrays(fakp, fakmap, hmym, fakabp, abmap) return data
[docs] @staticmethod def parse_f2_file(filepath: Path) -> F2Data: """ Parse a FOF2CCIR##.dat binary file. Args: filepath: Path to the FOF2CCIR##.dat file Returns: F2Data object containing F2 coefficient arrays Raises: FileNotFoundError: If the file doesn't exist ValueError: If the file size is incorrect """ if not filepath.exists(): raise FileNotFoundError(f"F2 file not found: {filepath}") # Expected file size: 2x76x13 floats = 1976 floats = 7,904 bytes expected_size = 1976 * 4 actual_size = filepath.stat().st_size if actual_size != expected_size: raise ValueError( f"Invalid F2 file size: expected {expected_size} bytes, " f"got {actual_size} bytes" ) data = F2Data() with open(filepath, 'rb') as f: # XF2COF: array[0..1, 0..75, 0..12] of Single (2x76x13) data.xf2cof = np.fromfile(f, dtype=np.float32, count=1976).reshape((2, 76, 13)) return data
[docs] @staticmethod def load_monthly_data(data_dir: Path, month: int) -> tuple[CoeffData, F2Data]: """ Load both coefficient and F2 data files for a given month. Args: data_dir: Directory containing the DVoaData files month: Month number (1-12) Returns: Tuple of (CoeffData, F2Data) Raises: ValueError: If month is not in range 1-12 FileNotFoundError: If data files don't exist """ if not (1 <= month <= 12): raise ValueError(f"Month must be 1-12, got {month}") coeff_file = data_dir / f"Coeff{month:02d}.dat" f2_file = data_dir / f"FOF2CCIR{month:02d}.dat" coeff_data = VoacapParser.parse_coeff_file(coeff_file) f2_data = VoacapParser.parse_f2_file(f2_file) return coeff_data, f2_data
[docs] @staticmethod def get_data_summary(coeff_data: CoeffData) -> dict[str, Any]: """ Get a summary of the coefficient data for inspection. Args: coeff_data: CoeffData object Returns: Dictionary with summary information """ return { 'ikim_shape': coeff_data.ikim.shape, 'ikim_sample': coeff_data.ikim[0, :].tolist(), 'dud_shape': coeff_data.dud.shape, 'dud_range': (float(coeff_data.dud.min()), float(coeff_data.dud.max())), 'fam_shape': coeff_data.fam.shape, 'sys_shape': coeff_data.sys.shape, 'fixed_coeff_P_shape': coeff_data.fixed_coeff.P.shape, 'fixed_coeff_ABP_shape': coeff_data.fixed_coeff.ABP.shape, 'xfm3cf_shape': coeff_data.xfm3cf.shape, 'anew': coeff_data.anew.tolist(), 'bnew': coeff_data.bnew.tolist(), 'achi': coeff_data.achi.tolist(), 'bchi': coeff_data.bchi.tolist(), 'f2d_shape': coeff_data.f2d.shape, 'perr_shape': coeff_data.perr.shape, 'xesmcf_shape': coeff_data.xesmcf.shape, 'xpmap_shape': coeff_data.xpmap.shape, 'xeslcf_shape': coeff_data.xeslcf.shape, 'xesucf_shape': coeff_data.xesucf.shape, 'xercof_shape': coeff_data.xercof.shape, }
# Convenience functions
[docs] def load_coeff_file(filepath: str) -> CoeffData: """ Load a Coeff##.dat file. Args: filepath: Path to the coefficient file Returns: CoeffData object """ return VoacapParser.parse_coeff_file(Path(filepath))
[docs] def load_f2_file(filepath: str) -> F2Data: """ Load a FOF2CCIR##.dat file. Args: filepath: Path to the F2 file Returns: F2Data object """ return VoacapParser.parse_f2_file(Path(filepath))
[docs] def load_month(data_dir: str, month: int) -> tuple[CoeffData, F2Data]: """ Load monthly coefficient data. Args: data_dir: Directory containing DVoaData files month: Month number (1-12) Returns: Tuple of (CoeffData, F2Data) """ return VoacapParser.load_monthly_data(Path(data_dir), month)