Skip to main content

sim_lib_interference_solve/
scenario.rs

1//! Bounded named builders for canonical interference fixtures.
2
3use sim_lib_interference_core::{
4    Emitter, FieldAmplitude, Hertz, InterferenceError, InterferenceProblem, Point3M,
5    PositiveMetres, Radians, ScalarMedium, SourceSet, UnitVector3,
6};
7
8use crate::ScenarioError;
9use crate::scenario_admission::{
10    IdPlan, allocate_sources, require_dimension, require_limit, sources_from, sources_from_fallible,
11};
12
13/// Absolute safety ceiling for a named scenario.
14pub const ABSOLUTE_MAX_SCENARIO_SOURCES: usize = 1_000_000;
15/// Absolute safety ceiling for one generated source identity.
16pub const ABSOLUTE_MAX_GENERATED_ID_BYTES: usize = 1_024;
17/// Absolute safety ceiling for all generated identities in one scenario.
18pub const ABSOLUTE_MAX_TOTAL_ID_BYTES: usize = 64 * 1_024 * 1_024;
19/// Maximum element spacing admitted by strict aperture policy.
20pub const STRICT_MAX_SPACING_WAVELENGTHS: f64 = 0.5;
21
22/// Explicit allocation limits applied before scenario construction.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct ScenarioLimits {
25    pub(crate) max_sources: usize,
26    pub(crate) max_generated_id_bytes: usize,
27    pub(crate) max_total_id_bytes: usize,
28}
29
30impl ScenarioLimits {
31    /// Constructs limits within the crate's absolute safety ceilings.
32    pub fn new(
33        max_sources: usize,
34        max_generated_id_bytes: usize,
35        max_total_id_bytes: usize,
36    ) -> Result<Self, ScenarioError> {
37        require_limit("max-sources", max_sources, ABSOLUTE_MAX_SCENARIO_SOURCES)?;
38        require_limit(
39            "max-generated-id-bytes",
40            max_generated_id_bytes,
41            ABSOLUTE_MAX_GENERATED_ID_BYTES,
42        )?;
43        require_limit(
44            "max-total-id-bytes",
45            max_total_id_bytes,
46            ABSOLUTE_MAX_TOTAL_ID_BYTES,
47        )?;
48        Ok(Self {
49            max_sources,
50            max_generated_id_bytes,
51            max_total_id_bytes,
52        })
53    }
54
55    /// Returns the maximum number of sources.
56    pub fn max_sources(self) -> usize {
57        self.max_sources
58    }
59
60    /// Returns the maximum bytes in one generated identity.
61    pub fn max_generated_id_bytes(self) -> usize {
62        self.max_generated_id_bytes
63    }
64
65    /// Returns the maximum aggregate generated identity bytes.
66    pub fn max_total_id_bytes(self) -> usize {
67        self.max_total_id_bytes
68    }
69}
70
71impl Default for ScenarioLimits {
72    fn default() -> Self {
73        Self {
74            max_sources: 4_096,
75            max_generated_id_bytes: 96,
76            max_total_id_bytes: 256 * 1_024,
77        }
78    }
79}
80
81/// Whether sparse spatial sampling is rejected or retained as evidence.
82#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
83pub enum AperturePolicy {
84    /// Refuse any active neighbour spacing above one half-wavelength.
85    #[default]
86    Strict,
87    /// Construct the approximation and retain its spacing in the certificate.
88    Annotate,
89}
90
91/// Named approximation created by a scenario builder.
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum ScenarioKind {
94    /// Two ideal isotropic point sources.
95    TwoPoint,
96    /// Two ideal plane waves directed into their shared slab.
97    CounterPropagatingPlanes,
98    /// A centred line of ideal isotropic point elements with progressive phase.
99    PhasedArray,
100    /// A centred rectangular grid of ideal isotropic point elements.
101    DiscreteAperture,
102}
103
104/// Neighbour spacing expressed against the unattenuated wavelength.
105#[derive(Clone, Copy, Debug, PartialEq)]
106pub struct ElementSpacingWavelengths {
107    /// Line or aperture-column spacing; absent for a singleton axis.
108    pub u: Option<f64>,
109    /// Aperture-row spacing; absent for a singleton axis or line array.
110    pub v: Option<f64>,
111}
112
113impl ElementSpacingWavelengths {
114    /// Returns the largest active neighbour spacing, or `None` for one element.
115    pub fn maximum(self) -> Option<f64> {
116        match (self.u, self.v) {
117            (Some(u), Some(v)) => Some(u.max(v)),
118            (Some(value), None) | (None, Some(value)) => Some(value),
119            (None, None) => None,
120        }
121    }
122}
123
124/// Evidence describing a named scenario and its discrete approximation.
125#[derive(Clone, Copy, Debug, PartialEq)]
126pub struct ScenarioCertificate {
127    /// Named builder used to create the problem.
128    pub kind: ScenarioKind,
129    /// Number of ideal sources created.
130    pub source_count: usize,
131    /// Requested coherent amplitude sum for array/aperture builders.
132    ///
133    /// Pair builders report the sum of their two equal per-source amplitudes.
134    pub total_source_amplitude: f64,
135    /// Equal amplitude carried by every source.
136    pub amplitude_per_source: f64,
137    /// Wavelength-relative neighbour spacing for discrete approximations.
138    pub element_spacing_wavelengths: ElementSpacingWavelengths,
139    /// Policy applied to the reported spacing.
140    pub aperture_policy: Option<AperturePolicy>,
141}
142
143/// One checked problem plus the approximation evidence that created it.
144#[derive(Clone, Debug, PartialEq)]
145pub struct NamedScenario {
146    problem: InterferenceProblem,
147    certificate: ScenarioCertificate,
148}
149
150impl NamedScenario {
151    /// Returns the complete coherent problem.
152    pub fn problem(&self) -> &InterferenceProblem {
153        &self.problem
154    }
155
156    /// Returns construction and approximation evidence.
157    pub fn certificate(&self) -> ScenarioCertificate {
158        self.certificate
159    }
160
161    /// Consumes the scenario into its problem and evidence.
162    pub fn into_parts(self) -> (InterferenceProblem, ScenarioCertificate) {
163        (self.problem, self.certificate)
164    }
165}
166
167/// Reusable physical context and limits for named scenario construction.
168#[derive(Clone, Copy, Debug, PartialEq)]
169pub struct ScenarioBuilder {
170    frequency: Hertz,
171    medium: ScalarMedium,
172    singularity_radius: PositiveMetres,
173    limits: ScenarioLimits,
174}
175
176impl ScenarioBuilder {
177    /// Constructs a builder from checked physical values and explicit limits.
178    pub fn new(
179        frequency: Hertz,
180        medium: ScalarMedium,
181        singularity_radius: PositiveMetres,
182        limits: ScenarioLimits,
183    ) -> Self {
184        Self {
185            frequency,
186            medium,
187            singularity_radius,
188            limits,
189        }
190    }
191
192    /// Builds two equal-amplitude ideal isotropic point sources.
193    pub fn two_point(
194        self,
195        first: Point3M,
196        second: Point3M,
197        amplitude_per_source: FieldAmplitude,
198        relative_phase: Radians,
199    ) -> Result<NamedScenario, ScenarioError> {
200        let plan = IdPlan::admit("scenario/two-point/", 2, self.limits)?;
201        let total_amplitude = checked_total_amplitude(amplitude_per_source, 2)?;
202        let phases = [Radians::new(0.0)?, relative_phase];
203        let positions = [first, second];
204        let sources = sources_from(plan, |index, id| Emitter::Point {
205            id,
206            position: positions[index],
207            amplitude_at_reference: amplitude_per_source,
208            phase: phases[index],
209        })?;
210        self.finish(
211            ScenarioKind::TwoPoint,
212            sources,
213            total_amplitude,
214            amplitude_per_source.get(),
215            ElementSpacingWavelengths { u: None, v: None },
216            None,
217        )
218    }
219
220    /// Builds equal plane waves directed into a slab around `centre`.
221    pub fn counter_propagating_planes(
222        self,
223        centre: Point3M,
224        axis: UnitVector3,
225        source_separation: PositiveMetres,
226        amplitude_per_source: FieldAmplitude,
227        relative_phase: Radians,
228    ) -> Result<NamedScenario, ScenarioError> {
229        let plan = IdPlan::admit("scenario/counter-plane/", 2, self.limits)?;
230        let total_amplitude = checked_total_amplitude(amplitude_per_source, 2)?;
231        let mut sources = allocate_sources(plan.count)?;
232        let half_separation = source_separation.get() / 2.0;
233        let first = offset_point(centre, axis, -half_separation)?;
234        let second = offset_point(centre, axis, half_separation)?;
235        let [x, y, z] = axis.components();
236        let reverse = UnitVector3::new(-x, -y, -z)?;
237        sources.push(Emitter::ForwardPlane {
238            id: plan.id(0),
239            through: first,
240            direction: axis,
241            amplitude: amplitude_per_source,
242            phase: Radians::new(0.0)?,
243        });
244        sources.push(Emitter::ForwardPlane {
245            id: plan.id(1),
246            through: second,
247            direction: reverse,
248            amplitude: amplitude_per_source,
249            phase: relative_phase,
250        });
251        self.finish(
252            ScenarioKind::CounterPropagatingPlanes,
253            sources,
254            total_amplitude,
255            amplitude_per_source.get(),
256            ElementSpacingWavelengths { u: None, v: None },
257            None,
258        )
259    }
260
261    /// Builds a centred line array of ideal point elements.
262    #[allow(clippy::too_many_arguments)]
263    pub fn phased_array(
264        self,
265        centre: Point3M,
266        element_axis: UnitVector3,
267        elements: usize,
268        spacing: PositiveMetres,
269        total_amplitude: FieldAmplitude,
270        first_phase: Radians,
271        progressive_phase: Radians,
272        policy: AperturePolicy,
273    ) -> Result<NamedScenario, ScenarioError> {
274        require_dimension("elements", elements)?;
275        let plan = IdPlan::admit("scenario/phased-array/", elements, self.limits)?;
276        let reported_spacing = self.spacing((elements > 1).then_some(spacing), None, policy)?;
277        let amplitude = normalized_amplitude(total_amplitude, elements)?;
278        let sources = sources_from_fallible(plan, |index, id| {
279            let offset = centred_offset(index, elements, spacing.get())?;
280            Ok(Emitter::Point {
281                id,
282                position: offset_point(centre, element_axis, offset)?,
283                amplitude_at_reference: amplitude,
284                phase: Radians::new(first_phase.get() + index as f64 * progressive_phase.get())?,
285            })
286        })?;
287        self.finish(
288            ScenarioKind::PhasedArray,
289            sources,
290            total_amplitude.get(),
291            amplitude.get(),
292            reported_spacing,
293            Some(policy),
294        )
295    }
296
297    /// Builds a centred rectangular aperture of equal ideal point elements.
298    #[allow(clippy::too_many_arguments)]
299    pub fn discrete_aperture(
300        self,
301        centre: Point3M,
302        u_axis: UnitVector3,
303        v_axis: UnitVector3,
304        rows: usize,
305        columns: usize,
306        spacing_u: PositiveMetres,
307        spacing_v: PositiveMetres,
308        total_amplitude: FieldAmplitude,
309        phase: Radians,
310        policy: AperturePolicy,
311    ) -> Result<NamedScenario, ScenarioError> {
312        require_dimension("rows", rows)?;
313        require_dimension("columns", columns)?;
314        let count = rows
315            .checked_mul(columns)
316            .ok_or(ScenarioError::SourceCountOverflow { rows, columns })?;
317        let plan = IdPlan::admit("scenario/aperture/", count, self.limits)?;
318        require_orthogonal_axes(u_axis, v_axis)?;
319        let spacing = self.spacing(
320            (columns > 1).then_some(spacing_u),
321            (rows > 1).then_some(spacing_v),
322            policy,
323        )?;
324        let amplitude = normalized_amplitude(total_amplitude, count)?;
325        let sources = sources_from_fallible(plan, |index, id| {
326            let row = index / columns;
327            let column = index % columns;
328            let offset_u = centred_offset(column, columns, spacing_u.get())?;
329            let offset_v = centred_offset(row, rows, spacing_v.get())?;
330            let along_u = offset_point(centre, u_axis, offset_u)?;
331            Ok(Emitter::Point {
332                id,
333                position: offset_point(along_u, v_axis, offset_v)?,
334                amplitude_at_reference: amplitude,
335                phase,
336            })
337        })?;
338        self.finish(
339            ScenarioKind::DiscreteAperture,
340            sources,
341            total_amplitude.get(),
342            amplitude.get(),
343            spacing,
344            Some(policy),
345        )
346    }
347
348    fn spacing(
349        self,
350        u: Option<PositiveMetres>,
351        v: Option<PositiveMetres>,
352        policy: AperturePolicy,
353    ) -> Result<ElementSpacingWavelengths, ScenarioError> {
354        let wavelength = self.medium.speed().get() / self.frequency.get();
355        let spacing = ElementSpacingWavelengths {
356            u: relative_spacing(u, wavelength)?,
357            v: relative_spacing(v, wavelength)?,
358        };
359        for (axis, value) in [("u", spacing.u), ("v", spacing.v)] {
360            if let Some(value) = value
361                && policy == AperturePolicy::Strict
362                && value > STRICT_MAX_SPACING_WAVELENGTHS
363            {
364                return Err(ScenarioError::SparseAperture {
365                    axis,
366                    spacing_wavelengths: value,
367                    maximum_wavelengths: STRICT_MAX_SPACING_WAVELENGTHS,
368                });
369            }
370        }
371        Ok(spacing)
372    }
373
374    fn finish(
375        self,
376        kind: ScenarioKind,
377        sources: Vec<Emitter>,
378        total_source_amplitude: f64,
379        amplitude_per_source: f64,
380        element_spacing_wavelengths: ElementSpacingWavelengths,
381        aperture_policy: Option<AperturePolicy>,
382    ) -> Result<NamedScenario, ScenarioError> {
383        let source_count = sources.len();
384        let problem = InterferenceProblem::new(
385            self.frequency,
386            self.medium,
387            SourceSet::new(sources)?,
388            self.singularity_radius,
389        );
390        Ok(NamedScenario {
391            problem,
392            certificate: ScenarioCertificate {
393                kind,
394                source_count,
395                total_source_amplitude,
396                amplitude_per_source,
397                element_spacing_wavelengths,
398                aperture_policy,
399            },
400        })
401    }
402}
403
404fn normalized_amplitude(
405    total: FieldAmplitude,
406    count: usize,
407) -> Result<FieldAmplitude, ScenarioError> {
408    FieldAmplitude::new(total.get() / count as f64).map_err(ScenarioError::from)
409}
410
411fn checked_total_amplitude(per_source: FieldAmplitude, count: usize) -> Result<f64, ScenarioError> {
412    FieldAmplitude::new(per_source.get() * count as f64)
413        .map(FieldAmplitude::get)
414        .map_err(ScenarioError::from)
415}
416
417fn centred_offset(index: usize, count: usize, spacing_metres: f64) -> Result<f64, ScenarioError> {
418    let offset = (index as f64 - (count - 1) as f64 / 2.0) * spacing_metres;
419    if offset.is_finite() {
420        Ok(offset)
421    } else {
422        Err(InterferenceError::InvalidQuantity {
423            name: "scenario-offset-m",
424            value: offset,
425        }
426        .into())
427    }
428}
429
430fn offset_point(
431    point: Point3M,
432    axis: UnitVector3,
433    distance_metres: f64,
434) -> Result<Point3M, ScenarioError> {
435    let [x, y, z] = point.coordinates_metres();
436    let [axis_x, axis_y, axis_z] = axis.components();
437    Point3M::from_metres(
438        x + distance_metres * axis_x,
439        y + distance_metres * axis_y,
440        z + distance_metres * axis_z,
441    )
442    .map_err(ScenarioError::from)
443}
444
445fn require_orthogonal_axes(u_axis: UnitVector3, v_axis: UnitVector3) -> Result<(), ScenarioError> {
446    let [ux, uy, uz] = u_axis.components();
447    let [vx, vy, vz] = v_axis.components();
448    let dot_product = ux * vx + uy * vy + uz * vz;
449    if dot_product.abs() > sim_lib_interference_core::SAMPLING_AXIS_ORTHOGONALITY_TOLERANCE {
450        Err(InterferenceError::NonOrthogonalSamplingAxes {
451            dot_product,
452            max_abs_dot_product: sim_lib_interference_core::SAMPLING_AXIS_ORTHOGONALITY_TOLERANCE,
453        }
454        .into())
455    } else {
456        Ok(())
457    }
458}
459
460fn relative_spacing(
461    spacing: Option<PositiveMetres>,
462    wavelength_metres: f64,
463) -> Result<Option<f64>, ScenarioError> {
464    let Some(spacing) = spacing else {
465        return Ok(None);
466    };
467    let relative = spacing.get() / wavelength_metres;
468    if relative.is_finite() && relative > 0.0 {
469        Ok(Some(relative))
470    } else {
471        Err(InterferenceError::InvalidQuantity {
472            name: "scenario-spacing-wavelengths",
473            value: relative,
474        }
475        .into())
476    }
477}
478
479#[cfg(test)]
480#[path = "scenario_tests.rs"]
481mod tests;