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(rng: np.random.Generator | None = None) -> float:
"""Draw a single standard normal variate.
Parameters
----------
rng : numpy.random.Generator | None, optional
Random number generator to use. If ``None``, a fresh default generator
is created.
Returns
-------
float
A single sample from the standard normal distribution.
Notes
-----
This uses :meth:`numpy.random.Generator.standard_normal`, which relies on
NumPy's normal sampling implementation.
"""
_rng = rng if rng is not None else np.random.default_rng()
return float(_rng.standard_normal())
[docs]
def lognormal_pp_radii(
rp_gstd: float,
rp_g: float,
n: int,
seed: int | None = None,
truncate: bool = True,
rng: np.random.Generator | None = None,
) -> 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
Deprecated. Prefer passing ``rng`` directly. If both are given,
``rng`` takes precedence.
truncate : bool, optional
Use the FracVAL 2*sigma truncate version
rng : np.random.Generator | None, optional
A NumPy Generator instance (e.g. ``np.random.default_rng(seed)``).
If provided, ``seed`` is ignored. If None and ``seed`` is also None,
a fresh Generator is created.
Returns
-------
np.ndarray
A 1D NumPy array of N generated radii.
Raises
------
ValueError
If `rp_g` is not positive.
Notes
-----
Uses `numpy.random.Generator.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 rng is not None:
_rng = rng
elif seed is not None:
_rng = np.random.default_rng(seed)
else:
_rng = np.random.default_rng()
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 = _rng.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.
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 = _rng.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