use dualis_units::Area;
use crate::conserved::Violation;
#[derive(Clone, Debug, PartialEq)]
pub struct Interface {
name: String,
areas: Vec<f64>,
}
impl Interface {
pub fn uniform(name: impl Into<String>, faces: usize, face_area: Area) -> Interface {
Interface {
name: name.into(),
areas: vec![face_area.to_si().max(0.0); faces.max(1)],
}
}
pub fn from_areas(name: impl Into<String>, areas: Vec<Area>) -> Interface {
let areas: Vec<f64> = areas.into_iter().map(|a| a.to_si().max(0.0)).collect();
Interface {
name: name.into(),
areas: if areas.is_empty() { vec![0.0] } else { areas },
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn faces(&self) -> usize {
self.areas.len()
}
pub fn area_of(&self, face: usize) -> Area {
Area::from_si(self.areas.get(face).copied().unwrap_or(0.0))
}
pub fn total_area(&self) -> Area {
Area::from_si(self.areas.iter().sum())
}
fn cumulative(&self) -> Vec<f64> {
let mut running = 0.0;
let mut out = Vec::with_capacity(self.areas.len() + 1);
out.push(0.0);
for a in &self.areas {
running += a;
out.push(running);
}
out
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Flux {
per_face: Vec<f64>,
}
impl Flux {
pub fn zeros(faces: usize) -> Flux {
Flux {
per_face: vec![0.0; faces.max(1)],
}
}
pub fn from_faces(per_face: Vec<f64>) -> Flux {
Flux {
per_face: if per_face.is_empty() {
vec![0.0]
} else {
per_face
},
}
}
pub fn spread_over(total: f64, interface: &Interface) -> Flux {
let area = interface.total_area().to_si();
if area <= 0.0 {
let faces = interface.faces();
return Flux::from_faces(vec![total / faces as f64; faces]);
}
Flux::from_faces(interface.areas.iter().map(|a| total * a / area).collect())
}
pub fn profiled<F>(total: f64, interface: &Interface, mut profile: F) -> Flux
where
F: FnMut(f64) -> f64,
{
let span = interface.total_area().to_si();
let mut weights = Vec::with_capacity(interface.faces());
let mut running = 0.0;
for area in &interface.areas {
let centre = if span > 0.0 {
(running + 0.5 * area) / span
} else {
0.5
};
running += area;
weights.push(profile(centre));
}
let sum: f64 = weights.iter().sum();
if !sum.is_finite() || sum == 0.0 {
return Flux::spread_over(total, interface);
}
Flux::from_faces(weights.into_iter().map(|w| total * w / sum).collect())
}
pub fn faces(&self) -> usize {
self.per_face.len()
}
pub fn at(&self, face: usize) -> f64 {
self.per_face.get(face).copied().unwrap_or(0.0)
}
pub fn per_face(&self) -> &[f64] {
&self.per_face
}
pub fn total(&self) -> f64 {
self.per_face.iter().sum()
}
pub fn largest(&self) -> f64 {
self.per_face.iter().fold(0.0f64, |a, v| a.max(v.abs()))
}
pub fn add(&mut self, other: &Flux) -> Result<(), Violation> {
if other.faces() != self.faces() {
return Err(mismatch("flux addition", self.faces(), other.faces()));
}
for (mine, theirs) in self.per_face.iter_mut().zip(other.per_face.iter()) {
*mine += theirs;
}
Ok(())
}
pub fn scaled(&self, by: f64) -> Flux {
Flux::from_faces(self.per_face.iter().map(|v| v * by).collect())
}
pub fn resample(&self, from: &Interface, to: &Interface) -> Result<Flux, Violation> {
if self.faces() != from.faces() {
return Err(mismatch("resampling source", from.faces(), self.faces()));
}
let (source, target) = (from.cumulative(), to.cumulative());
let source_span = source[source.len() - 1];
let target_span = target[target.len() - 1];
if source_span <= 0.0 || target_span <= 0.0 {
return Ok(Flux::spread_over(self.total(), to));
}
let mut out = vec![0.0; to.faces()];
for i in 0..from.faces() {
let (a0, a1) = (source[i] / source_span, source[i + 1] / source_span);
let width = a1 - a0;
if width <= 0.0 {
continue;
}
for (j, slot) in out.iter_mut().enumerate() {
let (b0, b1) = (target[j] / target_span, target[j + 1] / target_span);
let overlap = a1.min(b1) - a0.max(b0);
if overlap > 0.0 {
*slot += self.per_face[i] * (overlap / width);
}
}
}
Ok(Flux::from_faces(out))
}
}
pub(crate) fn mismatch(site: &str, expected: usize, found: usize) -> Violation {
Violation {
quantity: format!("face count (expected {expected}, found {found})"),
site: site.to_string(),
before: expected as f64,
after: found as f64,
scale: expected.max(found) as f64,
tolerance: 0.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cm2(v: f64) -> Area {
Area::from_si(v * 1e-4)
}
#[test]
fn an_interface_carries_its_faces_and_their_areas() {
let uniform = Interface::uniform("plate", 8, cm2(0.5));
assert_eq!(uniform.faces(), 8);
assert!((uniform.total_area().to_si() - 8.0 * 0.5e-4).abs() < 1e-18);
assert_eq!(uniform.area_of(0), uniform.area_of(7));
assert_eq!(uniform.name(), "plate");
let rings = Interface::from_areas("cap", vec![cm2(0.1), cm2(0.3), cm2(0.5)]);
assert_eq!(rings.faces(), 3);
assert!(rings.area_of(2) > rings.area_of(0));
assert!((rings.total_area().to_si() - 0.9e-4).abs() < 1e-18);
assert_eq!(Interface::uniform("none", 0, cm2(1.0)).faces(), 1);
assert_eq!(Interface::from_areas("none", vec![]).faces(), 1);
assert!(Interface::from_areas("odd", vec![Area::from_si(-1.0)]).area_of(0) >= Area::ZERO);
}
#[test]
fn a_lumped_total_spreads_by_area() {
let rings = Interface::from_areas("cap", vec![cm2(1.0), cm2(2.0), cm2(1.0)]);
let flux = Flux::spread_over(8.0, &rings);
assert_eq!(flux.faces(), 3);
assert!((flux.total() - 8.0).abs() < 1e-12);
assert!((flux.at(1) / flux.at(0) - 2.0).abs() < 1e-12);
assert!((flux.at(0) - flux.at(2)).abs() < 1e-15);
let empty = Interface::uniform("flat", 4, Area::ZERO);
let flux = Flux::spread_over(8.0, &empty);
assert!((flux.total() - 8.0).abs() < 1e-12);
assert!((flux.at(0) - 2.0).abs() < 1e-12);
}
#[test]
fn a_mismatched_face_count_is_refused_by_name() {
let mut coarse = Flux::zeros(4);
let fine = Flux::zeros(16);
let err = coarse
.add(&fine)
.expect_err("4 and 16 must not silently combine");
assert!(err.quantity.contains("expected 4"), "{err}");
assert!(err.quantity.contains("found 16"), "{err}");
let from = Interface::uniform("a", 4, cm2(1.0));
let err = Flux::zeros(7)
.resample(&from, &Interface::uniform("b", 4, cm2(1.0)))
.expect_err("a flux of 7 is not a flux over 4 faces");
assert!(err.site.contains("resampling"), "{err}");
}
#[test]
fn resampling_conserves_the_total() {
let cases = [
(4usize, 16usize),
(16, 4),
(3, 7),
(7, 3),
(5, 5),
(1, 9),
(9, 1),
];
for (n, m) in cases {
let from = Interface::uniform("from", n, cm2(1.0));
let to = Interface::uniform("to", m, cm2(1.0));
let flux = Flux::from_faces((0..n).map(|i| 1.0 + i as f64).collect());
let before = flux.total();
let moved = flux.resample(&from, &to).unwrap();
assert_eq!(moved.faces(), m, "{n} -> {m}: wrong face count");
assert!(
(moved.total() / before - 1.0).abs() < 1e-12,
"{n} -> {m}: {} became {}",
before,
moved.total()
);
}
}
#[test]
fn resampling_onto_the_same_interface_is_the_identity() {
let interface = Interface::from_areas("cap", vec![cm2(1.0), cm2(3.0), cm2(2.0)]);
let flux = Flux::from_faces(vec![2.0, 7.0, -1.5]);
let same = flux.resample(&interface, &interface).unwrap();
for face in 0..interface.faces() {
assert!(
(same.at(face) - flux.at(face)).abs() < 1e-12,
"face {face}: {} against {}",
same.at(face),
flux.at(face)
);
}
}
#[test]
fn a_round_trip_through_a_finer_grid_returns_the_distribution() {
let coarse = Interface::uniform("coarse", 4, cm2(1.0));
let fine = Interface::uniform("fine", 12, cm2(1.0) / 3.0);
let flux = Flux::from_faces(vec![1.0, 5.0, 2.0, 9.0]);
let refined = flux.resample(&coarse, &fine).unwrap();
assert!((refined.at(0) - 1.0 / 3.0).abs() < 1e-12);
assert!(
(refined.at(1) - refined.at(2)).abs() < 1e-15,
"flat inside a coarse face"
);
assert!(
(refined.at(3) / refined.at(2) - 5.0).abs() < 1e-12,
"the ramp survived"
);
let back = refined.resample(&fine, &coarse).unwrap();
for face in 0..4 {
assert!(
(back.at(face) - flux.at(face)).abs() < 1e-12,
"face {face}: {} against {}",
back.at(face),
flux.at(face)
);
}
}
#[test]
fn coarsening_loses_what_it_averages() {
let fine = Interface::uniform("fine", 8, cm2(1.0));
let coarse = Interface::uniform("coarse", 2, cm2(4.0));
let spike = Flux::from_faces(vec![0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
let coarsened = spike.resample(&fine, &coarse).unwrap();
assert!(
(coarsened.total() - 10.0).abs() < 1e-12,
"the total survives"
);
assert!(
(coarsened.at(0) - 10.0).abs() < 1e-12,
"and it stayed on its side"
);
let restored = coarsened.resample(&coarse, &fine).unwrap();
assert!((restored.total() - 10.0).abs() < 1e-12);
assert!(
(restored.at(2) - 2.5).abs() < 1e-12,
"a spike came back as an average, got {}",
restored.at(2)
);
assert!(restored.at(0) > 0.0, "and it leaked onto its neighbours");
}
#[test]
fn fluxes_add_and_report_their_scale() {
let mut a = Flux::from_faces(vec![1.0, -2.0, 0.5]);
let b = Flux::from_faces(vec![0.5, 2.0, 0.5]);
a.add(&b).unwrap();
assert_eq!(a.per_face(), &[1.5, 0.0, 1.0]);
assert!((a.total() - 2.5).abs() < 1e-15);
assert!((a.largest() - 1.5).abs() < 1e-15);
let cancelling = Flux::from_faces(vec![1e6, -1e6]);
assert!(cancelling.total().abs() < 1e-9);
assert!((cancelling.largest() - 1e6).abs() < 1e-9);
assert_eq!(a.scaled(2.0).per_face(), &[3.0, 0.0, 2.0]);
}
#[test]
fn a_profile_conserves_its_total_and_lands_where_the_beam_is() {
let faces = 41;
let length = 20e-3;
let plate = Interface::uniform("plate", faces, cm2(1.0));
let waist = 3e-3;
let profile = |u: f64| (-2.0 * (((u - 0.5) * length / waist).powi(2))).exp();
let flux = Flux::profiled(0.096, &plate, profile);
assert!((flux.total() - 0.096).abs() < 1e-15, "the total is exact");
assert_eq!(flux.faces(), faces);
let peak = flux.largest();
assert!(
(flux.at(20) - peak).abs() < 1e-18,
"the peak is at the centre face"
);
for offset in 1..=20 {
let (left, right) = (flux.at(20 - offset), flux.at(20 + offset));
assert!(
(left / right - 1.0).abs() < 1e-12,
"asymmetric at offset {offset}: {left} against {right}"
);
}
let u = |i: usize| (i as f64 + 0.5) / faces as f64;
assert!(
(flux.at(14) / flux.at(20) - profile(u(14)) / profile(u(20))).abs() < 1e-12,
"the normalisation must not bend the profile"
);
assert!(flux.at(0) / peak < 1e-9, "edge ratio {}", flux.at(0) / peak);
}
#[test]
fn a_profile_with_no_scale_falls_back_to_area() {
let rings = Interface::from_areas("cap", vec![cm2(1.0), cm2(3.0)]);
for degenerate in [0.0, f64::NAN, f64::INFINITY] {
let flux = Flux::profiled(4.0, &rings, |_| degenerate);
let spread = Flux::spread_over(4.0, &rings);
assert_eq!(
flux.per_face(),
spread.per_face(),
"for weight {degenerate}"
);
}
let flux = Flux::profiled(4.0, &rings, |u| if u < 0.5 { 1.0 } else { -1.0 / 3.0 });
assert!((flux.total() - 4.0).abs() < 1e-12);
}
#[test]
fn an_uneven_boundary_is_sampled_at_its_faces() {
let uneven = Interface::from_areas("cap", vec![cm2(1.0), cm2(8.0), cm2(1.0)]);
let mut seen = Vec::new();
let _ = Flux::profiled(1.0, &uneven, |u| {
seen.push(u);
1.0
});
assert!((seen[0] - 0.05).abs() < 1e-12, "{:?}", seen);
assert!((seen[1] - 0.50).abs() < 1e-12, "{:?}", seen);
assert!((seen[2] - 0.95).abs() < 1e-12, "{:?}", seen);
}
#[test]
fn reading_past_the_end_gives_nothing() {
let flux = Flux::from_faces(vec![1.0, 2.0]);
assert_eq!(flux.at(0), 1.0);
assert_eq!(flux.at(5), 0.0);
let interface = Interface::uniform("i", 2, cm2(1.0));
assert_eq!(interface.area_of(9), Area::ZERO);
}
}