2core/reductions/bin_moment.py — Logarithmic size-bin moment reduction.
4Host-independent. Implements the "reconstruct -> walk -> project"
5reduction of Ghoniem (2026), Section 5.4: a per-size cluster ladder of
6length N is replaced by a discrete prefix plus O(log N) logarithmically
7spaced bins, each carrying P in {1,2,3} moments
9 mu_k^(0) = sum_{n in B_k} c_n
10 mu_k^(1) = sum_{n in B_k} n c_n
11 mu_k^(2) = sum_{n in B_k} n^2 c_n
13evolved by the three-step procedure executed at every RHS call:
15 R reconstruct a per-size distribution c_n from the tracked moments
16 under the chosen intra-bin closure (piecewise-constant / linear
17 dual-basis / log-normal);
18 E evaluate the *exact* per-size master-equation rates with a
19 :class:`GraphWalker` — no bin-averaged rate constants;
20 P project the per-size rates onto the tracked moments,
21 d mu_k^(p)/dt = sum_{n in B_k} n^p (dc_n/dt).
23Because step E uses the exact per-size rates, every conservation
24property of the master equation is inherited automatically — no
25inter-bin flux has to be made upwind-consistent (Section 5.4).
27The pure bin-partition and closure mathematics is reused from the
28reviewed, validated ``bin_moment_rates`` module (those functions are
29host-independent); this class composes them with a graph walk.
31from __future__
import annotations
33from typing
import List, Tuple
37from ...bin_moment_rates
import (
39 moments_from_distribution,
41 reconstruct_distribution,
46 """A logarithmic bin-moment reduction of one cluster size axis.
51 Largest cluster size on the full per-size ladder.
53 Sizes 1..n_discrete are kept discrete (one ODE per size); sizes
54 above are aggregated into geometric bins.
56 Target number of geometric bins above the discrete prefix.
57 shape_function : {'constant', 'linear', 'lognormal'}
58 The intra-bin closure; selects P = 1, 2 or 3 moments per bin.
61 def __init__(self, n_max: int, n_discrete: int, n_bins: int,
62 shape_function: str =
"linear"):
81 """Number of ODE entries: discrete prefix + P moments per bin."""
86 """Reconstruct the full per-size distribution c_n (length n_max)."""
88 c = np.zeros(self.
n_max)
92 mu0 = np.maximum(blk[0::P][:self.
n_bins], 0.0)
93 mu1 = blk[1::P][:self.
n_bins]
if P >= 2
else None
94 mu2 = blk[2::P][:self.
n_bins]
if P >= 3
else None
95 c_bin = reconstruct_distribution(
101 def project(self, dc: np.ndarray) -> np.ndarray:
102 """Project per-size rates dc_n/dt onto the tracked moment rates."""
104 out = np.zeros(self.
length)
106 for k, (nlo, nhi)
in enumerate(self.
bins):
107 ns = np.arange(nlo, nhi)
109 valid = (idx >= 0) & (idx < self.
n_max)
111 nv = ns[valid].astype(float)
113 out[base] = dcv.sum()
115 out[base + 1] = float(nv @ dcv)
117 out[base + 2] = float((nv * nv) @ dcv)
122 """Pack a per-size distribution into the reduced moment vector."""
124 reduced = np.zeros(self.
length)
127 mu0, mu1, mu2 = moments_from_distribution(c, self.
bins, n_mom=P)
128 for k
in range(self.
n_bins):
130 reduced[base] = mu0[k]
132 reduced[base + 1] = mu1[k]
134 reduced[base + 2] = mu2[k]
138 return (f
"<BinMomentReduction n_max={self.n_max} "
139 f
"n_discrete={self.n_discrete} n_bins={self.n_bins} "
140 f
"closure={self.shape_function!r} P={self.moments_per_bin} "
141 f
"len={self.length}>")
np.ndarray moments_of(self, np.ndarray c)
__init__(self, int n_max, int n_discrete, int n_bins, str shape_function="linear")
np.ndarray reconstruct(self, np.ndarray reduced)
np.ndarray project(self, np.ndarray dc)