pyFracAggregate 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyFracAggregate/__init__.py +105 -0
- pyFracAggregate/analysis/__init__.py +1 -0
- pyFracAggregate/analysis/correlation.py +207 -0
- pyFracAggregate/analysis/morphology.py +72 -0
- pyFracAggregate/core/__init__.py +1 -0
- pyFracAggregate/core/aggregate.py +93 -0
- pyFracAggregate/core/distributions.py +62 -0
- pyFracAggregate/core/math_utils.py +124 -0
- pyFracAggregate/generators/__init__.py +21 -0
- pyFracAggregate/generators/base.py +53 -0
- pyFracAggregate/generators/cca.py +95 -0
- pyFracAggregate/generators/factory.py +35 -0
- pyFracAggregate/generators/fracval.py +258 -0
- pyFracAggregate/generators/optimizer_flage.py +171 -0
- pyFracAggregate/generators/pca.py +46 -0
- pyFracAggregate/generators/placement/__init__.py +3 -0
- pyFracAggregate/generators/placement/_helpers.py +113 -0
- pyFracAggregate/generators/placement/algebraic.py +135 -0
- pyFracAggregate/generators/placement/base.py +81 -0
- pyFracAggregate/generators/placement/random_.py +46 -0
- pyFracAggregate/generators/tdcca.py +251 -0
- pyFracAggregate/io/__init__.py +1 -0
- pyFracAggregate/io/data.py +57 -0
- pyFracAggregate/io/visualization.py +123 -0
- pyFracAggregate/io/vtk.py +28 -0
- pyfracaggregate-0.1.0.dist-info/METADATA +24 -0
- pyfracaggregate-0.1.0.dist-info/RECORD +29 -0
- pyfracaggregate-0.1.0.dist-info/WHEEL +4 -0
- pyfracaggregate-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from pyFracAggregate.core.aggregate import Aggregate
|
|
3
|
+
from pyFracAggregate.generators.factory import get_generator
|
|
4
|
+
from pyFracAggregate.core.distributions import Monodisperse, LognormalDistribution
|
|
5
|
+
from pyFracAggregate.analysis.morphology import radius_of_gyration, center_of_mass
|
|
6
|
+
from pyFracAggregate.analysis.correlation import (
|
|
7
|
+
pair_correlation_function,
|
|
8
|
+
estimate_fractal_dimension,
|
|
9
|
+
plot_pair_correlation
|
|
10
|
+
)
|
|
11
|
+
from pyFracAggregate.io.vtk import export_vtm, export_vtk
|
|
12
|
+
from pyFracAggregate.io.data import export_yaml
|
|
13
|
+
from pyFracAggregate.io.visualization import export_render, export_rotation_video
|
|
14
|
+
from pyFracAggregate.generators.pca import PCAGenerator
|
|
15
|
+
from pyFracAggregate.generators.cca import CCAGenerator
|
|
16
|
+
from pyFracAggregate.generators.fracval import FracVALGenerator
|
|
17
|
+
from pyFracAggregate.generators.tdcca import ThouyJullienGenerator
|
|
18
|
+
from pyFracAggregate.generators.placement.algebraic import AlgebraicPlacement
|
|
19
|
+
from pyFracAggregate.generators.placement.random_ import RandomPlacement
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
__author__ = "Fan Zhang"
|
|
23
|
+
|
|
24
|
+
def generate(
|
|
25
|
+
n_particles: int,
|
|
26
|
+
df: float,
|
|
27
|
+
kf: float,
|
|
28
|
+
method: str = 'pca',
|
|
29
|
+
particle_dist = None,
|
|
30
|
+
overlap_tolerance: float = 1e-5,
|
|
31
|
+
placement: str = 'algebraic',
|
|
32
|
+
**kwargs
|
|
33
|
+
) -> Aggregate:
|
|
34
|
+
"""
|
|
35
|
+
High-level API to generate a fractal aggregate.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
n_particles (int): Target number of particles.
|
|
39
|
+
df (float): Fractal dimension (typically 1.5 - 2.5).
|
|
40
|
+
kf (float): Fractal prefactor (typically 1.0 - 2.0).
|
|
41
|
+
method (str): Algorithm to use ('pca', 'cca', 'fracval', 'tdcca').
|
|
42
|
+
particle_dist: Particle radius distribution (defaults to Monodisperse(1.0)).
|
|
43
|
+
overlap_tolerance (float): Allowed overlap between spheres.
|
|
44
|
+
placement (str): Placement strategy ('algebraic' or 'random').
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Aggregate: The generated fractal aggregate.
|
|
48
|
+
"""
|
|
49
|
+
if particle_dist is None:
|
|
50
|
+
particle_dist = Monodisperse(1.0)
|
|
51
|
+
|
|
52
|
+
generator = get_generator(
|
|
53
|
+
method=method,
|
|
54
|
+
n_particles=n_particles,
|
|
55
|
+
df=df,
|
|
56
|
+
kf=kf,
|
|
57
|
+
particle_dist=particle_dist,
|
|
58
|
+
overlap_tolerance=overlap_tolerance,
|
|
59
|
+
placement=placement,
|
|
60
|
+
**kwargs
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return generator.generate()
|
|
64
|
+
|
|
65
|
+
def analyze(aggregate: Aggregate):
|
|
66
|
+
"""
|
|
67
|
+
Compute core morphological properties.
|
|
68
|
+
"""
|
|
69
|
+
rg = radius_of_gyration(aggregate)
|
|
70
|
+
r_centers, c_r = pair_correlation_function(aggregate)
|
|
71
|
+
df_est, r2, _ = estimate_fractal_dimension(r_centers, c_r,
|
|
72
|
+
r_min=np.mean(aggregate.radii),
|
|
73
|
+
r_max=rg)
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
"Rg": rg,
|
|
77
|
+
"CoM": center_of_mass(aggregate),
|
|
78
|
+
"N": aggregate.current_size,
|
|
79
|
+
"Df_estimated": df_est,
|
|
80
|
+
"R2": r2
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
__all__ = [
|
|
84
|
+
"generate",
|
|
85
|
+
"analyze",
|
|
86
|
+
"Aggregate",
|
|
87
|
+
"Monodisperse",
|
|
88
|
+
"LognormalDistribution",
|
|
89
|
+
"radius_of_gyration",
|
|
90
|
+
"center_of_mass",
|
|
91
|
+
"pair_correlation_function",
|
|
92
|
+
"estimate_fractal_dimension",
|
|
93
|
+
"plot_pair_correlation",
|
|
94
|
+
"export_yaml",
|
|
95
|
+
"export_render",
|
|
96
|
+
"export_rotation_video",
|
|
97
|
+
"export_vtm",
|
|
98
|
+
"export_vtk",
|
|
99
|
+
"PCAGenerator",
|
|
100
|
+
"CCAGenerator",
|
|
101
|
+
"FracVALGenerator",
|
|
102
|
+
"ThouyJullienGenerator",
|
|
103
|
+
"AlgebraicPlacement",
|
|
104
|
+
"RandomPlacement",
|
|
105
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Analysis tools for fractal aggregates."""
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.spatial import cKDTree
|
|
3
|
+
from pyFracAggregate.core.aggregate import Aggregate
|
|
4
|
+
|
|
5
|
+
def pair_correlation_function(
|
|
6
|
+
aggregate: Aggregate,
|
|
7
|
+
bins: int = 50,
|
|
8
|
+
r_max: float = None
|
|
9
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
10
|
+
"""Calculates the two-point density correlation function C(r).
|
|
11
|
+
|
|
12
|
+
Efficient calculation based on scipy.spatial.cKDTree.
|
|
13
|
+
|
|
14
|
+
C(r) = n(r) / (4 * pi * r^2 * h * N)
|
|
15
|
+
where n(r) is the number of particle pairs between distance r and r+h,
|
|
16
|
+
N is the total number of particles, and h is the step size (bin width).
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
aggregate (Aggregate): Cluster object.
|
|
20
|
+
bins (int): Number of bins for r.
|
|
21
|
+
r_max (float, optional): Maximum distance for calculating the correlation function.
|
|
22
|
+
If None, the maximum distance between particles is used.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
tuple[np.ndarray, np.ndarray]: (r_centers, C_r) where r_centers are the bin
|
|
26
|
+
center distances and C_r are the corresponding correlation values.
|
|
27
|
+
"""
|
|
28
|
+
if aggregate.current_size < 2:
|
|
29
|
+
return np.array([]), np.array([])
|
|
30
|
+
|
|
31
|
+
positions = aggregate.positions
|
|
32
|
+
N = aggregate.current_size
|
|
33
|
+
|
|
34
|
+
# Build KDTree for accelerated queries
|
|
35
|
+
tree = cKDTree(positions)
|
|
36
|
+
|
|
37
|
+
if r_max is None:
|
|
38
|
+
# Estimate maximum distance (upper bound of the furthest pair)
|
|
39
|
+
# Use 2x the maximum distance from center as a safe upper bound
|
|
40
|
+
com = np.mean(positions, axis=0)
|
|
41
|
+
max_dist_to_center = np.max(np.linalg.norm(positions - com, axis=1))
|
|
42
|
+
r_max = 2.0 * max_dist_to_center
|
|
43
|
+
if r_max == 0:
|
|
44
|
+
return np.array([]), np.array([])
|
|
45
|
+
|
|
46
|
+
# Calculate distance statistics (upper triangle only to avoid double counting,
|
|
47
|
+
# though tree.count_neighbors handles pair counting).
|
|
48
|
+
# tree.count_neighbors returns cumulative counts (<= r), so we differentiate.
|
|
49
|
+
r_edges = np.linspace(0, r_max, bins + 1)
|
|
50
|
+
|
|
51
|
+
# count_neighbors can take multiple radii at once
|
|
52
|
+
# cumulative_counts[i] contains number of pairs with distance <= r_edges[i]
|
|
53
|
+
cumulative_counts = tree.count_neighbors(tree, r_edges)
|
|
54
|
+
|
|
55
|
+
# Subtract self-matches (N) at r=0 to avoid interference with real pair statistics.
|
|
56
|
+
# (Coincident points are not expected due to collision detection).
|
|
57
|
+
cumulative_counts = np.array(cumulative_counts, dtype=np.float64)
|
|
58
|
+
# Subtract N self-matches for all r >= 0 counts
|
|
59
|
+
cumulative_counts -= N
|
|
60
|
+
# Ensure no negative values (preventing anomalies due to precision)
|
|
61
|
+
cumulative_counts = np.maximum(cumulative_counts, 0)
|
|
62
|
+
|
|
63
|
+
# Differentiate to get counts in each bin: n(r)
|
|
64
|
+
n_r = np.diff(cumulative_counts)
|
|
65
|
+
|
|
66
|
+
# r_centers are the interval midpoints
|
|
67
|
+
r_centers = (r_edges[:-1] + r_edges[1:]) / 2.0
|
|
68
|
+
h = r_edges[1] - r_edges[0]
|
|
69
|
+
|
|
70
|
+
# Avoid division by zero at r=0, although r_centers > 0
|
|
71
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
72
|
+
c_r = n_r / (4.0 * np.pi * (r_centers ** 2) * h * N)
|
|
73
|
+
|
|
74
|
+
# Assign 0 for r=0 or cases where distance is too small (causing div by 0)
|
|
75
|
+
c_r = np.nan_to_num(c_r, posinf=0.0)
|
|
76
|
+
|
|
77
|
+
return r_centers, c_r
|
|
78
|
+
|
|
79
|
+
def estimate_fractal_dimension(
|
|
80
|
+
r_centers: np.ndarray,
|
|
81
|
+
c_r: np.ndarray,
|
|
82
|
+
r_min: float = None,
|
|
83
|
+
r_max: float = None
|
|
84
|
+
) -> tuple[float, float, dict]:
|
|
85
|
+
"""Estimates the fractal dimension Df from the pair correlation function C(r).
|
|
86
|
+
|
|
87
|
+
Performs log-log linear regression on the fractal regime (a < r < Rg).
|
|
88
|
+
Df is calculated as: Df = slope + 3.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
r_centers (np.ndarray): Bin center distances.
|
|
92
|
+
c_r (np.ndarray): Correlation function values.
|
|
93
|
+
r_min (float, optional): Lower bound for regression.
|
|
94
|
+
r_max (float, optional): Upper bound for regression.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
tuple[float, float, dict]: (Df, R_squared, fit_results)
|
|
98
|
+
- Df: Estimated fractal dimension.
|
|
99
|
+
- R_squared: Coefficient of determination.
|
|
100
|
+
- fit_results: Dictionary containing 'slope', 'intercept', 'x_fit', 'y_fit'.
|
|
101
|
+
"""
|
|
102
|
+
# Filter valid data (C(r) > 0 for log)
|
|
103
|
+
mask = c_r > 0
|
|
104
|
+
if r_min is not None:
|
|
105
|
+
mask &= (r_centers >= r_min)
|
|
106
|
+
if r_max is not None:
|
|
107
|
+
mask &= (r_centers <= r_max)
|
|
108
|
+
|
|
109
|
+
x = r_centers[mask]
|
|
110
|
+
y = c_r[mask]
|
|
111
|
+
|
|
112
|
+
if len(x) < 2:
|
|
113
|
+
return 0.0, 0.0, {}
|
|
114
|
+
|
|
115
|
+
log_x = np.log10(x)
|
|
116
|
+
log_y = np.log10(y)
|
|
117
|
+
|
|
118
|
+
# Linear regression: log10(C(r)) = slope * log10(r) + intercept
|
|
119
|
+
slope, intercept = np.polyfit(log_x, log_y, 1)
|
|
120
|
+
|
|
121
|
+
# Calculate R-squared
|
|
122
|
+
y_pred = slope * log_x + intercept
|
|
123
|
+
ss_res = np.sum((log_y - y_pred) ** 2)
|
|
124
|
+
ss_tot = np.sum((log_y - np.mean(log_y)) ** 2)
|
|
125
|
+
r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0.0
|
|
126
|
+
|
|
127
|
+
df = slope + 3.0
|
|
128
|
+
|
|
129
|
+
fit_results = {
|
|
130
|
+
'slope': slope,
|
|
131
|
+
'intercept': intercept,
|
|
132
|
+
'r_min': np.min(x),
|
|
133
|
+
'r_max': np.max(x),
|
|
134
|
+
'x_fit': x,
|
|
135
|
+
'y_fit': 10 ** y_pred
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return df, r_squared, fit_results
|
|
139
|
+
|
|
140
|
+
def plot_pair_correlation(
|
|
141
|
+
aggregate: Aggregate,
|
|
142
|
+
bins: int = 50,
|
|
143
|
+
show_fit: bool = True,
|
|
144
|
+
reference_df: float = None,
|
|
145
|
+
save_path: str = None
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Plots the pair correlation function and optionally its fractal fit.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
aggregate (Aggregate): Cluster object.
|
|
151
|
+
bins (int): Number of bins for PCF.
|
|
152
|
+
show_fit (bool): Whether to show the fractal dimension fit.
|
|
153
|
+
reference_df (float, optional): Reference Df to show in plot.
|
|
154
|
+
save_path (str, optional): Path to save the figure.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
import matplotlib.pyplot as plt
|
|
158
|
+
except ImportError:
|
|
159
|
+
print("Error: matplotlib is required for plotting. Install it with 'pip install matplotlib'.")
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
from pyFracAggregate.analysis.morphology import radius_of_gyration
|
|
163
|
+
|
|
164
|
+
r_centers, c_r = pair_correlation_function(aggregate, bins=bins)
|
|
165
|
+
|
|
166
|
+
if len(r_centers) == 0:
|
|
167
|
+
print("Warning: Aggregate has too few particles for correlation analysis.")
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
plt.figure(figsize=(8, 6))
|
|
171
|
+
plt.loglog(r_centers, c_r, 'o', label='Data', markersize=4, alpha=0.7)
|
|
172
|
+
|
|
173
|
+
if show_fit:
|
|
174
|
+
# Use r_min = mean radius, r_max = Rg as default bounds for fractal regime
|
|
175
|
+
r_min = np.mean(aggregate.radii)
|
|
176
|
+
r_max = radius_of_gyration(aggregate)
|
|
177
|
+
|
|
178
|
+
df, r2, fit = estimate_fractal_dimension(r_centers, c_r, r_min=r_min, r_max=r_max)
|
|
179
|
+
|
|
180
|
+
if fit:
|
|
181
|
+
plt.loglog(fit['x_fit'], fit['y_fit'], 'r-', linewidth=2,
|
|
182
|
+
label=f'Fit: $D_f$={df:.2f}, $R^2$={r2:.3f}')
|
|
183
|
+
|
|
184
|
+
# Draw vertical lines for fitting range
|
|
185
|
+
plt.axvline(r_min, color='gray', linestyle='--', alpha=0.5, label='Min Fit Bound')
|
|
186
|
+
plt.axvline(r_max, color='gray', linestyle=':', alpha=0.5, label='Max Fit Bound ($R_g$)')
|
|
187
|
+
|
|
188
|
+
if reference_df is not None:
|
|
189
|
+
# Show a reference slope (slope = reference_df - 3)
|
|
190
|
+
mid_idx = len(r_centers) // 2
|
|
191
|
+
ref_x = r_centers
|
|
192
|
+
# Arbitrary intercept to place it near the data
|
|
193
|
+
ref_intercept = np.log10(c_r[mid_idx]) - (reference_df - 3.0) * np.log10(r_centers[mid_idx])
|
|
194
|
+
ref_y = 10 ** ((reference_df - 3.0) * np.log10(ref_x) + ref_intercept)
|
|
195
|
+
plt.loglog(ref_x, ref_y, 'g--', alpha=0.5, label=f'Ref: $D_f$={reference_df}')
|
|
196
|
+
|
|
197
|
+
plt.xlabel(f'Distance $r$ [{aggregate.length_unit}]')
|
|
198
|
+
plt.ylabel('Correlation function $C(r)$')
|
|
199
|
+
plt.title('Pair Correlation Function Analysis')
|
|
200
|
+
plt.grid(True, which="both", ls="-", alpha=0.2)
|
|
201
|
+
plt.legend()
|
|
202
|
+
|
|
203
|
+
if save_path:
|
|
204
|
+
plt.savefig(save_path, dpi=300, bbox_inches='tight')
|
|
205
|
+
print(f"Plot saved to {save_path}")
|
|
206
|
+
else:
|
|
207
|
+
plt.show()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from pyFracAggregate.core.aggregate import Aggregate
|
|
3
|
+
|
|
4
|
+
def center_of_mass(aggregate: Aggregate) -> np.ndarray:
|
|
5
|
+
"""
|
|
6
|
+
Calculate the center of mass of the aggregate.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
aggregate (Aggregate): The aggregate object.
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
np.ndarray: A 1D array of shape (3,) representing the (x, y, z) coordinates of the center of mass.
|
|
13
|
+
"""
|
|
14
|
+
if aggregate.current_size == 0:
|
|
15
|
+
return np.zeros(3)
|
|
16
|
+
|
|
17
|
+
masses = aggregate.masses
|
|
18
|
+
positions = aggregate.positions
|
|
19
|
+
|
|
20
|
+
total_mass = np.sum(masses)
|
|
21
|
+
if total_mass == 0:
|
|
22
|
+
return np.mean(positions, axis=0)
|
|
23
|
+
|
|
24
|
+
# sum(m_i * r_i) / sum(m_i)
|
|
25
|
+
com = np.sum(positions * masses[:, np.newaxis], axis=0) / total_mass
|
|
26
|
+
return com
|
|
27
|
+
|
|
28
|
+
def radius_of_gyration(aggregate: Aggregate) -> float:
|
|
29
|
+
"""
|
|
30
|
+
Calculate the radius of gyration (Rg) of the aggregate.
|
|
31
|
+
Uses the parallel axis theorem to account for the finite size of the primary particles.
|
|
32
|
+
For a solid sphere, the radius of gyration about its own center is sqrt(3/5) * r.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
aggregate (Aggregate): The aggregate object.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
float: The radius of gyration.
|
|
39
|
+
"""
|
|
40
|
+
if aggregate.current_size == 0:
|
|
41
|
+
return 0.0
|
|
42
|
+
|
|
43
|
+
if aggregate.current_size == 1:
|
|
44
|
+
# For a single sphere, Rg = sphere radius according to Filippov 2000 definition eq [4]
|
|
45
|
+
# Or more accurately based on eq [4]: Rg^2 = a^2 for N=1.
|
|
46
|
+
# But for polydisperse spheres with parallel axis theorem: Rg^2 = 3/5 * r^2.
|
|
47
|
+
# Wait, the Filippov paper uses: Rg^2 = 1/N sum((ri - r0)^2 + a^2).
|
|
48
|
+
# Let's check the Moran 2019 FracVAL equation for polydisperse:
|
|
49
|
+
# Rg^2 = 1/m_a sum(m_i * [(R_i - R_c)^2 + r_{g,i}^2]) where r_{g,i}^2 = 3/5 * r_{p,i}^2
|
|
50
|
+
# Let's implement the standard physical Rg (with 3/5 factor).
|
|
51
|
+
return np.sqrt(3.0 / 5.0) * aggregate.radii[0]
|
|
52
|
+
|
|
53
|
+
masses = aggregate.masses
|
|
54
|
+
positions = aggregate.positions
|
|
55
|
+
radii = aggregate.radii
|
|
56
|
+
|
|
57
|
+
total_mass = np.sum(masses)
|
|
58
|
+
if total_mass == 0:
|
|
59
|
+
return 0.0
|
|
60
|
+
|
|
61
|
+
com = center_of_mass(aggregate)
|
|
62
|
+
|
|
63
|
+
# Distance squared from CoM to each particle center
|
|
64
|
+
dist_sq = np.sum((positions - com) ** 2, axis=1)
|
|
65
|
+
|
|
66
|
+
# Intrinsic Rg squared of each solid sphere: 3/5 * r^2
|
|
67
|
+
intrinsic_rg_sq = (3.0 / 5.0) * (radii ** 2)
|
|
68
|
+
|
|
69
|
+
# sum(m_i * (dist_sq + intrinsic_rg_sq)) / M
|
|
70
|
+
rg_sq = np.sum(masses * (dist_sq + intrinsic_rg_sq)) / total_mass
|
|
71
|
+
|
|
72
|
+
return float(np.sqrt(rg_sq))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core data structures and math utils."""
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
class Aggregate:
|
|
4
|
+
"""Core physical entity representing a fractal cluster.
|
|
5
|
+
|
|
6
|
+
Uses pre-allocated contiguous memory via NumPy for high data locality
|
|
7
|
+
and access performance.
|
|
8
|
+
"""
|
|
9
|
+
def __init__(self, max_particles: int, length_unit: str = 'nm', mass_unit: str = 'g', density: float = 1.0):
|
|
10
|
+
"""Initializes the Aggregate object.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
max_particles (int): Maximum number of particles the cluster can hold.
|
|
14
|
+
length_unit (str, optional): Unit for length measurements (e.g., 'nm'). Defaults to 'nm'.
|
|
15
|
+
mass_unit (str, optional): Unit for mass measurements (e.g., 'g'). Defaults to 'g'.
|
|
16
|
+
density (float, optional): Density of particle material. Defaults to 1.0.
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
ValueError: If max_particles <= 0.
|
|
20
|
+
"""
|
|
21
|
+
if max_particles <= 0:
|
|
22
|
+
raise ValueError("max_particles must be positive")
|
|
23
|
+
|
|
24
|
+
# Data structure: [x, y, z, radius, mass] with pre-allocated memory
|
|
25
|
+
self._data = np.zeros((max_particles, 5), dtype=np.float64)
|
|
26
|
+
self._current_size = 0
|
|
27
|
+
self.length_unit = length_unit
|
|
28
|
+
self.mass_unit = mass_unit
|
|
29
|
+
self.density = density
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def positions(self) -> np.ndarray:
|
|
33
|
+
"""Gets particle coordinates.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
np.ndarray: A zero-copy view with shape (N, 3).
|
|
37
|
+
"""
|
|
38
|
+
return self._data[:self._current_size, :3]
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def radii(self) -> np.ndarray:
|
|
42
|
+
"""Gets particle radii.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
np.ndarray: A zero-copy view with shape (N,).
|
|
46
|
+
"""
|
|
47
|
+
return self._data[:self._current_size, 3]
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def masses(self) -> np.ndarray:
|
|
51
|
+
"""Gets particle masses.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
np.ndarray: A zero-copy view with shape (N,).
|
|
55
|
+
"""
|
|
56
|
+
return self._data[:self._current_size, 4]
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def current_size(self) -> int:
|
|
60
|
+
"""Gets the current total number of particles."""
|
|
61
|
+
return self._current_size
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def max_size(self) -> int:
|
|
65
|
+
"""Gets the maximum allowed number of particles."""
|
|
66
|
+
return len(self._data)
|
|
67
|
+
|
|
68
|
+
def add_particle(self, x: float, y: float, z: float, r: float, m: float) -> None:
|
|
69
|
+
"""Adds a new particle. O(1) complexity.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
x (float): X coordinate
|
|
73
|
+
y (float): Y coordinate
|
|
74
|
+
z (float): Z coordinate
|
|
75
|
+
r (float): Radius
|
|
76
|
+
m (float): Mass
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
RuntimeError: If cluster reaches maximum capacity.
|
|
80
|
+
"""
|
|
81
|
+
if self._current_size >= len(self._data):
|
|
82
|
+
raise RuntimeError("Aggregate capacity exceeded")
|
|
83
|
+
i = self._current_size
|
|
84
|
+
self._data[i] = [x, y, z, r, m]
|
|
85
|
+
self._current_size += 1
|
|
86
|
+
|
|
87
|
+
def to_numpy(self) -> np.ndarray:
|
|
88
|
+
"""Exports a copy of valid particles.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
np.ndarray: A copy of the data with shape (N, 5).
|
|
92
|
+
"""
|
|
93
|
+
return self._data[:self._current_size].copy()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
class ParticleDistribution(ABC):
|
|
5
|
+
"""Abstract base class for particle size distributions."""
|
|
6
|
+
@abstractmethod
|
|
7
|
+
def sample(self, n: int) -> np.ndarray:
|
|
8
|
+
"""Samples n particle sizes.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
n (int): Number of particle sizes to generate.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
np.ndarray: An array of particle sizes with shape (n,).
|
|
15
|
+
"""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
class Monodisperse(ParticleDistribution):
|
|
19
|
+
"""Monodisperse distribution (all particles have the same radius)."""
|
|
20
|
+
def __init__(self, radius: float):
|
|
21
|
+
"""Initializes the distribution.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
radius (float): Particle radius.
|
|
25
|
+
Raises:
|
|
26
|
+
ValueError: If radius <= 0.
|
|
27
|
+
"""
|
|
28
|
+
if radius <= 0:
|
|
29
|
+
raise ValueError("Radius must be positive")
|
|
30
|
+
self.radius = radius
|
|
31
|
+
|
|
32
|
+
def sample(self, n: int) -> np.ndarray:
|
|
33
|
+
return np.full(n, self.radius, dtype=np.float64)
|
|
34
|
+
|
|
35
|
+
class LognormalDistribution(ParticleDistribution):
|
|
36
|
+
"""Lognormal distribution."""
|
|
37
|
+
def __init__(self, mean: float, std: float):
|
|
38
|
+
"""Initializes the distribution.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
mean (float): Geometric mean.
|
|
42
|
+
std (float): Geometric standard deviation (should be >= 1.0).
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ValueError: If mean or std is invalid.
|
|
46
|
+
"""
|
|
47
|
+
if mean <= 0:
|
|
48
|
+
raise ValueError("Mean must be positive")
|
|
49
|
+
if std <= 0:
|
|
50
|
+
raise ValueError("Standard deviation must be positive")
|
|
51
|
+
|
|
52
|
+
self.mean = mean
|
|
53
|
+
self.std = std
|
|
54
|
+
|
|
55
|
+
# Calculate mean and standard deviation of the underlying normal distribution
|
|
56
|
+
self.normal_mean = np.log(self.mean)
|
|
57
|
+
self.normal_std = np.log(self.std) if self.std > 1.0 else 0.0
|
|
58
|
+
|
|
59
|
+
def sample(self, n: int) -> np.ndarray:
|
|
60
|
+
if self.normal_std == 0.0:
|
|
61
|
+
return np.full(n, self.mean, dtype=np.float64)
|
|
62
|
+
return np.random.lognormal(self.normal_mean, self.normal_std, n)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from typing import Tuple, Optional
|
|
3
|
+
|
|
4
|
+
import mathutils
|
|
5
|
+
|
|
6
|
+
def rotate_points(points: np.ndarray, euler_angles: Tuple[float, float, float]) -> np.ndarray:
|
|
7
|
+
"""
|
|
8
|
+
Rotate points using given euler angles (in radians).
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
points (np.ndarray): Shape (N, 3) points to rotate.
|
|
12
|
+
euler_angles (Tuple[float, float, float]): Rotation angles (x, y, z) in radians.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
np.ndarray: Rotated points of shape (N, 3).
|
|
16
|
+
"""
|
|
17
|
+
if points.size == 0:
|
|
18
|
+
return points.copy()
|
|
19
|
+
|
|
20
|
+
euler = mathutils.Euler(euler_angles, 'XYZ')
|
|
21
|
+
rot_matrix = np.array(euler.to_matrix())
|
|
22
|
+
return points @ rot_matrix.T
|
|
23
|
+
|
|
24
|
+
def rotate_points_quaternion(points: np.ndarray, quaternion: Tuple[float, float, float, float]) -> np.ndarray:
|
|
25
|
+
"""
|
|
26
|
+
Rotate points using given quaternion (w, x, y, z).
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
points (np.ndarray): Shape (N, 3) points to rotate.
|
|
30
|
+
quaternion (Tuple[float, float, float, float]): Quaternion parameters (w, x, y, z).
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
np.ndarray: Rotated points.
|
|
34
|
+
"""
|
|
35
|
+
if points.size == 0:
|
|
36
|
+
return points.copy()
|
|
37
|
+
|
|
38
|
+
q = mathutils.Quaternion(quaternion)
|
|
39
|
+
rot_matrix = np.array(q.to_matrix())
|
|
40
|
+
return points @ rot_matrix.T
|
|
41
|
+
|
|
42
|
+
def euler_rodrigues_rotation(points: np.ndarray, axis: np.ndarray, angle: float) -> np.ndarray:
|
|
43
|
+
"""Rotate points around an arbitrary axis by a given angle using Euler-Rodrigues formula.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
points (np.ndarray): Shape (N, 3) points to rotate.
|
|
47
|
+
axis (np.ndarray): Rotation axis (3,), must be non-zero.
|
|
48
|
+
angle (float): Rotation angle in radians.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
np.ndarray: Rotated points of shape (N, 3).
|
|
52
|
+
"""
|
|
53
|
+
if points.size == 0:
|
|
54
|
+
return points.copy()
|
|
55
|
+
axis = axis / np.linalg.norm(axis)
|
|
56
|
+
K = np.array([
|
|
57
|
+
[0, -axis[2], axis[1]],
|
|
58
|
+
[axis[2], 0, -axis[0]],
|
|
59
|
+
[-axis[1], axis[0], 0]
|
|
60
|
+
])
|
|
61
|
+
R = np.eye(3) + np.sin(angle) * K + (1 - np.cos(angle)) * (K @ K)
|
|
62
|
+
return points @ R.T
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sphere_sphere_intersection(
|
|
66
|
+
c1: np.ndarray, r1: float, c2: np.ndarray, r2: float
|
|
67
|
+
) -> Optional[Tuple[np.ndarray, float]]:
|
|
68
|
+
"""Compute the intersection circle of two spheres.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
c1: Center of sphere 1, shape (3,).
|
|
72
|
+
r1: Radius of sphere 1.
|
|
73
|
+
c2: Center of sphere 2, shape (3,).
|
|
74
|
+
r2: Radius of sphere 2.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
(circle_center, circle_radius) if intersection exists, None otherwise.
|
|
78
|
+
For tangent spheres, circle_radius is 0.0.
|
|
79
|
+
"""
|
|
80
|
+
d_vec = c2 - c1
|
|
81
|
+
d = np.linalg.norm(d_vec)
|
|
82
|
+
|
|
83
|
+
if d > r1 + r2 + 1e-12:
|
|
84
|
+
return None
|
|
85
|
+
if d < abs(r1 - r2) - 1e-12:
|
|
86
|
+
return None
|
|
87
|
+
if d < 1e-12:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
a = (r1**2 - r2**2 + d**2) / (2 * d)
|
|
91
|
+
h_sq = r1**2 - a**2
|
|
92
|
+
if h_sq < -1e-12:
|
|
93
|
+
return None
|
|
94
|
+
h = np.sqrt(max(h_sq, 0.0))
|
|
95
|
+
|
|
96
|
+
circle_center = c1 + (a / d) * d_vec
|
|
97
|
+
return circle_center, h
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def random_point_on_circle(
|
|
101
|
+
center: np.ndarray,
|
|
102
|
+
radius: float,
|
|
103
|
+
normal: np.ndarray,
|
|
104
|
+
) -> np.ndarray:
|
|
105
|
+
"""Sample a random point on a circle in 3D.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
center: Circle center, shape (3,).
|
|
109
|
+
radius: Circle radius.
|
|
110
|
+
normal: Normal vector to the circle plane, shape (3,).
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
A single point on the circle, shape (3,).
|
|
114
|
+
"""
|
|
115
|
+
normal = normal / np.linalg.norm(normal)
|
|
116
|
+
temp = np.array([1.0, 0.0, 0.0])
|
|
117
|
+
if np.abs(np.dot(normal, temp)) > 0.9:
|
|
118
|
+
temp = np.array([0.0, 1.0, 0.0])
|
|
119
|
+
u = np.cross(normal, temp)
|
|
120
|
+
u /= np.linalg.norm(u)
|
|
121
|
+
v = np.cross(normal, u)
|
|
122
|
+
|
|
123
|
+
theta = np.random.uniform(0, 2 * np.pi)
|
|
124
|
+
return center + radius * (np.cos(theta) * u + np.sin(theta) * v)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Generators for fractal aggregates."""
|
|
2
|
+
|
|
3
|
+
from pyFracAggregate.generators.base import BaseGenerator
|
|
4
|
+
from pyFracAggregate.generators.pca import PCAGenerator
|
|
5
|
+
from pyFracAggregate.generators.cca import CCAGenerator
|
|
6
|
+
from pyFracAggregate.generators.fracval import FracVALGenerator
|
|
7
|
+
from pyFracAggregate.generators.tdcca import ThouyJullienGenerator
|
|
8
|
+
from pyFracAggregate.generators.placement.base import PlacementStrategy
|
|
9
|
+
from pyFracAggregate.generators.placement.algebraic import AlgebraicPlacement
|
|
10
|
+
from pyFracAggregate.generators.placement.random_ import RandomPlacement
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BaseGenerator",
|
|
14
|
+
"PCAGenerator",
|
|
15
|
+
"CCAGenerator",
|
|
16
|
+
"FracVALGenerator",
|
|
17
|
+
"ThouyJullienGenerator",
|
|
18
|
+
"PlacementStrategy",
|
|
19
|
+
"AlgebraicPlacement",
|
|
20
|
+
"RandomPlacement",
|
|
21
|
+
]
|