Skip to content

Commit a9d5e63

Browse files
committed
Merge develop into main
2 parents 534f229 + cc8ea84 commit a9d5e63

File tree

8 files changed

+1929
-1782
lines changed

8 files changed

+1929
-1782
lines changed

MSUtils/TPMS/tpms.py

Lines changed: 49 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
import numpy as np
2-
from typing import Iterable, Optional
2+
from typing import Iterable, Optional, Callable
33

44
from MSUtils.general.MicrostructureImage import MicrostructureImage
55
from MSUtils.general.h52xdmf import write_xdmf
6+
from MSUtils.TPMS.tpms_functions import (
7+
gyroid,
8+
schwarz_p,
9+
diamond,
10+
neovius,
11+
iwp,
12+
lidinoid,
13+
)
614

715

816
def _to_angle(x, L):
@@ -15,8 +23,8 @@ class TPMS:
1523
1624
Parameters
1725
----------
18-
tpms_type : str
19-
Type of TPMS surface (e.g., 'gyroid', 'schwarz_p', 'diamond', 'neovius', 'iwp', 'lidinoid').
26+
func : Callable
27+
Function handle for the TPMS implicit function (e.g., gyroid, schwarz_p).
2028
resolution : tuple of int
2129
Number of voxels in each direction (Nx, Ny, Nz).
2230
L : tuple of float
@@ -35,7 +43,7 @@ class TPMS:
3543

3644
def __init__(
3745
self,
38-
tpms_type,
46+
func: Callable[[np.ndarray, np.ndarray, np.ndarray], np.ndarray],
3947
resolution: Optional[Iterable[int]] = (128, 128, 128),
4048
L: Optional[Iterable[float]] = (1.0, 1.0, 1.0),
4149
threshold: Optional[float] = 0.5,
@@ -44,7 +52,7 @@ def __init__(
4452
mode: str = "solid",
4553
shell_thickness: float = 0.1,
4654
):
47-
self.kind = tpms_type.lower()
55+
self.func = func
4856
self.resolution = tuple(int(v) for v in resolution)
4957
self.L = tuple(float(v) for v in L)
5058
if isinstance(unitcell_frequency, int):
@@ -62,61 +70,6 @@ def __init__(
6270
self._field = None # cache for field
6371
self.image = self.generate()
6472

65-
def implicit_function(
66-
self, x: np.ndarray, y: np.ndarray, z: np.ndarray
67-
) -> np.ndarray:
68-
# Map by frequency: scale coordinates before mapping to angle
69-
kx, ky, kz = self.frequency
70-
X = _to_angle(x * kx, self.L[0])
71-
Y = _to_angle(y * ky, self.L[1])
72-
Z = _to_angle(z * kz, self.L[2])
73-
74-
kind = self.kind
75-
# Standard references: https://minimalsurfaces.blog/home/repository/triply-periodic/
76-
# https://kenbrakke.com/evolver/examples/periodic/periodic.html
77-
if kind in ("gyroid",):
78-
# Gyroid: sin(x)cos(y) + sin(y)cos(z) + sin(z)cos(x)
79-
return np.sin(X) * np.cos(Y) + np.sin(Y) * np.cos(Z) + np.sin(Z) * np.cos(X)
80-
if kind in ("schwarz_p", "p"):
81-
# Schwarz Primitive: cos(x) + cos(y) + cos(z)
82-
return np.cos(X) + np.cos(Y) + np.cos(Z)
83-
if kind in ("schwarz_d", "diamond", "d"):
84-
# Diamond: sin(x)sin(y)sin(z) + sin(x)cos(y)cos(z) + cos(x)sin(y)cos(z) + cos(x)cos(y)sin(z)
85-
return (
86-
np.sin(X) * np.sin(Y) * np.sin(Z)
87-
+ np.sin(X) * np.cos(Y) * np.cos(Z)
88-
+ np.cos(X) * np.sin(Y) * np.cos(Z)
89-
+ np.cos(X) * np.cos(Y) * np.sin(Z)
90-
)
91-
if kind in ("neovius",):
92-
# Neovius: 3 * (cos(x) + cos(y) + cos(z)) + 4 * cos(x)*cos(y)*cos(z)
93-
return 3 * (np.cos(X) + np.cos(Y) + np.cos(Z)) + 4 * np.cos(X) * np.cos(
94-
Y
95-
) * np.cos(Z)
96-
if kind in ("iwp"):
97-
# I-WP : 2 * (cos(x)cos(y) + cos(y)cos(z) + cos(z)cos(x)) - (cos(2x) + cos(2y) + cos(2z))
98-
return 2 * (
99-
np.cos(X) * np.cos(Y) + np.cos(Y) * np.cos(Z) + np.cos(Z) * np.cos(X)
100-
) - (np.cos(2 * X) + np.cos(2 * Y) + np.cos(2 * Z))
101-
if kind in ("lidinoid",):
102-
# Lidinoid: 0.5 * (sin(2x)cos(y)sin(z) + sin(2y)cos(z)sin(x) + sin(2z)cos(x)sin(y)) - 0.5 * (cos(2x)cos(2y) + cos(2y)cos(2z) + cos(2z)cos(2x)) + 0.15
103-
return (
104-
0.5
105-
* (
106-
np.sin(2 * X) * np.cos(Y) * np.sin(Z)
107-
+ np.sin(2 * Y) * np.cos(Z) * np.sin(X)
108-
+ np.sin(2 * Z) * np.cos(X) * np.sin(Y)
109-
)
110-
- 0.5
111-
* (
112-
np.cos(2 * X) * np.cos(2 * Y)
113-
+ np.cos(2 * Y) * np.cos(2 * Z)
114-
+ np.cos(2 * Z) * np.cos(2 * X)
115-
)
116-
+ 0.15
117-
)
118-
raise ValueError(f"Unknown or unsupported TPMS kind: {self.kind}")
119-
12073
def _compute_field(self):
12174
# Compute and cache the field
12275
Nx, Ny, Nz = self.resolution
@@ -127,7 +80,14 @@ def _compute_field(self):
12780
X = xs[:, None, None]
12881
Y = ys[None, :, None]
12982
Z = zs[None, None, :]
130-
self._field = self.implicit_function(X, Y, Z)
83+
84+
# Map by frequency: scale coordinates before mapping to angle
85+
kx, ky, kz = self.frequency
86+
X_ang = _to_angle(X * kx, self.L[0])
87+
Y_ang = _to_angle(Y * ky, self.L[1])
88+
Z_ang = _to_angle(Z * kz, self.L[2])
89+
self._field = self.func(X_ang, Y_ang, Z_ang)
90+
13191
# range normalize to [0, 1]
13292
self._field = (self._field - np.min(self._field)) / (
13393
np.max(self._field) - np.min(self._field)
@@ -162,8 +122,6 @@ def find_threshold_for_volume_fraction(
162122
target_vf: float,
163123
tol: float = 1e-3,
164124
max_iter: int = 30,
165-
n_thresh: int = 50,
166-
optimize: str = "both",
167125
) -> tuple:
168126
"""
169127
Find threshold (and shell thickness if mode='shell') for target volume fraction.
@@ -187,97 +145,57 @@ def find_threshold_for_volume_fraction(
187145
flat = field.ravel()
188146
n_vox = flat.size
189147
if self.mode == "solid":
190-
# For solid: threshold at quantile
148+
# For solid: optimize threshold only
191149
k = int(np.round((1 - target_vf) * n_vox))
192150
sorted_field = np.partition(flat, k)
193151
thr = sorted_field[k]
194152
self.threshold = thr
195153
return thr, None
196154
elif self.mode == "shell":
197155
minf, maxf = float(np.min(flat)), float(np.max(flat))
198-
if optimize == "shell_thickness":
199-
# Only optimize shell_thickness, keep threshold fixed
200-
thr = self.threshold
201-
lo, hi = 0.0, max(maxf - thr, thr - minf)
202-
for _ in range(max_iter):
203-
mid = 0.5 * (lo + hi)
204-
vf = np.mean(np.abs(flat - thr) < mid)
205-
err = abs(vf - target_vf)
206-
if err < tol:
207-
break
208-
if vf > target_vf:
209-
hi = mid
210-
else:
211-
lo = mid
212-
self.shell_thickness = mid
213-
return thr, mid
214-
elif optimize == "threshold":
215-
# Only optimize threshold, keep shell_thickness fixed
216-
t = abs(self.shell_thickness)
217-
best_err = float("inf")
218-
best_thr = None
219-
for thr in np.linspace(minf, maxf, n_thresh):
220-
vf = np.mean(np.abs(flat - thr) < t)
221-
err = abs(vf - target_vf)
222-
if err < best_err:
223-
best_err = err
224-
best_thr = thr
225-
if best_err <= tol:
226-
break
227-
self.threshold = best_thr
228-
return best_thr, t
229-
elif optimize == "both":
230-
# Jointly optimize threshold and shell_thickness
231-
best_err = float("inf")
232-
best_thr = None
233-
best_t = None
234-
for thr in np.linspace(minf, maxf, n_thresh):
235-
lo, hi = 0.0, max(maxf - thr, thr - minf)
236-
for _ in range(max_iter):
237-
mid = 0.5 * (lo + hi)
238-
vf = np.mean(np.abs(flat - thr) < mid)
239-
err = abs(vf - target_vf)
240-
if err < tol:
241-
break
242-
if vf > target_vf:
243-
hi = mid
244-
else:
245-
lo = mid
246-
vf = np.mean(np.abs(flat - thr) < mid)
247-
err = abs(vf - target_vf)
248-
if err < best_err:
249-
best_err = err
250-
best_thr = thr
251-
best_t = mid
252-
if best_err <= tol:
253-
break
254-
self.threshold = best_thr
255-
self.shell_thickness = best_t
256-
return best_thr, best_t
257-
else:
258-
raise ValueError(f"Unknown optimize mode: {optimize}")
259-
else:
260-
raise ValueError(f"Unknown mode: {self.mode}")
156+
# For shell: optimize shell_thickness only, keep threshold fixed
157+
thr = self.threshold
158+
lo, hi = 0.0, max(maxf - thr, thr - minf)
159+
for _ in range(max_iter):
160+
mid = 0.5 * (lo + hi)
161+
vf = np.mean(np.abs(flat - thr) < mid)
162+
err = abs(vf - target_vf)
163+
if err < tol:
164+
break
165+
if vf > target_vf:
166+
hi = mid
167+
else:
168+
lo = mid
169+
self.shell_thickness = mid
170+
return thr, mid
261171

262172

263173
def main():
264174
N = 512, 256, 128
265175
L = 4.0, 2.0, 1.0
266-
tpms_types = ["gyroid", "schwarz_p", "diamond", "neovius", "iwp", "lidinoid"]
267176
h5_filename = "data/tpms.h5"
268177
unitcell_frequency = (4, 2, 1)
269178
invert = True
270179

271-
for tpms_type in tpms_types:
180+
tpms_funcs = {
181+
"gyroid": gyroid,
182+
"schwarz_p": schwarz_p,
183+
"diamond": diamond,
184+
"neovius": neovius,
185+
"iwp": iwp,
186+
"lidinoid": lidinoid,
187+
}
188+
189+
for tpms_type, func in tpms_funcs.items():
272190
tpms = TPMS(
273-
tpms_type=tpms_type,
191+
func=func,
274192
resolution=N,
275193
L=L,
276194
unitcell_frequency=unitcell_frequency,
277195
invert=invert,
278196
mode="solid",
279197
)
280-
MS = MicrostructureImage(image=tpms.image)
198+
MS = MicrostructureImage(image=tpms.image, L=L)
281199
MS.write(
282200
h5_filename=h5_filename,
283201
dset_name=tpms_type,

MSUtils/TPMS/tpms_example.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,33 @@
99
from MSUtils.TPMS.tpms import TPMS
1010
from MSUtils.general.MicrostructureImage import MicrostructureImage
1111
from MSUtils.general.h52xdmf import write_xdmf
12+
from MSUtils.TPMS.tpms_functions import (
13+
gyroid,
14+
schwarz_p,
15+
diamond,
16+
neovius,
17+
iwp,
18+
lidinoid,
19+
)
1220

1321
if __name__ == "__main__":
1422
N = 256, 256, 256
1523
L = 1.0, 1.0, 1.0
16-
tpms_type = "iwp"
1724
h5_filename = "data/tpms_opt.h5"
1825
unitcell_frequency = (1, 1, 1)
1926
invert = False
27+
tpms_type = "iwp"
2028

2129
tpms = TPMS(
22-
tpms_type=tpms_type,
30+
func=iwp,
2331
resolution=N,
2432
L=L,
2533
unitcell_frequency=unitcell_frequency,
2634
invert=invert,
2735
mode="shell",
2836
shell_thickness=0.1,
2937
)
30-
MS = MicrostructureImage(image=tpms.image)
38+
MS = MicrostructureImage(image=tpms.image, L=L)
3139
print(f"Volume fraction of phase 0: {MS.volume_fractions[0]:.4f}")
3240
print(f"Volume fraction of phase 1: {MS.volume_fractions[1]:.4f}")
3341
MS.write(
@@ -38,17 +46,17 @@
3846
)
3947

4048
# Optimize shell thickness to achieve target volume fraction for phase 1
41-
vf_target_phase_1 = 0.2
49+
vf_target_phase_1 = 0.3
4250
threshold_opt, thickness_opt = tpms.find_threshold_for_volume_fraction(
43-
vf_target_phase_1, optimize="shell_thickness"
51+
vf_target_phase_1,
4452
)
4553
print(
4654
f"New threshold and shell thickness for volume fraction {vf_target_phase_1}: {threshold_opt}, {thickness_opt}"
4755
)
4856

4957
# Regenerate TPMS with optimized parameters
5058
tpms = TPMS(
51-
tpms_type=tpms_type,
59+
func=iwp,
5260
resolution=N,
5361
L=L,
5462
unitcell_frequency=unitcell_frequency,
@@ -57,7 +65,7 @@
5765
threshold=threshold_opt,
5866
shell_thickness=thickness_opt,
5967
)
60-
MS = MicrostructureImage(image=tpms.image)
68+
MS = MicrostructureImage(image=tpms.image, L=L)
6169
print(f"Volume fraction of phase 0: {MS.volume_fractions[0]:.4f}")
6270
print(f"Volume fraction of phase 1: {MS.volume_fractions[1]:.4f}")
6371
MS.write(

MSUtils/TPMS/tpms_functions.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import numpy as np
2+
3+
4+
def gyroid(X: np.ndarray, Y: np.ndarray, Z: np.ndarray) -> np.ndarray:
5+
return np.sin(X) * np.cos(Y) + np.sin(Y) * np.cos(Z) + np.sin(Z) * np.cos(X)
6+
7+
8+
def schwarz_p(X: np.ndarray, Y: np.ndarray, Z: np.ndarray) -> np.ndarray:
9+
return np.cos(X) + np.cos(Y) + np.cos(Z)
10+
11+
12+
def diamond(X: np.ndarray, Y: np.ndarray, Z: np.ndarray) -> np.ndarray:
13+
return (
14+
np.sin(X) * np.sin(Y) * np.sin(Z)
15+
+ np.sin(X) * np.cos(Y) * np.cos(Z)
16+
+ np.cos(X) * np.sin(Y) * np.cos(Z)
17+
+ np.cos(X) * np.cos(Y) * np.sin(Z)
18+
)
19+
20+
21+
def neovius(X: np.ndarray, Y: np.ndarray, Z: np.ndarray) -> np.ndarray:
22+
return 3 * (np.cos(X) + np.cos(Y) + np.cos(Z)) + 4 * np.cos(X) * np.cos(Y) * np.cos(
23+
Z
24+
)
25+
26+
27+
def iwp(X: np.ndarray, Y: np.ndarray, Z: np.ndarray) -> np.ndarray:
28+
return 2 * (
29+
np.cos(X) * np.cos(Y) + np.cos(Y) * np.cos(Z) + np.cos(Z) * np.cos(X)
30+
) - (np.cos(2 * X) + np.cos(2 * Y) + np.cos(2 * Z))
31+
32+
33+
def lidinoid(X: np.ndarray, Y: np.ndarray, Z: np.ndarray) -> np.ndarray:
34+
return (
35+
0.5
36+
* (
37+
np.sin(2 * X) * np.cos(Y) * np.sin(Z)
38+
+ np.sin(2 * Y) * np.cos(Z) * np.sin(X)
39+
+ np.sin(2 * Z) * np.cos(X) * np.sin(Y)
40+
)
41+
- 0.5
42+
* (
43+
np.cos(2 * X) * np.cos(2 * Y)
44+
+ np.cos(2 * Y) * np.cos(2 * Z)
45+
+ np.cos(2 * Z) * np.cos(2 * X)
46+
)
47+
+ 0.15
48+
)

MSUtils/general/MicrostructureImage.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ def write(
163163
permuted_image = self.image
164164
elif order == "zyx":
165165
permuted_image = self.image.transpose(2, 1, 0)
166+
self.L = self.L[::-1]
166167
else:
167168
raise ValueError("Invalid order specified. Use 'xyz' or 'zyx'.")
168169

MSUtils/spinodoids/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""
2+
Spinodal microstructure generation via spectral filtering.
3+
"""
4+
5+
from .generate_spinodal_microstructure import generate_spinodal_microstructure
6+
7+
__all__ = ["generate_spinodal_microstructure"]

0 commit comments

Comments
 (0)