Skip to content

Commit 60ceb09

Browse files
committed
Refactor convolve_moments and convolve_moments_self functions for improved clarity and consistency; enhance tests for edge cases.
1 parent 66b928a commit 60ceb09

File tree

2 files changed

+125
-79
lines changed

2 files changed

+125
-79
lines changed

src/polykin/distributions/misc.py

Lines changed: 91 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
import numpy as np
99
from numpy.polynomial.laguerre import Laguerre
1010

11-
from polykin.utils.types import FloatArray, FloatArrayLike, FloatOrArrayLike
11+
from polykin.utils.types import (FloatArray, FloatArrayLike, FloatVector,
12+
FloatVectorLike)
1213

1314
__all__ = [
1415
'convolve_moments',
@@ -18,119 +19,130 @@
1819
]
1920

2021

21-
def convolve_moments(q0: float,
22-
q1: float,
23-
q2: float,
24-
r0: float,
25-
r1: float,
26-
r2: float
27-
) -> tuple[float, float, float]:
28-
r"""Compute the first three moments of the convolution of two distributions.
22+
def convolve_moments(
23+
q: FloatVectorLike,
24+
r: FloatVectorLike,
25+
) -> FloatVector:
26+
r"""Compute the moments of the convolution of two distributions.
2927
30-
If $P = Q * R$ is the convolution of $Q$ and $R$, defined as:
28+
If $P = Q * R$ is the convolution of distributions $Q$ and $R$, defined as:
3129
32-
$$ P_n = \sum_{i=0}^{n} Q_{n-i}R_{i} $$
30+
$$ P_n = \sum_{m=0}^{n} Q_{n-m}R_{m} $$
3331
34-
then the first three moments of $P$ are related to the moments of $Q$ and
35-
$R$ by:
32+
and $p_i$, $q_i$ and $r_i$ denote the $i$-th moments of $P$, $Q$ and $R$,
33+
respectively,
3634
3735
\begin{aligned}
38-
p_0 &= q_0 r_0 \\
39-
p_1 &= q_1 r_0 + q_0 r_1 \\
40-
p_2 &= q_2 r_0 + 2 q_1 r_1 + q_0 r_2
36+
p_i & = \sum_{n=0}^{\infty} n^i P_n \\
37+
q_i & = \sum_{n=0}^{\infty} n^i Q_n \\
38+
r_i & = \sum_{n=0}^{\infty} n^i R_n
4139
\end{aligned}
4240
43-
where $p_i$, $q_i$ and $r_i$ denote the $i$-th moments of $P$, $Q$ and $R$,
44-
respectively.
41+
then the moments of $P$ are related to the moments of $Q$ and $R$ by:
42+
43+
$$ p_i = \sum_{j=0}^{i} \binom{i}{j} q_j r_{i-j} $$
4544
4645
Parameters
4746
----------
48-
q0 : float
49-
0-th moment of $Q$.
50-
q1 : float
51-
1-st moment of $Q$.
52-
q2 : float
53-
2-nd moment of $Q$.
54-
r0 : float
55-
0-th moment of $R$.
56-
r1 : float
57-
1-st moment of $R$.
58-
r2 : float
59-
2-nd moment of $R$.
47+
q : FloatVectorLike (N)
48+
Moments of $Q$, denoted $(q_0, q_1, \ldots)$.
49+
r : FloatVectorLike (N)
50+
Moments of $R$, denoted $(r_0, r_1, \ldots)$.
6051
6152
Returns
6253
-------
63-
tuple[float, float, float]
64-
0-th, 1-st and 2-nd moments of $P=Q*R$.
54+
FloatVector (N)
55+
Moments of $P=Q*R$, denoted $(p_0, p_1, \ldots)$.
6556
6657
Examples
6758
--------
6859
>>> from polykin.distributions import convolve_moments
69-
>>> convolve_moments(1., 1e2, 2e4, 1., 50., 5e4)
70-
(1.0, 150.0, 80000.0)
60+
>>> convolve_moments([1.0, 1e2, 2e4], [1.0, 50.0, 5e4])
61+
array([1.0e+00, 1.5e+02, 8.0e+04])
7162
"""
72-
p0 = q0*r0
73-
p1 = q1*r0 + q0*r1
74-
p2 = q2*r0 + 2*q1*r1 + q0*r2
75-
return p0, p1, p2
63+
if len(q) != len(r):
64+
raise ValueError("`q` and `r` must have the same length.")
65+
66+
p = np.zeros(len(q))
67+
for i in range(len(q)):
68+
for j in range(i+1):
69+
p[i] += comb(i, j)*q[j]*r[i-j]
70+
71+
return p
7672

7773

78-
def convolve_moments_self(q0: float,
79-
q1: float,
80-
q2: float,
81-
order: int = 1
82-
) -> tuple[float, float, float]:
83-
r"""Compute the first three moments of the k-th order convolution of a
84-
distribution with itself.
74+
def convolve_moments_self(
75+
q: FloatVectorLike,
76+
order: int
77+
) -> FloatVector:
78+
r"""Compute the moments of the k-th order convolution of a distribution
79+
with itself.
8580
86-
If $P^k$ is the $k$-th order convolution of $Q$ with itself, defined as:
81+
If $P^{(k)}$ is the $k$-th order convolution of $Q$ with itself, defined as:
8782
8883
\begin{aligned}
89-
P^1_n &= Q*Q = \sum_{i=0}^{n} Q_{n-i} Q_{i} \\
90-
P^2_n &= (Q*Q)*Q = \sum_{i=0}^{n} Q_{n-i} P^1_{i} \\
91-
P^3_n &= ((Q*Q)*Q)*Q = \sum_{i=0}^{n} Q_{n-i} P^2_{i} \\
84+
P^{(1)}_n &= Q*Q = \sum_{m=0}^{n} Q_{n-m} Q_{m} \\
85+
P^{(2)}_n &= (Q*Q)*Q = \sum_{m=0}^{n} Q_{n-m} P^{(1)}_{m} \\
86+
P^{(3)}_n &= ((Q*Q)*Q)*Q = \sum_{m=0}^{n} Q_{n-m} P^{(2)}_{m} \\
9287
...
9388
\end{aligned}
9489
95-
then the first three moments of $P^k$ are related to the moments of $Q$ by:
90+
then the moments of $P^{(k)}$ are related to the moments of $Q$ by:
9691
9792
\begin{aligned}
9893
p_0 &= q_0^{k+1} \\
9994
p_1 &= (k+1) q_0^k q_1 \\
100-
p_2 &= (k+1) q_0^{k-1} (k q_1^2 +q_0 q_2)
95+
p_2 &= (k+1) q_0^{k-1} (k q_1^2 +q_0 q_2) \\
96+
\ldots
10197
\end{aligned}
10298
103-
where $p_i$ and $q_i$ denote the $i$-th moments of $P^k$ and $Q$,
99+
where $p_i$ and $q_i$ denote the $i$-th moments of $P^{(k)}$ and $Q$,
104100
respectively.
105101
106102
Parameters
107103
----------
108-
q0 : float
109-
0-th moment of $Q$.
110-
q1 : float
111-
1-st moment of $Q$.
112-
q2 : float
113-
2-nd moment of $Q$.
104+
q : FloatVectorLike (N)
105+
Moments of $Q$, denoted $(q_0, q_1, \ldots)$.
106+
order : int
107+
Order of the convolution.
114108
115109
Returns
116110
-------
117-
tuple[float, float, float]
118-
0-th, 1-st and 2-nd moments of $P^k=(Q*Q)*...$.
111+
FloatVector (N)
112+
Moments of $P^{(k)}=(Q*Q)*...$, denoted $(p_0, p_1, \ldots)$.
119113
120114
Examples
121115
--------
122116
>>> from polykin.distributions import convolve_moments_self
123-
>>> convolve_moments_self(1., 1e2, 2e4, 2)
124-
(1.0, 300.0, 120000.0)
117+
>>> convolve_moments_self([1e0, 1e2, 2e4], 2)
118+
array([1.0e+00, 3.0e+02, 1.2e+05])
125119
"""
126-
p0 = q0**(order+1)
127-
p1 = (order+1) * q0**order * q1
128-
p2 = (order+1) * q0**(order-1) * (order*q1**2 + q0*q2)
129-
return p0, p1, p2
120+
121+
q = np.asarray(q, dtype=float)
122+
123+
if order == 0:
124+
return q.copy()
125+
126+
N = len(q)
127+
if N <= 3:
128+
# Use closed-form expressions for the first three moments
129+
p = np.empty(N)
130+
if N > 0:
131+
p[0] = q[0]**(order + 1)
132+
if N > 1:
133+
p[1] = (order + 1) * q[0]**order * q[1]
134+
if N > 2:
135+
p[2] = (order + 1) * q[0]**(order - 1) * (order*q[1]**2 + q[0]*q[2])
136+
else:
137+
p = q.copy()
138+
for _ in range(order):
139+
p = convolve_moments(q, p)
140+
141+
return p
130142

131143

132144
def convert_polymer_standards(
133-
M1: FloatOrArrayLike,
145+
M1: float | FloatArrayLike,
134146
K1: float,
135147
K2: float,
136148
a1: float,
@@ -159,7 +171,7 @@ def convert_polymer_standards(
159171
160172
Parameters
161173
----------
162-
M1 : FloatOrArrayLike
174+
M1 : float | FloatArrayLike
163175
Molar mass in standard 1.
164176
K1 : float
165177
Mark-Houwink coefficient for standard 1.
@@ -177,30 +189,30 @@ def convert_polymer_standards(
177189
178190
Examples
179191
--------
180-
A sample of PMMA was mesured to have a molar mass of 100 kg/mol in PS
192+
A sample of PMMA was mesured to have a molar mass of 1e5 g/mol in PS
181193
equivalent weight. What is the sample molar mass in actual PMMA weight?
182194
183195
>>> from polykin.distributions import convert_polymer_standards
184196
>>> a1 = 0.77 # PS in THF
185197
>>> K1 = 6.82e-3 # PS in THF
186198
>>> a2 = 0.69 # PMMA in THF
187199
>>> K2 = 1.28e-2 # PMMA in THF
188-
>>> M2 = convert_polymer_standards(100, K1, K2, a1, a2)
189-
>>> print(f"{M2:.2f} kg/mol")
190-
85.68 kg/mol
200+
>>> M2 = convert_polymer_standards(1e5, K1, K2, a1, a2)
201+
>>> print(f"{M2:.2e} g/mol")
202+
1.19e+05 g/mol
191203
"""
192-
M1 = np.asarray(M1, dtype=np.float64)
204+
M1 = np.asarray(M1, dtype=float)
193205
return (K1/K2)**(1/(1 + a2)) * M1**((1 + a1)/(1 + a2))
194206

195207

196208
def reconstruct_Laguerre(
197-
moments: FloatArrayLike,
209+
moments: FloatVectorLike,
198210
) -> Callable[[FloatArrayLike], FloatArray]:
199-
r"""Reconstruct a differential number distribution from its first `k`
211+
r"""Reconstruct a differential number distribution from a finite set of
200212
moments using a Laguerre-series approximation.
201213
202-
According to Bamford and Tompa, a number distribution can be expressed as
203-
an (infinite) expansion in Laguerre polynomials:
214+
According to Bamford and Tompa, a number distribution $P(n)$ can be expressed
215+
as an (infinite) expansion in Laguerre polynomials:
204216
205217
$$ P(n) = \frac{e^{-\rho}}{(DP_n)^2}
206218
\sum_{m=0}^{\infty} \gamma_m L_m(\rho) $$
@@ -231,8 +243,8 @@ def reconstruct_Laguerre(
231243
232244
Parameters
233245
----------
234-
moments : FloatArrayLike
235-
First `k` raw moments of the number distribution (`λ₀`, `λ₁`, ..., `λ_k`).
246+
moments : FloatVectorLike
247+
Moments of $P$, denoted $(\lambda_0, \lambda_1, \ldots)$.
236248
237249
Returns
238250
-------

tests/distributions/test_misc.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,46 @@
33
# Copyright Hugo Vale 2025
44

55
import numpy as np
6+
import pytest
67
from numpy import allclose, isclose
78

89
from polykin.distributions import (Flory, convert_polymer_standards,
10+
convolve_moments, convolve_moments_self,
911
reconstruct_Laguerre)
1012

1113

14+
def test_convolve_moments():
15+
q0 = 1.0
16+
q1 = q0 * 100.0
17+
q2 = q1 * 200.0
18+
p0, p1, p2 = convolve_moments([q0, q1, q2], [q0, q1, q2])
19+
assert isclose(p0*p2/p1**2, 1.5)
20+
# different lengths
21+
with pytest.raises(ValueError):
22+
convolve_moments([q0, q1], [q0, q1, q2])
23+
24+
25+
def test_convolve_moments_self():
26+
27+
def convolve_moments_self_iter(q, order):
28+
r = q
29+
for _ in range(order):
30+
r = convolve_moments(q, r)
31+
return r
32+
33+
for order in range(1, 10):
34+
q0 = 1.0
35+
q1 = q0 * 100.0
36+
q2 = q1 * 200.0
37+
s = convolve_moments_self_iter([q0, q1, q2], order)
38+
p = convolve_moments_self([q0, q1, q2], order)
39+
assert allclose(s, p)
40+
41+
pA = convolve_moments_self([q0, q1, q2], order=4)
42+
pB = convolve_moments_self([q0, q1, q2, q2*100], order=4)
43+
assert allclose(pA, pB[:3])
44+
45+
1246
def test_convert_polymer_standards():
1347
a1 = 0.77 # PS in THF
1448
K1 = 6.82e-3 # PS in THF

0 commit comments

Comments
 (0)