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.
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/PKG-INFO +3 -2
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/README.md +2 -1
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/pyproject.toml +1 -1
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/__init__.py +1 -1
- wavesimpro-1.10.2/src/wavesimpro/model.py +588 -0
- wavesimpro-1.10.2/src/wavesimpro/runs.py +955 -0
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro.egg-info/PKG-INFO +3 -2
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro.egg-info/SOURCES.txt +2 -0
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/setup.cfg +0 -0
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/__main__.py +0 -0
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/binary_utils.py +0 -0
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/client.py +0 -0
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/config.py +0 -0
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/exceptions.py +0 -0
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/execute.py +0 -0
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/json_helper.py +0 -0
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/py.typed +0 -0
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/setup.py +0 -0
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/simulate.py +0 -0
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro/validate.py +0 -0
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro.egg-info/dependency_links.txt +0 -0
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro.egg-info/requires.txt +0 -0
- {wavesimpro-1.10.1 → wavesimpro-1.10.2}/src/wavesimpro.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -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))
|