pub(crate) use crate::iq::scene::synthetic::{build_scene, SceneParams};
use crate::portable_math::PortableFloat;
use super::{Args, Fail};
use crate::frames::{geodetic_to_ecef, Geodetic};
use crate::iq::channel::cn0_profile::{Cn0Profile, Cn0ProfileChannel, ProfiledTruth};
use crate::iq::io::inventory::{write_sidecar, RawSidecar};
use crate::iq::io::stream::create_raw;
use crate::iq::io::{Encoding, SampleFormat};
use crate::iq::scene::{
CsvTruthWriter, JsonLinesTruthWriter, NoiseConfig, ReceiverClock, Scene, SceneConfig,
SceneSatellite, Trajectory, TruthSink, T0_K,
};
use crate::iq::{IqError, SampleSpec};
use crate::rinex::parse_nav;
use std::fs::File;
use std::io::BufWriter;
use std::path::Path;
pub(crate) struct BroadcastParams {
pub(crate) fs_hz: f64,
pub(crate) duration_s: f64,
pub(crate) nav_text: String,
pub(crate) prns: Vec<i64>,
pub(crate) start_tow_s: f64,
pub(crate) rx_llh_deg: (f64, f64, f64),
pub(crate) center_hz: Option<f64>,
pub(crate) if_hz: f64,
pub(crate) cn0_dbhz: Option<f64>,
pub(crate) noise: bool,
pub(crate) noise_figure_db: f64,
pub(crate) seed: u64,
pub(crate) elevation_mask_deg: f64,
pub(crate) threads: usize,
}
pub(crate) fn build_broadcast_scene(p: &BroadcastParams) -> Result<Scene, String> {
let ephs = parse_nav(&p.nav_text)?;
let prns: Vec<u8> = if p.prns.is_empty() {
(1..=32).collect()
} else {
p.prns
.iter()
.map(|&id| u8::try_from(id).map_err(|_| format!("PRN {id} is out of range for GPS")))
.collect::<Result<Vec<_>, _>>()?
};
let mut chosen: Vec<&crate::rinex::RinexEphemeris> = Vec::new();
for prn in prns {
if let Some(e) = ephs
.iter()
.filter(|e| e.system == 'G' && e.prn == prn && e.sv_health == 0.0)
.min_by(|a, b| {
(a.toe - p.start_tow_s)
.abs()
.total_cmp(&(b.toe - p.start_tow_s).abs())
})
{
chosen.push(e);
}
}
if chosen.is_empty() {
return Err("no healthy GPS ephemeris found for the requested PRNs".to_string());
}
let center = p.center_hz.unwrap_or(crate::gnss_sim::L1_HZ);
let spec = SampleSpec {
fs_hz: p.fs_hz,
center_hz: center,
if_hz: p.if_hz,
};
let mut cfg = SceneConfig::new(spec, p.duration_s);
cfg.start_tow_s = p.start_tow_s;
cfg.seed = p.seed;
cfg.threads = p.threads.max(1);
cfg.elevation_mask_deg = p.elevation_mask_deg;
cfg.receiver = Trajectory::Static(geodetic_to_ecef(Geodetic {
lat_rad: p.rx_llh_deg.0.to_radians(),
lon_rad: p.rx_llh_deg.1.to_radians(),
alt_m: p.rx_llh_deg.2,
}));
cfg.clock = ReceiverClock::default();
cfg.noise = if p.noise {
NoiseConfig::from_noise_figure(p.noise_figure_db, T0_K)
} else {
NoiseConfig {
enabled: false,
..NoiseConfig::from_noise_figure(p.noise_figure_db, T0_K)
}
};
let mut scene = Scene::new(cfg).map_err(|e| e.to_string())?;
for e in chosen {
let sat = SceneSatellite::gps_l1ca_broadcast(e, p.cn0_dbhz, Default::default())
.map_err(|err| err.to_string())?;
scene.add_satellite(sat);
}
Ok(scene)
}
fn parse_llh(v: &str) -> Result<(f64, f64, f64), Fail> {
let parts: Vec<f64> = v
.split(',')
.map(|s| s.trim().parse::<f64>())
.collect::<Result<_, _>>()
.map_err(|_| Fail::Usage(format!("--rx-pos wants lat,lon,alt (got {v:?})")))?;
if parts.len() != 3 {
return Err(Fail::Usage(
"--rx-pos wants three values: lat,lon,alt (deg,deg,m)".into(),
));
}
Ok((parts[0], parts[1], parts[2]))
}
pub(crate) fn run(args: &[String]) -> Result<String, Fail> {
let switches: Vec<&str> = ["--no-noise", "--data"]
.into_iter()
.chain(super::channel::CHANNEL_SWITCHES.iter().copied())
.collect();
let a = Args::parse(args, &switches).map_err(Fail::Usage)?;
a.need_pos(1, "scene")?;
let out = Path::new(&a.pos[0]);
let fs_hz = a
.num("--rate")
.map_err(Fail::Usage)?
.ok_or(Fail::Usage("iq scene needs --rate <hz>".into()))?;
let duration_s = a
.num("--duration")
.map_err(Fail::Usage)?
.or(a.num("--window").map_err(Fail::Usage)?)
.ok_or(Fail::Usage(
"iq scene needs --duration <s> (or --window)".into(),
))?;
let seed = a.num("--seed").map_err(Fail::Usage)?.unwrap_or(1);
let threads = a.num("--threads").map_err(Fail::Usage)?.unwrap_or(1);
let cn0_dbhz = a.num("--cn0").map_err(Fail::Usage)?;
let noise = !a.has("--no-noise");
let noise_figure_db = a.num("--noise-figure").map_err(Fail::Usage)?.unwrap_or(2.0);
let center_hz = a.num("--center").map_err(Fail::Usage)?;
let if_hz = a.num("--if").map_err(Fail::Usage)?.unwrap_or(0.0);
let mut scene = if let Some(nav) = a.get("--nav") {
let text = std::fs::read_to_string(nav).map_err(|e| Fail::Run(format!("{nav}: {e}")))?;
let rx_llh_deg = match a.get("--rx-pos") {
Some(v) => parse_llh(v)?,
None => {
return Err(Fail::Usage(
"iq scene --nav needs --rx-pos lat,lon,alt".into(),
))
}
};
let bp = BroadcastParams {
fs_hz,
duration_s,
nav_text: text,
prns: a.list("--prn").map_err(Fail::Usage)?,
start_tow_s: a.num("--start").map_err(Fail::Usage)?.unwrap_or(0.0),
rx_llh_deg,
center_hz,
if_hz,
cn0_dbhz,
noise,
noise_figure_db,
seed,
elevation_mask_deg: a.num("--mask").map_err(Fail::Usage)?.unwrap_or(5.0),
threads,
};
build_broadcast_scene(&bp).map_err(Fail::Usage)?
} else {
let signal = a
.get("--signal")
.ok_or(Fail::Usage("iq scene needs --signal <name>".into()))?
.to_string();
let ids: Vec<i64> = a.list("--prn").map_err(Fail::Usage)?;
if ids.is_empty() {
return Err(Fail::Usage("iq scene needs --prn <list>".into()));
}
let params = SceneParams {
fs_hz,
duration_s,
signal,
ids,
dopplers: a.list("--doppler").map_err(Fail::Usage)?,
center_hz,
if_hz,
cn0_dbhz,
noise_figure_db,
noise,
seed,
data: a.has("--data"),
threads,
};
build_scene(¶ms).map_err(Fail::Usage)?
};
let spec = scene.config().spec;
let chan = super::channel::ChannelParams::from_args(&a)?;
let mut chan_desc = chan.describe();
let mut inner = None;
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;
inner =
super::channel::build_channel(&chan, carrier, seed, start_tow).map_err(Fail::Usage)?;
}
let cn0_profile = match a.get("--cn0-profile") {
Some(p) => {
let text = std::fs::read_to_string(p).map_err(|e| Fail::Run(format!("{p}: {e}")))?;
Some(Cn0Profile::parse(&text).map_err(|e| Fail::Usage(e.to_string()))?)
}
None => None,
};
match (&cn0_profile, inner) {
(Some(prof), inner) => {
chan_desc = format!(
"{chan_desc}; C/N0 profile ({} segment(s))",
prof.segments.len()
);
scene.set_channel(Box::new(Cn0ProfileChannel::new(prof.clone(), inner)));
}
(None, Some(ch)) => scene.set_channel(ch),
(None, None) => {}
}
let want_sigmf = a
.get("--format")
.map(|f| f.eq_ignore_ascii_case("sigmf"))
.unwrap_or(false)
|| is_sigmf_path(&a.pos[0]);
let sat_meta: Vec<SatMeta> = scene
.satellites()
.iter()
.map(|s| SatMeta {
id: s.id,
carrier_hz: s.code.carrier_hz(),
chip_rate_hz: s.code.chip_rate_hz(),
cn0_dbhz: s.cn0_dbhz,
})
.collect();
let format = if want_sigmf {
SampleFormat::CF32_LE
} else {
match a.get("--format") {
Some(f) => SampleFormat::parse(f)?,
None => SampleFormat::CF32_LE,
}
};
let truth_fmt = a.get("--truth-format").unwrap_or("csv").to_string();
let truth_path = a
.get("--truth")
.map(|s| s.to_string())
.unwrap_or_else(|| default_truth_path(out, &truth_fmt));
let data_path = if want_sigmf {
sigmf_data_path(&a.pos[0])
} else {
a.pos[0].clone()
};
let rms = expected_rms_per_component(&scene);
let scale = integer_target_rms(format).map(|target| rms / target);
let mut iq = create_raw(Path::new(&data_path), format)?.with_scale(scale.unwrap_or(1.0));
let truth_file = BufWriter::new(
File::create(&truth_path).map_err(|e| Fail::Run(format!("{truth_path}: {e}")))?,
);
let mut truth_writer: Box<dyn TruthSink> = match truth_fmt.as_str() {
"csv" => Box::new(CsvTruthWriter::new(truth_file)),
"jsonl" | "jsonlines" => Box::new(JsonLinesTruthWriter::new(truth_file)),
other => {
return Err(Fail::Usage(format!(
"--truth-format must be csv or jsonl (got {other:?})"
)))
}
};
let summary = match cn0_profile {
Some(prof) => generate(
scene,
&mut iq,
&mut ProfiledTruth::new(prof, truth_writer.as_mut()),
)?,
None => generate(scene, &mut iq, truth_writer.as_mut())?,
};
let clipped = iq.clipped();
let scale_note = match scale {
Some(sc) => format!(
"; integer scale: 1 LSB = {sc:.6e} noise-normalised units (expected RMS {:.4} LSB per component), {clipped} element(s) clipped",
rms / sc
),
None => String::new(),
};
let meta_note = if want_sigmf {
let meta_path = write_sigmf_meta(&a.pos[0], &spec, &sat_meta, &chan_desc, summary.samples)?;
format!("SigMF data {data_path}, meta {meta_path}")
} else {
let sidecar = RawSidecar {
format: format.name(),
sample_rate_hz: spec.fs_hz,
center_hz: Some(spec.center_hz),
if_hz: (spec.if_hz != 0.0).then_some(spec.if_hz),
header_bytes: None,
channels: None,
channel: None,
datetime: None,
description: Some(format!("written by kshana iq scene{scale_note}")),
};
let sidecar_path = write_sidecar(out, &sidecar)?;
format!("sidecar {}", sidecar_path.display())
};
Ok(format!(
"wrote {} samples ({}, {} Hz) to {}; {} truth records to {}; {}; channel: {}{}",
summary.samples,
format.name(),
spec.fs_hz,
data_path,
summary.truth_records,
truth_path,
meta_note,
chan_desc,
scale_note,
))
}
const INTEGER_RMS_FRACTION: f64 = 0.25;
pub(crate) fn integer_target_rms(format: SampleFormat) -> Option<f64> {
match format.encoding {
Encoding::I8 => Some(INTEGER_RMS_FRACTION * f64::from(i8::MAX)),
Encoding::I16Le | Encoding::I16Be => Some(INTEGER_RMS_FRACTION * f64::from(i16::MAX)),
Encoding::TwoBit { .. } | Encoding::TwoBitPerByte { .. } => Some(2.0),
Encoding::I4 { .. } => Some(INTEGER_RMS_FRACTION * 7.0),
Encoding::U4 { .. } => Some(INTEGER_RMS_FRACTION * 15.0),
Encoding::U8 => Some(INTEGER_RMS_FRACTION * 255.0),
Encoding::I12 { .. } => Some(INTEGER_RMS_FRACTION * 2047.0),
Encoding::U12 { .. } => Some(INTEGER_RMS_FRACTION * 4095.0),
Encoding::U16 { .. } => Some(INTEGER_RMS_FRACTION * 65535.0),
Encoding::F32Le | Encoding::F32Be => None,
}
}
pub(crate) fn expected_rms_per_component(scene: &Scene) -> f64 {
let cfg = scene.config();
let n0 = cfg.noise.n0_w_per_hz();
let fs = cfg.spec.fs_hz;
let gain2 = if cfg.noise.normalise {
1.0 / (n0 * fs)
} else {
1.0
};
let noise = if cfg.noise.enabled { n0 * fs } else { 0.0 };
let signals: f64 = scene
.satellites()
.iter()
.map(|s| {
let cn0 = s.cn0_dbhz.unwrap_or(cfg.cn0_model.zenith_dbhz);
10f64.ppowf(cn0 / 10.0) * n0
})
.sum();
((noise + signals) * gain2 / 2.0).sqrt()
}
struct SatMeta {
id: u32,
carrier_hz: f64,
chip_rate_hz: f64,
cn0_dbhz: Option<f64>,
}
fn is_sigmf_path(path: &str) -> bool {
path.ends_with(".sigmf-meta") || path.ends_with(".sigmf-data") || path.ends_with(".sigmf")
}
fn sigmf_base(path: &str) -> &str {
path.strip_suffix(".sigmf-meta")
.or_else(|| path.strip_suffix(".sigmf-data"))
.or_else(|| path.strip_suffix(".sigmf"))
.unwrap_or(path)
}
fn sigmf_data_path(path: &str) -> String {
format!("{}.sigmf-data", sigmf_base(path))
}
fn write_sigmf_meta(
path: &str,
spec: &SampleSpec,
sats: &[SatMeta],
chan_desc: &str,
samples: u64,
) -> Result<String, Fail> {
use crate::sigmf::{meta_to_json, Annotation, DataType, Meta};
let mut meta = Meta::new(
DataType::Cf32Le,
spec.fs_hz,
spec.center_hz,
"written by kshana iq scene",
);
for s in sats {
let cn0 = s
.cn0_dbhz
.map(|c| format!("{c} dB-Hz"))
.unwrap_or_else(|| "elevation default".to_string());
meta.annotations.push(Annotation {
sample_start: 0,
sample_count: Some(samples),
freq_lower_edge: Some(s.carrier_hz - s.chip_rate_hz),
freq_upper_edge: Some(s.carrier_hz + s.chip_rate_hz),
label: Some(format!("PRN {}", s.id)),
comment: Some(format!("C/N0 {cn0}")),
});
}
meta.annotations.push(Annotation {
sample_start: 0,
sample_count: Some(samples),
freq_lower_edge: None,
freq_upper_edge: None,
label: Some("channel".to_string()),
comment: Some(chan_desc.to_string()),
});
let json = meta_to_json(&meta).map_err(Fail::Run)?;
let meta_path = format!("{}.sigmf-meta", sigmf_base(path));
std::fs::write(&meta_path, json).map_err(|e| Fail::Run(format!("{meta_path}: {e}")))?;
Ok(meta_path)
}
fn default_truth_path(out: &Path, fmt: &str) -> String {
let ext = if fmt.starts_with("json") {
"jsonl"
} else {
"csv"
};
format!("{}.truth.{ext}", out.display())
}
fn generate(
scene: Scene,
iq: &mut dyn crate::iq::IqSink,
truth: &mut dyn TruthSink,
) -> Result<crate::iq::scene::SceneSummary, IqError> {
scene.generate(iq, truth)
}