Skip to main content

sva_formula/
series.rs

1// Concern: decides a series' convergence in A and enumerates the lines it yields | Non-concern: placing them on a grid (sva-samples) | IO: (&Series, ceiling) -> bool, Lines, Enumerated, a spacing
2
3use std::f64::consts::TAU;
4
5use crate::affine::{Axis, Reading, affine_in, affine_read, axis_read, exact_constant_at};
6use crate::closed_form::{
7    Body, Bound, IndexId, Part, Series, Unary, children, map_children, read_at,
8};
9use crate::complex::C64;
10use crate::env::Env;
11use crate::spectral_sum::atom::{Exp, Factors, Singular, SpectralAtom};
12use crate::table::series::{Shape, read_with};
13use crate::through::{Opaque, Reads};
14
15/// A tempered limit needs the coefficient polynomially bounded: `1/k` is, `2^k` is not.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum IndexGrowth {
18    Polynomial,
19    Unbounded,
20}
21
22pub fn summable(s: &Series, env: &dyn Env) -> bool {
23    match s.hi {
24        Bound::Finite(_) => true,
25        Bound::Infinite => growth(&s.term.body, s.index, env) == IndexGrowth::Polynomial,
26    }
27}
28
29/// Decided from where the index sits, never by evaluating a term; a ref read at a time the
30/// index moves is its form read there.
31pub fn growth(f: &Body, k: IndexId, env: &dyn Env) -> IndexGrowth {
32    if !mentions(f, k) {
33        return IndexGrowth::Polynomial;
34    }
35    match f {
36        Body::Index(_) | Body::Line | Body::Const(_) => IndexGrowth::Polynomial,
37        Body::Add(parts) | Body::Mul(parts) | Body::Join(parts) => join(parts, k, env),
38        Body::Div(a, b) => {
39            join(std::slice::from_ref(a), k, env).and(join(std::slice::from_ref(b), k, env))
40        }
41        Body::Pow(base, _) => growth(&base.body, k, env),
42        Body::Keyed { .. } => IndexGrowth::Polynomial,
43        Body::Apply(Unary::Sin | Unary::Cos, arg) => bounded_along(arg, k, Axis::Real, env),
44        Body::Apply(Unary::Exp, arg) if logarithmic(&arg.body, k) => IndexGrowth::Polynomial,
45        Body::Apply(Unary::Exp, arg) => exponential_in(arg, k, env),
46        Body::Channel(of, _) | Body::Crop { of, .. } => growth(&of.body, k, env),
47        Body::Shift { of, .. } | Body::Deriv { of, .. } => growth(&of.body, k, env),
48        Body::Warp { at, of } => match &*of.body {
49            Body::Crop { of: inner, .. } => growth(&read_at(&inner.body, &at.body), k, env),
50            Body::Node(id) => env
51                .reads()
52                .growth(*id, &at.body, k)
53                .unwrap_or(IndexGrowth::Polynomial),
54            other => growth(&read_at(other, &at.body), k, env),
55        },
56        Body::Pv(at) | Body::Delta { at, .. } => growth(&at.body, k, env),
57        Body::Series(inner) => growth(&inner.term.body, k, env),
58        _ => IndexGrowth::Unbounded,
59    }
60}
61
62fn join(parts: &[Part], k: IndexId, env: &dyn Env) -> IndexGrowth {
63    parts.iter().fold(IndexGrowth::Polynomial, |acc, p| {
64        acc.and(growth(&p.body, k, env))
65    })
66}
67
68/// A turning exponent is bounded and a falling one is a geometric decay; only a rising real
69/// part outgrows every polynomial.
70fn exponential_in(arg: &Part, k: IndexId, env: &dyn Env) -> IndexGrowth {
71    if let bounded @ IndexGrowth::Polynomial = bounded_along(arg, k, Axis::Imaginary, env) {
72        return bounded;
73    }
74    match affine_in(&arg.body, Reading::Index(k)).and_then(|(slope, _)| slope.exact()) {
75        Some(slope) if slope.re < 0.0 => IndexGrowth::Polynomial,
76        _ => IndexGrowth::Unbounded,
77    }
78}
79
80fn bounded_along(arg: &Part, k: IndexId, wanted: Axis, env: &dyn Env) -> IndexGrowth {
81    if !mentions(&arg.body, k) || axis_read(&arg.body, env, env.reads()) == wanted {
82        IndexGrowth::Polynomial
83    } else {
84        IndexGrowth::Unbounded
85    }
86}
87
88/// An exponent reaching the index only through a logarithm is a power of it.
89fn logarithmic(f: &Body, k: IndexId) -> bool {
90    match f {
91        _ if !mentions(f, k) => true,
92        Body::Apply(Unary::Log, _) => true,
93        Body::Add(parts) | Body::Mul(parts) => parts.iter().all(|p| logarithmic(&p.body, k)),
94        Body::Div(a, b) => logarithmic(&a.body, k) && logarithmic(&b.body, k),
95        _ => false,
96    }
97}
98
99impl IndexGrowth {
100    fn and(self, other: IndexGrowth) -> IndexGrowth {
101        match (self, other) {
102            (IndexGrowth::Polynomial, IndexGrowth::Polynomial) => IndexGrowth::Polynomial,
103            _ => IndexGrowth::Unbounded,
104        }
105    }
106}
107
108/// What separates a series term's coefficient from its wave.
109pub fn mentions_line(f: &Body) -> bool {
110    mentions_line_read(f, &Opaque)
111}
112
113pub fn mentions_line_read(f: &Body, reads: &dyn Reads) -> bool {
114    match f {
115        Body::Line => true,
116        Body::Node(id) => reads.mentions_line(*id),
117        other => children(other)
118            .iter()
119            .any(|p| mentions_line_read(&p.body, reads)),
120    }
121}
122
123/// A bound on `|c(k+1)| / |c(k)|` holding at every index.
124pub fn ratio(f: &Body, k: IndexId) -> Option<f64> {
125    if !mentions(f, k) {
126        return Some(1.0);
127    }
128    match f {
129        Body::Mul(parts) => parts
130            .iter()
131            .try_fold(1.0, |held, p| Some(held * ratio(&p.body, k)?)),
132        Body::Add(parts) => parts.iter().try_fold(0.0f64, |held, p| match &*p.body {
133            Body::Apply(Unary::Abs, _) => Some(held.max(ratio(&p.body, k)?)),
134            _ => None,
135        }),
136        Body::Div(num, den) if !mentions(&den.body, k) => ratio(&num.body, k),
137        Body::Apply(Unary::Abs, arg) => ratio(&arg.body, k),
138        Body::Apply(Unary::Exp, arg) => {
139            let (slope, _) = affine_in(&arg.body, Reading::Index(k))?;
140            Some(slope.exact()?.re.exp())
141        }
142        Body::Pow(base, n) if *n >= 0 => Some(ratio(&base.body, k)?.powi(*n)),
143        _ => None,
144    }
145}
146
147/// Whether `|f(j)| <= |f(i)|` for every `j >= i >= 1`.
148pub fn falls(f: &Body, k: IndexId) -> bool {
149    power(f, k).is_some_and(|p| p >= 0.0)
150}
151
152/// Bounds every term from an index on by that term's magnitude alone.
153#[derive(Clone, Copy, Debug, PartialEq)]
154enum Decay {
155    Geometric(f64),
156    Power(f64),
157}
158
159fn decay(f: &Body, k: IndexId) -> Option<Decay> {
160    if let Some(r) = ratio(f, k).filter(|r| *r < 1.0) {
161        return Some(Decay::Geometric(r));
162    }
163    power(f, k).filter(|p| *p > 1.0).map(Decay::Power)
164}
165
166impl Decay {
167    /// `sum_{j>=k} |c(j)|` from `|c(k)|`; a power law by `sum_{j>k} (k/j)^p <= k/(p-1)`.
168    fn tail(self, here: f64, k: i64) -> Option<f64> {
169        match self {
170            Decay::Geometric(r) => Some(here / (1.0 - r)),
171            Decay::Power(p) => (k >= 1).then(|| here * (1.0 + k as f64 / (p - 1.0))),
172        }
173    }
174}
175
176/// A `p` with `|f(j)| <= |f(i)|*(i/j)^p` for every `j >= i >= 1`.
177fn power(f: &Body, k: IndexId) -> Option<f64> {
178    if let Some(e) = exponent(f, k) {
179        return Some(-e);
180    }
181    match f {
182        Body::Mul(parts) => parts
183            .iter()
184            .try_fold(0.0, |held, p| Some(held + power(&p.body, k)?)),
185        Body::Add(parts) => parts
186            .iter()
187            .try_fold(f64::INFINITY, |held, p| match &*p.body {
188                Body::Apply(Unary::Abs, _) => Some(held.min(power(&p.body, k)?)),
189                _ => None,
190            }),
191        Body::Div(num, den) => Some(power(&num.body, k)? + rises(&den.body, k)?),
192        Body::Apply(Unary::Abs, arg) => power(&arg.body, k),
193        Body::Pow(base, n) if *n >= 0 => Some(power(&base.body, k)? * f64::from(*n)),
194        _ => ratio(f, k).filter(|r| *r <= 1.0).map(|_| 0.0),
195    }
196}
197
198/// An `e` with `|f(j)| >= |f(i)|*(j/i)^e` for every `j >= i >= 1`: `a*k + b`, `a > 0`,
199/// `b <= 0 < a + b`, rises at least as `k` does.
200fn rises(f: &Body, k: IndexId) -> Option<f64> {
201    if let Some(e) = exponent(f, k) {
202        return Some(e);
203    }
204    match f {
205        Body::Pow(base, n) if *n >= 0 => Some(rises(&base.body, k)? * f64::from(*n)),
206        _ => {
207            let (slope, offset) = affine_in(f, Reading::Index(k))?;
208            let (a, b) = (slope.exact()?, offset.exact()?);
209            let real = a.im == 0.0 && b.im == 0.0;
210            (real && a.re > 0.0 && b.re <= 0.0 && a.re + b.re > 0.0).then_some(1.0)
211        }
212    }
213}
214
215/// The `e` with `|f(j)| = |f(i)|*(j/i)^e` exactly for every `i, j >= 1`.
216fn exponent(f: &Body, k: IndexId) -> Option<f64> {
217    if !mentions(f, k) {
218        return Some(0.0);
219    }
220    match f {
221        Body::Index(i) if *i == k => Some(1.0),
222        Body::Mul(parts) => parts
223            .iter()
224            .try_fold(0.0, |held, p| Some(held + exponent(&p.body, k)?)),
225        Body::Div(num, den) => Some(exponent(&num.body, k)? - exponent(&den.body, k)?),
226        Body::Apply(Unary::Abs, arg) => exponent(&arg.body, k),
227        Body::Pow(base, n) => Some(exponent(&base.body, k)? * f64::from(*n)),
228        _ => None,
229    }
230}
231
232pub fn mentions(f: &Body, k: IndexId) -> bool {
233    reaches(f, &|x| matches!(x, Body::Index(i) if *i == k))
234}
235
236fn reaches(f: &Body, leaf: &dyn Fn(&Body) -> bool) -> bool {
237    leaf(f) || children(f).iter().any(|p| reaches(&p.body, leaf))
238}
239
240#[derive(Clone, Copy, Debug, PartialEq)]
241pub struct Line {
242    pub hz: f64,
243    pub amp: C64,
244    /// Where the term's own frequency places it, which `hz` rounds.
245    pub rung: Option<Rung>,
246}
247
248impl Line {
249    pub fn bare(hz: f64, amp: C64) -> Line {
250        Line {
251            hz,
252            amp,
253            rung: None,
254        }
255    }
256}
257
258/// Exactly `offset + step*k` Hz: the frequency a series term writes at index `k`.
259#[derive(Clone, Copy, Debug, PartialEq)]
260pub struct Rung {
261    pub offset: f64,
262    pub step: f64,
263    pub k: i64,
264}
265
266/// `tail_db` is what was dropped against the loudest taken line: the loudest dropped line, or
267/// where the walk stopped short, the bound on the summed magnitude of every term it left.
268#[derive(Clone, Debug, PartialEq)]
269pub struct Lines {
270    pub taken: Vec<Line>,
271    pub dropped: Vec<Line>,
272    pub tail_db: f64,
273}
274
275/// Stops at the ceiling where the frequency closed form leaves the band. Where it never does, the
276/// walk stops only where a decay bounds the whole tail: a geometric one at `precision`, a power
277/// law under the floor against the loudest line taken. `None` where the term is no line, or an
278/// infinite tail has no such bound.
279pub fn lines(s: &Series, ceiling: f64, floor_db: f64, precision: f64) -> Option<Lines> {
280    lines_read(s, (ceiling, floor_db, precision), &Opaque)
281}
282
283pub fn lines_read(s: &Series, band: (f64, f64, f64), reads: &dyn Reads) -> Option<Lines> {
284    walk(s, band, reads, false)
285}
286
287/// The lines `lines_read` takes at the first index; `None` for none, or a walk that may not end.
288pub fn leading_read(s: &Series, band: (f64, f64, f64), reads: &dyn Reads) -> Option<Vec<Line>> {
289    let first = walk(s, band, reads, true)?.taken;
290    (!first.is_empty()).then_some(first)
291}
292
293fn walk(
294    s: &Series,
295    (ceiling, floor_db, precision): (f64, f64, f64),
296    reads: &dyn Reads,
297    leading: bool,
298) -> Option<Lines> {
299    let shape = read_with(&s.term.body, reads)?;
300    let voices = places(&shape);
301    let band = voices
302        .iter()
303        .filter_map(|(place, _)| leaves_band(place, s.index, ceiling, reads))
304        .fold(None, |held: Option<i64>, next| {
305            Some(held.map_or(next, |held| held.max(next)))
306        });
307    let hi = match (s.hi, band) {
308        (Bound::Finite(n), Some(last)) => n.min(last),
309        (Bound::Finite(n), None) => n,
310        (Bound::Infinite, Some(last)) => last,
311        (Bound::Infinite, None) => s.lo.saturating_add(MAX_TERMS),
312    };
313    let ratio = voices
314        .iter()
315        .try_fold(0.0f64, |held, (_, weight)| {
316            Some(held.max(ratio(weight, s.index)?))
317        })
318        .filter(|r| *r < 1.0);
319    let decays: Option<Vec<Decay>> = voices
320        .iter()
321        .map(|(_, weight)| decay(weight, s.index))
322        .collect();
323    let truncates = band.is_none() && s.hi == Bound::Infinite;
324    if leading && truncates {
325        return None;
326    }
327    let unread = |f: &Body| at_index(f, s.index, s.lo, reads).is_none();
328    if truncates
329        && voices
330            .iter()
331            .any(|(place, weight)| unread(place) || unread(weight))
332    {
333        return Some(Lines {
334            taken: Vec::new(),
335            dropped: Vec::new(),
336            tail_db: f64::NEG_INFINITY,
337        });
338    }
339    if truncates && ratio.is_none() && decays.is_none() {
340        return None;
341    }
342    let floor = 10f64.powf(floor_db / 20.0);
343    let ladders: Vec<Option<(f64, f64)>> = voices
344        .iter()
345        .map(|(place, _)| ladder(place, s.index, reads))
346        .collect();
347
348    let mut taken = Vec::new();
349    let mut dropped = Vec::new();
350    let mut peak = 0.0f64;
351    let mut left = None;
352    let last = if leading { hi.min(s.lo) } else { hi };
353    for k in s.lo..=last {
354        let mut here = Vec::new();
355        for ((place, weight), ladder) in voices.iter().zip(&ladders) {
356            let (Some(hz), Some(amp)) = (
357                at_index(place, s.index, k, reads).map(|c| c.re),
358                at_index(weight, s.index, k, reads),
359            ) else {
360                continue;
361            };
362            let rung = ladder.map(|(offset, step)| Rung { offset, step, k });
363            here.push(Line { hz, amp, rung });
364        }
365        let whole = here.len() == voices.len();
366        let tail = match (ratio, &decays) {
367            (Some(r), _) => {
368                whole.then(|| here.iter().map(|l| l.amp.abs()).sum::<f64>() / (1.0 - r))
369            }
370            (None, Some(decays)) if whole => here
371                .iter()
372                .zip(decays)
373                .try_fold(0.0, |held, (l, d)| Some(held + d.tail(l.amp.abs(), k)?)),
374            (None, _) => None,
375        };
376        let gone = tail.filter(|tail| match ratio {
377            Some(_) => *tail <= precision,
378            None => peak > 0.0 && *tail < peak * floor,
379        });
380        if truncates && k > s.lo && gone.is_some() {
381            dropped.extend(here);
382            left = gone;
383            break;
384        }
385        for line in here {
386            if line.hz.abs() < ceiling {
387                peak = peak.max(line.amp.abs());
388                taken.push(line);
389            } else {
390                dropped.push(line);
391            }
392        }
393    }
394    if truncates && left.is_none() {
395        return None;
396    }
397    let loudest = |set: &[Line]| set.iter().map(|l| l.amp.abs()).fold(0.0f64, f64::max);
398    let gone = loudest(&dropped).max(left.unwrap_or(0.0));
399    Some(Lines {
400        tail_db: if gone > 0.0 && peak > 0.0 {
401            20.0 * (gone / peak).log10()
402        } else {
403            f64::NEG_INFINITY
404        },
405        taken,
406        dropped,
407    })
408}
409
410fn ladder(place: &Body, k: IndexId, reads: &dyn Reads) -> Option<(f64, f64)> {
411    let (slope, offset) = affine_read(place, Reading::Index(k), reads)?;
412    let (slope, offset) = (slope.exact()?, offset.exact()?);
413    let real = slope.im == 0.0 && offset.im == 0.0;
414    (real && slope.re.is_finite() && offset.re.is_finite()).then_some((offset.re, slope.re))
415}
416
417/// The step a series' own frequency walks: every term lands on a multiple of it. A
418/// delta train's places are instants, not frequencies, and name no such step.
419pub fn spacing(s: &Series) -> Option<f64> {
420    spacing_read(s, &Opaque)
421}
422
423pub fn spacing_read(s: &Series, reads: &dyn Reads) -> Option<f64> {
424    let Some(shape @ Shape::Lines(_)) = read_with(&s.term.body, reads) else {
425        return None;
426    };
427    let mut held: Option<f64> = None;
428    for (place, _) in places(&shape) {
429        let (slope, offset) = affine_read(&place, Reading::Index(s.index), reads)?;
430        let (slope, offset) = (slope.exact()?.re, offset.exact()?.re);
431        if slope == 0.0 || !slope.is_finite() || !offset.is_finite() {
432            return None;
433        }
434        let steps = offset / slope;
435        if (steps.round() - steps).abs() > TURN_EPSILON * steps.abs().max(1.0) {
436            return None;
437        }
438        match held {
439            Some(step) if step != slope.abs() => return None,
440            _ => held = Some(slope.abs()),
441        }
442    }
443    held
444}
445
446#[derive(Clone, Debug, PartialEq)]
447pub struct Enumerated {
448    pub atoms: Vec<SpectralAtom>,
449    pub dropped: Vec<Line>,
450}
451
452/// A crop of a series is the series of cropped terms: the window lifts off, goes back on
453/// each. `None` where no line closed form reads under it. A delta's `hz` is an instant, not a pitch.
454pub fn line_atoms(s: &Series, ceiling: f64, floor_db: f64, precision: f64) -> Option<Enumerated> {
455    line_atoms_read(s, (ceiling, floor_db, precision), &Opaque)
456}
457
458pub fn line_atoms_read(s: &Series, band: (f64, f64, f64), reads: &dyn Reads) -> Option<Enumerated> {
459    let (body, window) =
460        crate::spectral_sum::image::crop_peeled(&crate::through::looked(&s.term.body, reads));
461    let bare = Series {
462        term: Part::new(s.term.origin, body),
463        ..s.clone()
464    };
465    let singular = match read_with(&bare.term.body, reads)? {
466        Shape::Deltas(_) => true,
467        Shape::Lines(_) => false,
468    };
469    let found = lines_read(&bare, band, reads)?;
470    let atoms = found
471        .taken
472        .into_iter()
473        .filter_map(|l| match singular {
474            true => window.is_none_or(|w| w.contains(l.hz)).then(|| {
475                SpectralAtom::new(
476                    l.amp,
477                    Factors::NONE,
478                    Singular::Delta { at: l.hz, order: 0 },
479                    s.term.origin,
480                )
481            }),
482            false => Some(SpectralAtom::new(
483                l.amp,
484                Factors {
485                    exp: Some(Exp::at(0.0, TAU * l.hz)),
486                    ind: window,
487                    ..Factors::NONE
488                },
489                Singular::Regular,
490                s.term.origin,
491            )),
492        })
493        .collect();
494    Some(Enumerated {
495        atoms,
496        dropped: found.dropped,
497    })
498}
499
500/// A whole turn count to floating precision: a tolerance would put a line on a neighbouring
501/// bin, which `exact` cannot carry.
502pub fn commensurate(hz: f64, horizon: f64) -> bool {
503    let turns = hz * horizon;
504    (turns.round() - turns).abs() <= TURN_EPSILON * turns.abs().max(1.0)
505}
506
507const TURN_EPSILON: f64 = 1e-9;
508
509fn places(shape: &Shape) -> Vec<(Body, Body)> {
510    match shape {
511        Shape::Lines(lines) => lines
512            .iter()
513            .map(|l| (l.freq.clone(), l.amp.clone()))
514            .collect(),
515        Shape::Deltas(deltas) => deltas
516            .iter()
517            .map(|d| (d.at.clone(), d.weight.clone()))
518            .collect(),
519    }
520}
521
522/// The last index the walk reaches, solving `|slope*k + offset| <= ceiling`
523/// at both signs: an offset opposing the slope carries the line back in before it leaves.
524fn leaves_band(place: &Body, k: IndexId, ceiling: f64, reads: &dyn Reads) -> Option<i64> {
525    let (slope, offset) = affine_read(place, Reading::Index(k), reads)?;
526    let (slope, offset) = (slope.exact()?.re, offset.exact()?.re);
527    if slope == 0.0 {
528        return None;
529    }
530    let ends = [(ceiling - offset) / slope, (-ceiling - offset) / slope];
531    let last = ends[0].max(ends[1]).floor();
532    Some(last.clamp(0.0, MAX_TERMS as f64) as i64 + 1)
533}
534
535const MAX_TERMS: i64 = 1 << 20;
536
537fn at_index(f: &Body, k: IndexId, value: i64, reads: &dyn Reads) -> Option<C64> {
538    exact_constant_at(f, k, value as f64, reads)
539}
540
541pub fn substitute(f: &Body, k: IndexId, value: f64) -> Body {
542    match f {
543        Body::Index(i) if *i == k => Body::Const(C64::real(value)),
544        other => map_children(other, |p| {
545            Part::new(p.origin, substitute(&p.body, k, value))
546        }),
547    }
548}
549
550const MAX_WRITTEN_TERMS: i64 = 1 << 13;
551
552/// `f`, each finite series summed term by term; `None` where one is infinite or too long.
553pub fn written_out(f: &Body) -> Option<Body> {
554    let mut left = MAX_WRITTEN_TERMS;
555    within(f, &mut left)
556}
557
558/// `left` counts the terms every series written out so far may still add, nesting included.
559fn within(f: &Body, left: &mut i64) -> Option<Body> {
560    if let Body::Series(s) = f {
561        let Bound::Finite(hi) = s.hi else {
562            return None;
563        };
564        *left = left.checked_sub(hi.checked_sub(s.lo)?.checked_add(1)?.max(0))?;
565        if *left < 0 {
566            return None;
567        }
568        let terms = (s.lo..=hi)
569            .map(|k| {
570                Some(Part::new(
571                    s.term.origin,
572                    within(&substitute(&s.term.body, s.index, k as f64), left)?,
573                ))
574            })
575            .collect::<Option<Vec<_>>>()?;
576        return Some(match terms.len() {
577            0 => Body::Const(C64::ZERO),
578            _ => Body::Add(terms),
579        });
580    }
581    let mut whole = true;
582    let out = map_children(f, |p| match within(&p.body, left) {
583        Some(body) => Part::new(p.origin, body),
584        None => {
585            whole = false;
586            p.clone()
587        }
588    });
589    whole.then_some(out)
590}