mod common;
use common::{check, check_between, heading};
use pantometry::prelude::*;
use pantometry_view::{gltf, report};
const NX: usize = 33;
const NY: usize = 10;
const NZ: usize = 33;
const DX: f64 = 2e-3;
const RADIUS: f64 = 29e-3;
const POROSITY: f64 = 0.45;
const GAP_POROSITY: f64 = 0.60;
const HEADSPACE: f64 = 3e-3;
const PARCELS: usize = 160;
const TRAIL: usize = 8;
const FRAMES: usize = 36;
const FRAME_S: f64 = 0.7;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Phase {
Headspace,
Bed,
Jet,
Cup,
}
#[derive(Clone, Debug)]
struct Parcel {
at: [f64; 3],
trail: Vec<[f64; 3]>,
load: f64,
phase: Phase,
entered: Option<f64>,
through: Option<f64>,
entry_r: f64,
fall: f64,
}
fn machine(channel: bool) -> Puck {
let mut p = Puck::new(
if channel { "wall gap" } else { "even" },
Basket {
counts: (NX, NY, NZ),
cell: Length::from_si(DX),
radius: Length::from_si(RADIUS),
porosity: POROSITY,
..Basket::espresso()
},
);
if channel {
let ring = wall_ring(&p);
p.repack(GAP_POROSITY, |i, j, k| ring[i + NX * (j + NY * k)]);
}
p
}
fn wall_ring(p: &Puck) -> Vec<bool> {
let mut ring = vec![false; NX * NY * NZ];
for k in 0..NZ {
for j in 0..NY {
for i in 0..NX {
if !p.is_packed(i, j, k) {
continue;
}
ring[i + NX * (j + NY * k)] = i == 0
|| k == 0
|| i + 1 == NX
|| k + 1 == NZ
|| !p.is_packed(i - 1, j, k)
|| !p.is_packed(i + 1, j, k)
|| !p.is_packed(i, j, k - 1)
|| !p.is_packed(i, j, k + 1);
}
}
}
ring
}
fn centre() -> (f64, f64) {
(0.5 * NX as f64 * DX, 0.5 * NZ as f64 * DX)
}
fn draw(p: [f64; 3]) -> [f64; 3] {
let (cx, cz) = centre();
[p[0] - cx, -p[1], p[2] - cz]
}
fn main() {
heading("1. The two baskets, and the flow each one passes");
let mut even = machine(false);
let mut gap = machine(true);
println!(
" {:<38} {:>10.2} g identical dose; only the ring differs",
"dose",
even.dose().to_si() * 1000.0
);
let f = ring_fraction(&even);
let mobility = |e: f64| e.powi(3) / (1.0 - e).powi(2);
let predicted = (1.0 - f) + f * mobility(GAP_POROSITY) / mobility(POROSITY);
let measured = gap.flow_rate().to_si() / even.flow_rate().to_si();
println!(
" {:<38} {:>10.1} % of the cross-section is the ring, counted rather than estimated",
"ring",
f * 100.0
);
check(
"columns in parallel give the split",
measured,
predicted,
1e-9,
"x",
);
let pore = |p: &Puck| {
p.flow_rate().to_si() / Liquid::water().density.to_si() / open_area(p) / POROSITY
};
let transit = NY as f64 * DX / pore(&even);
println!(
" {:<38} {:>10.1} s the pore transit, eps L / u -- not L / u, which is {:.1} s",
"how long the water is in the grounds",
transit,
transit / POROSITY
);
heading("2. Two hundred parcels, advected by the field rather than placed");
let even_run = trace(&mut even, 1);
let gap_run = trace(&mut gap, 2);
for run in [&even_run, &gap_run] {
println!(
" {:<20} {:>5} released, {:>4} still in the grounds, {:>4} in the cup",
run.name, run.released, run.in_bed, run.arrived
);
assert_eq!(
run.released,
run.in_head + run.in_bed + run.in_jet + run.arrived,
"{}: every parcel is somewhere",
run.name
);
assert!(
run.max_radius <= RADIUS + 1e-9,
"{}: nothing leaves through the basket wall: {:.4} mm against {:.4} mm",
run.name,
run.max_radius * 1000.0,
RADIUS * 1000.0
);
}
check_between(
"mean transit against eps L / u",
even_run.mean_transit / transit,
0.85,
1.15,
"x",
);
assert!(
even_run.mean_transit < 0.6 * transit / POROSITY,
"the parcels are on the pore velocity, not the Darcy one: {:.2} s against {:.2} s",
even_run.mean_transit,
transit / POROSITY
);
println!(
" {:<38} {:>10.1} kg/m3 what a parcel has picked up by the spout",
"load at the exit", even_run.mean_load
);
check_between(
"the exit load against the outlet TDS",
even_run.mean_load / (even.tds() * Liquid::water().density.to_si()),
0.6,
1.6,
"x",
);
heading("3. What the ring does to where the water goes");
println!(
" {:<38} {:>10.1} s against {:.1} s through the even bed",
"mean transit through the ring", gap_run.ring_transit, even_run.ring_transit
);
println!(
" {:<38} {:>10.2} x the ring is faster than the core by this much",
"ring against core, in speed",
gap_run.core_transit / gap_run.ring_transit
);
assert!(
gap_run.core_transit / gap_run.ring_transit > 1.3,
"the gap must make the ring visibly quicker: {:.2}x",
gap_run.core_transit / gap_run.ring_transit
);
assert!(
(even_run.core_transit / even_run.ring_transit - 1.0).abs() < 0.15,
"and an even bed must not: {:.3}x",
even_run.core_transit / even_run.ring_transit
);
println!(
" {:<38} {:>10.1} kg/m3 against {:.1} through the even bed -- the same water, less coffee",
"what the ring's water carries out", gap_run.ring_load, even_run.ring_load
);
println!(
" {:<38} {:>10} both run the same clock, so the gap's cup is heavier and its yield reads",
"note", ""
);
println!(
" {:<38} {:>10} higher. Compare to a weight, as espresso_shot does, and it loses.",
"", ""
);
assert!(
gap_run.ring_load < even_run.ring_load,
"water that hurried through picked up less: {:.1} against {:.1}",
gap_run.ring_load,
even_run.ring_load
);
match common::output_path() {
Some(path) if path.ends_with(".gltf") => {
let frame = Frame {
time_s: even_run.frames[FRAMES - 1].time_s,
panels: vec![
even_run.frames[FRAMES - 1].panels[0].clone(),
gap_run.frames[FRAMES - 1].panels[0].clone(),
],
readings: Vec::new(),
};
let out = gltf::gltf("A portafilter, and the water through it", &frame);
for why in &out.skipped {
println!(" skipped: {why}");
}
common::write(&path, &out.document);
}
Some(path) => {
let mut frames = Vec::with_capacity(FRAMES);
for n in 0..FRAMES {
frames.push(Frame {
time_s: even_run.frames[n].time_s,
panels: vec![
even_run.frames[n].panels[0].clone(),
gap_run.frames[n].panels[0].clone(),
],
readings: even_run.frames[n]
.readings
.iter()
.chain(gap_run.frames[n].readings.iter())
.cloned()
.collect(),
});
}
let title = "A portafilter, and the water through it";
if path.ends_with(".json") {
println!("\n {FRAMES} frames. Open it in the native window:");
println!(" cd runtime/viewer && cargo run --release -- ../../{path}");
common::write(&path, &pantometry_view::to_json(title, &frames));
} else {
println!(
"\n {FRAMES} frames of two baskets, {:.1} s apart. Drag to rotate, scroll \
to zoom.",
FRAME_S
);
common::write(&path, &report::html(title, &frames));
}
}
None => println!(
"\n Pass a filename for the machine:\n cargo run --release --example \
portafilter_flow flow.html\n cargo run --release --example portafilter_flow \
flow.gltf\n cargo run --release --example portafilter_flow flow.json"
),
}
}
fn outlet_concentration(puck: &Puck) -> f64 {
let (mut num, mut den) = (0.0, 0.0);
for k in 0..NZ {
for i in 0..NX {
if !puck.is_packed(i, NY - 1, k) {
continue;
}
let q = puck.pore_velocity_at(i, NY - 1, k).y.max(0.0);
num += q * puck.concentration_at(i, NY - 1, k).to_si();
den += q;
}
}
if den > 0.0 {
num / den
} else {
0.0
}
}
fn open_area(p: &Puck) -> f64 {
let mut n = 0;
for k in 0..NZ {
for i in 0..NX {
if p.is_packed(i, 0, k) {
n += 1;
}
}
}
n as f64 * DX * DX
}
fn ring_fraction(p: &Puck) -> f64 {
let ring = wall_ring(p);
let (mut packed, mut edge) = (0usize, 0usize);
for k in 0..NZ {
for i in 0..NX {
if p.is_packed(i, 0, k) {
packed += 1;
if ring[i + NX * (NY * k)] {
edge += 1;
}
}
}
}
edge as f64 / packed as f64
}
struct Traced {
name: &'static str,
frames: Vec<Frame>,
released: usize,
in_head: usize,
in_bed: usize,
in_jet: usize,
arrived: usize,
mean_transit: f64,
ring_transit: f64,
core_transit: f64,
mean_load: f64,
ring_load: f64,
max_radius: f64,
}
fn trace(puck: &mut Puck, seed: u64) -> Traced {
let name: &'static str = if puck.name().starts_with("wall") {
"wall gap"
} else {
"even"
};
let hardware = hardware_paths();
let depth = NY as f64 * DX;
let floor = depth;
let superficial = puck.flow_rate().to_si() / Liquid::water().density.to_si() / open_area(puck);
let mut parcels: Vec<Parcel> = Vec::new();
let mut bus = Exchange::new();
let mut t = 0.0;
let mut released = 0usize;
let mut done: Vec<(f64, f64, f64)> = Vec::new(); let mut max_radius: f64 = 0.0;
let mut frames = Vec::with_capacity(FRAMES);
let flight = HEADSPACE / superficial.max(1e-12) + depth / superficial.max(1e-12) * POROSITY;
let per_second = PARCELS as f64 / flight.max(1e-9);
let mut owed = 0.0;
for n in 0..FRAMES {
let target = n as f64 * FRAME_S;
while t < target {
let dt = (puck.max_stable_dt(Time::from_si(t)).to_si() * 0.5).min(target - t);
if dt <= 0.0 {
break;
}
puck.step(Time::from_si(t), Time::from_si(dt), &mut bus)
.expect("stable");
t += dt;
}
owed += per_second * FRAME_S;
while owed >= 1.0 {
owed -= 1.0;
let mut rng = Rng::for_index(seed, released as u64);
let r = RADIUS * rng.unit().sqrt();
let a = rng.range(0.0, std::f64::consts::TAU);
let (cx, cz) = centre();
let at = [cx + r * a.cos(), -HEADSPACE, cz + r * a.sin()];
parcels.push(Parcel {
at,
trail: vec![at],
load: 0.0,
phase: Phase::Headspace,
entered: None,
through: None,
entry_r: r,
fall: 0.0,
});
released += 1;
}
let sub = 8;
for _ in 0..sub {
let h = FRAME_S / sub as f64;
for p in parcels.iter_mut() {
advance(p, puck, t, h, superficial, floor);
}
}
for p in parcels.iter_mut() {
if p.phase == Phase::Bed {
let (cx, cz) = centre();
let r = ((p.at[0] - cx).powi(2) + (p.at[2] - cz).powi(2)).sqrt();
max_radius = max_radius.max(r);
}
p.trail.push(p.at);
if p.trail.len() > TRAIL {
p.trail.remove(0);
}
}
for p in parcels.iter() {
if p.phase == Phase::Cup {
if let (Some(entered), Some(through)) = (p.entered, p.through) {
done.push((through - entered, p.load, p.entry_r));
}
}
}
parcels.retain(|p| p.phase != Phase::Cup);
let mut runs: Vec<Vec<[f64; 3]>> = hardware.0.clone();
let mut values: Vec<f64> = hardware.1.clone();
for p in parcels.iter() {
if p.trail.len() < 2 || p.phase == Phase::Jet {
continue;
}
runs.push(p.trail.iter().map(|q| draw(*q)).collect());
values.push(p.load);
}
if done.len() + parcels.iter().filter(|q| q.phase == Phase::Jet).count() > 0 {
let out = outlet_concentration(puck);
for strand in 0..3 {
let a = std::f64::consts::TAU * strand as f64 / 3.0;
let (wx, wz) = (1.1e-3 * a.cos(), 1.1e-3 * a.sin());
runs.push(
(0..=10)
.map(|n| {
let u = n as f64 / 10.0;
let taper = 1.0 - 0.55 * u;
[wx * taper, -(depth + 0.044 + u * 0.051), wz * taper]
})
.collect(),
);
values.push(out);
}
}
frames.push(Frame {
time_s: t,
panels: vec![Panel {
name: format!("{name} — the machine and the water"),
unit: "kg/m3",
data: PanelData::paths(runs, values),
}],
readings: vec![
Reading::new(name, "in the cup", puck.delivered().to_si() * 1000.0, "g"),
Reading::new(name, "yield", puck.yield_fraction() * 100.0, "%"),
Reading::new(name, "parcels through", done.len() as f64, ""),
],
});
}
let mean = |f: &dyn Fn(&(f64, f64, f64)) -> bool, g: &dyn Fn(&(f64, f64, f64)) -> f64| {
let picked: Vec<f64> = done.iter().filter(|d| f(d)).map(g).collect();
if picked.is_empty() {
0.0
} else {
picked.iter().sum::<f64>() / picked.len() as f64
}
};
let ring_r = RADIUS * (1.0 - ring_fraction(puck)).sqrt();
let all = |_: &(f64, f64, f64)| true;
let in_ring = |d: &(f64, f64, f64)| d.2 >= ring_r;
let in_core = |d: &(f64, f64, f64)| d.2 < ring_r;
Traced {
name,
frames,
released,
in_head: parcels
.iter()
.filter(|p| p.phase == Phase::Headspace)
.count(),
in_bed: parcels.iter().filter(|p| p.phase == Phase::Bed).count(),
in_jet: parcels.iter().filter(|p| p.phase == Phase::Jet).count(),
arrived: done.len(),
mean_transit: mean(&all, &|d| d.0),
ring_transit: mean(&in_ring, &|d| d.0),
core_transit: mean(&in_core, &|d| d.0),
mean_load: mean(&all, &|d| d.1),
ring_load: mean(&in_ring, &|d| d.1),
max_radius,
}
}
fn advance(p: &mut Parcel, puck: &Puck, now: f64, h: f64, superficial: f64, floor: f64) {
match p.phase {
Phase::Headspace => {
p.at[1] += superficial * h;
if p.at[1] >= 0.0 {
p.at[1] = 0.0;
p.phase = Phase::Bed;
p.entered = Some(now);
}
}
Phase::Bed => {
let v0 = velocity(puck, p.at);
let mid = [
p.at[0] + 0.5 * h * v0[0],
p.at[1] + 0.5 * h * v0[1],
p.at[2] + 0.5 * h * v0[2],
];
let v = velocity(puck, mid);
for (at, vel) in p.at.iter_mut().zip(v) {
*at += h * vel;
}
{
let clamp = |v: f64, n: usize| ((v / DX).floor().max(0.0) as usize).min(n - 1);
let c = puck
.concentration_at(clamp(p.at[0], NX), clamp(p.at[1], NY), clamp(p.at[2], NZ))
.to_si();
let tau = (DX / v_len(v).max(1e-9)).max(1e-6);
p.load += (c - p.load) * (1.0 - (-h / tau).exp());
}
if p.at[1] >= floor {
p.at[1] = floor;
p.phase = Phase::Jet;
p.through = Some(now);
}
}
Phase::Jet => {
let (cx, cz) = centre();
p.fall += 9.81 * h;
p.at[1] += p.fall * h;
let pull = (0.6 * h * 9.81).min(1.0);
p.at[0] += (cx - p.at[0]) * pull;
p.at[2] += (cz - p.at[2]) * pull;
if p.at[1] > floor + 0.075 {
p.phase = Phase::Cup;
}
}
Phase::Cup => {}
}
}
fn v_len(v: [f64; 3]) -> f64 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
fn velocity(puck: &Puck, at: [f64; 3]) -> [f64; 3] {
let clamp = |v: f64, n: usize| -> usize { ((v / DX).floor().max(0.0) as usize).min(n - 1) };
let v = puck.pore_velocity_at(clamp(at[0], NX), clamp(at[1], NY), clamp(at[2], NZ));
[v.x, v.y, v.z]
}
const SEG: usize = 48;
fn hardware_paths() -> (Vec<Vec<[f64; 3]>>, Vec<f64>) {
let mut runs: Vec<Vec<[f64; 3]>> = Vec::new();
let depth = NY as f64 * DX;
let circle = |r: f64, y: f64, n: usize| -> Vec<[f64; 3]> {
(0..=n)
.map(|i| {
let a = std::f64::consts::TAU * i as f64 / n as f64;
[r * a.cos(), y, r * a.sin()]
})
.collect()
};
let screen_y = HEADSPACE;
runs.push(circle(RADIUS, screen_y, SEG));
runs.push(circle(RADIUS * 0.97, screen_y, SEG));
for ring in 1..=3 {
let r = RADIUS * 0.25 * ring as f64;
let holes = 6 * ring;
for hole in 0..holes {
let a = std::f64::consts::TAU * hole as f64 / holes as f64;
let (hx, hz) = (r * a.cos(), r * a.sin());
runs.push(
(0..=5)
.map(|i| {
let b = std::f64::consts::TAU * i as f64 / 5.0;
[hx + 0.0012 * b.cos(), screen_y, hz + 0.0012 * b.sin()]
})
.collect(),
);
}
}
let bottom_r = RADIUS - 1.0e-3;
for (r, y) in [
(RADIUS, 0.0),
(RADIUS - 0.25e-3, -depth * 0.25),
(RADIUS - 0.5e-3, -depth * 0.5),
(RADIUS - 0.75e-3, -depth * 0.75),
(bottom_r, -depth),
] {
runs.push(circle(r, y, SEG));
}
for rib in 0..24 {
let a = std::f64::consts::TAU * rib as f64 / 24.0;
runs.push(vec![
[RADIUS * a.cos(), 0.0, RADIUS * a.sin()],
[bottom_r * a.cos(), -depth, bottom_r * a.sin()],
]);
}
runs.push(circle(RADIUS + 1.5e-3, 0.0, SEG));
for rib in 0..24 {
let a = std::f64::consts::TAU * rib as f64 / 24.0;
runs.push(vec![
[RADIUS * a.cos(), 0.0, RADIUS * a.sin()],
[
(RADIUS + 1.5e-3) * a.cos(),
0.0,
(RADIUS + 1.5e-3) * a.sin(),
],
]);
}
for ring in 1..=3 {
let r = bottom_r * 0.3 * ring as f64;
let holes = 8 * ring;
for hole in 0..holes {
let a = std::f64::consts::TAU * hole as f64 / holes as f64;
let (hx, hz) = (r * a.cos(), r * a.sin());
runs.push(
(0..=5)
.map(|i| {
let b = std::f64::consts::TAU * i as f64 / 5.0;
[hx + 0.0009 * b.cos(), -depth, hz + 0.0009 * b.sin()]
})
.collect(),
);
}
}
let body_r = RADIUS + 4.5e-3;
let (skirt, throat, spout) = (-depth - 12e-3, -depth - 30e-3, -depth - 44e-3);
runs.push(circle(body_r, 0.0, SEG));
runs.push(circle(body_r, skirt, SEG));
runs.push(circle(7e-3, throat, SEG / 2));
runs.push(circle(5.5e-3, spout, SEG / 2));
for rib in 0..24 {
let a = std::f64::consts::TAU * rib as f64 / 24.0;
runs.push(vec![
[body_r * a.cos(), 0.0, body_r * a.sin()],
[body_r * a.cos(), skirt, body_r * a.sin()],
[7e-3 * a.cos(), throat, 7e-3 * a.sin()],
[5.5e-3 * a.cos(), spout, 5.5e-3 * a.sin()],
]);
}
let (cup_y, cup_r) = (-depth - 95e-3, 32e-3);
runs.push(circle(cup_r, cup_y + 42e-3, SEG));
runs.push(circle(cup_r * 0.72, cup_y, SEG));
for rib in 0..20 {
let a = std::f64::consts::TAU * rib as f64 / 20.0;
runs.push(vec![
[cup_r * a.cos(), cup_y + 42e-3, cup_r * a.sin()],
[cup_r * 0.72 * a.cos(), cup_y, cup_r * 0.72 * a.sin()],
]);
}
let values = vec![0.0; runs.len()];
(runs, values)
}