wavesimpro 1.10.1__tar.gz → 1.10.2__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.
Files changed (24) hide show
  1. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/PKG-INFO +3 -2
  2. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/README.md +2 -1
  3. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/pyproject.toml +1 -1
  4. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/__init__.py +1 -1
  5. wavesimpro-1.10.2/src/wavesimpro/model.py +588 -0
  6. wavesimpro-1.10.2/src/wavesimpro/runs.py +955 -0
  7. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro.egg-info/PKG-INFO +3 -2
  8. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro.egg-info/SOURCES.txt +2 -0
  9. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/setup.cfg +0 -0
  10. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/__main__.py +0 -0
  11. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/binary_utils.py +0 -0
  12. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/client.py +0 -0
  13. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/config.py +0 -0
  14. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/exceptions.py +0 -0
  15. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/execute.py +0 -0
  16. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/json_helper.py +0 -0
  17. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/py.typed +0 -0
  18. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/setup.py +0 -0
  19. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/simulate.py +0 -0
  20. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/validate.py +0 -0
  21. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro.egg-info/dependency_links.txt +0 -0
  22. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro.egg-info/requires.txt +0 -0
  23. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro.egg-info/top_level.txt +0 -0
  24. {wavesimpro-1.10.1 → wavesimpro-1.10.2}/tests/test_command_wait.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wavesimpro
3
- Version: 1.10.1
3
+ Version: 1.10.2
4
4
  Summary: WavesimPro Python API — cloud execution client for the Rayfos simulation platform
5
5
  Author: Rayfos
6
6
  Project-URL: Repository, https://github.com/rayfos/wavesimpro
@@ -67,7 +67,8 @@ field = wp.read_binary_field(run.download("output.bin"))
67
67
  `create_stl_primitive`, `create_gds_primitive` (with `create_gds_layer_config` /
68
68
  `create_gds_structure_override`), `create_voxels_model_primitive`.
69
69
  - Per-primitive sub-pixel flag (`subpixel=True/False` on every shape, STL and GDSII helper)
70
- to choose which shapes get smoothed interfaces.
70
+ to choose which shapes get smoothed interfaces. Off by default: a setup with no averaged
71
+ shape is voxelised exactly as before 1.10 and runs on every compute node.
71
72
  - Per-axis pixel sizes (`pixel_size=[dx, dy, dz]`) and sub-pixel permittivity averaging
72
73
  (`subpixel_factor=`), when the compute node supports them.
73
74
  - Preflight memory check and cost estimate before a run.
@@ -44,7 +44,8 @@ field = wp.read_binary_field(run.download("output.bin"))
44
44
  `create_stl_primitive`, `create_gds_primitive` (with `create_gds_layer_config` /
45
45
  `create_gds_structure_override`), `create_voxels_model_primitive`.
46
46
  - Per-primitive sub-pixel flag (`subpixel=True/False` on every shape, STL and GDSII helper)
47
- to choose which shapes get smoothed interfaces.
47
+ to choose which shapes get smoothed interfaces. Off by default: a setup with no averaged
48
+ shape is voxelised exactly as before 1.10 and runs on every compute node.
48
49
  - Per-axis pixel sizes (`pixel_size=[dx, dy, dz]`) and sub-pixel permittivity averaging
49
50
  (`subpixel_factor=`), when the compute node supports them.
50
51
  - Preflight memory check and cost estimate before a run.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wavesimpro"
7
- version = "1.10.1"
7
+ version = "1.10.2"
8
8
  description = "WavesimPro Python API — cloud execution client for the Rayfos simulation platform"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -54,7 +54,7 @@ from .json_helper import (
54
54
  parse_progress_data,
55
55
  )
56
56
 
57
- __version__ = "1.10.1"
57
+ __version__ = "1.10.2"
58
58
  __all__ = [
59
59
  # Client
60
60
  "RayfosClient",
@@ -0,0 +1,588 @@
1
+ """
2
+ Object model for a Wavesim setup: media, primitives, sources and monitors.
3
+
4
+ Everything here is a thin, typed layer over the ``create_*`` JSON helpers in
5
+ ``json_helper``. The objects carry the user's intent (a sphere of index 1.2,
6
+ sub-pixel averaged); ``Setup`` / ``submit`` turn them into WavesimProperties,
7
+ upload what needs uploading (STL/GDS files, voxel arrays, source fields) and fill
8
+ in the file ids and medium ids. The user never writes a medium id.
9
+
10
+ Coordinates
11
+ -----------
12
+ Primitive positions, service-computed source positions and monitor positions are
13
+ WORLD coordinates in micrometres, with the origin at the CENTRE of the simulation
14
+ domain (the server's CoordinateSystemCenter). The domain spans
15
+ [-size/2, +size/2] on every axis, where size = grid × pixel_size.
16
+
17
+ Array arguments (a permittivity array, or a user field for ``FieldSource``) are
18
+ VOXEL-INDEX based, like the FOSS ``wavesim.simulate``: index (0, 0, 0) is the
19
+ domain corner at (-size_x/2, -size_y/2, -size_z/2) µm, and a field source's
20
+ position is the voxel index of its corner.
21
+
22
+ Overlapping primitives
23
+ ----------------------
24
+ The service resolves overlaps with the painter's rule: the LATER primitive in
25
+ the list wins wherever two primitives cover the same probe. Nothing is blended.
26
+ ``Setup`` warns loudly when two shapes of different material may overlap (their
27
+ bounding boxes intersect); overlaps between shapes of the same material are
28
+ harmless and not reported.
29
+
30
+ Staircase rule (``subpixel=False``)
31
+ -----------------------------------
32
+ A voxel belongs to the shape when its centre is inside. A centre exactly on the
33
+ surface may go either way (floating point); keep the radius / size off exact
34
+ multiples of the pixel size when that matters.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import math
40
+ import re
41
+ from dataclasses import dataclass, field
42
+ from pathlib import Path
43
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
44
+
45
+ import numpy as np
46
+
47
+ from . import json_helper as jh
48
+
49
+ Number = Union[int, float, complex]
50
+ Vec3 = Tuple[float, float, float]
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Media
55
+ # ---------------------------------------------------------------------------
56
+
57
+ def _slug(text: str) -> str:
58
+ s = re.sub(r"[^a-z0-9]+", "-", text.strip().lower()).strip("-")
59
+ return s or "medium"
60
+
61
+
62
+ def _n_id(n: complex) -> str:
63
+ """Stable medium id derived from the refractive index, e.g. ``n1.2`` / ``n3.48k0.01``."""
64
+ re_s = f"{n.real:.6g}"
65
+ im_s = f"k{n.imag:.6g}" if n.imag else ""
66
+ return _slug(f"n{re_s}{im_s}")
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Medium:
71
+ """A material: complex refractive index ``n + i k``.
72
+
73
+ ``Medium("silicon", 3.48)`` or ``Medium(n=1.2)``. Two media with the same
74
+ (n, k) are merged into one entry when the setup is submitted; the first
75
+ name seen is kept. ``k`` (the imaginary part of ``n``) is absorption; the
76
+ server stores it as ``primaryImaginary`` and uses eps = (n + ik)^2.
77
+ """
78
+
79
+ name: Optional[str] = None
80
+ n: Number = 1.0
81
+ color: Optional[str] = None
82
+
83
+ def __post_init__(self):
84
+ n = complex(self.n)
85
+ if not math.isfinite(n.real) or not math.isfinite(n.imag):
86
+ raise ValueError(f"Medium {self.name or ''}: refractive index must be finite, got {self.n!r}")
87
+ if n.real <= 0:
88
+ raise ValueError(f"Medium {self.name or ''}: refractive index real part must be > 0, got {n.real}")
89
+ object.__setattr__(self, "n", n)
90
+
91
+ @property
92
+ def key(self) -> Tuple[float, float]:
93
+ n = complex(self.n)
94
+ return (round(n.real, 9), round(n.imag, 9))
95
+
96
+ @property
97
+ def id(self) -> str:
98
+ return _slug(self.name) if self.name else _n_id(complex(self.n))
99
+
100
+ @property
101
+ def display_name(self) -> str:
102
+ if self.name:
103
+ return self.name
104
+ n = complex(self.n)
105
+ return f"n={n.real:g}" + (f"+{n.imag:g}i" if n.imag else "")
106
+
107
+ def to_dict(self, medium_id: Optional[str] = None) -> Dict[str, Any]:
108
+ n = complex(self.n)
109
+ return jh.create_medium(medium_id or self.id, self.display_name, n.real, n.imag, color=self.color)
110
+
111
+
112
+ def as_medium(value: Union[Medium, Number], label: str = "n") -> Medium:
113
+ if isinstance(value, Medium):
114
+ return value
115
+ if isinstance(value, (int, float, complex, np.integer, np.floating, np.complexfloating)):
116
+ return Medium(n=complex(value))
117
+ raise TypeError(f"{label} must be a number (refractive index) or a Medium, got {type(value).__name__}")
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Primitives
122
+ # ---------------------------------------------------------------------------
123
+
124
+ _prim_counter = {"n": 0}
125
+
126
+
127
+ def _auto_name(prefix: str) -> str:
128
+ _prim_counter["n"] += 1
129
+ return f"{prefix}_{_prim_counter['n']}"
130
+
131
+
132
+ def _vec3(v, label: str) -> Vec3:
133
+ try:
134
+ x, y, z = (float(c) for c in v)
135
+ except (TypeError, ValueError):
136
+ raise TypeError(f"{label} must be three numbers (x, y, z), got {v!r}") from None
137
+ return (x, y, z)
138
+
139
+
140
+ @dataclass
141
+ class Primitive:
142
+ """Base of every geometry object a ``Setup`` can hold.
143
+
144
+ ``n`` is the material (a refractive index or a ``Medium``); ``subpixel``
145
+ chooses sub-pixel averaging of the interface voxels for THIS shape (True),
146
+ a centre-sampled staircase (False) or the setup's ``subpixel`` default (None).
147
+ When that is None too, the primitive is sent without a flag: with no averaged
148
+ primitive in the setup the server runs the classic voxeliser (the volume of
149
+ versions before 1.10) on any compute node; once some primitive is averaged,
150
+ unflagged ones are sent as False (centre-sampled).
151
+ """
152
+
153
+ n: Union[Medium, Number] = 1.0
154
+ name: Optional[str] = None
155
+ subpixel: Optional[bool] = None
156
+
157
+ kind: str = field(default="Primitive", init=False, repr=False)
158
+
159
+ def __post_init__(self):
160
+ self.n = as_medium(self.n, "n")
161
+ if self.name is None:
162
+ self.name = _auto_name(self.kind.lower())
163
+
164
+ @property
165
+ def medium(self) -> Medium:
166
+ return self.n # type: ignore[return-value]
167
+
168
+ # Conservative axis-aligned bounding box in world µm, or None when unknown.
169
+ def bounding_box(self) -> Optional[Tuple[Vec3, Vec3]]:
170
+ return None
171
+
172
+ # Files to upload before the primitive can be serialised: list of (attribute, path).
173
+ def files(self) -> List[Tuple[str, Path]]:
174
+ return []
175
+
176
+ def to_dict(self, medium_id: str, file_ids: Dict[str, str], subpixel: Optional[bool]) -> Dict[str, Any]:
177
+ raise NotImplementedError
178
+
179
+
180
+ def _rotated(center: Vec3, half: Vec3, rotation: Vec3) -> Tuple[Vec3, Vec3]:
181
+ """AABB of a box of half-extents ``half`` at ``center``; when rotated, the box
182
+ is replaced by its circumscribed sphere (conservative)."""
183
+ if any(abs(r) > 1e-12 for r in rotation):
184
+ r = math.sqrt(sum(h * h for h in half))
185
+ half = (r, r, r)
186
+ return (tuple(c - h for c, h in zip(center, half)), # type: ignore[return-value]
187
+ tuple(c + h for c, h in zip(center, half)))
188
+
189
+
190
+ @dataclass
191
+ class Sphere(Primitive):
192
+ center: Vec3 = (0.0, 0.0, 0.0)
193
+ radius: float = 1.0
194
+ kind: str = field(default="Sphere", init=False, repr=False)
195
+
196
+ def __post_init__(self):
197
+ super().__post_init__()
198
+ self.center = _vec3(self.center, "center")
199
+ if self.radius <= 0:
200
+ raise ValueError(f"Sphere {self.name}: radius must be > 0")
201
+
202
+ def bounding_box(self):
203
+ r = float(self.radius)
204
+ return _rotated(self.center, (r, r, r), (0.0, 0.0, 0.0))
205
+
206
+ def to_dict(self, medium_id, file_ids, subpixel):
207
+ return jh.create_sphere_primitive(self.name, medium_id, *self.center, float(self.radius), subpixel=subpixel)
208
+
209
+
210
+ @dataclass
211
+ class Box(Primitive):
212
+ center: Vec3 = (0.0, 0.0, 0.0)
213
+ size: Vec3 = (1.0, 1.0, 1.0)
214
+ rotation: Vec3 = (0.0, 0.0, 0.0)
215
+ kind: str = field(default="Box", init=False, repr=False)
216
+
217
+ def __post_init__(self):
218
+ super().__post_init__()
219
+ self.center = _vec3(self.center, "center")
220
+ self.size = _vec3(self.size, "size")
221
+ self.rotation = _vec3(self.rotation, "rotation")
222
+ if any(s <= 0 for s in self.size):
223
+ raise ValueError(f"Box {self.name}: every size component must be > 0")
224
+
225
+ def bounding_box(self):
226
+ return _rotated(self.center, tuple(s / 2 for s in self.size), self.rotation) # type: ignore[arg-type]
227
+
228
+ def to_dict(self, medium_id, file_ids, subpixel):
229
+ return jh.create_box_primitive(self.name, medium_id, *self.center, *self.size, subpixel=subpixel,
230
+ rotation_x=self.rotation[0], rotation_y=self.rotation[1],
231
+ rotation_z=self.rotation[2])
232
+
233
+
234
+ @dataclass
235
+ class Ellipsoid(Primitive):
236
+ center: Vec3 = (0.0, 0.0, 0.0)
237
+ radii: Vec3 = (1.0, 1.0, 1.0)
238
+ rotation: Vec3 = (0.0, 0.0, 0.0)
239
+ kind: str = field(default="Ellipsoid", init=False, repr=False)
240
+
241
+ def __post_init__(self):
242
+ super().__post_init__()
243
+ self.center = _vec3(self.center, "center")
244
+ self.radii = _vec3(self.radii, "radii")
245
+ self.rotation = _vec3(self.rotation, "rotation")
246
+ if any(r <= 0 for r in self.radii):
247
+ raise ValueError(f"Ellipsoid {self.name}: every radius must be > 0")
248
+
249
+ def bounding_box(self):
250
+ return _rotated(self.center, self.radii, self.rotation)
251
+
252
+ def to_dict(self, medium_id, file_ids, subpixel):
253
+ return jh.create_ellipsoid_primitive(self.name, medium_id, *self.center, *self.radii,
254
+ rotation_x=self.rotation[0], rotation_y=self.rotation[1],
255
+ rotation_z=self.rotation[2], subpixel=subpixel)
256
+
257
+
258
+ def _axial_bbox(position: Vec3, radius: float, height: float, axis: str, anchor: str, rotation: Vec3):
259
+ """AABB of a cylinder-like solid of ``radius`` and ``height`` along ``axis``."""
260
+ i = "XYZ".index(axis.upper())
261
+ half = [radius, radius, radius]
262
+ half[i] = height / 2
263
+ center = list(position)
264
+ if anchor == "BottomCenter":
265
+ center[i] += height / 2
266
+ elif anchor == "TopCenter":
267
+ center[i] -= height / 2
268
+ return _rotated(tuple(center), tuple(half), rotation) # type: ignore[arg-type]
269
+
270
+
271
+ @dataclass
272
+ class Cylinder(Primitive):
273
+ """Circular cylinder of ``radius`` extruded ``height`` along ``axis``.
274
+
275
+ ``position`` is the centre of the bottom face by default (``anchor="BottomCenter"``,
276
+ the server's default for axial shapes); ``anchor="Center"`` makes it the centroid.
277
+ """
278
+
279
+ position: Vec3 = (0.0, 0.0, 0.0)
280
+ radius: float = 1.0
281
+ height: float = 1.0
282
+ axis: str = "Z"
283
+ anchor: str = "BottomCenter"
284
+ rotation: Vec3 = (0.0, 0.0, 0.0)
285
+ kind: str = field(default="Cylinder", init=False, repr=False)
286
+
287
+ def __post_init__(self):
288
+ super().__post_init__()
289
+ self.position = _vec3(self.position, "position")
290
+ self.rotation = _vec3(self.rotation, "rotation")
291
+ if self.radius <= 0 or self.height <= 0:
292
+ raise ValueError(f"{self.kind} {self.name}: radius and height must be > 0")
293
+
294
+ def bounding_box(self):
295
+ return _axial_bbox(self.position, float(self.radius), float(self.height), self.axis, self.anchor, self.rotation)
296
+
297
+ def to_dict(self, medium_id, file_ids, subpixel):
298
+ return jh.create_cylinder_primitive(self.name, medium_id, *self.position, float(self.radius),
299
+ float(self.height), axis=self.axis, rotation_x=self.rotation[0],
300
+ rotation_y=self.rotation[1], rotation_z=self.rotation[2],
301
+ position_anchor=self.anchor, subpixel=subpixel)
302
+
303
+
304
+ @dataclass
305
+ class Cone(Cylinder):
306
+ """Cone: base disc of ``radius`` at ``position`` (BottomCenter), apex ``height`` along +``axis``."""
307
+
308
+ kind: str = field(default="Cone", init=False, repr=False)
309
+
310
+ def to_dict(self, medium_id, file_ids, subpixel):
311
+ return jh.create_cone_primitive(self.name, medium_id, *self.position, float(self.radius),
312
+ float(self.height), axis=self.axis, rotation_x=self.rotation[0],
313
+ rotation_y=self.rotation[1], rotation_z=self.rotation[2],
314
+ position_anchor=self.anchor, subpixel=subpixel)
315
+
316
+
317
+ @dataclass
318
+ class Prism(Cylinder):
319
+ """Regular N-sided prism: polygon of circumradius ``radius`` extruded ``height`` along ``axis``."""
320
+
321
+ sides: int = 6
322
+ top_scale: float = 1.0
323
+ twist_degrees: float = 0.0
324
+ kind: str = field(default="Prism", init=False, repr=False)
325
+
326
+ def to_dict(self, medium_id, file_ids, subpixel):
327
+ return jh.create_prism_primitive(self.name, medium_id, *self.position, float(self.radius),
328
+ float(self.height), number_of_sides=int(self.sides), axis=self.axis,
329
+ top_scale=float(self.top_scale), twist_degrees=float(self.twist_degrees),
330
+ rotation_x=self.rotation[0], rotation_y=self.rotation[1],
331
+ rotation_z=self.rotation[2], position_anchor=self.anchor,
332
+ subpixel=subpixel)
333
+
334
+
335
+ @dataclass
336
+ class Stl(Primitive):
337
+ """A triangle mesh from a local ``.stl`` file; uploaded by ``submit``.
338
+
339
+ ``center`` positions the mesh (world µm, domain centre = origin); ``scale``
340
+ and ``rotation`` (degrees) are applied by the server.
341
+ """
342
+
343
+ path: Union[str, Path] = ""
344
+ center: Vec3 = (0.0, 0.0, 0.0)
345
+ rotation: Vec3 = (0.0, 0.0, 0.0)
346
+ scale: Vec3 = (1.0, 1.0, 1.0)
347
+ kind: str = field(default="Stl", init=False, repr=False)
348
+
349
+ def __post_init__(self):
350
+ super().__post_init__()
351
+ self.path = Path(self.path)
352
+ if not self.path.is_file():
353
+ raise FileNotFoundError(f"Stl {self.name}: file not found: {self.path}")
354
+ self.center = _vec3(self.center, "center")
355
+ self.rotation = _vec3(self.rotation, "rotation")
356
+ self.scale = _vec3(self.scale, "scale")
357
+
358
+ def files(self):
359
+ return [("file", self.path)]
360
+
361
+ def to_dict(self, medium_id, file_ids, subpixel):
362
+ return jh.create_stl_primitive(self.name, file_ids["file"], medium_id, *self.center,
363
+ rotation_x=self.rotation[0], rotation_y=self.rotation[1],
364
+ rotation_z=self.rotation[2], scale_x=self.scale[0],
365
+ scale_y=self.scale[1], scale_z=self.scale[2], subpixel=subpixel)
366
+
367
+
368
+ @dataclass
369
+ class Gds(Primitive):
370
+ """A GDSII layout from a local ``.gds`` file, extruded per layer; uploaded by ``submit``.
371
+
372
+ ``layers`` maps a GDS layer number to ``(n, height_um)`` or ``(n, height_um, z_offset_um)``;
373
+ ``n`` may be a number or a ``Medium``. Layers not listed are not included. When
374
+ ``layers`` is None every layer of the file is extruded by ``layer_height`` µm in the
375
+ primitive's own material ``n``. The layout is centred on its top structure's bounding
376
+ box centre, placed at ``center`` (world µm).
377
+ """
378
+
379
+ path: Union[str, Path] = ""
380
+ center: Vec3 = (0.0, 0.0, 0.0)
381
+ layers: Optional[Dict[int, Sequence[Any]]] = None
382
+ layer_height: float = 1.0
383
+ rotation: Vec3 = (0.0, 0.0, 0.0)
384
+ scale: Vec3 = (1.0, 1.0, 1.0)
385
+ voxelization_method: str = "ViaStl"
386
+ kind: str = field(default="Gds", init=False, repr=False)
387
+
388
+ def __post_init__(self):
389
+ super().__post_init__()
390
+ self.path = Path(self.path)
391
+ if not self.path.is_file():
392
+ raise FileNotFoundError(f"Gds {self.name}: file not found: {self.path}")
393
+ self.center = _vec3(self.center, "center")
394
+ self.rotation = _vec3(self.rotation, "rotation")
395
+ self.scale = _vec3(self.scale, "scale")
396
+ if self.layer_height <= 0:
397
+ raise ValueError(f"Gds {self.name}: layer_height must be > 0")
398
+ if self.layers is not None:
399
+ parsed: Dict[int, Tuple[Medium, float, Optional[float]]] = {}
400
+ for num, spec in self.layers.items():
401
+ spec = tuple(spec) if isinstance(spec, (list, tuple)) else (spec,)
402
+ if len(spec) < 2 or len(spec) > 3:
403
+ raise ValueError(
404
+ f"Gds {self.name}: layers[{num}] must be (n, height_um) or (n, height_um, z_offset_um), got {spec!r}")
405
+ med = as_medium(spec[0], f"layers[{num}].n")
406
+ h = float(spec[1])
407
+ if h <= 0:
408
+ raise ValueError(f"Gds {self.name}: layers[{num}] height must be > 0")
409
+ z = float(spec[2]) if len(spec) == 3 else None
410
+ parsed[int(num)] = (med, h, z)
411
+ self._layers = parsed
412
+ else:
413
+ self._layers = None
414
+
415
+ def layer_media(self) -> List[Medium]:
416
+ return [m for (m, _, _) in (self._layers or {}).values()]
417
+
418
+ def files(self):
419
+ return [("file", self.path)]
420
+
421
+ def to_dict(self, medium_id, file_ids, subpixel, layer_medium_ids: Optional[Dict[int, str]] = None):
422
+ configs = None
423
+ selected = None
424
+ if self._layers is not None:
425
+ selected = sorted(self._layers)
426
+ configs = [
427
+ jh.create_gds_layer_config(num, medium_id=(layer_medium_ids or {}).get(num, medium_id),
428
+ height_um=h, z_offset_um=z)
429
+ for num, (_, h, z) in sorted(self._layers.items())
430
+ ]
431
+ return jh.create_gds_primitive(self.name, file_ids["file"], medium_id, *self.center,
432
+ layer_height_um=float(self.layer_height), selected_layers=selected,
433
+ layer_configs=configs, voxelization_method=self.voxelization_method,
434
+ rotation_x=self.rotation[0], rotation_y=self.rotation[1],
435
+ rotation_z=self.rotation[2], scale_x=self.scale[0], scale_y=self.scale[1],
436
+ scale_z=self.scale[2], subpixel=subpixel)
437
+
438
+
439
+ @dataclass
440
+ class VoxelArray(Primitive):
441
+ """A user permittivity (or refractive index) array drawn as a voxel block.
442
+
443
+ The array is VOXEL-INDEX based; its corner (0,0,0) lands on the domain
444
+ corner unless ``center`` moves it. ``pixel_size`` None = the simulation
445
+ pixel size (array at grid resolution); a finer whole-number pitch is
446
+ block-averaged on the service when ``subpixel=True``. ``n`` is ignored
447
+ (the array carries its own values) and only names the medium entry.
448
+ """
449
+
450
+ array: Optional[np.ndarray] = None
451
+ data_type: str = "Permittivity"
452
+ center: Optional[Vec3] = None
453
+ pixel_size: Optional[Any] = None
454
+ antialias_width: Optional[float] = None
455
+ kind: str = field(default="VoxelArray", init=False, repr=False)
456
+
457
+ def __post_init__(self):
458
+ super().__post_init__()
459
+ if self.array is None:
460
+ raise ValueError("VoxelArray: array is required")
461
+ arr = np.asarray(self.array)
462
+ shape = arr.shape
463
+ while len(shape) < 3:
464
+ shape = shape + (1,)
465
+ self.array = arr.reshape(shape)
466
+ if self.data_type not in ("Permittivity", "RefractiveIndex"):
467
+ raise ValueError("VoxelArray: data_type must be 'Permittivity' or 'RefractiveIndex'")
468
+ if self.center is not None:
469
+ self.center = _vec3(self.center, "center")
470
+
471
+ @property
472
+ def shape(self) -> Tuple[int, int, int]:
473
+ return tuple(int(s) for s in self.array.shape[:3]) # type: ignore[return-value]
474
+
475
+ def to_dict(self, medium_id, file_ids, subpixel, pitch: Vec3 = (0.1, 0.1, 0.1), center: Vec3 = (0.0, 0.0, 0.0)):
476
+ nx, ny, nz = self.shape
477
+ fmt = "Complex64" if np.iscomplexobj(self.array) else "Float32"
478
+ return jh.create_voxels_model_primitive(
479
+ self.name, file_ids["array"], medium_id, *center, *pitch, nx, ny, nz, fmt, self.data_type,
480
+ subpixel=(True if subpixel else None), antialias_width=self.antialias_width)
481
+
482
+
483
+ GEOMETRY_KINDS = (Sphere, Box, Ellipsoid, Cylinder, Cone, Prism, Stl, Gds)
484
+
485
+
486
+ # ---------------------------------------------------------------------------
487
+ # Sources and monitors
488
+ # ---------------------------------------------------------------------------
489
+
490
+ _POL_NAMES = {None: None, "none": None, "x": "x", "y": "y", "z": "z", 0: "x", 1: "y", 2: "z"}
491
+
492
+
493
+ def normalize_polarization(pol) -> Optional[str]:
494
+ """None / 'none' → None (scalar); 'x'|'y'|'z' or 0|1|2 → 'x'|'y'|'z'."""
495
+ key = pol.lower() if isinstance(pol, str) else pol
496
+ if key not in _POL_NAMES:
497
+ raise ValueError(f"polarization must be None, 'x', 'y', 'z' or 0/1/2, got {pol!r}")
498
+ return _POL_NAMES[key]
499
+
500
+
501
+ @dataclass
502
+ class FieldSource:
503
+ """A user-computed source field placed on the grid.
504
+
505
+ ``field`` is a complex array (any shape up to 3D; a leading singleton
506
+ component axis of a 4D FOSS array is dropped). ``position`` is the VOXEL
507
+ INDEX of the field's corner (ix, iy, iz), as in ``wavesim.simulate``.
508
+ ``polarization`` None = scalar Helmholtz; 'x'|'y'|'z' (or 0|1|2) = Maxwell.
509
+ ``amplitude`` scales the field.
510
+ """
511
+
512
+ field: np.ndarray
513
+ position: Tuple[int, int, int] = (0, 0, 0)
514
+ polarization: Optional[Union[str, int]] = None
515
+ amplitude: complex = 1.0
516
+ name: Optional[str] = None
517
+
518
+ def __post_init__(self):
519
+ arr = np.asarray(self.field)
520
+ if arr.ndim == 4:
521
+ if arr.shape[0] != 1:
522
+ raise ValueError("FieldSource: a 4D field must have a single leading component (shape [1, nx, ny, nz])")
523
+ arr = arr[0]
524
+ shape = arr.shape
525
+ while len(shape) < 3:
526
+ shape = shape + (1,)
527
+ self.field = arr.reshape(shape).astype(np.complex64, copy=False)
528
+ pos = tuple(self.position)
529
+ if len(pos) != 3:
530
+ raise ValueError(f"FieldSource: position must be (ix, iy, iz) voxel indices, got {self.position!r}")
531
+ self.position = tuple(int(round(float(p))) for p in pos) # type: ignore[assignment]
532
+ self.polarization = normalize_polarization(self.polarization)
533
+ if self.name is None:
534
+ self.name = _auto_name("source")
535
+
536
+ @classmethod
537
+ def from_foss(cls, item: tuple) -> "FieldSource":
538
+ """From the FOSS ``(array, position)`` tuple: position is (ix, iy, iz) for
539
+ scalar runs or (pol, ix, iy, iz) for vectorial ones."""
540
+ arr, pos = item
541
+ pos = list(pos)
542
+ if len(pos) == 4:
543
+ return cls(arr, (pos[1], pos[2], pos[3]), polarization=int(pos[0]))
544
+ if len(pos) == 3:
545
+ return cls(arr, (pos[0], pos[1], pos[2]), polarization=None)
546
+ raise ValueError(f"source position must be (ix, iy, iz) or (pol, ix, iy, iz), got {pos!r}")
547
+
548
+
549
+ def PlaneWave(name: str = "PlaneWave1", center: Vec3 = (0.0, 0.0, 0.0), **kwargs) -> Dict[str, Any]:
550
+ """Service-computed plane wave; see ``create_plane_wave_source`` for the keyword arguments."""
551
+ x, y, z = _vec3(center, "center")
552
+ return jh.create_plane_wave_source(name, x, y, z, **kwargs)
553
+
554
+
555
+ def GaussianBeam(name: str = "GaussianBeam1", center: Vec3 = (0.0, 0.0, 0.0), **kwargs) -> Dict[str, Any]:
556
+ """Service-computed Gaussian beam; see ``create_gaussian_beam_source``."""
557
+ x, y, z = _vec3(center, "center")
558
+ return jh.create_gaussian_beam_source(name, x, y, z, **kwargs)
559
+
560
+
561
+ def PointSource(name: str = "PointSource1", center: Vec3 = (0.0, 0.0, 0.0), **kwargs) -> Dict[str, Any]:
562
+ """Service-computed point source; see ``create_point_source``."""
563
+ x, y, z = _vec3(center, "center")
564
+ return jh.create_point_source(name, x, y, z, **kwargs)
565
+
566
+
567
+ def ModeSource(name: str = "Mode1", center: Vec3 = (0.0, 0.0, 0.0), **kwargs) -> Dict[str, Any]:
568
+ """Waveguide mode solved on the service at the source plane; see ``create_mode_source``."""
569
+ x, y, z = _vec3(center, "center")
570
+ return jh.create_mode_source(name, x, y, z, **kwargs)
571
+
572
+
573
+ def Monitor(name: str = "Monitor_0", center: Vec3 = (0.0, 0.0, 0.0), size: Vec3 = (1.0, 1.0, 1.0),
574
+ **kwargs) -> Dict[str, Any]:
575
+ """A 3D field monitor (sub-cube of the output); see ``create_monitor``."""
576
+ cx, cy, cz = _vec3(center, "center")
577
+ sx, sy, sz = _vec3(size, "size")
578
+ return jh.create_monitor(name, cx, cy, cz, sx, sy, sz, **kwargs)
579
+
580
+
581
+ def source_dict_is_polarized(src: Dict[str, Any]) -> bool:
582
+ pol = src.get("Polarization", "None")
583
+ return str(pol).lower() not in ("", "none")
584
+
585
+
586
+ def boxes_intersect(a: Tuple[Vec3, Vec3], b: Tuple[Vec3, Vec3]) -> bool:
587
+ (amin, amax), (bmin, bmax) = a, b
588
+ return all(amin[i] <= bmax[i] and bmin[i] <= amax[i] for i in range(3))