evoxels 0.1.0__py3-none-any.whl → 0.1.2__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.
evoxels/inversion.py CHANGED
@@ -72,7 +72,7 @@ class InversionModel:
72
72
  solver = PseudoSpectralIMEX_dfx(problem.fourier_symbol)
73
73
 
74
74
  solution = dfx.diffeqsolve(
75
- dfx.ODETerm(lambda t, y, args: problem.rhs(y, t)),
75
+ dfx.ODETerm(lambda t, y, args: problem.rhs(t, y)),
76
76
  solver,
77
77
  t0=saveat.subs.ts[0],
78
78
  t1=saveat.subs.ts[-1],
@@ -23,7 +23,7 @@ def run_allen_cahn_solver(
23
23
  plot_bounds = None,
24
24
  ):
25
25
  """
26
- Runs the Cahn-Hilliard solver with a predefined problem and timestepper.
26
+ Solves time-dependent Allen-Cahn problem with ForwardEuler timestepper.
27
27
  """
28
28
  solver = TimeDependentSolver(
29
29
  voxelfields,
@@ -20,7 +20,7 @@ def run_cahn_hilliard_solver(
20
20
  plot_bounds = None,
21
21
  ):
22
22
  """
23
- Runs the Cahn-Hilliard solver with a predefined problem and timestepper.
23
+ Solves time-dependent Cahn-Hilliard problem with PseudoSpectralIMEX timestepper.
24
24
  """
25
25
  solver = TimeDependentSolver(
26
26
  voxelfields,
@@ -13,17 +13,17 @@ _i_ = slice(1, -1) # inner elements [1:-1]
13
13
  class ODE(ABC):
14
14
  @property
15
15
  @abstractmethod
16
- def order(self):
16
+ def order(self) -> int:
17
17
  """Spatial order of convergence for numerical right-hand side."""
18
18
  pass
19
19
 
20
20
  @abstractmethod
21
- def rhs_analytic(self, u, t):
21
+ def rhs_analytic(self, t, u):
22
22
  """Sympy expression of the problem right-hand side.
23
23
 
24
24
  Args:
25
- u : Sympy function of current state.
26
25
  t (float): Current time.
26
+ u : Sympy function of current state.
27
27
 
28
28
  Returns:
29
29
  Sympy function of problem right-hand side.
@@ -31,12 +31,12 @@ class ODE(ABC):
31
31
  pass
32
32
 
33
33
  @abstractmethod
34
- def rhs(self, u, t):
34
+ def rhs(self, t, u):
35
35
  """Numerical right-hand side of the ODE system.
36
36
 
37
37
  Args:
38
- u (array): Current state.
39
38
  t (float): Current time.
39
+ u (array): Current state.
40
40
 
41
41
  Returns:
42
42
  Same type as ``u`` containing the time derivative.
@@ -122,7 +122,7 @@ class ReactionDiffusion(SemiLinearODE):
122
122
  k_squared = self.vg.fft_k_squared_nonperiodic()
123
123
 
124
124
  self._fourier_symbol = -self.D * self.A * k_squared
125
-
125
+
126
126
  @property
127
127
  def order(self):
128
128
  return 2
@@ -130,14 +130,14 @@ class ReactionDiffusion(SemiLinearODE):
130
130
  @property
131
131
  def fourier_symbol(self):
132
132
  return self._fourier_symbol
133
-
134
- def _eval_f(self, c, t, lib):
133
+
134
+ def _eval_f(self, t, c, lib):
135
135
  """Evaluate source/forcing term using ``self.f``."""
136
136
  try:
137
- return self.f(c, t, lib)
137
+ return self.f(t, c, lib)
138
138
  except TypeError:
139
- return self.f(c, t)
140
-
139
+ return self.f(t, c)
140
+
141
141
  @property
142
142
  def bc_type(self):
143
143
  return self.BC_type
@@ -145,12 +145,12 @@ class ReactionDiffusion(SemiLinearODE):
145
145
  def pad_bc(self, u):
146
146
  return self.pad_boundary(u, self.bcs[0], self.bcs[1])
147
147
 
148
- def rhs_analytic(self, u, t):
149
- return self.D*spv.laplacian(u) + self._eval_f(u, t, sp)
150
-
151
- def rhs(self, u, t):
148
+ def rhs_analytic(self, t, u):
149
+ return self.D*spv.laplacian(u) + self._eval_f(t, u, sp)
150
+
151
+ def rhs(self, t, u):
152
152
  laplace = self.vg.laplace(self.pad_bc(u))
153
- update = self.D * laplace + self._eval_f(u, t, self.vg.lib)
153
+ update = self.D * laplace + self._eval_f(t, u, self.vg.lib)
154
154
  return update
155
155
 
156
156
  @dataclass
@@ -185,15 +185,15 @@ class ReactionDiffusionSBM(ReactionDiffusion, SmoothedBoundaryODE):
185
185
  def pad_bc(self, u):
186
186
  return self.pad_boundary(u, self.bcs[0], self.bcs[1])
187
187
 
188
- def rhs_analytic(self, mask, u, t):
189
- grad = spv.gradient(u)
190
- norm_grad = sp.sqrt(grad.dot(grad))
188
+ def rhs_analytic(self, t, u, mask):
189
+ grad_m = spv.gradient(mask)
190
+ norm_grad_m = sp.sqrt(grad_m.dot(grad_m))
191
191
 
192
- divergence = spv.divergence(self.D*(grad - u/mask*spv.gradient(mask)))
193
- du = divergence + norm_grad*self.bc_flux + mask*self._eval_f(u/mask, t, sp)
192
+ divergence = spv.divergence(self.D*(spv.gradient(u) - u/mask*grad_m))
193
+ du = divergence + norm_grad_m*self.bc_flux + mask*self._eval_f(t, u/mask, sp)
194
194
  return du
195
195
 
196
- def rhs(self, u, t):
196
+ def rhs(self, t, u):
197
197
  z = self.pad_bc(u)
198
198
  divergence = self.vg.grad_x_face(self.vg.grad_x_face(z) -\
199
199
  self.vg.to_x_face(z/self.mask) * self.vg.grad_x_face(self.mask)
@@ -207,7 +207,7 @@ class ReactionDiffusionSBM(ReactionDiffusion, SmoothedBoundaryODE):
207
207
 
208
208
  update = self.D * divergence + \
209
209
  self.norm*self.bc_flux + \
210
- self.mask[:,1:-1,1:-1,1:-1]*self._eval_f(u/self.mask[:,1:-1,1:-1,1:-1], t, self.vg.lib)
210
+ self.mask[:,1:-1,1:-1,1:-1]*self._eval_f(t, u/self.mask[:,1:-1,1:-1,1:-1], self.vg.lib)
211
211
  return update
212
212
 
213
213
 
@@ -249,13 +249,13 @@ class PeriodicCahnHilliard(SemiLinearODE):
249
249
  except TypeError:
250
250
  return self.mu_hom(c)
251
251
 
252
- def rhs_analytic(self, c, t):
252
+ def rhs_analytic(self, t, c):
253
253
  mu = self._eval_mu(c, sp) - 2*self.eps*spv.laplacian(c)
254
254
  fluxes = self.D*c*(1-c)*spv.gradient(mu)
255
255
  rhs = spv.divergence(fluxes)
256
256
  return rhs
257
257
 
258
- def rhs(self, c, t):
258
+ def rhs(self, t, c):
259
259
  r"""Evaluate :math:`\partial c / \partial t` for the CH equation.
260
260
 
261
261
  Numerical computation of
@@ -341,7 +341,7 @@ class AllenCahnEquation(SemiLinearODE):
341
341
  except TypeError:
342
342
  return self.potential(phi)
343
343
 
344
- def rhs_analytic(self, phi, t):
344
+ def rhs_analytic(self, t, phi):
345
345
  grad = spv.gradient(phi)
346
346
  laplace = spv.laplacian(phi)
347
347
  norm_grad = sp.sqrt(grad.dot(grad))
@@ -354,7 +354,7 @@ class AllenCahnEquation(SemiLinearODE):
354
354
  + 3/self.eps * phi * (1-phi) * self.force
355
355
  return self.M * df_dphi
356
356
 
357
- def rhs(self, phi, t):
357
+ def rhs(self, t, phi):
358
358
  r"""Two-phase Allen-Cahn equation
359
359
 
360
360
  Microstructural evolution of the order parameter ``\phi``
@@ -422,13 +422,13 @@ class CoupledReactionDiffusion(SemiLinearODE):
422
422
  except TypeError:
423
423
  return self.interaction(u)
424
424
 
425
- def rhs_analytic(self, u, t):
425
+ def rhs_analytic(self, t, u):
426
426
  interaction = self._eval_interaction(u, sp)
427
427
  dc_A = self.D_A*spv.laplacian(u[0]) - interaction + self.feed * (1-u[0])
428
428
  dc_B = self.D_B*spv.laplacian(u[1]) + interaction - self.kill * u[1]
429
429
  return (dc_A, dc_B)
430
430
 
431
- def rhs(self, u, t):
431
+ def rhs(self, t, u):
432
432
  r"""Two-component reaction-diffusion system
433
433
 
434
434
  Use batch channels for multiple species:
evoxels/profiler.py CHANGED
@@ -3,12 +3,20 @@ import psutil
3
3
  import os
4
4
  import subprocess
5
5
  import tracemalloc
6
+ import shutil
6
7
  from abc import ABC, abstractmethod
7
8
 
8
9
  class MemoryProfiler(ABC):
9
10
  """Base interface for tracking host and device memory usage."""
11
+ def __init__(self):
12
+ self.max_used_cpu = 0.0
13
+ self.max_used_gpu = 0.0
14
+ self.track_gpu = False # subclasses set this
15
+
10
16
  def get_cuda_memory_from_nvidia_smi(self):
11
17
  """Return currently used CUDA memory in megabytes."""
18
+ if shutil.which("nvidia-smi") is None:
19
+ return None
12
20
  try:
13
21
  output = subprocess.check_output(
14
22
  ['nvidia-smi', '--query-gpu=memory.used',
@@ -23,8 +31,10 @@ class MemoryProfiler(ABC):
23
31
  process = psutil.Process(os.getpid())
24
32
  used_cpu = process.memory_info().rss / 1024**2
25
33
  self.max_used_cpu = np.max((self.max_used_cpu, used_cpu))
26
- used = self.get_cuda_memory_from_nvidia_smi()
27
- self.max_used_gpu = np.max((self.max_used_gpu, used))
34
+ if self.track_gpu:
35
+ used = self.get_cuda_memory_from_nvidia_smi()
36
+ if used is not None:
37
+ self.max_used_gpu = np.max((self.max_used_gpu, used))
28
38
 
29
39
  @abstractmethod
30
40
  def print_memory_stats(self, start: float, end: float, iters: int):
@@ -35,13 +45,14 @@ class TorchMemoryProfiler(MemoryProfiler):
35
45
  def __init__(self, device):
36
46
  """Initialize the profiler for a given torch device."""
37
47
  import torch
48
+ super().__init__()
38
49
  self.torch = torch
39
50
  self.device = device
51
+ self.track_gpu = (device.type == 'cuda')
52
+
40
53
  tracemalloc.start()
41
- if device.type == 'cuda':
54
+ if self.track_gpu:
42
55
  torch.cuda.reset_peak_memory_stats(device=device)
43
- self.max_used_gpu = 0
44
- self.max_used_cpu = 0
45
56
 
46
57
  def print_memory_stats(self, start, end, iters):
47
58
  """Print usage statistics for the Torch backend."""
@@ -60,7 +71,10 @@ class TorchMemoryProfiler(MemoryProfiler):
60
71
  elif self.device.type == 'cuda':
61
72
  self.update_memory_stats()
62
73
  used = self.get_cuda_memory_from_nvidia_smi()
63
- print(f"GPU-RAM (nvidia-smi) current: {used} MB ({self.max_used_gpu} MB max)")
74
+ if used is None:
75
+ print("GPU-RAM (nvidia-smi) unavailable.")
76
+ else:
77
+ print(f"GPU-RAM (nvidia-smi) current: {used} MB ({self.max_used_gpu} MB max)")
64
78
  print(f"GPU-RAM (torch) current: "
65
79
  f"{self.torch.cuda.memory_allocated(self.device) / 1024**2:.2f} MB "
66
80
  f"({self.torch.cuda.max_memory_allocated(self.device) / 1024**2:.2f} MB max, "
@@ -70,9 +84,9 @@ class JAXMemoryProfiler(MemoryProfiler):
70
84
  def __init__(self):
71
85
  """Initialize the profiler for JAX."""
72
86
  import jax
87
+ super().__init__()
73
88
  self.jax = jax
74
- self.max_used_gpu = 0
75
- self.max_used_cpu = 0
89
+ self.track_gpu = any(d.platform == "gpu" for d in jax.devices())
76
90
  tracemalloc.start()
77
91
 
78
92
  def print_memory_stats(self, start, end, iters):
@@ -88,7 +102,10 @@ class JAXMemoryProfiler(MemoryProfiler):
88
102
  current = process.memory_info().rss / 1024**2
89
103
  print(f"CPU-RAM (psutil) current: {current:.2f} MB ({self.max_used_cpu:.2f} MB max)")
90
104
 
91
- if self.jax.default_backend() == 'gpu':
105
+ if self.track_gpu:
92
106
  self.update_memory_stats()
93
107
  used = self.get_cuda_memory_from_nvidia_smi()
94
- print(f"GPU-RAM (nvidia-smi) current: {used} MB ({self.max_used_gpu} MB max)")
108
+ if used is None:
109
+ print("GPU-RAM (nvidia-smi) unavailable.")
110
+ else:
111
+ print(f"GPU-RAM (nvidia-smi) current: {used} MB ({self.max_used_gpu} MB max)")
evoxels/solvers.py CHANGED
@@ -99,7 +99,7 @@ class TimeDependentSolver:
99
99
  self._handle_outputs(u, frame, time, slice_idx, vtk_out, verbose, plot_bounds, colormap)
100
100
  frame += 1
101
101
 
102
- u = step(u, time)
102
+ u = step(time, u)
103
103
 
104
104
  end = timer()
105
105
  time = max_iters * time_increment
@@ -129,6 +129,7 @@ class TimeDependentSolver:
129
129
  filename = self.problem_cls.__name__ + "_" +\
130
130
  self.fieldnames[0] + f"_{frame:03d}.vtk"
131
131
  self.vf.export_to_vtk(filename=filename, field_names=self.fieldnames)
132
+
132
133
  if verbose == 'plot':
133
134
  clear_output(wait=True)
134
135
  self.vf.plot_slice(self.fieldnames[0], slice_idx, time=time, colormap=colormap, value_bounds=plot_bounds)
evoxels/timesteppers.py CHANGED
@@ -16,13 +16,13 @@ class TimeStepper(ABC):
16
16
  pass
17
17
 
18
18
  @abstractmethod
19
- def step(self, u: State, t: float) -> State:
19
+ def step(self, t: float, u: State) -> State:
20
20
  """
21
21
  Take one timestep from t to (t+dt).
22
22
 
23
23
  Args:
24
- u : Current state
25
24
  t : Current time
25
+ u : Current state
26
26
  Returns:
27
27
  Updated state at t + dt.
28
28
  """
@@ -39,8 +39,8 @@ class ForwardEuler(TimeStepper):
39
39
  def order(self) -> int:
40
40
  return 1
41
41
 
42
- def step(self, u: State, t: float) -> State:
43
- return u + self.dt * self.problem.rhs(u, t)
42
+ def step(self, t: float, u: State) -> State:
43
+ return u + self.dt * self.problem.rhs(t, u)
44
44
 
45
45
 
46
46
  @dataclass
@@ -68,8 +68,8 @@ class PseudoSpectralIMEX(TimeStepper):
68
68
  def order(self) -> int:
69
69
  return 1
70
70
 
71
- def step(self, u: State, t: float) -> State:
72
- dc = self.pad(self.problem.rhs(u, t))
71
+ def step(self, t: float, u: State) -> State:
72
+ dc = self.pad(self.problem.rhs(t, u))
73
73
  dc_fft = self._fft_prefac * self.problem.vg.rfftn(dc, dc.shape)
74
74
  update = self.problem.vg.irfftn(dc_fft, dc.shape)[:,:u.shape[1]]
75
75
  return u + update
evoxels/utils.py CHANGED
@@ -3,15 +3,21 @@ import sympy as sp
3
3
  import sympy.vector as spv
4
4
  import evoxels as evo
5
5
  from evoxels.problem_definition import SmoothedBoundaryODE
6
+ from evoxels.solvers import TimeDependentSolver
7
+ import contextlib
8
+ import io
9
+ import matplotlib.pyplot as plt
10
+ from mpl_toolkits.mplot3d import Axes3D # noqa: F401 (needed for 3D projection)
11
+ from matplotlib.patches import Patch
6
12
 
7
13
  ### Generalized test case
8
14
  def rhs_convergence_test(
9
- ODE_class, # an ODE class with callable rhs(field, t)->torch.Tensor (shape [x,y,z])
10
- problem_kwargs, # problem parameters to instantiate ODE
11
- test_function, # exact init_fun(x,y,z)->np.ndarray
12
- mask_function=None,
13
- convention="cell_center",
14
- dtype="float32",
15
+ ODE_class,
16
+ problem_kwargs,
17
+ test_function,
18
+ mask_function = None,
19
+ convention = "cell_center",
20
+ dtype = "float32",
15
21
  powers = np.array([3,4,5,6,7]),
16
22
  backend = "torch"
17
23
  ):
@@ -22,7 +28,7 @@ def rhs_convergence_test(
22
28
  slope arrays have one entry for each provided function.
23
29
 
24
30
  Args:
25
- ODE_class: an ODE class with callable rhs(field, t).
31
+ ODE_class: ODE class with callable rhs(t, u).
26
32
  problem_kwargs: problem-specific parameters to instantiate ODE.
27
33
  test_function: single sympy expression or a list of expressions.
28
34
  mask_function: static mask for smoothed boundary method.
@@ -38,7 +44,9 @@ def rhs_convergence_test(
38
44
  "is not a SmoothedBoundaryODE."
39
45
  )
40
46
  CS = spv.CoordSys3D('CS')
47
+
41
48
  # Prepare lambdified mask if needed
49
+ # Assumed to be static i.e. no function of t
42
50
  mask = (
43
51
  sp.lambdify((CS.x, CS.y, CS.z), mask_function, "numpy")
44
52
  if mask_function is not None
@@ -67,50 +75,51 @@ def rhs_convergence_test(
67
75
  elif convention == 'staggered_x':
68
76
  vf = evo.VoxelFields((2**p + 1, 2**p, 2**p), (1, 1, 1), convention=convention)
69
77
  vf.precision = dtype
70
- grid = vf.meshgrid()
78
+ dx[i] = vf.spacing[0]
79
+
71
80
  if backend == 'torch':
72
81
  vg = evo.voxelgrid.VoxelGridTorch(vf.grid_info(), precision=vf.precision, device='cpu')
73
82
  elif backend == 'jax':
74
83
  vg = evo.voxelgrid.VoxelGridJax(vf.grid_info(), precision=vf.precision)
75
-
84
+
85
+ # Init mask if smoothed boundary ODE
86
+ numpy_grid = vf.meshgrid()
87
+ if mask is not None:
88
+ problem_kwargs["mask"] = mask(*numpy_grid)
89
+
76
90
  # Initialise fields
77
91
  u_list = []
78
92
  for func in test_functions:
79
93
  init_fun = sp.lambdify((CS.x, CS.y, CS.z), func, "numpy")
80
- init_data = init_fun(*grid)
94
+ init_data = init_fun(*numpy_grid)
81
95
  u_list.append(vg.init_scalar_field(init_data))
82
96
 
83
97
  u = vg.concatenate(u_list, 0)
84
98
  u = vg.bc.trim_boundary_nodes(u)
85
99
 
86
- # Init mask if smoothed boundary ODE
87
- if mask is not None:
88
- problem_kwargs["mask"] = mask(*grid)
89
-
90
100
  ODE = ODE_class(vg, **problem_kwargs)
91
- rhs_numeric = ODE.rhs(u, 0)
101
+ rhs_numeric = ODE.rhs(0, u)
92
102
 
93
103
  if n_funcs > 1 and mask is not None:
94
- rhs_analytic = ODE.rhs_analytic(mask_function, test_functions, 0)
104
+ rhs_analytic = ODE.rhs_analytic(0, test_functions, mask_function)
95
105
  elif n_funcs > 1 and mask is None:
96
- rhs_analytic = ODE.rhs_analytic(test_functions, 0)
106
+ rhs_analytic = ODE.rhs_analytic(0, test_functions)
97
107
  elif n_funcs == 1 and mask is not None:
98
- rhs_analytic = [ODE.rhs_analytic(mask_function, test_functions[0], 0)]
108
+ rhs_analytic = [ODE.rhs_analytic(0, test_functions[0], mask_function)]
99
109
  else:
100
- rhs_analytic = [ODE.rhs_analytic(test_functions[0], 0)]
110
+ rhs_analytic = [ODE.rhs_analytic(0, test_functions[0])]
101
111
 
102
112
  # Compute solutions
103
113
  for j, func in enumerate(test_functions):
104
114
  comp = vg.export_scalar_field_to_numpy(rhs_numeric[j:j+1])
105
115
  exact_fun = sp.lambdify((CS.x, CS.y, CS.z), rhs_analytic[j], "numpy")
106
- exact = exact_fun(*grid)
116
+ exact = exact_fun(*numpy_grid)
107
117
  if convention == "staggered_x":
108
118
  exact = exact[1:-1, :, :]
109
119
 
110
120
  # Error norm
111
121
  diff = comp - exact
112
122
  errors[j, i] = np.linalg.norm(diff) / np.linalg.norm(exact)
113
- dx[i] = vf.spacing[0]
114
123
 
115
124
  # Fit slope after loop
116
125
  slopes = np.array(
@@ -121,4 +130,310 @@ def rhs_convergence_test(
121
130
  order = ODE.order
122
131
 
123
132
  return dx, errors if errors.shape[0] > 1 else errors[0], slopes, order
124
-
133
+
134
+
135
+ def mms_convergence_test(
136
+ ODE_class, # an ODE class with callable rhs(field, t)->torch.Tensor (shape [x,y,z])
137
+ problem_kwargs, # problem parameters to instantiate ODE
138
+ test_function, # exact init_fun(x,y,z)->np.ndarray
139
+ mask_function=None,
140
+ timestepper_cls=None,
141
+ convention="cell_center",
142
+ dtype="float32",
143
+ mode = 'temporal',
144
+ g_powers = np.array([3,4,5,6,7]),
145
+ t_powers = np.array([3,4,5,6,7]),
146
+ t_final = 1,
147
+ backend = "jax",
148
+ device = 'cpu'
149
+ ):
150
+ """Evaluate temporal and spatial order of ODE solution.
151
+
152
+ ``test_function`` can be a single sympy expression or a list of
153
+ expressions representing multiple variables. The returned error and
154
+ slope arrays have one entry for each provided function.
155
+
156
+ Args:
157
+ ODE_class: ODE class with callable rhs(t, u).
158
+ problem_kwargs: problem-specific parameters to instantiate ODE.
159
+ test_function: single sympy expression or a list of expressions.
160
+ mask_function: static mask for smoothed boundary method.
161
+ timestepper_cls: timestepper class with callable step(t, u).
162
+ convention: grid convention.
163
+ dtype: floate precision (``float32`` or ``float64``).
164
+ mode: Use ``temporal`` or ``spatial`` to construct MMS forcing.
165
+ g_powers: refine grid in powers of two (i.e. ``Nx = 2**p``).
166
+ t_powers: refine time increment in powers of two (i.e. ``dt = 2**p``).
167
+ t_final: End time for evaluation. Should be order of L^2/D.
168
+ backend: use ``torch`` or ``jax`` for testing.
169
+ device: use ``cpu`` or ``cuda`` for testing in torch.
170
+ """
171
+ # Verify mask_function only used with SmoothedBoundaryODE
172
+ if mask_function is not None and not issubclass(ODE_class, SmoothedBoundaryODE):
173
+ raise TypeError(
174
+ f"Mask function provided but {ODE_class.__name__} "
175
+ "is not a SmoothedBoundaryODE."
176
+ )
177
+ CS = spv.CoordSys3D('CS')
178
+ t = sp.symbols('t', real=True)
179
+
180
+ # Prepare lambdified mask if needed
181
+ # Assumed to be static i.e. no function of t
182
+ mask = (
183
+ sp.lambdify((CS.x, CS.y, CS.z), mask_function, "numpy")
184
+ if mask_function is not None
185
+ else None
186
+ )
187
+
188
+ if isinstance(test_function, (list, tuple)):
189
+ test_functions = list(test_function)
190
+ else:
191
+ test_functions = [test_function]
192
+ n_funcs = len(test_functions)
193
+
194
+ # Multiply test functions with mask for SBM testing
195
+ if mask is not None:
196
+ temp_list = []
197
+ for func in test_functions:
198
+ temp_list.append(func*mask_function)
199
+ test_functions = temp_list
200
+
201
+ if mode == 'temporal':
202
+ u_list = [sp.lambdify((t, CS.x, CS.y, CS.z),
203
+ sp.N(func), backend) \
204
+ for func in test_functions]
205
+ u_t_list = [sp.lambdify((t, CS.x, CS.y, CS.z),
206
+ sp.N(sp.diff(func, t)), backend) \
207
+ for func in test_functions]
208
+
209
+ dx = np.zeros(len(g_powers))
210
+ dt = np.zeros(len(t_powers))
211
+ errors = np.zeros((n_funcs, len(t_powers), len(g_powers)))
212
+
213
+ for i, p in enumerate(g_powers):
214
+ if convention == 'cell_center':
215
+ vf = evo.VoxelFields((2**p, 2**p, 2**p), (1, 1, 1), convention=convention)
216
+ elif convention == 'staggered_x':
217
+ vf = evo.VoxelFields((2**p + 1, 2**p, 2**p), (1, 1, 1), convention=convention)
218
+ vf.precision = dtype
219
+ dx[i] = vf.spacing[0]
220
+
221
+ if backend == 'torch':
222
+ vg = evo.voxelgrid.VoxelGridTorch(vf.grid_info(), precision=vf.precision, device=device)
223
+ elif backend == 'jax':
224
+ vg = evo.voxelgrid.VoxelGridJax(vf.grid_info(), precision=vf.precision)
225
+
226
+ # Init mask if smoothed boundary ODE
227
+ numpy_grid = vf.meshgrid()
228
+ if mask is not None:
229
+ problem_kwargs["mask"] = mask(*numpy_grid)
230
+
231
+ ODE = ODE_class(vg, **problem_kwargs)
232
+ rhs_orig = ODE.rhs
233
+ grid = vg.meshgrid()
234
+
235
+ # Construct new rhs including forcing term from MMS
236
+ if mode == 'temporal':
237
+ def mms_rhs(t, u):
238
+ """Manufactured solution rhs
239
+ with numerical evaluation of rhs in forcing, i.e.
240
+ forcing = du/dt_exact(t,grid) - rhs_num(t, u_exact(t,grid))
241
+ """
242
+ rhs = rhs_orig(t, u)
243
+ t_ = vg.to_backend(t)
244
+ u_ex_list = []
245
+ for j, func in enumerate(test_functions):
246
+ u_ex_list.append(vg.expand_dim(u_list[j](t_, *grid), 0))
247
+ rhs = vg.set(rhs, j, rhs[j] + u_t_list[j](t_, *grid))
248
+ u_ex = vg.concatenate(u_ex_list, 0)
249
+ u_ex = vg.bc.trim_boundary_nodes(u_ex)
250
+ rhs -= rhs_orig(t, u_ex)
251
+ return rhs
252
+
253
+ elif mode == 'spatial':
254
+ if n_funcs > 1 and mask is not None:
255
+ rhs_func = ODE.rhs_analytic(t, test_functions, mask_function)
256
+ rhs_analytic = [sp.lambdify((t, CS.x, CS.y, CS.z), sp.N(func), backend) for func in rhs_func]
257
+ elif n_funcs > 1 and mask is None:
258
+ rhs_func = ODE.rhs_analytic(t, test_functions)
259
+ rhs_analytic = [sp.lambdify((t, CS.x, CS.y, CS.z), sp.N(func), backend) for func in rhs_func]
260
+ elif n_funcs == 1 and mask is not None:
261
+ rhs_func = ODE.rhs_analytic(t, test_functions[0], mask_function)
262
+ rhs_analytic = [sp.lambdify((t, CS.x, CS.y, CS.z), sp.N(rhs_func), backend)]
263
+ else:
264
+ rhs_func = ODE.rhs_analytic(t, test_functions[0])
265
+ rhs_analytic = [sp.lambdify((t, CS.x, CS.y, CS.z), sp.N(rhs_func), backend)]
266
+
267
+ def mms_rhs(t, u):
268
+ """Manufactured solution rhs
269
+ with analytical evaluation of rhs in forcing, i.e.
270
+ forcing = du/dt_exact(t,grid) - rhs_exact(t, grid)
271
+ """
272
+ rhs = rhs_orig(t, u)
273
+ t_ = vg.to_backend(t)
274
+ for j, func in enumerate(test_functions):
275
+ rhs = vg.set(rhs, j, rhs[j] - rhs_analytic[j](t_, *grid))
276
+ rhs = vg.set(rhs, j, rhs[j] + u_t_list[j](t_, *grid))
277
+ return rhs
278
+ else:
279
+ raise ValueError("Mode must be 'temporal' or 'spatial'.")
280
+
281
+ # Over-write original rhs with contructed mms_rhs
282
+ ODE.rhs = mms_rhs
283
+
284
+ # Loop over time refinements
285
+ for k, q in enumerate(t_powers):
286
+ # Initialise fields
287
+ field_names = []
288
+ for j, func in enumerate(test_functions):
289
+ fun = sp.lambdify((t, CS.x, CS.y, CS.z), func, "numpy")
290
+ init_data = fun(0, *numpy_grid)
291
+ final_data = fun(t_final, *numpy_grid)
292
+ vf.add_field(f'u{j}', init_data)
293
+ vf.add_field(f'u{j}_final', final_data)
294
+ field_names.append(f'u{j}')
295
+
296
+ # Init time increment and step function
297
+ dt[k] = t_final / 2**q
298
+ timestepper = timestepper_cls(ODE, dt[k])
299
+ step = timestepper.step
300
+
301
+ # Init solver
302
+ solver = TimeDependentSolver(
303
+ vf, field_names,
304
+ backend, device=device,
305
+ step_fn=step
306
+ )
307
+
308
+ # Wrap solve to capture NaN exit
309
+ nan_hit = False
310
+ buf = io.StringIO()
311
+ with contextlib.redirect_stdout(buf):
312
+ try:
313
+ solver.solve(dt[k], 8, int(2**q), problem_kwargs, verbose=False)
314
+ except SystemExit:
315
+ nan_hit = True
316
+
317
+ if nan_hit:
318
+ errors[:, k, i] = np.nan
319
+ continue
320
+
321
+ # Compute relative L2 error
322
+ for j, func in enumerate(test_functions):
323
+ exact = vf.fields[f'u{j}_final']
324
+ diff = vf.fields[f'u{j}'] - exact
325
+ errors[j, k, i] = np.linalg.norm(diff) / np.linalg.norm(exact)
326
+
327
+ # Fit slope after loop
328
+ def calc_slope(x, y):
329
+ mask = np.isfinite(y)
330
+ if mask.sum() < 2:
331
+ return np.nan
332
+ return np.polyfit(np.log(x[mask]), np.log(y[mask]), 1)[0]
333
+
334
+ t_slopes = np.array([calc_slope(dt, err[:,0]) for err in errors])
335
+ g_slopes = np.array([calc_slope(dx, err[-1,:]) for err in errors])
336
+
337
+ results = {
338
+ 'dt': dt,
339
+ 'dx': dx,
340
+ 'error': errors if n_funcs > 1 else errors[0],
341
+ 't_slopes': t_slopes if n_funcs > 1 else t_slopes[0],
342
+ 'g_slopes': g_slopes if n_funcs > 1 else g_slopes[0],
343
+ 'n_funcs': n_funcs,
344
+ 't_order': timestepper.order,
345
+ 'g_order': ODE.order,
346
+ }
347
+ return results
348
+
349
+
350
+ def plot_error_surface(series, log_axes=(True, True, True), z_max=0, title=None, alpha=0.4):
351
+ """
352
+ Plot one or more 3D surfaces z(x, y) with semi-transparent tiles and solid mesh lines.
353
+
354
+ Parameters
355
+ ----------
356
+ series : tuple[list] of dict
357
+ Each dict must have:
358
+ - 'dt': 1D array-like of dt-values (length Nx)
359
+ - 'dx': 1D array-like of dx-values (length Ny)
360
+ - 'error': 2D array-like of values Z(X, Y) with shape (Nx, Ny)
361
+ - 'name': (optional) label for legend
362
+ log_axes : tuple(bool, bool, bool)
363
+ (log_x, log_y, log_z): apply log10 to respective axis data when True.
364
+ For Z, nonpositive values are masked to NaN before log10.
365
+ title : str or None
366
+ Plot title.
367
+ alpha : float
368
+ Face transparency for surfaces.
369
+ """
370
+ if not isinstance(series, (list, tuple)) or len(series) == 0:
371
+ raise ValueError("`series` must be a non-empty tuple/list of dictionaries.")
372
+
373
+ log_x, log_y, log_z = log_axes
374
+
375
+ # Distinct colors
376
+ base_colors = ['tab:red', 'tab:blue', 'tab:green', 'tab:gray',
377
+ 'tab:purple', 'tab:brown', 'tab:pink', 'tab:orange',
378
+ 'tab:olive', 'tab:cyan']
379
+
380
+ fig = plt.figure(figsize=(5, 5))
381
+ ax = fig.add_subplot(111, projection='3d')
382
+ legend_patches = []
383
+
384
+ count = 0
385
+ for i, s in enumerate(series):
386
+ if not isinstance(s, dict) or not all(k in s for k in ('dt', 'dx', 'error')):
387
+ raise ValueError(f"Item {i} must be a dict with keys 'dt', 'dx', 'error' (and optional 'name').")
388
+
389
+ x_in = np.asarray(s['dt'])
390
+ y_in = np.asarray(s['dx'])
391
+ Z = np.asarray(s['error'])
392
+ Z = np.expand_dims(Z, axis=0) if Z.ndim == 2 else Z
393
+ name = s.get('name', f'[{i}]')
394
+
395
+ # Handle (1D,1D,2D) or (2D,2D,2D)
396
+ if x_in.ndim == 1 and y_in.ndim == 1:
397
+ X, Y = np.meshgrid(x_in, y_in, indexing='ij') # (Nx, Ny)
398
+ else:
399
+ raise ValueError(f"Item {i}: dt and dx must both be 1D grids.")
400
+
401
+ if Z.shape[1:] != X.shape:
402
+ raise ValueError(f"Item {i}: z.shape {Z.shape} must match x/y grid shape {X.shape}.")
403
+
404
+ # Apply log scaling
405
+ Xp = np.log10(X) if log_x else X
406
+ Yp = np.log10(Y) if log_y else Y
407
+ if log_z:
408
+ Z = np.where(Z > 0, Z, np.nan)
409
+ Zp = np.log10(Z)
410
+ else:
411
+ Zp = Z
412
+
413
+ for j in range(s['n_funcs']):
414
+ color = base_colors[count % len(base_colors)]
415
+ ax.plot_surface(
416
+ Xp, Yp, Zp[j],
417
+ color=color, # uniform color per surface
418
+ alpha=alpha, # semi-transparent tiles
419
+ edgecolor=color, # solid mesh lines
420
+ linewidth=0.6,
421
+ antialiased=True,
422
+ shade=False
423
+ )
424
+ label = name + f"_u{j}" if j > 0 else name
425
+ legend_patches.append(Patch(facecolor=color, edgecolor=color, alpha=alpha, label=label))
426
+ count += 1
427
+
428
+ # Axis labels reflect log choice
429
+ ax.set_xlabel('log10(dt)' if log_x else 'dt')
430
+ ax.set_ylabel('log10(dx)' if log_y else 'dx')
431
+ ax.text2D(0.0, 0.8, 'log10(error)' if log_z else 'error',
432
+ transform=ax.transAxes, va="top", ha="left")
433
+ ax.set_zlim(top=z_max)
434
+ ax.set_title(title or 'Error Surfaces')
435
+ ax.view_init(elev=25., azim=-145, roll=0)
436
+
437
+ ax.legend(handles=legend_patches, loc='best')
438
+ fig.tight_layout()
439
+ plt.show()
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evoxels
3
- Version: 0.1.0
4
- Summary: Voxel-based structure simulation solvers
3
+ Version: 0.1.2
4
+ Summary: Differentiable physics framework for voxel-based microstructure simulations
5
5
  Author-email: Simon Daubner <s.daubner@imperial.ac.uk>
6
6
  License: MIT
7
7
  Classifier: Development Status :: 4 - Beta
@@ -49,9 +49,7 @@ A differentiable physics framework for voxel-based microstructure simulations
49
49
 
50
50
  For more detailed information about the code [read the docs](https://evoxels.readthedocs.io).
51
51
 
52
- <p align="center">
53
- <img src="evoxels.png" width="90%"></img>
54
- </p>
52
+ ![Evoxels overview](https://raw.githubusercontent.com/daubners/evoxels/main/evoxels.png)
55
53
 
56
54
  ```
57
55
  In a world of cubes and blocks,
@@ -0,0 +1,20 @@
1
+ evoxels/__init__.py,sha256=LLogj7BjzLNi9voNIQkmDYmj_dT82Aw8ph9RAlaPLuU,376
2
+ evoxels/boundary_conditions.py,sha256=IZbPFvPVknWtTyXdDYEQiWCmw1RhwAlCfx0GBpGt7wg,5129
3
+ evoxels/fd_stencils.py,sha256=kpFszQqjSPuWzRPyscR13uaDM9GWiBvmBVopBRosohs,4581
4
+ evoxels/function_approximators.py,sha256=_WwsypBWSigMlyvT_Qgc8rHN7SsyvyFL9WgB1QqPdHY,2545
5
+ evoxels/inversion.py,sha256=9EQcr2-Xe5zAEhbqd-ZbOPRqqfRtrHE8pKW2TFPXdC8,8694
6
+ evoxels/problem_definition.py,sha256=Xa0thRebxCZsvbKuxWv-vWzpgeEGRLQwsfOLjL8j7tA,14558
7
+ evoxels/profiler.py,sha256=3-kIku5ebOEawKL73LIKZ6HnM0odqhGRrLf54fbY7SY,4544
8
+ evoxels/solvers.py,sha256=5H94_Bu1vB3MR9xCfJgBxpYwQI-NLd7ST4UB2peburY,5173
9
+ evoxels/timesteppers.py,sha256=VUvPrnhsHW3H7FwfrGpwvXl0GAWpThf-ULH-tIT44_8,3509
10
+ evoxels/utils.py,sha256=oA79ziEUQ1fyyVquJSHt9k_f60bEDjUcBMV1y_QuUsE,16967
11
+ evoxels/voxelfields.py,sha256=e6DEqv1C7MavOqFx9RhEMD-SktVEO1JDLRv43WNiCTU,13300
12
+ evoxels/voxelgrid.py,sha256=r5yoo2J6ogHEOzFbhjikbi4wXQfurmZB4EWf6AEHIK0,9549
13
+ evoxels/precompiled_solvers/__init__.py,sha256=V8oekjyg13ziQ1Gdf0pHHYubZcB6Oec5a8RG0d09Y1c,50
14
+ evoxels/precompiled_solvers/allen_cahn.py,sha256=h44HKG_NNalJBN6_uyBciAthAjUaoQ4rw_JsV1rQCeo,1372
15
+ evoxels/precompiled_solvers/cahn_hilliard.py,sha256=yC-iGEvD8Efdmp6YEUfysl2L1jbfjU62-TR1T05S68E,1166
16
+ evoxels-0.1.2.dist-info/licenses/LICENSE,sha256=2ScNJCT83dGOKEIpmeO0sq7sf6-Rru3SsnDHJ2UFdcg,1065
17
+ evoxels-0.1.2.dist-info/METADATA,sha256=z6hysZ42sheVY4aGltMNtnliyKLhAu2AjprxiZhWKfA,7618
18
+ evoxels-0.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
19
+ evoxels-0.1.2.dist-info/top_level.txt,sha256=g6OihMiKjYgojKrMM8ckpmFVh-ExPN8f4MZPWscCbqo,8
20
+ evoxels-0.1.2.dist-info/RECORD,,
@@ -1,20 +0,0 @@
1
- evoxels/__init__.py,sha256=LLogj7BjzLNi9voNIQkmDYmj_dT82Aw8ph9RAlaPLuU,376
2
- evoxels/boundary_conditions.py,sha256=IZbPFvPVknWtTyXdDYEQiWCmw1RhwAlCfx0GBpGt7wg,5129
3
- evoxels/fd_stencils.py,sha256=kpFszQqjSPuWzRPyscR13uaDM9GWiBvmBVopBRosohs,4581
4
- evoxels/function_approximators.py,sha256=_WwsypBWSigMlyvT_Qgc8rHN7SsyvyFL9WgB1QqPdHY,2545
5
- evoxels/inversion.py,sha256=wKRaJ14eLGBWIeJbAk1pSFP6kFmKDeM9OBvSHndUAz4,8694
6
- evoxels/problem_definition.py,sha256=nfr0M1yH0v16btJoE9_0xlukxjyHx3BmnM1pWyXIsew,14559
7
- evoxels/profiler.py,sha256=2nGEbQBTaP_FUdA7OojmxQe2npLJwgDC-RVF4DNJ51A,3995
8
- evoxels/solvers.py,sha256=tA-3bqHApktZnENMNWZIz-bsgX2QKRzW8FzKrwsvQXE,5172
9
- evoxels/timesteppers.py,sha256=YbaWViKklhZeYKjl4SetWUALS4q6Fm7zCYy6cBesGgw,3509
10
- evoxels/utils.py,sha256=wp-tL2SkFIjSM3B1IwbMGLcVGPO6T8wr4bGitgn7z1k,4686
11
- evoxels/voxelfields.py,sha256=e6DEqv1C7MavOqFx9RhEMD-SktVEO1JDLRv43WNiCTU,13300
12
- evoxels/voxelgrid.py,sha256=r5yoo2J6ogHEOzFbhjikbi4wXQfurmZB4EWf6AEHIK0,9549
13
- evoxels/precompiled_solvers/__init__.py,sha256=V8oekjyg13ziQ1Gdf0pHHYubZcB6Oec5a8RG0d09Y1c,50
14
- evoxels/precompiled_solvers/allen_cahn.py,sha256=ZutF6L3LYqyCC3tIEQwHmQchZSbhZFmcLy-UNaN4yH0,1373
15
- evoxels/precompiled_solvers/cahn_hilliard.py,sha256=Bn2Tbv-kbLwinvVkCr07CT8OrR9im0kvKk0o9ySghFI,1158
16
- evoxels-0.1.0.dist-info/licenses/LICENSE,sha256=2ScNJCT83dGOKEIpmeO0sq7sf6-Rru3SsnDHJ2UFdcg,1065
17
- evoxels-0.1.0.dist-info/METADATA,sha256=s4G2NK70_BCfZG9N8dzRXQAEwNrkYHCiLyoaFm-ERMM,7562
18
- evoxels-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
19
- evoxels-0.1.0.dist-info/top_level.txt,sha256=g6OihMiKjYgojKrMM8ckpmFVh-ExPN8f4MZPWscCbqo,8
20
- evoxels-0.1.0.dist-info/RECORD,,