spin.rs

15.5 kB · rust · 433 lines

1use std::f64::consts::{PI, SQRT_2};23fn centre(size: usize) -> f64 {4    size as f64 / 2.05}67/// The radius of the corner circle of a square raster of the side, the last radius a profile reads.8pub fn reach(size: usize) -> f64 {9    size as f64 / SQRT_210}1112/// The arcs of the circle of the radius about the raster's centre: each as its start angle, end angle and the value of the one cell it lies in, zero outside.13pub fn arcs(data: &[f32], size: usize, radius: f64) -> Vec<(f64, f64, f32)> {14    assert_eq!(data.len(), size * size, "data must be size*size");15    if size == 0 {16        return Vec::new();17    }18    let c = centre(size);19    let r = radius.max(1e-9);20    let mut cuts = vec![0.0, 2.0 * PI];21    let low = (c - r).floor().max(0.0) as usize;22    let high = ((c + r).ceil() as usize).min(size);23    for line in low..=high {24        let u = (line as f64 - c) / r;25        if u.abs() < 1.0 {26            let a = u.acos();27            cuts.push(a);28            cuts.push(2.0 * PI - a);29            let b = u.asin();30            cuts.push(b.rem_euclid(2.0 * PI));31            cuts.push((PI - b).rem_euclid(2.0 * PI));32        }33    }34    cuts.sort_by(|a, b| a.partial_cmp(b).unwrap());35    let mut out = Vec::with_capacity(cuts.len());36    for pair in cuts.windows(2) {37        let (a, b) = (pair[0], pair[1]);38        if b <= a {39            continue;40        }41        let mid = (a + b) / 2.0;42        let x = c + r * mid.cos();43        let y = c + r * mid.sin();44        let mut value = 0.0;45        if x >= 0.0 && y >= 0.0 {46            let (col, row) = (x as usize, y as usize);47            if col < size && row < size {48                value = data[row * size + col];49            }50        }51        out.push((a, b, value));52    }53    out54}5556/// The exact mean of a square raster over the circle of the radius about its centre, each cell read as a constant and the outside as zero.57///58/// ```59/// let solid = vec![1.0; 16];60/// assert!((mrlynum::spin::ring(&solid, 4, 1.0) - 1.0).abs() < 1e-12);61/// assert!(mrlynum::spin::ring(&solid, 4, 3.0).abs() < 1e-12);62/// ```63pub fn ring(data: &[f32], size: usize, radius: f64) -> f64 {64    arcs(data, size, radius)65        .iter()66        .map(|&(a, b, v)| v as f64 * (b - a))67        .sum::<f64>()68        / (2.0 * PI)69}7071/// The circular-harmonic power of a raster: for every order `m` up to the last, the energy `sum |c_m(r)|^2 2 pi r dr` of its `m`-th harmonic over rings radii, each ring's coefficient exact from its arcs.72pub fn harmonics(data: &[f32], size: usize, rings: usize, orders: usize) -> Vec<f64> {73    let rings = rings.max(2);74    let far = reach(size);75    let step = far / (rings - 1) as f64;76    let mut power = vec![0.0; orders + 1];77    for k in 0..rings {78        let r = k as f64 * step;79        let pieces = arcs(data, size, r);80        let mut re = vec![0.0; orders + 1];81        let mut im = vec![0.0; orders + 1];82        for &(a, b, v) in &pieces {83            if v == 0.0 {84                continue;85            }86            let v = v as f64;87            re[0] += v * (b - a);88            for m in 1..=orders {89                let f = m as f64;90                re[m] += v * ((f * b).sin() - (f * a).sin()) / f;91                im[m] += v * ((f * b).cos() - (f * a).cos()) / f;92            }93        }94        for m in 0..=orders {95            let (x, y) = (re[m] / (2.0 * PI), im[m] / (2.0 * PI));96            power[m] += (x * x + y * y) * 2.0 * PI * r * step;97        }98    }99    power100}101102fn gcd(a: usize, b: usize) -> usize {103    if b == 0 {104        a105    } else {106        gcd(b, a % b)107    }108}109110/// The rotation order a harmonic power spectrum reveals: the gcd of the orders carrying more than a ten-thousandth of the power, the share pixel aliasing stays under, or zero when none does.111pub fn turns(power: &[f64]) -> usize {112    let total: f64 = power.iter().sum();113    power114        .iter()115        .enumerate()116        .skip(1)117        .filter(|&(_, &p)| p > 1e-4 * total)118        .fold(0, |g, (m, _)| gcd(g, m))119}120121/// The petals a full radial stack of the copies shows on a design of the rotation order: their least common multiple.122pub fn petals(copies: usize, order: usize) -> usize {123    if copies == 0 || order == 0 {124        return 0;125    }126    copies / gcd(copies, order) * order127}128129/// The way radial copies merge: their mean, their sum, their union, their meet, their parity or what the first keeps that no other has.130#[derive(Clone, Copy, Debug, PartialEq, Eq)]131pub enum Blend {132    /// The mean of the copies.133    Mean,134    /// The sum of the copies.135    Sum,136    /// The largest copy.137    Union,138    /// The smallest copy.139    Meet,140    /// The sum folded to its parity.141    Parity,142    /// The first copy less the largest of the rest, floored at zero.143    Difference,144}145146impl Blend {147    /// Reads a blend by name: mean, sum, union, meet, parity or difference.148    pub fn named(name: &str) -> Option<Blend> {149        match name {150            "mean" => Some(Blend::Mean),151            "sum" => Some(Blend::Sum),152            "union" => Some(Blend::Union),153            "meet" => Some(Blend::Meet),154            "parity" => Some(Blend::Parity),155            "difference" => Some(Blend::Difference),156            _ => None,157        }158    }159160    /// Merges one site's copies into the blended value.161    pub fn fold(self, values: &[f32]) -> f32 {162        let sum: f32 = values.iter().sum();163        let max = values.iter().cloned().fold(f32::NEG_INFINITY, f32::max);164        match self {165            Blend::Mean => sum / values.len() as f32,166            Blend::Sum => sum,167            Blend::Union => max,168            Blend::Meet => values.iter().cloned().fold(f32::INFINITY, f32::min),169            Blend::Parity => 1.0 - (sum.rem_euclid(2.0) - 1.0).abs(),170            Blend::Difference => {171                let rest = values[1..].iter().cloned().fold(0.0, f32::max);172                (values[0] - rest).max(0.0)173            }174        }175    }176}177178/// Stacks a raster radially: copies turned by multiples of the step, in turns, about the centre and merged by the blend, on an output raster of the side whose inscribed circle is the source's corner circle, every pixel the mean of samples by samples points.179pub fn radial(180    data: &[f32],181    size: usize,182    out: usize,183    copies: usize,184    step: f64,185    blend: Blend,186    samples: usize,187) -> Vec<f32> {188    assert_eq!(data.len(), size * size, "data must be size*size");189    let copies = copies.max(1);190    let samples = samples.max(1);191    let c = centre(size);192    let scale = 2.0 * reach(size) / out as f64;193    let turns: Vec<(f64, f64)> = (0..copies)194        .map(|k| {195            let angle = 2.0 * PI * step * k as f64;196            (angle.cos(), angle.sin())197        })198        .collect();199    let mut values = vec![0.0f32; copies];200    let mut field = Vec::with_capacity(out * out);201    for i in 0..out {202        for j in 0..out {203            let mut total = 0.0;204            for a in 0..samples {205                for b in 0..samples {206                    let px =207                        (j as f64 + (b as f64 + 0.5) / samples as f64 - out as f64 / 2.0) * scale;208                    let py =209                        (i as f64 + (a as f64 + 0.5) / samples as f64 - out as f64 / 2.0) * scale;210                    for (k, &(cos, sin)) in turns.iter().enumerate() {211                        let x = c + px * cos + py * sin;212                        let y = c - px * sin + py * cos;213                        values[k] = if x >= 0.0 && y >= 0.0 && x < size as f64 && y < size as f64 {214                            data[y as usize * size + x as usize]215                        } else {216                            0.0217                        };218                    }219                    total += blend.fold(&values);220                }221            }222            field.push(total / (samples * samples) as f32);223        }224    }225    field226}227228/// The ring profile: the circle means at steps radii spaced evenly from the centre to the corner circle.229pub fn profile(data: &[f32], size: usize, steps: usize) -> Vec<f32> {230    let steps = steps.max(2);231    let far = reach(size);232    (0..steps)233        .map(|k| ring(data, size, far * k as f64 / (steps - 1) as f64) as f32)234        .collect()235}236237/// The wheel: a profile spread over a square raster of the side, the corner circle it ends on drawn as the inscribed circle, every pixel reading the profile at its own radius.238pub fn wheel(profile: &[f32], size: usize) -> Vec<f32> {239    let last = profile.len().saturating_sub(1);240    if last == 0 {241        return vec![profile.first().copied().unwrap_or(0.0); size * size];242    }243    let c = centre(size);244    let scale = 2.0 * last as f64 / size as f64;245    let mut out = Vec::with_capacity(size * size);246    for row in 0..size {247        for col in 0..size {248            let (dx, dy) = (col as f64 + 0.5 - c, row as f64 + 0.5 - c);249            let t = (dx * dx + dy * dy).sqrt() * scale;250            let i = (t.floor() as usize).min(last);251            let f = (t - i as f64) as f32;252            let value = if i == last {253                profile[last]254            } else {255                profile[i] * (1.0 - f) + profile[i + 1] * f256            };257            out.push(value);258        }259    }260    out261}262263/// The mass a profile carries, the trapezoid integral of `2 pi r F(r)` in cells of the raster it came from.264pub fn mass(profile: &[f32], size: usize) -> f64 {265    let last = profile.len().saturating_sub(1);266    if last == 0 {267        return 0.0;268    }269    let step = reach(size) / last as f64;270    let weight = |k: usize| 2.0 * PI * (k as f64 * step) * profile[k] as f64;271    let inner: f64 = (1..last).map(weight).sum();272    step * (inner + (weight(0) + weight(last)) / 2.0)273}274275/// The mass a profile carries inside the radius, the trapezoid integral of `2 pi r F(r)` from the centre out, in cells of the raster it came from.276///277/// ```278/// let solid = vec![1.0; 64];279/// let rings = mrlynum::spin::profile(&solid, 8, 4000);280/// let inner = mrlynum::spin::mass_within(&rings, 8, 3.0);281/// assert!((inner / (9.0 * std::f64::consts::PI) - 1.0).abs() < 1e-3);282/// ```283pub fn mass_within(profile: &[f32], size: usize, radius: f64) -> f64 {284    let last = profile.len().saturating_sub(1);285    if last == 0 || radius <= 0.0 {286        return 0.0;287    }288    let step = reach(size) / last as f64;289    let weight = |k: usize| 2.0 * PI * (k as f64 * step) * profile[k] as f64;290    let full = (radius / step).floor().min(last as f64) as usize;291    let mut total = 0.0;292    for k in 1..=full {293        total += (weight(k - 1) + weight(k)) / 2.0 * step;294    }295    if full < last {296        let rest = radius - full as f64 * step;297        let share = rest / step;298        let edge = weight(full) + (weight(full + 1) - weight(full)) * share;299        total += (weight(full) + edge) / 2.0 * rest;300    }301    total302}303304#[cfg(test)]305mod tests {306    use super::*;307    use mrlycore::atoms;308309    fn floats(grid: &mrlycore::tensor::Tensor) -> Vec<f32> {310        grid.bytes().iter().map(|&b| b as f32).collect()311    }312313    #[test]314    fn a_solid_square_leaves_through_four_arcs() {315        let side = 8usize;316        let solid = floats(&atoms::ones_2d(side));317        for r in [0.5, 2.0, 3.99] {318            assert!((ring(&solid, side, r) - 1.0).abs() < 1e-12);319        }320        for r in [4.5, 5.0, 5.5] {321            let expect = 1.0 - 4.0 / PI * (side as f64 / 2.0 / r).acos();322            assert!((ring(&solid, side, r) - expect).abs() < 1e-12);323        }324        assert!(ring(&solid, side, reach(side) + 0.01).abs() < 1e-12);325    }326327    #[test]328    fn the_carpet_opens_on_a_black_disc() {329        let carpet = atoms::carpet_nd(3, 2).kron(&atoms::carpet_nd(3, 2));330        let data = floats(&carpet);331        assert!(ring(&data, 9, 1.4).abs() < 1e-12);332        assert!(ring(&data, 9, 2.0) > 0.0);333        assert!((ring(&data, 9, 1.0) - 0.0).abs() < 1e-12);334    }335336    #[test]337    fn the_mass_of_the_profile_is_the_fill() {338        let carpet = atoms::carpet_nd(3, 2)339            .kron(&atoms::carpet_nd(3, 2))340            .kron(&atoms::carpet_nd(3, 2));341        let data = floats(&carpet);342        let fills = data.iter().sum::<f32>() as f64;343        assert_eq!(fills, 512.0);344        let rings = profile(&data, 27, 4000);345        assert!((mass(&rings, 27) - fills).abs() / fills < 0.002);346    }347348    #[test]349    fn the_spin_mass_scales_by_the_fill_about_a_filled_corner() {350        let tile = atoms::carpet_nd(3, 2);351        let carpet = tile.kron(&tile).kron(&tile).kron(&tile);352        let side = 81usize;353        let wide = 2 * side;354        let mut data = vec![0.0f32; wide * wide];355        let bytes = carpet.bytes();356        for row in 0..side {357            for col in 0..side {358                data[(row + side) * wide + col + side] = bytes[row * side + col] as f32;359            }360        }361        let rings = profile(&data, wide, 4000);362        assert!(mass_within(&rings, wide, 1e-6).abs() < 1e-9);363        for radius in [12.0f64, 18.0, 27.0] {364            let near = mass_within(&rings, wide, radius);365            let far = mass_within(&rings, wide, 3.0 * radius);366            assert!(near > 0.0);367            assert!((far / (8.0 * near) - 1.0).abs() < 0.08, "radius {radius}");368        }369        let whole = mass_within(&rings, wide, reach(wide));370        assert!((whole - mass(&rings, wide)).abs() / whole < 1e-9);371    }372373    #[test]374    fn the_wheel_reads_the_profile_by_radius() {375        let rings = profile(&floats(&atoms::ones_2d(8)), 8, 64);376        let spun = wheel(&rings, 16);377        assert_eq!(spun.len(), 256);378        assert!((spun[8 * 16 + 8] - 1.0).abs() < 1e-6);379        assert!((spun[8 * 16 + 12] - 1.0).abs() < 1e-6);380        assert!(spun[8 * 16 + 15] < 0.2);381        assert_eq!(spun[0], 0.0);382        assert_eq!(profile(&[], 0, 0).len(), 2);383    }384385    fn at(field: &[f32], out: usize, size: usize, px: f64, py: f64) -> f32 {386        let scale = 2.0 * reach(size) / out as f64;387        let col = (px / scale + out as f64 / 2.0) as usize;388        let row = (py / scale + out as f64 / 2.0) as usize;389        field[row * out + col]390    }391392    #[test]393    fn two_squares_at_an_eighth_turn_make_a_star() {394        let solid = floats(&atoms::ones_2d(8));395        let stack = |blend| radial(&solid, 8, 64, 2, 0.125, blend, 1);396        let probe = |blend, px, py| at(&stack(blend), 64, 8, px, py);397        assert_eq!(probe(Blend::Union, 0.0, 0.0), 1.0);398        assert_eq!(probe(Blend::Union, 0.0, -5.0), 1.0);399        assert_eq!(probe(Blend::Meet, 0.0, -5.0), 0.0);400        assert_eq!(probe(Blend::Mean, 0.0, -5.0), 0.5);401        assert_eq!(probe(Blend::Sum, 0.0, 0.0), 2.0);402        assert_eq!(probe(Blend::Parity, 0.0, -5.0), 1.0);403        assert_eq!(probe(Blend::Parity, 0.0, 0.0), 0.0);404        assert_eq!(probe(Blend::Difference, 0.0, -5.0), 0.0);405        assert_eq!(probe(Blend::Difference, 3.8, -3.8), 1.0);406        assert_eq!(Blend::named("soup"), None);407    }408409    #[test]410    fn the_harmonics_read_the_rotation_order() {411        let square = harmonics(&floats(&atoms::ones_2d(8)), 8, 128, 12);412        assert!(square[0] > 0.0);413        assert!(square[4] > 1e-3 * square[0]);414        assert!(square[8] > 1e-3 * square[0]);415        for m in [1, 2, 3, 5, 6, 7, 9, 10, 11] {416            assert!(square[m] < 1e-9 * square[0], "m {m}");417        }418        assert_eq!(turns(&square), 4);419        let mut bar = vec![0.0f32; 16];420        for i in [5, 6, 9, 10, 4, 11] {421            bar[i] = 1.0;422        }423        assert_eq!(turns(&harmonics(&bar, 4, 64, 8)), 2);424        let mut blob = vec![0.0f32; 16];425        blob[0] = 1.0;426        assert_eq!(turns(&harmonics(&blob, 4, 64, 8)), 1);427        assert_eq!(turns(&[1.0, 0.0, 0.0]), 0);428        assert_eq!(429            (petals(6, 4), petals(8, 4), petals(5, 1), petals(3, 0)),430            (12, 8, 5, 0)431        );432    }433}