Skip to main content

secs_project/
plan.rs

1use crate::{Project, PyJson, ordered_speakers};
2use std::collections::{BTreeMap, BTreeSet};
3
4pub use secs_types::rules::SWEEP_LENGTHS;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum TimingReference {
8    Acoustic,
9    Loopback,
10    None,
11}
12
13impl TimingReference {
14    pub fn as_disk_str(self) -> &'static str {
15        match self {
16            Self::Acoustic => "acoustic",
17            Self::Loopback => "loopback",
18            Self::None => "none",
19        }
20    }
21
22    fn from_disk(value: &str) -> Self {
23        match value.to_ascii_lowercase().as_str() {
24            "loopback" => Self::Loopback,
25            "none" => Self::None,
26            _ => Self::Acoustic,
27        }
28    }
29}
30
31#[derive(Debug, Clone, PartialEq)]
32pub struct MeasurementPlan {
33    pub roles: Vec<String>,
34    pub outputs: BTreeMap<String, i64>,
35    pub positions: Vec<i64>,
36    pub output_device: String,
37    pub input_device: String,
38    pub input_channel: i64,
39    pub sweep_level_db: f64,
40    pub sweep_length: String,
41    pub repetitions: i64,
42    pub timing_reference: TimingReference,
43    pub timing_ref_output: Option<i64>,
44    pub naming: String,
45    pub start_frequency: f64,
46    pub end_frequency: f64,
47    pub sample_rate: i64,
48}
49
50impl Default for MeasurementPlan {
51    fn default() -> Self {
52        Self {
53            roles: Vec::new(),
54            outputs: BTreeMap::new(),
55            positions: Vec::new(),
56            output_device: String::new(),
57            input_device: String::new(),
58            input_channel: 1,
59            sweep_level_db: -20.0,
60            sweep_length: "256k".into(),
61            repetitions: 1,
62            timing_reference: TimingReference::Acoustic,
63            timing_ref_output: None,
64            naming: "{role} P{pos}".into(),
65            start_frequency: 20.0,
66            end_frequency: 20_000.0,
67            sample_rate: 48_000,
68        }
69    }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct PlanValidationError {
74    pub code: &'static str,
75    pub params: BTreeMap<String, String>,
76}
77
78impl PlanValidationError {
79    fn new(code: &'static str) -> Self {
80        Self {
81            code,
82            params: BTreeMap::new(),
83        }
84    }
85
86    fn role(code: &'static str, role: &str) -> Self {
87        Self {
88            code,
89            params: BTreeMap::from([("role".into(), role.into())]),
90        }
91    }
92}
93
94impl MeasurementPlan {
95    pub fn name_for(&self, role: &str, pos: i64) -> String {
96        let (base, band) = split_role(role);
97        let mut out = String::new();
98        let mut rest = self.naming.as_str();
99        while let Some(open) = rest.find('{') {
100            out.push_str(&rest[..open]);
101            let Some(close) = rest[open + 1..].find('}') else {
102                return format!("{role} P{pos}");
103            };
104            let key = &rest[open + 1..open + 1 + close];
105            match key {
106                "role" => out.push_str(role),
107                "pos" => out.push_str(&pos.to_string()),
108                "base" => out.push_str(&base),
109                "band" => out.push_str(band.as_deref().unwrap_or("")),
110                _ => return format!("{role} P{pos}"),
111            }
112            rest = &rest[open + close + 2..];
113        }
114        out.push_str(rest);
115        out
116    }
117
118    pub fn output_for(&self, role: &str) -> i64 {
119        self.outputs.get(role).copied().unwrap_or(1)
120    }
121
122    pub fn validate(&self) -> Vec<PlanValidationError> {
123        let mut errors = Vec::new();
124        if self.roles.is_empty() {
125            errors.push(PlanValidationError::new("measurement.roles.empty"));
126        }
127        if self.roles.iter().collect::<BTreeSet<_>>().len() != self.roles.len() {
128            errors.push(PlanValidationError::new("measurement.roles.duplicate"));
129        }
130        if self.positions.is_empty() {
131            errors.push(PlanValidationError::new("measurement.positions.empty"));
132        }
133        if self.positions.iter().collect::<BTreeSet<_>>().len() != self.positions.len() {
134            errors.push(PlanValidationError::new("measurement.positions.duplicate"));
135        }
136        for role in &self.roles {
137            if !self.outputs.contains_key(role) {
138                errors.push(PlanValidationError::role(
139                    "measurement.output.missing",
140                    role,
141                ));
142            }
143        }
144        if self.repetitions != 1 && self.timing_reference == TimingReference::Acoustic {
145            errors.push(PlanValidationError::new(
146                "measurement.acoustic_repetitions.invalid",
147            ));
148        }
149        if !SWEEP_LENGTHS.contains(&self.sweep_length.as_str()) {
150            let mut error = PlanValidationError::new("measurement.sweep_length.invalid");
151            error
152                .params
153                .insert("allowed".into(), SWEEP_LENGTHS.join(" / "));
154            errors.push(error);
155        }
156        errors
157    }
158
159    pub fn total_steps(&self) -> usize {
160        self.roles.len() * self.positions.len()
161    }
162}
163
164pub fn split_role(role: &str) -> (String, Option<String>) {
165    if let Some((base, band)) = role.split_once(':') {
166        let band = band.trim();
167        (base.trim().into(), (!band.is_empty()).then(|| band.into()))
168    } else {
169        (role.trim().into(), None)
170    }
171}
172
173pub fn sweep_length_samples(length: &str) -> Result<i64, std::num::ParseFloatError> {
174    let text = length.trim().to_ascii_lowercase();
175    let (number, scale) = if let Some(value) = text.strip_suffix('k') {
176        (value, 1024.0)
177    } else if let Some(value) = text.strip_suffix('m') {
178        (value, 1024.0 * 1024.0)
179    } else {
180        (text.as_str(), 1.0)
181    };
182    Ok((number.parse::<f64>()? * scale) as i64)
183}
184
185pub fn plan_from_project(
186    project: &Project,
187    positions: impl IntoIterator<Item = i64>,
188) -> MeasurementPlan {
189    let mut plan = MeasurementPlan {
190        positions: positions.into_iter().collect(),
191        output_device: project.config.device.clone(),
192        ..MeasurementPlan::default()
193    };
194    for speaker in ordered_speakers(&project.config.speakers, true) {
195        for band in &speaker.bands {
196            let role = if speaker.bands.len() <= 1 {
197                speaker.role.clone()
198            } else {
199                format!("{}:{}", speaker.role, band.band)
200            };
201            plan.outputs.insert(role.clone(), band.out);
202            plan.roles.push(role);
203        }
204    }
205    plan
206}
207
208pub fn measurement_roles(project: &Project) -> Vec<(String, i64)> {
209    let mut result = Vec::new();
210    for speaker in ordered_speakers(&project.config.speakers, false) {
211        if speaker.bands.len() <= 1 {
212            result.push((
213                speaker.role.clone(),
214                speaker.bands.first().map_or(1, |band| band.out),
215            ));
216        } else {
217            result.extend(
218                speaker
219                    .bands
220                    .iter()
221                    .map(|band| (format!("{}:{}", speaker.role, band.band), band.out)),
222            );
223        }
224    }
225    result
226}
227
228pub fn default_ref_role(project: &Project) -> String {
229    let roles = measurement_roles(project);
230    let mains = roles
231        .iter()
232        .filter(|(role, _)| !role.to_ascii_uppercase().starts_with("SW"))
233        .collect::<Vec<_>>();
234    let mut wanted = mains
235        .iter()
236        .find(|(role, _)| role.split(':').next() == Some("R"))
237        .copied()
238        .or_else(|| mains.first().copied())
239        .map(|(role, _)| role.clone())
240        .or_else(|| roles.first().map(|(role, _)| role.clone()))
241        .unwrap_or_default();
242    if let Some((base, _)) = wanted.split_once(':')
243        && let Some((role, _)) = roles
244            .iter()
245            .find(|(role, _)| role.starts_with(&format!("{base}:")) && role.ends_with(":HF"))
246    {
247        wanted = role.clone();
248    }
249    wanted
250}
251
252pub fn timing_settings(project: &Project) -> (TimingReference, Option<i64>) {
253    let devices = devices(project);
254    let kind = TimingReference::from_disk(&py_string(
255        devices.and_then(|value| value.get("timing_reference")),
256        "acoustic",
257    ));
258    match kind {
259        TimingReference::Acoustic => {
260            let table = measurement_roles(project)
261                .into_iter()
262                .collect::<BTreeMap<_, _>>();
263            let stored = py_string(devices.and_then(|value| value.get("timing_ref_role")), "");
264            let role = if table.contains_key(&stored) {
265                stored
266            } else {
267                default_ref_role(project)
268            };
269            (kind, table.get(&role).copied())
270        }
271        TimingReference::Loopback => {
272            let channel = devices.and_then(|value| value.get("timing_ref_output"));
273            let output = channel
274                .filter(|value| value.is_truthy())
275                .map(|value| py_i64(Some(value), 0));
276            (kind, output)
277        }
278        TimingReference::None => (kind, None),
279    }
280}
281
282pub fn start_delay_seconds(text: &str) -> i64 {
283    let digits = text
284        .chars()
285        .filter(char::is_ascii_digit)
286        .collect::<String>();
287    digits.parse().unwrap_or(0)
288}
289
290pub fn sweep_seconds(project: &Project) -> f64 {
291    let devices = devices(project);
292    let rate = py_f64(devices.and_then(|value| value.get("sample_rate")), 48_000.0).max(1.0);
293    let length = py_string(devices.and_then(|value| value.get("sweep_length")), "256k");
294    let samples = sweep_length_samples(&length).unwrap_or(262_144) as f64;
295    let sweep = samples / rate;
296    if py_string(
297        devices.and_then(|value| value.get("timing_reference")),
298        "acoustic",
299    )
300    .to_ascii_lowercase()
301    .starts_with("acoustic")
302    {
303        2.0 * sweep + 0.7
304    } else {
305        sweep + 2.5
306    }
307}
308
309pub fn position_seconds(project: &Project) -> f64 {
310    let bands = project
311        .config
312        .speakers
313        .iter()
314        .map(|speaker| speaker.bands.len())
315        .sum::<usize>() as f64;
316    let delay = project
317        .extra
318        .iter()
319        .find(|(key, _)| key == "start_delay")
320        .map(|(_, value)| py_string(Some(value), ""))
321        .map_or(0, |value| start_delay_seconds(&value));
322    bands * sweep_seconds(project) * normalized_repetitions(project) as f64 + delay as f64
323}
324
325fn normalized_repetitions(project: &Project) -> i64 {
326    let devices = devices(project);
327    let mut repetitions = py_i64(devices.and_then(|value| value.get("repetitions")), 1);
328    if ![1, 2, 4, 8].contains(&repetitions) {
329        repetitions = 1;
330    }
331    if py_string(
332        devices.and_then(|value| value.get("timing_reference")),
333        "acoustic",
334    )
335    .to_ascii_lowercase()
336    .starts_with("acoustic")
337    {
338        repetitions = 1;
339    }
340    repetitions
341}
342
343fn devices(project: &Project) -> Option<&PyJson> {
344    project
345        .extra
346        .iter()
347        .find(|(key, _)| key == "devices")
348        .map(|(_, value)| value)
349        .filter(|value| value.object().is_some())
350}
351
352fn py_string(value: Option<&PyJson>, default: &str) -> String {
353    crate::project::string(value, default)
354}
355
356fn py_i64(value: Option<&PyJson>, default: i64) -> i64 {
357    crate::project::integer(value, default, "measurement option").unwrap_or(default)
358}
359
360fn py_f64(value: Option<&PyJson>, default: f64) -> f64 {
361    value
362        .and_then(|value| crate::project::float(value).ok())
363        .unwrap_or(default)
364}