#![allow(clippy::useless_conversion)]
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList};
fn json_to_py<'py>(py: Python<'py>, v: &serde_json::Value) -> PyResult<pyo3::Bound<'py, PyAny>> {
use serde_json::Value;
Ok(match v {
Value::Null => py.None().into_bound(py),
Value::Bool(b) => (*b).into_pyobject(py)?.to_owned().into_any(),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
i.into_pyobject(py)?.into_any()
} else if let Some(u) = n.as_u64() {
u.into_pyobject(py)?.into_any()
} else {
n.as_f64().unwrap_or(f64::NAN).into_pyobject(py)?.into_any()
}
}
Value::String(s) => s.into_pyobject(py)?.into_any(),
Value::Array(a) => {
let list = PyList::empty(py);
for item in a {
list.append(json_to_py(py, item)?)?;
}
list.into_any()
}
Value::Object(o) => {
let dict = PyDict::new(py);
for (k, val) in o {
dict.set_item(k, json_to_py(py, val)?)?;
}
dict.into_any()
}
})
}
#[pyclass(name = "RunOutput", skip_from_py_object)]
#[derive(Clone)]
struct PyRunOutput {
#[pyo3(get)]
json: String,
#[pyo3(get)]
svg: String,
#[pyo3(get)]
summary: String,
#[pyo3(get)]
csv: Option<String>,
}
#[pymethods]
impl PyRunOutput {
fn data<'py>(&self, py: Python<'py>) -> PyResult<pyo3::Bound<'py, PyAny>> {
let v: serde_json::Value =
serde_json::from_str(&self.json).map_err(|e| PyValueError::new_err(e.to_string()))?;
json_to_py(py, &v)
}
fn write_csv(&self, path: &str) -> PyResult<usize> {
match &self.csv {
Some(csv) => std::fs::write(path, csv)
.map(|()| csv.len())
.map_err(|e| PyValueError::new_err(e.to_string())),
None => Ok(0),
}
}
fn __repr__(&self) -> String {
format!(
"RunOutput(json={} bytes, svg={} bytes, csv={} bytes, summary={:?})",
self.json.len(),
self.svg.len(),
self.csv.as_ref().map_or(0, |c| c.len()),
self.summary.chars().take(48).collect::<String>()
)
}
}
#[pyfunction]
fn run_typed(toml: &str) -> PyResult<PyRunOutput> {
crate::api::run_toml(toml)
.map(|o| PyRunOutput {
json: o.json,
svg: o.svg,
summary: o.summary,
csv: o.csv,
})
.map_err(PyValueError::new_err)
}
#[pyfunction]
fn receiver_trust(toml: &str) -> PyResult<PyRunOutput> {
crate::receiver_trust::scenario::run_toml(toml)
.map(|o| PyRunOutput {
json: o.json,
svg: o.svg,
summary: o.summary,
csv: Some(o.csv),
})
.map_err(PyValueError::new_err)
}
#[pyfunction]
fn scenario_kinds<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let json = crate::api::list_scenario_kinds_json();
let v: serde_json::Value =
serde_json::from_str(&json).map_err(|e| PyValueError::new_err(e.to_string()))?;
json_to_py(py, &v)
}
#[pyfunction]
fn validate_toml(toml: &str) -> Vec<String> {
match crate::api::run_scenario(toml) {
Ok(_) => vec![],
Err(e) => vec![e.to_string()],
}
}
#[pyfunction]
fn run(toml: &str) -> PyResult<String> {
crate::api::run_toml(toml)
.map(|o| o.json)
.map_err(PyValueError::new_err)
}
#[pyfunction]
fn run_full(toml: &str) -> PyResult<(String, String, String)> {
crate::api::run_toml(toml)
.map(|o| (o.json, o.svg, o.summary))
.map_err(PyValueError::new_err)
}
#[pyfunction]
fn list_kinds() -> String {
crate::api::list_scenario_kinds_json()
}
#[pyfunction]
fn error_kind(toml: &str) -> Option<String> {
crate::api::run_scenario(toml)
.err()
.map(|e| e.kind_tag().to_string())
}
#[pyfunction]
fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
use crate::iq::acq::{acquire, auto_coherent_periods, AcqConfig};
use crate::iq::cli::{
build_broadcast_scene, build_chain, build_channel, build_code, build_scene, BroadcastParams,
ChannelParams, FrontendParams, SceneParams,
};
use crate::iq::scene::TruthRecord;
use crate::iq::signals::SignalCode;
use crate::iq::track::{replay, CarrierLoop, ChannelInit, EpochOutput, LoopConfig};
use crate::iq::{Cf64, SampleSpec, SpreadingCode, VecSink};
fn codes_for(signal: &str, prns: &[i64]) -> PyResult<Vec<SignalCode>> {
if prns.is_empty() {
return Err(PyValueError::new_err("prns must list at least one PRN"));
}
prns.iter()
.map(|&id| build_code(signal, id).map_err(PyValueError::new_err))
.collect()
}
fn samples_from(i: &[f64], q: &[f64]) -> PyResult<Vec<Cf64>> {
if i.len() != q.len() {
return Err(PyValueError::new_err(format!(
"i and q must be the same length ({} vs {})",
i.len(),
q.len()
)));
}
Ok(i.iter()
.zip(q)
.map(|(&re, &im)| Cf64::new(re, im))
.collect())
}
#[pyfunction]
#[pyo3(signature = (fs_hz, duration_s, signal, prns, dopplers=None, cn0_dbhz=None, center_hz=None, if_hz=0.0, noise=true, noise_figure_db=2.0, seed=1, data=false, threads=1, iono_stec=None, iono_vtec=None, iono_klobuchar=false, tropo=false, tropo_doy=180.0, s4=None, scint_tau0=1.0, sigma_phi=0.0, multipath_height=None, multipath_ground="dry".to_string(), land_mobile=false, nlos=false))]
#[allow(clippy::too_many_arguments)]
fn iq_scene<'py>(
py: Python<'py>,
fs_hz: f64,
duration_s: f64,
signal: String,
prns: Vec<i64>,
dopplers: Option<Vec<f64>>,
cn0_dbhz: Option<f64>,
center_hz: Option<f64>,
if_hz: f64,
noise: bool,
noise_figure_db: f64,
seed: u64,
data: bool,
threads: usize,
iono_stec: Option<f64>,
iono_vtec: Option<f64>,
iono_klobuchar: bool,
tropo: bool,
tropo_doy: f64,
s4: Option<f64>,
scint_tau0: f64,
sigma_phi: f64,
multipath_height: Option<f64>,
multipath_ground: String,
land_mobile: bool,
nlos: bool,
) -> PyResult<Bound<'py, PyAny>> {
let params = SceneParams {
fs_hz,
duration_s,
signal,
ids: prns,
dopplers: dopplers.unwrap_or_default(),
center_hz,
if_hz,
cn0_dbhz,
noise_figure_db,
noise,
seed,
data,
threads,
};
let mut scene = build_scene(¶ms).map_err(PyValueError::new_err)?;
let spec = scene.config().spec;
let chan = ChannelParams {
iono_stec_tecu: iono_stec,
iono_vtec_tecu: iono_vtec,
iono_klobuchar,
tropo,
tropo_doy,
s4,
scint_tau0_s: scint_tau0,
sigma_phi_rad: sigma_phi,
multipath_height_m: multipath_height,
multipath_ground,
land_mobile,
nlos,
};
if chan.any() {
let carrier = scene
.satellites()
.first()
.map(|s| s.code.carrier_hz())
.unwrap_or(spec.center_hz);
let start_tow = scene.config().start_tow_s;
if let Some(ch) =
build_channel(&chan, carrier, params.seed, start_tow).map_err(PyValueError::new_err)?
{
scene.set_channel(ch);
}
}
let mut sink = VecSink::default();
let mut truth: Vec<TruthRecord> = Vec::new();
scene
.generate(&mut sink, &mut truth)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
let i: Vec<f64> = sink.samples.iter().map(|s| s.re).collect();
let q: Vec<f64> = sink.samples.iter().map(|s| s.im).collect();
let truth_json: Vec<serde_json::Value> = truth
.iter()
.map(|r| serde_json::to_value(r).unwrap_or(serde_json::Value::Null))
.collect();
let v = serde_json::json!({
"fs_hz": spec.fs_hz,
"center_hz": spec.center_hz,
"if_hz": spec.if_hz,
"samples_i": i,
"samples_q": q,
"truth": truth_json,
});
json_to_py(py, &v)
}
#[pyfunction]
#[pyo3(signature = (fs_hz, window_s, nav_text, rx_lat, rx_lon, rx_alt, prns=None, start_tow=0.0, cn0_dbhz=None, center_hz=None, if_hz=0.0, noise=true, noise_figure_db=2.0, seed=1, mask_deg=5.0, threads=1))]
#[allow(clippy::too_many_arguments)]
fn iq_scene_broadcast<'py>(
py: Python<'py>,
fs_hz: f64,
window_s: f64,
nav_text: String,
rx_lat: f64,
rx_lon: f64,
rx_alt: f64,
prns: Option<Vec<i64>>,
start_tow: f64,
cn0_dbhz: Option<f64>,
center_hz: Option<f64>,
if_hz: f64,
noise: bool,
noise_figure_db: f64,
seed: u64,
mask_deg: f64,
threads: usize,
) -> PyResult<Bound<'py, PyAny>> {
let bp = BroadcastParams {
fs_hz,
duration_s: window_s,
nav_text,
prns: prns.unwrap_or_default(),
start_tow_s: start_tow,
rx_llh_deg: (rx_lat, rx_lon, rx_alt),
center_hz,
if_hz,
cn0_dbhz,
noise,
noise_figure_db,
seed,
elevation_mask_deg: mask_deg,
threads,
};
let scene = build_broadcast_scene(&bp).map_err(PyValueError::new_err)?;
let spec = scene.config().spec;
let mut sink = VecSink::default();
let mut truth: Vec<TruthRecord> = Vec::new();
scene
.generate(&mut sink, &mut truth)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
let i: Vec<f64> = sink.samples.iter().map(|s| s.re).collect();
let q: Vec<f64> = sink.samples.iter().map(|s| s.im).collect();
let truth_json: Vec<serde_json::Value> = truth
.iter()
.map(|r| serde_json::to_value(r).unwrap_or(serde_json::Value::Null))
.collect();
let v = serde_json::json!({
"fs_hz": spec.fs_hz,
"center_hz": spec.center_hz,
"if_hz": spec.if_hz,
"samples_i": i,
"samples_q": q,
"truth": truth_json,
});
json_to_py(py, &v)
}
#[pyfunction]
#[pyo3(signature = (i, q, fs_hz, signal, prns, if_hz=0.0, center_hz=None, coherent=1, noncoherent=1, doppler_max=5000.0, doppler_step=None, pfa=1e-3))]
#[allow(clippy::too_many_arguments)]
fn iq_acquire<'py>(
py: Python<'py>,
i: Vec<f64>,
q: Vec<f64>,
fs_hz: f64,
signal: String,
prns: Vec<i64>,
if_hz: f64,
center_hz: Option<f64>,
coherent: usize,
noncoherent: usize,
doppler_max: f64,
doppler_step: Option<f64>,
pfa: f64,
) -> PyResult<Bound<'py, PyAny>> {
let codes = codes_for(&signal, &prns)?;
let samples = samples_from(&i, &q)?;
let spec = SampleSpec {
fs_hz,
center_hz: center_hz.unwrap_or_else(|| codes[0].carrier_hz()),
if_hz,
};
let coherent = coherent.max(1);
let cfg = AcqConfig {
coherent_periods: coherent,
noncoherent: noncoherent.max(1),
doppler_max_hz: doppler_max,
doppler_step_hz: doppler_step
.unwrap_or(2.0 / (3.0 * coherent as f64 * codes[0].period_s())),
pfa,
};
let mut out = Vec::new();
for code in &codes {
let grid = acquire(&samples, &spec, code, &cfg).map_err(PyValueError::new_err)?;
let r = grid.result;
out.push(serde_json::json!({
"code": r.code_name,
"acquired": r.acquired,
"doppler_hz": r.doppler_hz,
"code_phase_chips": r.code_phase_chips,
"delay_samples": r.delay_samples,
"statistic": r.statistic,
"threshold": r.threshold,
"peak_ratio": r.peak_ratio,
"second_peak": r.second_peak,
"sample_power": r.sample_power,
}));
}
json_to_py(py, &serde_json::Value::Array(out))
}
#[pyfunction]
#[pyo3(signature = (i, q, fs_hz, signal, prns, if_hz=0.0, center_hz=None, pll_bw=None, fll_bw=None, dll_bw=None, spacing=None, coherent=None, periods_per_bit=None, acq_coherent=None, acq_noncoherent=1, doppler_max=5000.0, max_seconds=None))]
#[allow(clippy::too_many_arguments)]
fn iq_track<'py>(
py: Python<'py>,
i: Vec<f64>,
q: Vec<f64>,
fs_hz: f64,
signal: String,
prns: Vec<i64>,
if_hz: f64,
center_hz: Option<f64>,
pll_bw: Option<f64>,
fll_bw: Option<f64>,
dll_bw: Option<f64>,
spacing: Option<f64>,
coherent: Option<usize>,
periods_per_bit: Option<usize>,
acq_coherent: Option<usize>,
acq_noncoherent: usize,
doppler_max: f64,
max_seconds: Option<f64>,
) -> PyResult<Bound<'py, PyAny>> {
let codes = codes_for(&signal, &prns)?;
let samples = samples_from(&i, &q)?;
let spec = SampleSpec {
fs_hz,
center_hz: center_hz.unwrap_or_else(|| codes[0].carrier_hz()),
if_hz,
};
let acq_coherent = acq_coherent
.unwrap_or_else(|| auto_coherent_periods(codes[0].period_s()))
.max(1);
let acq = AcqConfig {
coherent_periods: acq_coherent,
noncoherent: acq_noncoherent.max(1),
doppler_max_hz: doppler_max,
doppler_step_hz: 2.0 / (3.0 * acq_coherent as f64 * codes[0].period_s()),
pfa: 1e-3,
};
let mut inits = Vec::new();
for code in &codes {
let grid = acquire(&samples, &spec, code, &acq).map_err(PyValueError::new_err)?;
if !grid.result.acquired {
return Err(PyValueError::new_err(format!(
"{}: not acquired (statistic {:.2} < threshold {:.2})",
code.name(),
grid.result.statistic,
grid.result.threshold
)));
}
let arc: std::sync::Arc<dyn SpreadingCode + Send + Sync> =
std::sync::Arc::new(code.clone());
inits.push(ChannelInit::from_acquisition(
arc,
&grid.result,
&spec,
0,
periods_per_bit,
));
}
let mut cfg = LoopConfig::default();
if let Some(d) = spacing {
cfg.spacing_chips = d;
}
if let Some(bn) = dll_bw {
cfg.dll_bn_hz = bn;
}
if let Some(n) = coherent {
cfg.coherent_periods = n.max(1);
}
if pll_bw.is_some() || fll_bw.is_some() {
cfg.carrier = CarrierLoop::FllAssistedPll {
pll_order: 2,
pll_bn_hz: pll_bw.unwrap_or(15.0),
fll_order: 1,
fll_bn_hz: fll_bw.unwrap_or(10.0),
};
}
let max_samples = max_seconds.map(|s| (s * fs_hz).round() as u64);
let mut src = crate::iq::VecSource::new(spec, samples);
let results = replay(&mut src, &inits, &[cfg], max_samples)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
let chans: Vec<serde_json::Value> = codes
.iter()
.zip(&results[0].channels)
.map(|(code, epochs)| {
serde_json::json!({
"code": code.name(),
"epochs": epochs.iter().map(epoch_value).collect::<Vec<_>>(),
})
})
.collect();
json_to_py(
py,
&serde_json::json!({ "fs_hz": fs_hz, "channels": chans }),
)
}
fn epoch_value(e: &EpochOutput) -> serde_json::Value {
serde_json::json!({
"epoch": e.epoch,
"sample_index": e.sample_index,
"code_epoch_s": e.code_epoch_s,
"t_coh_s": e.t_coh_s,
"doppler_hz": e.doppler_hz,
"code_rate_hz": e.code_rate_hz,
"code_phase_chips": e.code_phase_chips,
"i_prompt": e.prompt.re,
"q_prompt": e.prompt.im,
"dll_chips": e.disc.dll_chips,
"pll_rad": e.disc.pll_rad,
"fll_hz": e.disc.fll_hz,
"pli": e.pli,
"phase_lock": e.phase_lock,
"code_lock": e.code_lock,
"cn0_nwpr_dbhz": e.cn0_nwpr_dbhz,
"cn0_beaulieu_dbhz": e.cn0_beaulieu_dbhz,
})
}
#[pyfunction]
fn iq_labfit<'py>(py: Python<'py>, toml: &str) -> PyResult<Bound<'py, PyAny>> {
let out = crate::iq::labfit::run_toml(toml).map_err(PyValueError::new_err)?;
let report: serde_json::Value =
serde_json::from_str(&out.json).map_err(|e| PyValueError::new_err(e.to_string()))?;
let v = serde_json::json!({
"report": report,
"residuals_csv": out.residuals_csv,
"predictions_csv": out.predictions_csv,
"markdown": out.markdown,
});
json_to_py(py, &v)
}
#[pyfunction]
#[pyo3(signature = (i, q, fs_hz, bandpass_lo=None, bandpass_hi=None, bandpass_transition=None, bandpass_atten=60.0, notch=false, notch_r=0.95, notch_mu=0.05, blank=None, blank_hold=0, excise=false, excise_fft=256, excise_pfa=1e-3, agc=false, agc_tau=1e-3, bits=None, quant_step=None, no_agc=false))]
#[allow(clippy::too_many_arguments)]
fn iq_frontend<'py>(
py: Python<'py>,
i: Vec<f64>,
q: Vec<f64>,
fs_hz: f64,
bandpass_lo: Option<f64>,
bandpass_hi: Option<f64>,
bandpass_transition: Option<f64>,
bandpass_atten: f64,
notch: bool,
notch_r: f64,
notch_mu: f64,
blank: Option<f64>,
blank_hold: usize,
excise: bool,
excise_fft: usize,
excise_pfa: f64,
agc: bool,
agc_tau: f64,
bits: Option<u32>,
quant_step: Option<f64>,
no_agc: bool,
) -> PyResult<Bound<'py, PyAny>> {
use crate::iq::frontend::Stage;
let mut samples = samples_from(&i, &q)?;
let bandpass = match (bandpass_lo, bandpass_hi) {
(Some(lo), Some(hi)) => Some((lo, hi)),
(None, None) => None,
_ => {
return Err(PyValueError::new_err(
"bandpass needs both bandpass_lo and bandpass_hi",
))
}
};
let params = FrontendParams {
bandpass,
bandpass_transition_hz: bandpass_transition,
bandpass_atten_db: bandpass_atten,
notch,
notch_r,
notch_mu,
blank,
blank_hold,
excise,
excise_fft,
excise_pfa,
agc,
agc_tau_s: agc_tau,
bits,
quant_step,
no_agc,
};
let mut chain = build_chain(¶ms, fs_hz).map_err(PyValueError::new_err)?;
chain.process(&mut samples);
let oi: Vec<f64> = samples.iter().map(|s| s.re).collect();
let oq: Vec<f64> = samples.iter().map(|s| s.im).collect();
json_to_py(py, &serde_json::json!({ "samples_i": oi, "samples_q": oq }))
}
#[pyfunction]
fn iq_signals() -> Vec<String> {
crate::iq::cli::signal_names()
.iter()
.map(|s| s.to_string())
.collect()
}
#[pymodule]
fn kshana(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyRunOutput>()?;
m.add_function(wrap_pyfunction!(run, m)?)?;
m.add_function(wrap_pyfunction!(run_full, m)?)?;
m.add_function(wrap_pyfunction!(run_typed, m)?)?;
m.add_function(wrap_pyfunction!(scenario_kinds, m)?)?;
m.add_function(wrap_pyfunction!(validate_toml, m)?)?;
m.add_function(wrap_pyfunction!(list_kinds, m)?)?;
m.add_function(wrap_pyfunction!(error_kind, m)?)?;
m.add_function(wrap_pyfunction!(version, m)?)?;
m.add_function(wrap_pyfunction!(receiver_trust, m)?)?;
m.add_function(wrap_pyfunction!(iq_scene, m)?)?;
m.add_function(wrap_pyfunction!(iq_scene_broadcast, m)?)?;
m.add_function(wrap_pyfunction!(iq_acquire, m)?)?;
m.add_function(wrap_pyfunction!(iq_track, m)?)?;
m.add_function(wrap_pyfunction!(iq_labfit, m)?)?;
m.add_function(wrap_pyfunction!(iq_frontend, m)?)?;
m.add_function(wrap_pyfunction!(iq_signals, m)?)?;
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
Ok(())
}