secs-project 0.15.3

.secs project files, migrations, role vocabulary, measurement plans and the .irs store.
Documentation
use crate::{Project, PyJson, ordered_speakers};
use std::collections::{BTreeMap, BTreeSet};

pub use secs_types::rules::SWEEP_LENGTHS;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimingReference {
    Acoustic,
    Loopback,
    None,
}

impl TimingReference {
    pub fn as_disk_str(self) -> &'static str {
        match self {
            Self::Acoustic => "acoustic",
            Self::Loopback => "loopback",
            Self::None => "none",
        }
    }

    fn from_disk(value: &str) -> Self {
        match value.to_ascii_lowercase().as_str() {
            "loopback" => Self::Loopback,
            "none" => Self::None,
            _ => Self::Acoustic,
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct MeasurementPlan {
    pub roles: Vec<String>,
    pub outputs: BTreeMap<String, i64>,
    pub positions: Vec<i64>,
    pub output_device: String,
    pub input_device: String,
    pub input_channel: i64,
    pub sweep_level_db: f64,
    pub sweep_length: String,
    pub repetitions: i64,
    pub timing_reference: TimingReference,
    pub timing_ref_output: Option<i64>,
    pub naming: String,
    pub start_frequency: f64,
    pub end_frequency: f64,
    pub sample_rate: i64,
}

impl Default for MeasurementPlan {
    fn default() -> Self {
        Self {
            roles: Vec::new(),
            outputs: BTreeMap::new(),
            positions: Vec::new(),
            output_device: String::new(),
            input_device: String::new(),
            input_channel: 1,
            sweep_level_db: -20.0,
            sweep_length: "256k".into(),
            repetitions: 1,
            timing_reference: TimingReference::Acoustic,
            timing_ref_output: None,
            naming: "{role} P{pos}".into(),
            start_frequency: 20.0,
            end_frequency: 20_000.0,
            sample_rate: 48_000,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlanValidationError {
    pub code: &'static str,
    pub params: BTreeMap<String, String>,
}

impl PlanValidationError {
    fn new(code: &'static str) -> Self {
        Self {
            code,
            params: BTreeMap::new(),
        }
    }

    fn role(code: &'static str, role: &str) -> Self {
        Self {
            code,
            params: BTreeMap::from([("role".into(), role.into())]),
        }
    }
}

impl MeasurementPlan {
    pub fn name_for(&self, role: &str, pos: i64) -> String {
        let (base, band) = split_role(role);
        let mut out = String::new();
        let mut rest = self.naming.as_str();
        while let Some(open) = rest.find('{') {
            out.push_str(&rest[..open]);
            let Some(close) = rest[open + 1..].find('}') else {
                return format!("{role} P{pos}");
            };
            let key = &rest[open + 1..open + 1 + close];
            match key {
                "role" => out.push_str(role),
                "pos" => out.push_str(&pos.to_string()),
                "base" => out.push_str(&base),
                "band" => out.push_str(band.as_deref().unwrap_or("")),
                _ => return format!("{role} P{pos}"),
            }
            rest = &rest[open + close + 2..];
        }
        out.push_str(rest);
        out
    }

    pub fn output_for(&self, role: &str) -> i64 {
        self.outputs.get(role).copied().unwrap_or(1)
    }

    pub fn validate(&self) -> Vec<PlanValidationError> {
        let mut errors = Vec::new();
        if self.roles.is_empty() {
            errors.push(PlanValidationError::new("measurement.roles.empty"));
        }
        if self.roles.iter().collect::<BTreeSet<_>>().len() != self.roles.len() {
            errors.push(PlanValidationError::new("measurement.roles.duplicate"));
        }
        if self.positions.is_empty() {
            errors.push(PlanValidationError::new("measurement.positions.empty"));
        }
        if self.positions.iter().collect::<BTreeSet<_>>().len() != self.positions.len() {
            errors.push(PlanValidationError::new("measurement.positions.duplicate"));
        }
        for role in &self.roles {
            if !self.outputs.contains_key(role) {
                errors.push(PlanValidationError::role(
                    "measurement.output.missing",
                    role,
                ));
            }
        }
        if self.repetitions != 1 && self.timing_reference == TimingReference::Acoustic {
            errors.push(PlanValidationError::new(
                "measurement.acoustic_repetitions.invalid",
            ));
        }
        if !SWEEP_LENGTHS.contains(&self.sweep_length.as_str()) {
            let mut error = PlanValidationError::new("measurement.sweep_length.invalid");
            error
                .params
                .insert("allowed".into(), SWEEP_LENGTHS.join(" / "));
            errors.push(error);
        }
        errors
    }

    pub fn total_steps(&self) -> usize {
        self.roles.len() * self.positions.len()
    }
}

pub fn split_role(role: &str) -> (String, Option<String>) {
    if let Some((base, band)) = role.split_once(':') {
        let band = band.trim();
        (base.trim().into(), (!band.is_empty()).then(|| band.into()))
    } else {
        (role.trim().into(), None)
    }
}

pub fn sweep_length_samples(length: &str) -> Result<i64, std::num::ParseFloatError> {
    let text = length.trim().to_ascii_lowercase();
    let (number, scale) = if let Some(value) = text.strip_suffix('k') {
        (value, 1024.0)
    } else if let Some(value) = text.strip_suffix('m') {
        (value, 1024.0 * 1024.0)
    } else {
        (text.as_str(), 1.0)
    };
    Ok((number.parse::<f64>()? * scale) as i64)
}

pub fn plan_from_project(
    project: &Project,
    positions: impl IntoIterator<Item = i64>,
) -> MeasurementPlan {
    let mut plan = MeasurementPlan {
        positions: positions.into_iter().collect(),
        output_device: project.config.device.clone(),
        ..MeasurementPlan::default()
    };
    for speaker in ordered_speakers(&project.config.speakers, true) {
        for band in &speaker.bands {
            let role = if speaker.bands.len() <= 1 {
                speaker.role.clone()
            } else {
                format!("{}:{}", speaker.role, band.band)
            };
            plan.outputs.insert(role.clone(), band.out);
            plan.roles.push(role);
        }
    }
    plan
}

pub fn measurement_roles(project: &Project) -> Vec<(String, i64)> {
    let mut result = Vec::new();
    for speaker in ordered_speakers(&project.config.speakers, false) {
        if speaker.bands.len() <= 1 {
            result.push((
                speaker.role.clone(),
                speaker.bands.first().map_or(1, |band| band.out),
            ));
        } else {
            result.extend(
                speaker
                    .bands
                    .iter()
                    .map(|band| (format!("{}:{}", speaker.role, band.band), band.out)),
            );
        }
    }
    result
}

pub fn default_ref_role(project: &Project) -> String {
    let roles = measurement_roles(project);
    let mains = roles
        .iter()
        .filter(|(role, _)| !role.to_ascii_uppercase().starts_with("SW"))
        .collect::<Vec<_>>();
    let mut wanted = mains
        .iter()
        .find(|(role, _)| role.split(':').next() == Some("R"))
        .copied()
        .or_else(|| mains.first().copied())
        .map(|(role, _)| role.clone())
        .or_else(|| roles.first().map(|(role, _)| role.clone()))
        .unwrap_or_default();
    if let Some((base, _)) = wanted.split_once(':')
        && let Some((role, _)) = roles
            .iter()
            .find(|(role, _)| role.starts_with(&format!("{base}:")) && role.ends_with(":HF"))
    {
        wanted = role.clone();
    }
    wanted
}

pub fn timing_settings(project: &Project) -> (TimingReference, Option<i64>) {
    let devices = devices(project);
    let kind = TimingReference::from_disk(&py_string(
        devices.and_then(|value| value.get("timing_reference")),
        "acoustic",
    ));
    match kind {
        TimingReference::Acoustic => {
            let table = measurement_roles(project)
                .into_iter()
                .collect::<BTreeMap<_, _>>();
            let stored = py_string(devices.and_then(|value| value.get("timing_ref_role")), "");
            let role = if table.contains_key(&stored) {
                stored
            } else {
                default_ref_role(project)
            };
            (kind, table.get(&role).copied())
        }
        TimingReference::Loopback => {
            let channel = devices.and_then(|value| value.get("timing_ref_output"));
            let output = channel
                .filter(|value| value.is_truthy())
                .map(|value| py_i64(Some(value), 0));
            (kind, output)
        }
        TimingReference::None => (kind, None),
    }
}

pub fn start_delay_seconds(text: &str) -> i64 {
    let digits = text
        .chars()
        .filter(char::is_ascii_digit)
        .collect::<String>();
    digits.parse().unwrap_or(0)
}

pub fn sweep_seconds(project: &Project) -> f64 {
    let devices = devices(project);
    let rate = py_f64(devices.and_then(|value| value.get("sample_rate")), 48_000.0).max(1.0);
    let length = py_string(devices.and_then(|value| value.get("sweep_length")), "256k");
    let samples = sweep_length_samples(&length).unwrap_or(262_144) as f64;
    let sweep = samples / rate;
    if py_string(
        devices.and_then(|value| value.get("timing_reference")),
        "acoustic",
    )
    .to_ascii_lowercase()
    .starts_with("acoustic")
    {
        2.0 * sweep + 0.7
    } else {
        sweep + 2.5
    }
}

pub fn position_seconds(project: &Project) -> f64 {
    let bands = project
        .config
        .speakers
        .iter()
        .map(|speaker| speaker.bands.len())
        .sum::<usize>() as f64;
    let delay = project
        .extra
        .iter()
        .find(|(key, _)| key == "start_delay")
        .map(|(_, value)| py_string(Some(value), ""))
        .map_or(0, |value| start_delay_seconds(&value));
    bands * sweep_seconds(project) * normalized_repetitions(project) as f64 + delay as f64
}

fn normalized_repetitions(project: &Project) -> i64 {
    let devices = devices(project);
    let mut repetitions = py_i64(devices.and_then(|value| value.get("repetitions")), 1);
    if ![1, 2, 4, 8].contains(&repetitions) {
        repetitions = 1;
    }
    if py_string(
        devices.and_then(|value| value.get("timing_reference")),
        "acoustic",
    )
    .to_ascii_lowercase()
    .starts_with("acoustic")
    {
        repetitions = 1;
    }
    repetitions
}

fn devices(project: &Project) -> Option<&PyJson> {
    project
        .extra
        .iter()
        .find(|(key, _)| key == "devices")
        .map(|(_, value)| value)
        .filter(|value| value.object().is_some())
}

fn py_string(value: Option<&PyJson>, default: &str) -> String {
    crate::project::string(value, default)
}

fn py_i64(value: Option<&PyJson>, default: i64) -> i64 {
    crate::project::integer(value, default, "measurement option").unwrap_or(default)
}

fn py_f64(value: Option<&PyJson>, default: f64) -> f64 {
    value
        .and_then(|value| crate::project::float(value).ok())
        .unwrap_or(default)
}