Source code for pyfracval.particle_generation
# particle_generation.py
"""Functions for generating primary particle radii."""
import logging
import numpy as np
logger = logging.getLogger(__name__)
[docs]
def random_normal_custom() -> float:
"""
Generates a normally distributed random number using the Ziggurat method
as implemented in numpy, which is generally preferred over custom
acceptance-rejection methods unless specific properties are needed.
"""
# The custom Fortran implementation is an acceptance-rejection method.
# For standard normal distribution, numpy's is highly optimized and recommended.
return np.random.normal(0.0, 1.0)
[docs]
def lognormal_pp_radii(
rp_gstd: float,
rp_g: float,
n: int,
seed: int | None = None,
truncate: bool = True,
) -> np.ndarray:
"""Generate N random radii from a lognormal distribution.
Parameters
----------
rp_gstd : float
Geometric standard deviation of the distribution (must be >= 1.0).
If 1.0, generates monodisperse particles.
rp_g : float
Geometric mean radius of the distribution (must be > 0).
n : int
Number of radii to generate.
seed : int | None, optional
Random seed for reproducibility, by default None.
truncate : bool, optional
Use the FracVAL 2*sigma truncate version
Returns
-------
np.ndarray
A 1D NumPy array of N generated radii.
Raises
------
ValueError
If `rp_g` is not positive.
Notes
-----
Uses `numpy.random.lognormal`. The underlying normal distribution's
parameters are mu=log(rp_g) and sigma=log(rp_gstd).
The original Fortran code included optional truncation at approximately
+/- 2 geometric standard deviations; this is not enabled by default here.
"""
if seed is not None:
np.random.seed(seed)
if rp_gstd < 1.0:
logger.warning("Geometric standard deviation should be >= 1.0. Setting to 1.0.")
rp_gstd = 1.0
if rp_g <= 0:
raise ValueError("Geometric mean radius (rp_g) must be positive.")
if rp_gstd == 1.0:
# Monodisperse case
logger.info("Generating monodisperse particles.")
return np.full(n, rp_g, dtype=float)
else:
# Polydisperse case using numpy's lognormal
# The parameters for np.random.lognormal are mu and sigma of the *underlying* normal distribution.
# mu = log(geometric_mean)
# sigma = log(geometric_standard_deviation)
mu = np.log(rp_g)
sigma = np.log(rp_gstd)
if not truncate:
radii = np.random.lognormal(mean=mu, sigma=sigma, size=n)
logger.info(
f"Generated polydisperse particles (mean={np.mean(radii):.2f}, std={np.std(radii):.2f})."
)
else:
# The Fortran code truncates at rp_g / (rp_gstd**2) and rp_g * (rp_gstd**2)
# This corresponds to approximately +/- 2 sigma in the underlying normal distribution.
# np.random.lognormal directly generates from the distribution.
# If strict truncation matching the Fortran code is needed:
min_val = rp_g / (rp_gstd**2)
max_val = rp_g * (rp_gstd**2)
radii = np.zeros(n, dtype=float)
generated_count = 0
while generated_count < n:
# Generate candidates (can generate more than needed for efficiency)
num_needed = n - generated_count
candidates = np.random.lognormal(
mean=mu, sigma=sigma, size=num_needed * 2
) # Generate extras
valid = candidates[(candidates >= min_val) & (candidates <= max_val)]
num_valid = len(valid)
take = min(num_valid, num_needed)
if take > 0:
radii[generated_count : generated_count + take] = valid[:take]
generated_count += take
logger.info(
f"Generated polydisperse particles (truncated between {min_val:.2f} and {max_val:.2f})."
)
return radii