use dualis::prelude::*;
use dualis_optics::detector::{Detector, Exposure};
use dualis_optics::spectrum::Spectrum as Spec;
mod common;
use common::svg::{document, rgb, ticks, Plot};
use common::{check, check_between, heading};
const FRAMES: usize = 200_000;
fn main() {
let scientific = Detector::scientific_cmos();
let consumer = Detector::consumer_cmos();
heading("Two sensors, and the electron count where each stops being read-limited");
for (name, d) in [("scientific", &scientific), ("consumer", &consumer)] {
check(
&format!("{name}: crossover, R squared"),
d.shot_noise_crossover(),
d.read_noise * d.read_noise,
1e-12,
"e-",
);
}
println!(" a 1.4 e- sensor is shot-limited from 2 electrons; a 6 e- one needs 36");
heading("The two regimes, measured rather than restated");
let faint = |d: &Detector, s: f64| {
Exposure {
signal: s,
dark: 0.0,
}
.snr(d.read_noise)
};
let r = consumer.read_noise * consumer.read_noise;
check(
"read-limited: 2x signal at S = R2/16",
faint(&consumer, r / 8.0) / faint(&consumer, r / 16.0),
2.0,
0.04,
"x",
);
check(
"shot-limited: 2x signal at S = 400 R2",
faint(&consumer, 800.0 * r) / faint(&consumer, 400.0 * r),
std::f64::consts::SQRT_2,
0.01,
"x",
);
let ideal = Detector::ideal();
check(
"an ideal counter at 10 000 electrons",
faint(&ideal, 10_000.0),
100.0,
1e-12,
"SNR",
);
heading("And the distribution itself, from 200 000 frames");
let tol = 4.0 * (2.0 / FRAMES as f64).sqrt();
for mean in [4.0f64, 45.0, 900.0] {
let frames = Ensemble::new(0xD0_5E_11_A5, FRAMES as u64).with_threads(8);
let e = frames
.estimate(|_, mut rng| rng.poisson(mean) as f64)
.expect("two hundred thousand frames");
check(
&format!("mean {mean:>5.0}: sampled mean"),
e.mean,
mean,
tol,
"e-",
);
check(
&format!("mean {mean:>5.0}: variance equals it"),
e.standard_deviation() * e.standard_deviation(),
mean,
tol,
"e-",
);
assert_eq!(e.samples, FRAMES as u64);
}
heading("The same frames, on a different number of threads");
let one_frame = |_: u64, mut rng: Rng| rng.poisson(45.0) as f64;
let sequential = Ensemble::new(0xD0_5E_11_A5, FRAMES as u64)
.estimate(one_frame)
.expect("frames");
for threads in [2usize, 8, 32] {
let parallel = Ensemble::new(0xD0_5E_11_A5, FRAMES as u64)
.with_threads(threads)
.estimate(one_frame)
.expect("frames");
assert_eq!(
sequential.mean.to_bits(),
parallel.mean.to_bits(),
"{threads} threads moved the mean"
);
println!(
" {threads:>2} threads: mean {:.9} e-, identical to the bit",
parallel.mean
);
}
heading("What that means for an exposure");
let star = SpectralPower::new(
Spec::blackbody(5800.0),
Power::from_si(2.0e-17),
VISIBLE_RANGE,
);
let target = 10.0;
let rate_of = |d: &Detector| star.photon_rate_through(&d.quantum_efficiency).to_si();
let t_sci = scientific.exposure_for_snr(&star, target);
let t_con = consumer.exposure_for_snr(&star, target);
println!(
" a 20 aW star: {:.1} e-/s on the scientific sensor, {:.1} on the consumer one",
rate_of(&scientific),
rate_of(&consumer)
);
check_between(
" detected rate, scientific",
rate_of(&scientific),
5.0,
500.0,
"e-/s",
);
println!(" target SNR {target:.0}");
check_between(" scientific cmos needs", t_sci.to_si(), 0.2, 60.0, "s");
check_between(" consumer cmos needs", t_con.to_si(), 1.0, 6000.0, "s");
check_between(
" so the quiet sensor is faster by",
t_con.to_si() / t_sci.to_si(),
1.5,
200.0,
"x",
);
check_between(
" consumer dark current, for comparison",
consumer.dark_current.to_si(),
5.0,
100.0,
"e-/s",
);
let Some(path) = common::output_path() else {
println!("\npass a path to write an SVG, e.g. `cargo run --example detector_snr out.svg`");
return;
};
common::write(&path, &draw(&scientific, &consumer, &ideal));
}
fn draw(scientific: &Detector, consumer: &Detector, ideal: &Detector) -> String {
let (w, h) = (880.0, 380.0);
let (lo, hi) = (0.0f64, 6.0);
let snr = |d: &Detector, s: f64| s / (s + d.read_noise * d.read_noise).sqrt();
let curve = |d: &Detector| -> Vec<(f64, f64)> {
(0..=300)
.map(|i| {
let l = lo + (hi - lo) * i as f64 / 300.0;
(l, snr(d, 10f64.powf(l)).max(1e-3).log10())
})
.collect()
};
let mut left = Plot::new(w, h, (lo, hi), (-1.0, 3.0)).viewport(64.0, 54.0, 340.0, 262.0);
left.axes(
&[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
&[-1.0, 0.0, 1.0, 2.0, 3.0],
|v| format!("10^{v:.0}"),
|v| format!("10^{v:.0}"),
);
left.polyline(curve(ideal), &rgb(150, 150, 150), 1.6);
left.polyline(curve(scientific), &rgb(48, 108, 186), 2.2);
left.polyline(curve(consumer), &rgb(158, 40, 32), 2.2);
for (d, colour) in [
(scientific, rgb(48, 108, 186)),
(consumer, rgb(158, 40, 32)),
] {
let x = d.shot_noise_crossover().log10();
let y = snr(d, d.shot_noise_crossover()).log10();
left.polyline([(x, -1.0), (x, y)], "#00000030", 1.0);
left.text(
x + 0.1,
-0.82,
&format!("R2 = {:.0} e-", d.shot_noise_crossover()),
11.0,
&colour,
"start",
);
}
left.text(4.2, 1.55, "ideal counter", 11.5, "#8a8a8a", "start");
left.text(3.4, 2.05, "1.4 e- read", 11.5, &rgb(48, 108, 186), "start");
left.text(4.3, 0.85, "6 e- read", 11.5, &rgb(158, 40, 32), "start");
left.title("Signal to noise, and where each sensor changes regime");
left.caption("both axes log10");
left.footnote("SNR against signal electrons");
let seconds = 60.0;
let rate = 40.0;
let dark_rate = consumer.dark_current.to_si();
let noise_at = |t: f64| {
let (s, d) = (rate * t, dark_rate * t);
(s + d + consumer.read_noise * consumer.read_noise).sqrt()
};
let top = noise_at(seconds) * 1.08;
let mut right = Plot::new(w, h, (0.0, seconds), (0.0, top)).viewport(470.0, 54.0, 350.0, 262.0);
right.axes(
&ticks(0.0, seconds, 6),
&ticks(0.0, top, 5),
|v| format!("{v:.0}"),
|v| format!("{v:.0}"),
);
let sampled = |f: &dyn Fn(f64) -> f64| -> Vec<(f64, f64)> {
(0..=240)
.map(|i| {
let t = seconds * i as f64 / 240.0;
(t, f(t))
})
.collect()
};
right.polyline(sampled(&|_t| consumer.read_noise), &rgb(120, 120, 120), 1.8);
right.polyline(sampled(&|t| (dark_rate * t).sqrt()), &rgb(158, 40, 32), 1.8);
right.polyline(sampled(&|t| (rate * t).sqrt()), &rgb(48, 108, 186), 1.8);
right.polyline(sampled(&noise_at), &rgb(24, 24, 24), 2.4);
right.text(
seconds * 0.06,
consumer.read_noise + 1.4,
"read",
11.5,
"#787878",
"start",
);
right.text(
seconds * 0.62,
(dark_rate * seconds * 0.62).sqrt() + 1.4,
"dark",
11.5,
&rgb(158, 40, 32),
"start",
);
right.text(
seconds * 0.34,
(rate * seconds * 0.34).sqrt() + 1.4,
"shot",
11.5,
&rgb(48, 108, 186),
"start",
);
right.text(
seconds * 0.72,
noise_at(seconds * 0.72) + 1.6,
"total",
11.5,
"#1a1a1a",
"start",
);
right.footnote("noise electrons against exposure seconds, 6 e- sensor on a 40 e-/s source");
document(w, h, [left.into_body(), right.into_body()])
}