use num_complex::Complex;
use serde::{Deserialize, Serialize};
pub const ILS_BASEBAND_LPF_CUTOFF: f64 = 500.0;
pub const ILS_BASEBAND_LPF_ORDER: usize = 5;
pub const ILS_90HZ_BPF_LOW: f64 = 80.0;
pub const ILS_90HZ_BPF_HIGH: f64 = 100.0;
pub const ILS_90HZ_BPF_ORDER: usize = 4;
pub const ILS_150HZ_BPF_LOW: f64 = 140.0;
pub const ILS_150HZ_BPF_HIGH: f64 = 160.0;
pub const ILS_150HZ_BPF_ORDER: usize = 4;
pub const ILS_MORSE_BPF_LOW: f64 = 900.0;
pub const ILS_MORSE_BPF_HIGH: f64 = 1_100.0;
pub const ILS_MORSE_BPF_ORDER: usize = 2;
pub const ILS_MORSE_AUDIO_BPF_LOW: f64 = 980.0;
pub const ILS_MORSE_AUDIO_BPF_HIGH: f64 = 1_060.0;
pub const ILS_MORSE_AUDIO_BPF_ORDER: usize = 4;
pub const ILS_MORSE_ENV_LPF_CUTOFF: f64 = 16.0;
pub const ILS_MORSE_ENV_LPF_ORDER: usize = 4;
pub const ILS_DDM_ON_COURSE_THRESHOLD: f64 = 0.015;
pub const ILS_DDM_SUM_MIN: f64 = 1e-9;
pub const ILS_CARRIER_NORMALIZATION: f64 = 0.5;
pub const ILS_DECIMATION_TARGET_RATE: f64 = 9_000.0;
pub const SIGNAL_RMS_MIN: f64 = 1e-10;
pub const MODULATION_DEPTH_MIN: f64 = 1e-9;
use std::f64::consts::PI;
use super::error::{Error, IlsDdmResult, Result};
use super::morse;
use crate::dsp_utils::{envelope, hilbert_transform};
use desperado::dsp::filters::ButterworthFilter;
use desperado::dsp::iir::{filtfilt_bandpass, filtfilt_lowpass};
pub const ILS_SAMPLE_RATE_1_8M: u32 = 1_800_000;
pub const ILS_AUDIO_RATE: f64 = 9_000.0;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum IlsSide {
Left,
OnCourse,
Right,
}
impl IlsSide {
pub fn from_ddm(ddm: f64) -> Self {
if ddm > ILS_DDM_ON_COURSE_THRESHOLD {
IlsSide::Left
} else if ddm < -ILS_DDM_ON_COURSE_THRESHOLD {
IlsSide::Right
} else {
IlsSide::OnCourse
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IlsFrame {
pub timestamp: f64,
pub frequency_mhz: f64,
pub ddm: f64,
pub mod_90_pct: f64,
pub mod_150_pct: f64,
pub signal_strength: f64,
pub ident: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub morse_debug: Option<IlsMorseDebugInfo>,
pub side: IlsSide,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IlsMorseDebugInfo {
pub candidates: Vec<IlsMorseCandidate>,
pub total_tokens: usize,
pub decode_attempts: Vec<morse::MorseDecodeAttempt>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IlsMorseCandidate {
pub token: String,
pub count: usize,
pub confidence: f64,
}
pub struct IlsLocalizerDemodulator {
pub sample_rate: f64,
pub audio_rate: f64,
decim_factor: usize,
baseband_lpf: ButterworthFilter,
bpf_90: ButterworthFilter,
bpf_150: ButterworthFilter,
}
impl IlsLocalizerDemodulator {
pub fn new(sample_rate: u32) -> Self {
let sample_rate_f = sample_rate as f64;
let decim_factor = ((sample_rate_f / ILS_AUDIO_RATE).round() as usize).max(1);
let audio_rate = sample_rate_f / decim_factor as f64;
Self {
sample_rate: sample_rate_f,
audio_rate,
decim_factor,
baseband_lpf: ButterworthFilter::lowpass(
ILS_BASEBAND_LPF_CUTOFF,
sample_rate_f,
ILS_BASEBAND_LPF_ORDER,
),
bpf_90: ButterworthFilter::bandpass(
ILS_90HZ_BPF_LOW,
ILS_90HZ_BPF_HIGH,
audio_rate,
ILS_90HZ_BPF_ORDER,
),
bpf_150: ButterworthFilter::bandpass(
ILS_150HZ_BPF_LOW,
ILS_150HZ_BPF_HIGH,
audio_rate,
ILS_150HZ_BPF_ORDER,
),
}
}
pub fn new_at_audio_rate(audio_rate: f64) -> Self {
Self {
sample_rate: audio_rate,
audio_rate,
decim_factor: 1,
baseband_lpf: ButterworthFilter::lowpass(audio_rate / 2.0 * 0.9, audio_rate, 1),
bpf_90: ButterworthFilter::bandpass(
ILS_90HZ_BPF_LOW,
ILS_90HZ_BPF_HIGH,
audio_rate,
ILS_90HZ_BPF_ORDER,
),
bpf_150: ButterworthFilter::bandpass(
ILS_150HZ_BPF_LOW,
ILS_150HZ_BPF_HIGH,
audio_rate,
ILS_150HZ_BPF_ORDER,
),
}
}
pub fn audio_rate(&self) -> f64 {
self.audio_rate
}
pub fn filter_audio_envelopes(&mut self, audio: &[f64]) -> (Vec<f64>, Vec<f64>) {
let tone_90 = filtfilt_bandpass(
audio,
ILS_90HZ_BPF_LOW,
ILS_90HZ_BPF_HIGH,
self.audio_rate,
ILS_90HZ_BPF_ORDER,
);
let analytic_90 = hilbert_transform(&tone_90);
let env_90 = envelope(&analytic_90);
let tone_150 = filtfilt_bandpass(
audio,
ILS_150HZ_BPF_LOW,
ILS_150HZ_BPF_HIGH,
self.audio_rate,
ILS_150HZ_BPF_ORDER,
);
let analytic_150 = hilbert_transform(&tone_150);
let env_150 = envelope(&analytic_150);
(env_90, env_150)
}
pub fn decode_ident_audio(
&mut self,
audio: &[f64],
) -> (Option<String>, Vec<String>, Vec<morse::MorseDecodeAttempt>) {
self.decode_ident(audio)
}
#[allow(dead_code)]
pub fn precompute_morse_envelope(&mut self, audio: &[f64]) -> Vec<f64> {
let tone = filtfilt_bandpass(
audio,
ILS_MORSE_BPF_LOW,
ILS_MORSE_BPF_HIGH,
self.audio_rate,
ILS_MORSE_BPF_ORDER,
);
let analytic = hilbert_transform(&tone);
let env_raw = envelope(&analytic);
filtfilt_lowpass(
&env_raw,
ILS_MORSE_ENV_LPF_CUTOFF,
self.audio_rate,
ILS_MORSE_ENV_LPF_ORDER,
)
}
pub fn demodulate(
&mut self,
iq_samples: &[Complex<f32>],
freq_offset: f64,
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
if iq_samples.is_empty() {
return (Vec::new(), Vec::new(), Vec::new());
}
let iq_shifted: Vec<Complex<f32>> = iq_samples
.iter()
.enumerate()
.map(|(i, &s)| {
let t = i as f64 / self.sample_rate;
let shift = Complex::new(
(-2.0 * PI * freq_offset * t).cos() as f32,
(-2.0 * PI * freq_offset * t).sin() as f32,
);
s * shift
})
.collect();
let iq_filtered = self.baseband_lpf.filter_complex(&iq_shifted);
let am_envelope: Vec<f64> = iq_filtered.iter().map(|c| c.norm() as f64).collect();
let audio: Vec<f64> = am_envelope
.iter()
.step_by(self.decim_factor)
.copied()
.collect();
if audio.is_empty() {
return (Vec::new(), Vec::new(), Vec::new());
}
let (env_90, env_150) = self.extract_tones_and_audio(&audio);
(env_90, env_150, audio)
}
pub fn extract_tones_and_audio(&mut self, audio: &[f64]) -> (Vec<f64>, Vec<f64>) {
if audio.is_empty() {
return (Vec::new(), Vec::new());
}
let tone_90 = self.bpf_90.filter(audio);
let analytic_90 = hilbert_transform(&tone_90);
let env_90 = envelope(&analytic_90);
let tone_150 = self.bpf_150.filter(audio);
let analytic_150 = hilbert_transform(&tone_150);
let env_150 = envelope(&analytic_150);
(env_90, env_150)
}
pub fn demodulate_audio(&mut self, audio_samples: &[f64]) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
if audio_samples.is_empty() {
return (Vec::new(), Vec::new(), Vec::new());
}
let (env_90, env_150) = self.extract_tones_and_audio(audio_samples);
(env_90, env_150, audio_samples.to_vec())
}
pub fn decode_ident(
&mut self,
audio: &[f64],
) -> (Option<String>, Vec<String>, Vec<morse::MorseDecodeAttempt>) {
if audio.len() < (self.audio_rate as usize / 2) {
return (None, Vec::new(), Vec::new());
}
let tone = filtfilt_bandpass(
audio,
ILS_MORSE_BPF_LOW,
ILS_MORSE_BPF_HIGH,
self.audio_rate,
ILS_MORSE_BPF_ORDER,
);
let analytic = hilbert_transform(&tone);
let env_raw = envelope(&analytic);
let env = filtfilt_lowpass(
&env_raw,
ILS_MORSE_ENV_LPF_CUTOFF,
self.audio_rate,
ILS_MORSE_ENV_LPF_ORDER,
);
morse::decode_morse_ident(&env, self.audio_rate)
}
}
pub fn compute_ddm(
env_90: &[f64],
env_150: &[f64],
audio_envelope: &[f64],
) -> Result<IlsDdmResult> {
let n = env_90.len().min(env_150.len()).min(audio_envelope.len());
if n == 0 {
return Err(Error::EmptyInput);
}
if env_90[..n].iter().any(|&x| !x.is_finite())
|| env_150[..n].iter().any(|&x| !x.is_finite())
|| audio_envelope[..n].iter().any(|&x| !x.is_finite())
{
return Err(Error::NonFiniteSignal);
}
let mean_90 = env_90[..n].iter().sum::<f64>() / n as f64;
let mean_150 = env_150[..n].iter().sum::<f64>() / n as f64;
let mean_env = audio_envelope[..n].iter().sum::<f64>() / n as f64;
let sum_90_150 = mean_90 + mean_150;
if sum_90_150 < ILS_DDM_SUM_MIN {
return Err(Error::DdmComputationFailed {
reason: "both 90 Hz and 150 Hz modulation depths are near zero".to_string(),
});
}
let ddm = (mean_90 - mean_150) / sum_90_150;
let (mod_90_pct, mod_150_pct) = if mean_env > MODULATION_DEPTH_MIN {
let pct_90 = (mean_90 / mean_env * 100.0).min(100.0);
let pct_150 = (mean_150 / mean_env * 100.0).min(100.0);
(pct_90, pct_150)
} else {
(0.0, 0.0)
};
let carrier_strength = (mean_env / ILS_CARRIER_NORMALIZATION).clamp(0.0, 1.0);
Ok(IlsDdmResult {
ddm,
mod_90_hz: mod_90_pct,
mod_150_hz: mod_150_pct,
carrier_strength,
})
}
impl IlsFrame {
pub fn new(
timestamp: f64,
frequency_mhz: f64,
ddm: f64,
mod_90_pct: f64,
mod_150_pct: f64,
signal_strength: f64,
ident: Option<String>,
) -> Self {
let side = IlsSide::from_ddm(ddm);
Self {
timestamp,
frequency_mhz,
ddm,
mod_90_pct,
mod_150_pct,
signal_strength,
ident,
morse_debug: None,
side,
}
}
pub fn with_morse_debug(mut self, debug: Option<IlsMorseDebugInfo>) -> Self {
self.morse_debug = debug;
self
}
}