morpho.py 0.0.4__tar.gz → 0.1.0__tar.gz

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.
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: morpho.py
3
+ Version: 0.1.0
4
+ Summary: PWE framework for photonic crystal analysis
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Author: Tiago Vilela
8
+ Author-email: tiagovla@gmail.com
9
+ Requires-Python: >=3.9,<3.16
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Programming Language :: Python :: 3.15
18
+ Provides-Extra: docs
19
+ Requires-Dist: matplotlib (>=3.4.3,<4.0.0)
20
+ Requires-Dist: mkdocs (>=1.6.1,<2) ; extra == "docs"
21
+ Requires-Dist: mkdocs-material (>=9.7.7) ; extra == "docs"
22
+ Requires-Dist: mkdocstrings[python] (>=0.24.0) ; extra == "docs"
23
+ Requires-Dist: numpy (>=1.20.1,<3.0.0)
24
+ Requires-Dist: scipy (>=1.6.2,<2.0.0)
@@ -0,0 +1,28 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from morpho.brillouinzone import (
4
+ BrillouinZonePath,
5
+ BrillouinZonePath1D,
6
+ BrillouinZonePath2D,
7
+ BrillouinZonePath3D,
8
+ SymmetryPoint,
9
+ )
10
+ from morpho.geometry import Geometry, Geometry1D, Geometry2D, Geometry3D
11
+ from morpho.solver import Solver, Solver1D, Solver2D, Solver3D
12
+
13
+ __all__ = [
14
+ "__version__",
15
+ "BrillouinZonePath",
16
+ "BrillouinZonePath1D",
17
+ "BrillouinZonePath2D",
18
+ "BrillouinZonePath3D",
19
+ "SymmetryPoint",
20
+ "Geometry",
21
+ "Geometry1D",
22
+ "Geometry2D",
23
+ "Geometry3D",
24
+ "Solver",
25
+ "Solver1D",
26
+ "Solver2D",
27
+ "Solver3D",
28
+ ]
@@ -0,0 +1,339 @@
1
+ """This module implements classes related to the brillouinzone."""
2
+
3
+ from abc import ABC, abstractproperty
4
+ from dataclasses import dataclass
5
+ from numbers import Integral
6
+ from typing import List, Optional, Tuple, Union
7
+
8
+ import numpy as np
9
+ from numpy.linalg import norm
10
+
11
+
12
+ @dataclass
13
+ class BlochVectors:
14
+ """Represents a data structure of Bloch wave vectors."""
15
+
16
+ values: np.ndarray
17
+ cumsum: np.ndarray
18
+
19
+
20
+ class SymmetryPoint:
21
+ """Model of a symmetry point at the brillouinzone.
22
+
23
+ Parameters
24
+ ----------
25
+ point : Tuple[float, ...]
26
+ A point in the reciprocal domain.
27
+ name : str
28
+ A label to describe the point.
29
+ """
30
+
31
+ def __init__(self, point: Tuple[float, ...], name: str):
32
+ """Initialize a SymmetryPoint."""
33
+ self.point = np.array(point)
34
+ self.name = name
35
+
36
+
37
+ class BrillouinZonePathBase(ABC):
38
+ """BrillouinZoneBase class."""
39
+
40
+ def __init__(
41
+ self,
42
+ path: List[SymmetryPoint],
43
+ n_points: int = 50,
44
+ strategy: str = "linear",
45
+ points_per_segment: Optional[int] = None,
46
+ ):
47
+ self.path_points = path
48
+ self.n_points = n_points
49
+ self.strat = strategy
50
+ if points_per_segment is not None:
51
+ if (
52
+ not isinstance(points_per_segment, Integral)
53
+ or isinstance(points_per_segment, bool)
54
+ or points_per_segment < 1
55
+ ):
56
+ raise ValueError("points_per_segment must be a positive integer.")
57
+ if len(path) < 2:
58
+ raise ValueError(
59
+ "At least two symmetry points are needed for segment sampling."
60
+ )
61
+ points_per_segment = int(points_per_segment)
62
+ self.points_per_segment = points_per_segment
63
+ self._path: np.ndarray
64
+
65
+ @abstractproperty
66
+ def betas(self) -> BlochVectors:
67
+ """Return bloch wave vectors."""
68
+
69
+ @staticmethod
70
+ def _interpolate_beta(beta_cs, path, n_points, points_per_segment=None):
71
+ """Interpolate a path globally or by segment."""
72
+ if points_per_segment is not None:
73
+ beta_values = []
74
+ beta_distances = []
75
+ for index in range(path.shape[1] - 1):
76
+ fractions = np.linspace(0.0, 1.0, points_per_segment + 1)
77
+ start = path[:, index, None]
78
+ delta = (path[:, index + 1] - path[:, index])[:, None]
79
+ segment_values = start + delta * fractions[None, :]
80
+
81
+ start_distance = beta_cs[index]
82
+ segment_length = beta_cs[index + 1] - start_distance
83
+ segment_distances = start_distance + segment_length * fractions
84
+
85
+ if index:
86
+ segment_values = segment_values[:, 1:]
87
+ segment_distances = segment_distances[1:]
88
+ beta_values.append(segment_values)
89
+ beta_distances.append(segment_distances)
90
+
91
+ return BlochVectors(
92
+ np.concatenate(beta_values, axis=1),
93
+ np.concatenate(beta_distances),
94
+ )
95
+
96
+ beta_csi = np.linspace(0, beta_cs[-1], n_points)
97
+ beta_vi = np.vstack(
98
+ [
99
+ np.interp(beta_csi, beta_cs, path[i, :]).flatten()
100
+ for i in range(path.shape[0])
101
+ ]
102
+ )
103
+ return BlochVectors(beta_vi, beta_csi)
104
+
105
+ @property
106
+ def point_locations(self):
107
+ """Return cumulative sum of vectors' length."""
108
+ return np.concatenate(
109
+ (np.zeros(1), np.cumsum(norm(np.diff(self._path, axis=1), axis=0)))
110
+ )
111
+
112
+ @property
113
+ def point_names(self):
114
+ """Return list of symmetry point names."""
115
+ return [p.name for p in self.path_points]
116
+
117
+
118
+ class BrillouinZonePath1D(BrillouinZonePathBase):
119
+ """BrillouinZonePath1D.
120
+
121
+ Parameters
122
+ ----------
123
+ a1 : Tuple[float]
124
+ Direct lattice vector a1.
125
+ path : List[SymmetryPoint]
126
+ List of symmetry points.
127
+ n_points : int
128
+ Total number of vectors sampled along the full path. Used when
129
+ ``points_per_segment`` is not set.
130
+ strategy : str = 'linear'
131
+ Strategy to interpolate.
132
+ points_per_segment : int, optional
133
+ Number of intervals on each segment. Every symmetry point is sampled;
134
+ the total is one plus this value times the number of segments. This
135
+ takes precedence over ``n_points``.
136
+ """
137
+
138
+ def __init__(
139
+ self,
140
+ a1: Tuple[float],
141
+ path: List[SymmetryPoint],
142
+ n_points: int = 50,
143
+ strategy: str = "linear",
144
+ points_per_segment: Optional[int] = None,
145
+ ):
146
+ """Initialize a BrillouinZonePath."""
147
+ super().__init__(path, n_points, strategy, points_per_segment)
148
+ self.a1 = np.array(a1)
149
+ self.dim = 1
150
+
151
+ @property
152
+ def _path(self) -> np.ndarray:
153
+ """Return the path matrix."""
154
+ path = np.stack([p.point for p in self.path_points], axis=1)
155
+ return path[0, :] * self.b1[:, None]
156
+
157
+ @property
158
+ def betas(self) -> BlochVectors:
159
+ """Return beta vector values and cumsum."""
160
+ beta_cs = np.cumsum(norm(np.diff(self._path, axis=1), axis=0))
161
+ beta_cs = np.pad(beta_cs, (1, 0), "constant")
162
+ if self.strat == "linear":
163
+ return self._interpolate_beta(
164
+ beta_cs, self._path, self.n_points, self.points_per_segment
165
+ )
166
+ raise NotImplementedError
167
+
168
+ @property
169
+ def b1(self) -> np.ndarray:
170
+ """Return reciprocal lattice vector b1."""
171
+ return 2 * np.pi / self.a1
172
+
173
+
174
+ class BrillouinZonePath2D(BrillouinZonePathBase):
175
+ """BrillouinZonePath2D.
176
+
177
+ Parameters
178
+ ----------
179
+ a1 : Tuple[float, float]
180
+ Direct lattice vector a1.
181
+ a2 : Tuple[float, float]
182
+ Direct lattice vector a2.
183
+ path : List[SymmetryPoint]
184
+ List of symmetry points.
185
+ n_points : int
186
+ Total number of vectors sampled along the full path. Used when
187
+ ``points_per_segment`` is not set.
188
+ strategy : str = 'linear'
189
+ Strategy to interpolate.
190
+ points_per_segment : int, optional
191
+ Number of intervals on each segment. Every symmetry point is sampled;
192
+ the total is one plus this value times the number of segments. This
193
+ takes precedence over ``n_points``.
194
+ """
195
+
196
+ def __init__(
197
+ self,
198
+ a1: Tuple[float, float],
199
+ a2: Tuple[float, float],
200
+ path: List[SymmetryPoint],
201
+ n_points: int = 50,
202
+ strategy: str = "linear",
203
+ points_per_segment: Optional[int] = None,
204
+ ):
205
+ """Initialize a BrillouinZonePath."""
206
+ super().__init__(path, n_points, strategy, points_per_segment)
207
+ self.a1 = np.array(a1)
208
+ self.a2 = np.array(a2)
209
+ self.dim = 2
210
+
211
+ @property
212
+ def _path(self) -> np.ndarray:
213
+ """Return the path matrix."""
214
+ path = np.stack([p.point for p in self.path_points], axis=1)
215
+ return path[0, :] * self.b1[:, None] + path[1, :] * self.b2[:, None]
216
+
217
+ @property
218
+ def betas(self) -> BlochVectors:
219
+ """Return beta vector values and cumsum."""
220
+ beta_cs = np.cumsum(norm(np.diff(self._path, axis=1), axis=0))
221
+ beta_cs = np.pad(beta_cs, (1, 0), "constant")
222
+ if self.strat == "linear":
223
+ return self._interpolate_beta(
224
+ beta_cs, self._path, self.n_points, self.points_per_segment
225
+ )
226
+ raise NotImplementedError
227
+
228
+ @property
229
+ def b1(self) -> np.ndarray:
230
+ """Return reciprocal lattice vector b1."""
231
+ Q = np.array([[0, -1], [1, 0]])
232
+ return 2 * np.pi * Q @ self.a2 / np.dot(self.a1, Q @ self.a2)
233
+
234
+ @property
235
+ def b2(self) -> np.ndarray:
236
+ """Return reciprocal lattice vector b2."""
237
+ Q = np.array([[0, -1], [1, 0]])
238
+ return 2 * np.pi * Q @ self.a1 / np.dot(self.a2, Q @ self.a1)
239
+
240
+
241
+ class BrillouinZonePath3D(BrillouinZonePathBase):
242
+ """BrillouinZonePath3D.
243
+
244
+ Parameters
245
+ ----------
246
+ a1 : Tuple[float, float, float]
247
+ Direct lattice vector a1.
248
+ a2 : Tuple[float, float, float]
249
+ Direct lattice vector a2.
250
+ a3 : Tuple[float, float, float]
251
+ Direct lattice vector a3.
252
+ path : List[SymmetryPoint]
253
+ List of symmetry points.
254
+ n_points : int
255
+ Total number of vectors sampled along the full path. Used when
256
+ ``points_per_segment`` is not set.
257
+ strategy : str = 'linear'
258
+ Strategy to interpolate.
259
+ points_per_segment : int, optional
260
+ Number of intervals on each segment. Every symmetry point is sampled;
261
+ the total is one plus this value times the number of segments. This
262
+ takes precedence over ``n_points``.
263
+ """
264
+
265
+ def __init__(
266
+ self,
267
+ a1: Tuple[float, float, float],
268
+ a2: Tuple[float, float, float],
269
+ a3: Tuple[float, float, float],
270
+ path: List[SymmetryPoint],
271
+ n_points: int = 50,
272
+ strategy: str = "interpolate",
273
+ points_per_segment: Optional[int] = None,
274
+ ):
275
+ """Initialize a BrillouinZonePath."""
276
+ super().__init__(path, n_points, strategy, points_per_segment)
277
+ self.a1 = np.array(a1)
278
+ self.a2 = np.array(a2)
279
+ self.a3 = np.array(a3)
280
+ self.dim = 3
281
+
282
+ @property
283
+ def _path(self) -> np.ndarray:
284
+ """Return the path matrix."""
285
+ path = np.stack([p.point for p in self.path_points], axis=1)
286
+ return (
287
+ path[0, :] * self.b1[:, None]
288
+ + path[1, :] * self.b2[:, None]
289
+ + path[2, :] * self.b3[:, None]
290
+ )
291
+
292
+ @property
293
+ def betas(self) -> BlochVectors:
294
+ """Return beta vector values and cumsum."""
295
+ beta_cs = np.cumsum(norm(np.diff(self._path, axis=1), axis=0))
296
+ beta_cs = np.pad(beta_cs, (1, 0), "constant")
297
+ if self.strat == "interpolate":
298
+ return self._interpolate_beta(
299
+ beta_cs, self._path, self.n_points, self.points_per_segment
300
+ )
301
+ raise NotImplementedError
302
+
303
+ @property
304
+ def b1(self) -> np.ndarray:
305
+ """Return reciprocal lattice vector b1."""
306
+ return (
307
+ 2
308
+ * np.pi
309
+ * np.cross(self.a2, self.a3)
310
+ / np.dot(self.a1, np.cross(self.a2, self.a3))
311
+ )
312
+
313
+ @property
314
+ def b2(self) -> np.ndarray:
315
+ """Return reciprocal lattice vector b2."""
316
+ return (
317
+ 2
318
+ * np.pi
319
+ * np.cross(self.a3, self.a1)
320
+ / np.dot(self.a1, np.cross(self.a2, self.a3))
321
+ )
322
+
323
+ @property
324
+ def b3(self) -> np.ndarray:
325
+ """Return reciprocal lattice vector b3."""
326
+ return (
327
+ 2
328
+ * np.pi
329
+ * np.cross(self.a1, self.a2)
330
+ / np.dot(self.a1, np.cross(self.a2, self.a3))
331
+ )
332
+
333
+
334
+ def BrillouinZonePath(
335
+ a1, *args, **kwargs
336
+ ) -> Union[BrillouinZonePath1D, BrillouinZonePath2D, BrillouinZonePath3D]:
337
+ """BrillouinZonePath factory."""
338
+ dim_obj = {1: BrillouinZonePath1D, 2: BrillouinZonePath2D, 3: BrillouinZonePath3D}
339
+ return dim_obj[len(a1)](a1, *args, **kwargs)
@@ -26,7 +26,7 @@ class GeometryBase(ABC):
26
26
  func : Callable
27
27
  func
28
28
  """
29
- if func.__name__ in ['eps_rf', 'mu_rf']:
29
+ if func.__name__ in ["eps_rf", "mu_rf"]:
30
30
  setattr(self, func.__name__, func)
31
31
  else:
32
32
  raise Exception("Can only overwrite eps_rf or mu_rf.")
@@ -47,24 +47,40 @@ class Geometry1D(GeometryBase):
47
47
  Permeabillity matrix mu_r if directly supplied.
48
48
  """
49
49
 
50
- def __init__(self,
51
- a1: Tuple[float],
52
- n1: int = 64,
53
- eps_rf: Optional[Callable] = None,
54
- mu_rf: Optional[Callable] = None):
50
+ def __init__(
51
+ self,
52
+ a1: Tuple[float],
53
+ n1: int = 64,
54
+ eps_rf: Optional[Callable] = None,
55
+ mu_rf: Optional[Callable] = None,
56
+ ):
55
57
  super().__init__(eps_rf, mu_rf)
56
58
  self.a1: np.ndarray = np.array(a1)
57
59
  self.n1: int = n1
58
- self._eps_r: np.ndarray = np.ones((n1, ), dtype=complex)
59
- self._mu_r: np.ndarray = np.ones((n1, ), dtype=complex)
60
+ self._eps_r: np.ndarray = np.ones((n1,), dtype=complex)
61
+ self._mu_r: np.ndarray = np.ones((n1,), dtype=complex)
60
62
 
61
63
  @property
62
64
  def X(self) -> CartesianVectors1D:
63
65
  """Return cartesian positions."""
64
- P0 = np.linspace(-0.5, 0.5, self.n1)
66
+ P0 = np.arange(self.n1) / self.n1 - 0.5
65
67
  x = P0 * self.a1[0]
66
68
  return CartesianVectors1D(x)
67
69
 
70
+ @property
71
+ def eps_r(self) -> np.ndarray:
72
+ """Return the relative permittivity profile."""
73
+ if self.eps_rf:
74
+ self.eps_rf(eps_r=self._eps_r, x=self.X.x)
75
+ return self._eps_r
76
+
77
+ @property
78
+ def mu_r(self) -> np.ndarray:
79
+ """Return the relative permeability profile."""
80
+ if self.mu_rf:
81
+ self.mu_rf(self._mu_r, x=self.X.x)
82
+ return self._mu_r
83
+
68
84
 
69
85
  class Geometry2D(GeometryBase):
70
86
  """Geometry class.
@@ -85,13 +101,15 @@ class Geometry2D(GeometryBase):
85
101
  Permeabillity matrix mu_r if directly supplied.
86
102
  """
87
103
 
88
- def __init__(self,
89
- a1: Tuple[float, float],
90
- a2: Tuple[float, float],
91
- n1: int = 64,
92
- n2: int = 64,
93
- eps_rf: Optional[Callable] = None,
94
- mu_rf: Optional[Callable] = None):
104
+ def __init__(
105
+ self,
106
+ a1: Tuple[float, float],
107
+ a2: Tuple[float, float],
108
+ n1: int = 64,
109
+ n2: int = 64,
110
+ eps_rf: Optional[Callable] = None,
111
+ mu_rf: Optional[Callable] = None,
112
+ ):
95
113
  super().__init__(eps_rf, mu_rf)
96
114
  self.a1: np.ndarray = np.array(a1)
97
115
  self.a2: np.ndarray = np.array(a2)
@@ -104,8 +122,9 @@ class Geometry2D(GeometryBase):
104
122
  def X(self) -> CartesianVectors2D:
105
123
  """Return cartesian positions."""
106
124
  P0, Q0 = np.meshgrid(
107
- np.linspace(-0.5, 0.5, self.n1),
108
- np.linspace(-0.5, 0.5, self.n2),
125
+ np.arange(self.n1) / self.n1 - 0.5,
126
+ np.arange(self.n2) / self.n2 - 0.5,
127
+ indexing="ij",
109
128
  )
110
129
 
111
130
  x = P0 * self.a1[0] + Q0 * self.a2[0]
@@ -124,7 +143,7 @@ class Geometry2D(GeometryBase):
124
143
  def mu_r(self) -> np.ndarray:
125
144
  """Return the relative permeabillity matrice."""
126
145
  if self.mu_rf:
127
- self.mu_rf(eps_r=self._eps_r, x=self.X.x, y=self.X.y)
146
+ self.mu_rf(self._mu_r, x=self.X.x, y=self.X.y)
128
147
  return self._mu_r
129
148
 
130
149
 
@@ -151,15 +170,17 @@ class Geometry3D(GeometryBase):
151
170
  Permeabillity matrix function mu_rf if directly supplied.
152
171
  """
153
172
 
154
- def __init__(self,
155
- a1: Tuple[float, float, float],
156
- a2: Tuple[float, float, float],
157
- a3: Tuple[float, float, float],
158
- n1: int = 64,
159
- n2: int = 64,
160
- n3: int = 64,
161
- eps_rf: Optional[Callable] = None,
162
- mu_rf: Optional[Callable] = None):
173
+ def __init__(
174
+ self,
175
+ a1: Tuple[float, float, float],
176
+ a2: Tuple[float, float, float],
177
+ a3: Tuple[float, float, float],
178
+ n1: int = 64,
179
+ n2: int = 64,
180
+ n3: int = 64,
181
+ eps_rf: Optional[Callable] = None,
182
+ mu_rf: Optional[Callable] = None,
183
+ ):
163
184
  super().__init__(eps_rf, mu_rf)
164
185
  self.a1: np.ndarray = np.array(a1)
165
186
  self.a2: np.ndarray = np.array(a2)
@@ -174,9 +195,10 @@ class Geometry3D(GeometryBase):
174
195
  def X(self) -> CartesianVectors3D:
175
196
  """Return cartesian positions."""
176
197
  P0, Q0, R0 = np.meshgrid(
177
- np.linspace(-0.5, 0.5, self.n1),
178
- np.linspace(-0.5, 0.5, self.n2),
179
- np.linspace(-0.5, 0.5, self.n3),
198
+ np.arange(self.n1) / self.n1 - 0.5,
199
+ np.arange(self.n2) / self.n2 - 0.5,
200
+ np.arange(self.n3) / self.n3 - 0.5,
201
+ indexing="ij",
180
202
  )
181
203
 
182
204
  x = P0 * self.a1[0] + Q0 * self.a2[0] + R0 * self.a3[0]
@@ -196,7 +218,7 @@ class Geometry3D(GeometryBase):
196
218
  def mu_r(self) -> np.ndarray:
197
219
  """Return the relative permeabillity matrice."""
198
220
  if self.mu_rf:
199
- self.mu_rf(eps_r=self._eps_r, x=self.X.x, y=self.X.y, z=self.X.z)
221
+ self.mu_rf(self._mu_r, x=self.X.x, y=self.X.y, z=self.X.z)
200
222
  return self._mu_r
201
223
 
202
224
 
@@ -9,7 +9,7 @@ from .brillouinzone import BrillouinZonePath1D as BZPath1D
9
9
  from .brillouinzone import BrillouinZonePath2D as BZPath2D
10
10
  from .brillouinzone import BrillouinZonePath3D as BZPath3D
11
11
  from .geometry import Geometry1D, Geometry2D, Geometry3D
12
- from .utils import convmat
12
+ from .utils import convmat, harmonic_indices
13
13
 
14
14
 
15
15
  class Solver1D:
@@ -25,11 +25,9 @@ class Solver1D:
25
25
  Number of terms in the direction of the reciprocal vector b1.
26
26
  """
27
27
 
28
- def __init__(self,
29
- geometry: Geometry1D,
30
- path: BZPath1D,
31
- P: int = 1,
32
- pol: str = "TM"):
28
+ def __init__(
29
+ self, geometry: Geometry1D, path: BZPath1D, P: int = 1, pol: str = "TM"
30
+ ):
33
31
  """Initialize the PWE Solver."""
34
32
  self.geo = geometry
35
33
  self.path = path
@@ -43,7 +41,29 @@ class Solver1D:
43
41
 
44
42
  def run(self):
45
43
  """Calculate the eigen-wavenumbers and modes."""
46
- raise NotImplementedError
44
+ self.eps_rc = convmat(self.geo.eps_r, self.P)
45
+ self.mu_rc = convmat(self.geo.mu_r, self.P)
46
+ mu_rci = convmat(1 / self.geo.mu_r, self.P)
47
+ eps_rci = convmat(1 / self.geo.eps_r, self.P)
48
+
49
+ p = harmonic_indices(self.P)
50
+ G_k = p * self.path.b1[0]
51
+ beta = self.path.betas.values
52
+
53
+ for col in range(beta.shape[1]):
54
+ k = beta[0, col] - G_k
55
+ KX = np.diag(k)
56
+
57
+ if self.pol == "TM":
58
+ A = KX @ mu_rci @ KX
59
+ B = self.eps_rc
60
+ else:
61
+ A = KX @ eps_rci @ KX
62
+ B = self.mu_rc
63
+
64
+ D, V = eigh(A, B)
65
+ self.wn.append(np.sqrt(np.maximum(D, 0)))
66
+ self.modes.append(V)
47
67
 
48
68
 
49
69
  class Solver2D:
@@ -61,12 +81,14 @@ class Solver2D:
61
81
  Number of terms in the direction of the reciprocal vector b2.
62
82
  """
63
83
 
64
- def __init__(self,
65
- geometry: Geometry2D,
66
- path: BZPath2D,
67
- P: int = 1,
68
- Q: int = 1,
69
- pol: str = "TM"):
84
+ def __init__(
85
+ self,
86
+ geometry: Geometry2D,
87
+ path: BZPath2D,
88
+ P: int = 1,
89
+ Q: int = 1,
90
+ pol: str = "TM",
91
+ ):
70
92
  """Initialize the PWE Solver."""
71
93
  self.geo = geometry
72
94
  self.path = path
@@ -83,16 +105,20 @@ class Solver2D:
83
105
  self.eps_rc = convmat(self.geo.eps_r, self.P, self.Q)
84
106
  self.mu_rc = convmat(self.geo.mu_r, self.P, self.Q)
85
107
 
86
- mu_rci = np.linalg.inv(self.mu_rc)
87
- eps_rci = np.linalg.inv(self.eps_rc)
108
+ mu_rci = convmat(1 / self.geo.mu_r, self.P, self.Q)
109
+ if self.pol != "TM":
110
+ # Factorize epsilon before truncating its reciprocal. This
111
+ # inverse rule converges much faster for TE modes at sharp
112
+ # dielectric interfaces than Fourier coefficients of 1/eps.
113
+ eps_rci = np.linalg.inv(self.eps_rc)
88
114
 
89
- p = np.arange(-(self.P // 2), self.P // 2 + 1)
90
- q = np.arange(-(self.Q // 2), self.Q // 2 + 1)
115
+ p = harmonic_indices(self.P)
116
+ q = harmonic_indices(self.Q)
91
117
 
92
- P0, Q0 = np.meshgrid(p, q)
118
+ P0, Q0 = np.meshgrid(p, q, indexing="ij")
93
119
  b1, b2 = self.path.b1, self.path.b2
94
120
 
95
- G_k = P0.flatten() * b1[:, None] + Q0.flatten() * b2[:, None]
121
+ G_k = P0.ravel(order="F") * b1[:, None] + Q0.ravel(order="F") * b2[:, None]
96
122
  beta = self.path.betas.values
97
123
 
98
124
  for col in range(beta.shape[1]):
@@ -102,10 +128,10 @@ class Solver2D:
102
128
  KY = np.diag(k[1, :])
103
129
 
104
130
  if self.pol == "TM":
105
- A = KX@mu_rci@KX + KY@mu_rci@KY
131
+ A = KX @ mu_rci @ KX + KY @ mu_rci @ KY
106
132
  B = self.eps_rc
107
133
  else:
108
- A = KX@eps_rci@KX + KY@eps_rci@KY
134
+ A = KX @ eps_rci @ KX + KY @ eps_rci @ KY
109
135
  B = self.mu_rc
110
136
 
111
137
  D, V = eigh(A, B)
@@ -131,12 +157,9 @@ class Solver3D:
131
157
  Number of terms in the direction of the reciprocal vector b3.
132
158
  """
133
159
 
134
- def __init__(self,
135
- geometry: Geometry3D,
136
- path: BZPath3D,
137
- P: int = 1,
138
- Q: int = 1,
139
- R: int = 1):
160
+ def __init__(
161
+ self, geometry: Geometry3D, path: BZPath3D, P: int = 1, Q: int = 1, R: int = 1
162
+ ):
140
163
  """Initialize the PWE Solver."""
141
164
  self.geo = geometry
142
165
  self.path = path
@@ -153,17 +176,21 @@ class Solver3D:
153
176
  self.mu_rc = convmat(self.geo.mu_r, self.P, self.Q, self.R)
154
177
 
155
178
  eps_rk = np.kron(np.eye(3), self.eps_rc)
156
- mu_rki = np.kron(np.eye(3), np.linalg.inv(self.mu_rc))
179
+ mu_ri = convmat(1 / self.geo.mu_r, self.P, self.Q, self.R)
180
+ mu_rki = np.kron(np.eye(3), mu_ri)
157
181
 
158
- p = np.arange(-(self.P // 2), self.P // 2 + 1)
159
- q = np.arange(-(self.Q // 2), self.Q // 2 + 1)
160
- r = np.arange(-(self.R // 2), self.R // 2 + 1)
182
+ p = harmonic_indices(self.P)
183
+ q = harmonic_indices(self.Q)
184
+ r = harmonic_indices(self.R)
161
185
 
162
- P0, Q0, R0 = np.meshgrid(p, q, r)
186
+ P0, Q0, R0 = np.meshgrid(p, q, r, indexing="ij")
163
187
  b1, b2, b3 = self.path.b1, self.path.b2, self.path.b3
164
188
 
165
- G_k = (b1[:, None] * P0.flatten() + b2[:, None] * Q0.flatten() +
166
- b3[:, None] * R0.flatten())
189
+ G_k = (
190
+ b1[:, None] * P0.ravel(order="F")
191
+ + b2[:, None] * Q0.ravel(order="F")
192
+ + b3[:, None] * R0.ravel(order="F")
193
+ )
167
194
 
168
195
  beta = self.path.betas.values
169
196
  for col in range(beta.shape[1]):
@@ -172,22 +199,32 @@ class Solver3D:
172
199
  KX = np.diag(k[0, :])
173
200
  KY = np.diag(k[1, :])
174
201
  KZ = np.diag(k[2, :])
175
- K_V = np.vstack((
176
- np.hstack((0 * KX, -KZ, KY)),
177
- np.hstack((KZ, 0 * KY, -KX)),
178
- np.hstack((-KY, KX, 0 * KZ)),
179
- ))
202
+ K_V = np.vstack(
203
+ (
204
+ np.hstack((0 * KX, -KZ, KY)),
205
+ np.hstack((KZ, 0 * KY, -KX)),
206
+ np.hstack((-KY, KX, 0 * KZ)),
207
+ )
208
+ )
180
209
  A = K_V @ mu_rki @ K_V
181
210
  B = eps_rk
182
211
 
183
212
  D, V = eigh(A, B)
184
213
 
185
- self.wn.append(np.sqrt(-np.minimum(D, 0)))
186
- self.modes.append(V)
214
+ # The curl-curl operator has one longitudinal null mode per
215
+ # retained reciprocal vector. Drop those N zero eigenvalues;
216
+ # at Gamma the two physical zero-frequency modes remain.
217
+ n_harmonics = KX.shape[0]
218
+ D = D[:-n_harmonics]
219
+ V = V[:, :-n_harmonics]
220
+
221
+ # eigh returns the negative eigenvalues from most negative to
222
+ # least negative, so reverse to report frequencies low to high.
223
+ self.wn.append(np.sqrt(np.maximum(-D, 0))[::-1])
224
+ self.modes.append(V[:, ::-1])
187
225
 
188
226
 
189
- def Solver(geometry, path, *args,
190
- **kwargs) -> Union[Solver1D, Solver2D, Solver3D]:
227
+ def Solver(geometry, path, *args, **kwargs) -> Union[Solver1D, Solver2D, Solver3D]:
191
228
  """Solver factory."""
192
229
  dim_obj = {1: Solver1D, 2: Solver2D, 3: Solver3D}
193
230
  return dim_obj[path.dim](geometry, path, *args, **kwargs)
@@ -1,7 +1,19 @@
1
1
  """This module implements utilities."""
2
+
2
3
  import numpy as np
3
4
 
4
5
 
6
+ def harmonic_indices(count: int) -> np.ndarray:
7
+ """Return ``count`` integer harmonics centered as closely as possible at 0.
8
+
9
+ For an even count, the integer range is necessarily asymmetric; this
10
+ convention keeps zero and the negative Nyquist-side harmonic.
11
+ """
12
+ if count < 1:
13
+ raise ValueError("The number of harmonics must be positive.")
14
+ return np.arange(-(count // 2), (count + 1) // 2)
15
+
16
+
5
17
  def convmat(A: np.ndarray, P: int = 1, Q: int = 1, R: int = 1):
6
18
  """Return a permittivity/permeability convolution matrix.
7
19
 
@@ -17,12 +29,18 @@ def convmat(A: np.ndarray, P: int = 1, Q: int = 1, R: int = 1):
17
29
  Number of terms in the direction of the reciprocal vector T3.
18
30
  """
19
31
  NH = P * Q * R
20
- A = np.atleast_3d(A)
32
+ A = np.asarray(A)
33
+ if A.ndim == 1:
34
+ A = A[:, None, None]
35
+ elif A.ndim == 2:
36
+ A = A[:, :, None]
37
+ elif A.ndim != 3:
38
+ raise ValueError("A must have one, two, or three dimensions.")
21
39
  N1, N2, N3 = A.shape
22
40
 
23
- p = np.arange(-(P // 2), P//2 + 1)
24
- q = np.arange(-(Q // 2), Q//2 + 1)
25
- r = np.arange(-(R // 2), R//2 + 1)
41
+ p = harmonic_indices(P)
42
+ q = harmonic_indices(Q)
43
+ r = harmonic_indices(R)
26
44
 
27
45
  A = np.fft.fftshift(np.fft.fftn(A) / A.size)
28
46
 
@@ -0,0 +1,64 @@
1
+ [project]
2
+ name = "morpho.py"
3
+ version = "0.1.0"
4
+ description = "PWE framework for photonic crystal analysis"
5
+ license = "MIT"
6
+ authors = [{name = "Tiago Vilela", email = "tiagovla@gmail.com"}]
7
+ requires-python = ">=3.9,<3.16"
8
+ classifiers = [
9
+ "Programming Language :: Python :: 3",
10
+ "Programming Language :: Python :: 3.9",
11
+ "Programming Language :: Python :: 3.10",
12
+ "Programming Language :: Python :: 3.11",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Programming Language :: Python :: 3.14",
16
+ "Programming Language :: Python :: 3.15",
17
+ ]
18
+ dependencies = [
19
+ "numpy>=1.20.1,<3.0.0",
20
+ "scipy>=1.6.2,<2.0.0",
21
+ "matplotlib>=3.4.3,<4.0.0",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ docs = [
26
+ "mkdocs>=1.6.1,<2",
27
+ "mkdocs-material>=9.7.7",
28
+ "mkdocstrings[python]>=0.24.0",
29
+ ]
30
+
31
+ [tool.poetry]
32
+ packages = [
33
+ {include = "morpho", from = "."}
34
+ ]
35
+
36
+ [dependency-groups]
37
+ dev = [
38
+ "black==24.8.0",
39
+ "pre-commit>=3.8.0,<4.0.0",
40
+ "pytest>=5.2",
41
+ "ruff==0.16.10",
42
+ "mkdocs>=1.6.1,<2",
43
+ "mkdocs-material>=9.7.7",
44
+ "mkdocstrings[python]>=0.24.0",
45
+ ]
46
+
47
+ [tool.uv]
48
+ package = true
49
+
50
+ [build-system]
51
+ requires = ["poetry-core>=2.0.0"]
52
+ build-backend = "poetry.core.masonry.api"
53
+
54
+ [tool.black]
55
+ line-length = 88
56
+ target-version = ["py39"]
57
+
58
+ [tool.ruff]
59
+ line-length = 88
60
+ target-version = "py39"
61
+
62
+ [tool.ruff.lint]
63
+ select = ["E4", "E7", "E9", "F"]
64
+
morpho.py-0.0.4/PKG-INFO DELETED
@@ -1,15 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: morpho.py
3
- Version: 0.0.4
4
- Summary:
5
- License: MIT
6
- Author: Tiago Vilela
7
- Author-email: tiagovla@gmail.com
8
- Requires-Python: >=3.8,<4.0
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.8
12
- Classifier: Programming Language :: Python :: 3.9
13
- Requires-Dist: matplotlib (>=3.3.4,<4.0.0)
14
- Requires-Dist: numpy (>=1.20.1,<2.0.0)
15
- Requires-Dist: scipy (>=1.6.1,<2.0.0)
@@ -1,7 +0,0 @@
1
- __version__ = "0.0.4"
2
-
3
- from morpho.brillouinzone import (BrillouinZonePath, BrillouinZonePath1D,
4
- BrillouinZonePath2D, BrillouinZonePath3D,
5
- SymmetryPoint)
6
- from morpho.geometry import Geometry, Geometry1D, Geometry2D, Geometry3D
7
- from morpho.solver import Solver, Solver1D, Solver2D, Solver3D
@@ -1,253 +0,0 @@
1
- """This module implements classes related to the brillouinzone."""
2
-
3
- from abc import ABC, abstractproperty
4
- from dataclasses import dataclass
5
- from typing import List, Tuple, Union
6
-
7
- import numpy as np
8
- from numpy.linalg import norm
9
-
10
-
11
- @dataclass
12
- class BlochVectors:
13
- """Represents a data structure of Bloch wave vectors."""
14
-
15
- values: np.ndarray
16
- cumsum: np.ndarray
17
-
18
-
19
- class SymmetryPoint:
20
- """Model of a symmetry point at the brillouinzone.
21
-
22
- Parameters
23
- ----------
24
- point : Tuple[float, ...]
25
- A point in the reciprocal domain.
26
- name : str
27
- A label to describe the point.
28
- """
29
-
30
- def __init__(self, point: Tuple[float, ...], name: str):
31
- """Initialize a SymmetryPoint."""
32
- self.point = np.array(point)
33
- self.name = name
34
-
35
-
36
- class BrillouinZonePathBase(ABC):
37
- """BrillouinZoneBase class."""
38
-
39
- def __init__(self,
40
- path: List[SymmetryPoint],
41
- n_points: int = 50,
42
- strategy: str = 'linear'):
43
- self.path_points = path
44
- self.n_points = n_points
45
- self.strat = strategy
46
- self._path: np.ndarray
47
-
48
- @abstractproperty
49
- def betas(self) -> BlochVectors:
50
- """Return bloch wave vectors."""
51
-
52
- @staticmethod
53
- def _interpolate_beta(beta_cs, path, n_points):
54
- """Perform linear interpolation."""
55
- beta_csi = np.linspace(0, beta_cs[-1], n_points)
56
- beta_vi = np.vstack([
57
- np.interp(beta_csi, beta_cs, path[i, :]).flatten()
58
- for i in range(path.shape[0])
59
- ])
60
- return BlochVectors(beta_vi, beta_csi)
61
-
62
- @property
63
- def point_locations(self):
64
- """Return cumulative sum of vectors' length."""
65
- return np.cumsum(norm(np.diff(self._path, axis=1), axis=0))
66
-
67
- @property
68
- def point_names(self):
69
- """Return list of symmetry point names."""
70
- return [p.name for p in self.path_points]
71
-
72
-
73
- class BrillouinZonePath1D(BrillouinZonePathBase):
74
- """BrillouinZonePath1D.
75
-
76
- Parameters
77
- ----------
78
- a1 : Tuple[float]
79
- Direct lattice vector a1.
80
- path : List[SymmetryPoint]
81
- List of symmetry points.
82
- n_points : int
83
- Number of vectors.
84
- strategy : str = 'linear'
85
- Strategy to interpolate.
86
- """
87
-
88
- def __init__(self,
89
- a1: Tuple[float],
90
- path: List[SymmetryPoint],
91
- n_points: int = 50,
92
- strategy: str = 'linear'):
93
- """Initialize a BrillouinZonePath."""
94
- super().__init__(path, n_points, strategy)
95
- self.a1 = np.array(a1)
96
- self.dim = 1
97
-
98
- @property
99
- def _path(self) -> np.ndarray:
100
- """Return the path matrix."""
101
- path = np.stack([p.point for p in self.path_points], axis=1)
102
- return path[0, :] * self.b1[:, None]
103
-
104
- @property
105
- def betas(self) -> BlochVectors:
106
- """Return beta vector values and cumsum."""
107
- beta_cs = np.cumsum(norm(np.diff(self._path, axis=1), axis=0))
108
- beta_cs = np.pad(beta_cs, (1, 0), "constant")
109
- if self.strat == 'linear':
110
- return self._interpolate_beta(beta_cs, self._path, self.n_points)
111
- raise NotImplementedError
112
-
113
- @property
114
- def b1(self) -> np.ndarray:
115
- """Return reciprocal lattice vector b1."""
116
- return 2 * np.pi / self.a1
117
-
118
-
119
- class BrillouinZonePath2D(BrillouinZonePathBase):
120
- """BrillouinZonePath2D.
121
-
122
- Parameters
123
- ----------
124
- a1 : Tuple[float, float]
125
- Direct lattice vector a1.
126
- a2 : Tuple[float, float]
127
- Direct lattice vector a2.
128
- path : List[SymmetryPoint]
129
- List of symmetry points.
130
- n_points : int
131
- Number of vectors.
132
- strategy : str = 'linear'
133
- Strategy to interpolate.
134
- """
135
-
136
- def __init__(self,
137
- a1: Tuple[float, float],
138
- a2: Tuple[float, float],
139
- path: List[SymmetryPoint],
140
- n_points: int = 50,
141
- strategy: str = 'linear'):
142
- """Initialize a BrillouinZonePath."""
143
- super().__init__(path, n_points, strategy)
144
- self.a1 = np.array(a1)
145
- self.a2 = np.array(a2)
146
- self.dim = 2
147
-
148
- @property
149
- def _path(self) -> np.ndarray:
150
- """Return the path matrix."""
151
- path = np.stack([p.point for p in self.path_points], axis=1)
152
- return (path[0, :] * self.b1[:, None] + path[1, :] * self.b2[:, None])
153
-
154
- @property
155
- def betas(self) -> BlochVectors:
156
- """Return beta vector values and cumsum."""
157
- beta_cs = np.cumsum(norm(np.diff(self._path, axis=1), axis=0))
158
- beta_cs = np.pad(beta_cs, (1, 0), "constant")
159
- if self.strat == 'linear':
160
- return self._interpolate_beta(beta_cs, self._path, self.n_points)
161
- raise NotImplementedError
162
-
163
- @property
164
- def b1(self) -> np.ndarray:
165
- """Return reciprocal lattice vector b1."""
166
- Q = np.array([[0, -1], [1, 0]])
167
- return 2 * np.pi * Q @ self.a2 / np.dot(self.a1, Q @ self.a2)
168
-
169
- @property
170
- def b2(self) -> np.ndarray:
171
- """Return reciprocal lattice vector b2."""
172
- Q = np.array([[0, -1], [1, 0]])
173
- return 2 * np.pi * Q @ self.a1 / np.dot(self.a2, Q @ self.a1)
174
-
175
-
176
- class BrillouinZonePath3D(BrillouinZonePathBase):
177
- """BrillouinZonePath3D.
178
-
179
- Parameters
180
- ----------
181
- a1 : Tuple[float, float, float]
182
- Direct lattice vector a1.
183
- a2 : Tuple[float, float, float]
184
- Direct lattice vector a2.
185
- a3 : Tuple[float, float, float]
186
- Direct lattice vector a3.
187
- path : List[SymmetryPoint]
188
- List of symmetry points.
189
- n_points : int
190
- Number of vectors.
191
- strategy : str = 'linear'
192
- Strategy to interpolate.
193
- """
194
-
195
- def __init__(self,
196
- a1: Tuple[float, float, float],
197
- a2: Tuple[float, float, float],
198
- a3: Tuple[float, float, float],
199
- path: List[SymmetryPoint],
200
- n_points: int = 50,
201
- strategy: str = 'interpolate'):
202
- """Initialize a BrillouinZonePath."""
203
- super().__init__(path, n_points, strategy)
204
- self.a1 = np.array(a1)
205
- self.a2 = np.array(a2)
206
- self.a3 = np.array(a3)
207
- self.dim = 3
208
-
209
- @property
210
- def _path(self) -> np.ndarray:
211
- """Return the path matrix."""
212
- path = np.stack([p.point for p in self.path_points], axis=1)
213
- return (path[0, :] * self.b1[:, None] + path[1, :] * self.b2[:, None] +
214
- path[2, :] * self.b3[:, None])
215
-
216
- @property
217
- def betas(self) -> BlochVectors:
218
- """Return beta vector values and cumsum."""
219
- beta_cs = np.cumsum(norm(np.diff(self._path, axis=1), axis=0))
220
- beta_cs = np.pad(beta_cs, (1, 0), "constant")
221
- if self.strat == 'interpolate':
222
- return self._interpolate_beta(beta_cs, self._path, self.n_points)
223
- raise NotImplementedError
224
-
225
- @property
226
- def b1(self) -> np.ndarray:
227
- """Return reciprocal lattice vector b1."""
228
- return (2 * np.pi * np.cross(self.a2, self.a3) /
229
- np.dot(self.a1, np.cross(self.a2, self.a3)))
230
-
231
- @property
232
- def b2(self) -> np.ndarray:
233
- """Return reciprocal lattice vector b2."""
234
- return (2 * np.pi * np.cross(self.a3, self.a1) /
235
- np.dot(self.a1, np.cross(self.a2, self.a3)))
236
-
237
- @property
238
- def b3(self) -> np.ndarray:
239
- """Return reciprocal lattice vector b3."""
240
- return (2 * np.pi * np.cross(self.a1, self.a2) /
241
- np.dot(self.a1, np.cross(self.a2, self.a3)))
242
-
243
-
244
- def BrillouinZonePath(
245
- a1, *args, **kwargs
246
- ) -> Union[BrillouinZonePath1D, BrillouinZonePath2D, BrillouinZonePath3D]:
247
- """BrillouinZonePath factory."""
248
- dim_obj = {
249
- 1: BrillouinZonePath1D,
250
- 2: BrillouinZonePath2D,
251
- 3: BrillouinZonePath3D
252
- }
253
- return dim_obj[len(a1)](a1, *args, **kwargs)
@@ -1,26 +0,0 @@
1
- [tool.poetry]
2
- name = "morpho.py"
3
- version = "0.0.4"
4
- description = ""
5
- license = "MIT"
6
- authors = ["Tiago Vilela <tiagovla@gmail.com>"]
7
-
8
- packages = [
9
- {include = "morpho", from="."}
10
- ]
11
-
12
- [tool.poetry.dependencies]
13
- python = "^3.8"
14
- numpy = "^1.20.1"
15
- matplotlib = "^3.3.4"
16
- scipy = "^1.6.1"
17
-
18
- [tool.poetry.dev-dependencies]
19
- pytest = "^5.2"
20
- Sphinx = "^3.5.1"
21
- sphinx-rtd-theme = "^0.5.1"
22
- sphinx-autodoc-typehints = "^1.11.1"
23
-
24
- [build-system]
25
- requires = ["poetry-core>=1.0.0"]
26
- build-backend = "poetry.core.masonry.api"
morpho.py-0.0.4/setup.py DELETED
@@ -1,34 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- from setuptools import setup
3
-
4
- package_dir = \
5
- {'': '.'}
6
-
7
- packages = \
8
- ['morpho']
9
-
10
- package_data = \
11
- {'': ['*']}
12
-
13
- install_requires = \
14
- ['matplotlib>=3.3.4,<4.0.0', 'numpy>=1.20.1,<2.0.0', 'scipy>=1.6.1,<2.0.0']
15
-
16
- setup_kwargs = {
17
- 'name': 'morpho.py',
18
- 'version': '0.0.4',
19
- 'description': '',
20
- 'long_description': None,
21
- 'author': 'Tiago Vilela',
22
- 'author_email': 'tiagovla@gmail.com',
23
- 'maintainer': None,
24
- 'maintainer_email': None,
25
- 'url': None,
26
- 'package_dir': package_dir,
27
- 'packages': packages,
28
- 'package_data': package_data,
29
- 'install_requires': install_requires,
30
- 'python_requires': '>=3.8,<4.0',
31
- }
32
-
33
-
34
- setup(**setup_kwargs)
File without changes