cwt3d 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cwt3d/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Independent 3D-CWT core; importing it never loads legacy/COMSOL backends.
2
+
3
+ NumPy/SciPy form the reference backend. Numba is optional and is neither
4
+ imported nor required by the core mathematical conventions.
5
+ """
6
+
7
+ from . import conventions
8
+
9
+ __all__ = ["conventions"]
cwt3d/cache.py ADDED
@@ -0,0 +1,324 @@
1
+ """Opt-in, local NPZ+JSON coupling snapshots; never deserialize Python objects.
2
+
3
+ Construction/key/load do not create directories. Only explicit build misses or
4
+ store write. Corrupt/mismatched entries raise CacheError (never silent success).
5
+ Expiration is a miss. No automatic prune: invoke prune/clear explicitly.
6
+ Checksums detect damage, not an adversary able to rewrite both data and manifest;
7
+ use a directory whose writers you trust. Reading cannot execute pickled code.
8
+ """
9
+ from dataclasses import fields
10
+ from hashlib import sha256
11
+ import inspect
12
+ import json
13
+ import math
14
+ import os
15
+ from pathlib import Path
16
+ import re
17
+ import sys
18
+ import tempfile
19
+ import time
20
+ import zipfile
21
+
22
+ import numpy as np
23
+ import scipy
24
+ from .coupling import CouplingResult, build_coupling, _pack
25
+ from .geometry import CircleHole, RightTriangleHole, SampledPermittivity
26
+ from .waveguide import TEMode, _positive_scalar
27
+
28
+ SCHEMA = 'cwt3d-coupling-v1'
29
+ DEFAULT_MAX_BYTES = 256*1024*1024
30
+ DEFAULT_MAX_AGE = 7*24*60*60
31
+ _BUILD_SIGNATURE = inspect.signature(build_coupling)
32
+ _ARRAY_NAMES = ('C1D', 'Crad', 'C2D', 'C2D_green', 'C2D_local', 'C', 'I', 'J',
33
+ 'K', 'xi', 'xi_orders', 'aux_orders', 'mu', 'nu', 'varsigma')
34
+
35
+
36
+ class CacheError(ValueError):
37
+ """Malformed, damaged or mismatched cache entry; explicit clear to rebuild."""
38
+
39
+
40
+ def _json(value):
41
+ return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False)
42
+
43
+
44
+ def _array(value):
45
+ a = np.ascontiguousarray(value)
46
+ if a.dtype.kind not in 'iufc':
47
+ raise TypeError('cache arrays must be numeric')
48
+ return {'shape': list(a.shape), 'dtype': a.dtype.str,
49
+ 'sha256': sha256(a.tobytes()).hexdigest()}
50
+
51
+
52
+ def _canonical(value):
53
+ if isinstance(value, np.generic):
54
+ value = value.item()
55
+ if value is None or isinstance(value, (str, bool, int)):
56
+ return value
57
+ if isinstance(value, float):
58
+ return {'float': value.hex()}
59
+ if isinstance(value, complex):
60
+ return {'complex': [value.real.hex(), value.imag.hex()]}
61
+ if isinstance(value, (list, tuple)):
62
+ return [_canonical(v) for v in value]
63
+ if isinstance(value, np.ndarray):
64
+ return _array(value)
65
+ raise TypeError(f'unsupported cache key type: {type(value).__name__}')
66
+
67
+
68
+ def algorithm_digest():
69
+ """Actual source digest, not callable identity/repr or a manual version only."""
70
+ root = Path(__file__).parent
71
+ digest = sha256()
72
+ for path in sorted(root.rglob('*.py')):
73
+ digest.update(path.relative_to(root).as_posix().encode())
74
+ digest.update(path.read_bytes())
75
+ return digest.hexdigest()
76
+
77
+
78
+ def _document(mode, period_um, pc_layers, options):
79
+ if not isinstance(mode, TEMode):
80
+ raise TypeError('mode must be a normalized TEMode')
81
+ bound = _BUILD_SIGNATURE.bind(mode, period_um, pc_layers, **options)
82
+ bound.apply_defaults()
83
+ # Match the real build's scalar semantics, not the caller's container/dtype.
84
+ # Do not generalize this normalization to geometry or solved-field arrays.
85
+ period_um = _positive_scalar(period_um, 'period_um')
86
+ guide = mode.guide
87
+ geometry = []
88
+ for index, shape in sorted(dict(pc_layers).items()):
89
+ if isinstance(shape, SampledPermittivity):
90
+ state = {'period_um': shape.period_um, 'values': shape.values}
91
+ elif isinstance(shape, (CircleHole, RightTriangleHole)):
92
+ state = {f.name: getattr(shape, f.name) for f in fields(shape)}
93
+ else:
94
+ raise TypeError('unsupported cache geometry')
95
+ geometry.append([_canonical(index), type(shape).__name__,
96
+ {k: _canonical(v) for k, v in state.items()}])
97
+ # Include the actual interpolation mesh/materials and normalized field,
98
+ # not just beta or a caller-assigned mode index. Altered solved shapes miss.
99
+ state = {'eps': guide.eps, 'thickness_um': guide.thickness_um,
100
+ 'interfaces_um': guide.interfaces_um, 'k0': guide.k0,
101
+ 'period_um': guide.period_um, 'nodes': guide._nodes,
102
+ 'lengths': guide._lengths, 'materials': guide._materials,
103
+ 'beta': mode.beta, 'value': mode._value, 'field': mode._field}
104
+ return {'schema': SCHEMA, 'algorithm': algorithm_digest(),
105
+ 'numpy': np.__version__, 'scipy': scipy.__version__, 'byteorder': sys.byteorder,
106
+ 'guide_mode': {k: _canonical(v) for k,v in state.items()},
107
+ 'geometry': geometry, 'period_um': _canonical(period_um),
108
+ 'options': {k: _canonical(v) for k,v in bound.arguments.items()
109
+ if k not in ('mode', 'period_um', 'pc_layers')}}
110
+
111
+
112
+ def _tuple(value):
113
+ return tuple(_tuple(v) for v in value) if isinstance(value, list) else value
114
+
115
+
116
+ def _validate_metadata(metadata, document):
117
+ """Reject accidental public store mis-keying before writing/returning a hit."""
118
+ if metadata.get('provenance') != _json(document):
119
+ raise CacheError('result metadata provenance does not match complete key inputs')
120
+ options = document['options']
121
+ for name in ('cutoff', 'quadrature_order', 'fourier_order', 'backend'):
122
+ if _canonical(metadata[name]) != options[name]:
123
+ raise CacheError('result metadata does not match key numerical controls')
124
+ def real(value):
125
+ return float.fromhex(value['float']) if isinstance(value, dict) else float(value)
126
+ period = real(document['period_um'])
127
+ beta0 = 2*np.pi/period
128
+ beta = real(document['guide_mode']['beta'])
129
+ expected = dict(period_um=period, beta0=beta0,
130
+ k0=real(document['guide_mode']['k0']),
131
+ relative_detuning=(beta-beta0)/beta0, units='um^-1')
132
+ if any(metadata[name] != value for name, value in expected.items()):
133
+ raise CacheError('result metadata does not match key structure/period')
134
+ ids = [item[0] for item in document['geometry']]
135
+ if _json(metadata['pc_layers']) != _json(ids):
136
+ raise CacheError('result metadata does not match PC layer indices')
137
+ shapes = [[item[0], item[2]['values']['shape']] for item in document['geometry']
138
+ if item[1] == 'SampledPermittivity']
139
+ if _json(metadata['sampling_shapes']) != _json(shapes):
140
+ raise CacheError('result metadata does not match sampled dimensions')
141
+
142
+
143
+ def _check_archive(path, max_bytes):
144
+ """Bound expanded bytes AND NPY declared shapes before np.load allocates."""
145
+ with zipfile.ZipFile(path) as archive:
146
+ members = archive.infolist()
147
+ if (len(members) != len(_ARRAY_NAMES)+1
148
+ or {x.filename for x in members} != {n+'.npy' for n in (*_ARRAY_NAMES, 'manifest')}
149
+ or sum(x.file_size for x in members) > max_bytes):
150
+ raise CacheError('entry exceeds cache schema/expanded byte budget')
151
+ for member in members:
152
+ with archive.open(member) as stream:
153
+ version = np.lib.format.read_magic(stream)
154
+ if version == (1, 0):
155
+ shape, _, dtype = np.lib.format.read_array_header_1_0(stream)
156
+ elif version == (2, 0):
157
+ shape, _, dtype = np.lib.format.read_array_header_2_0(stream)
158
+ else:
159
+ raise CacheError('unsupported NPY format')
160
+ if (dtype.hasobject or dtype.kind not in 'iufc'
161
+ or any(d < 0 for d in shape)
162
+ or math.prod(shape)*dtype.itemsize != member.file_size-stream.tell()):
163
+ raise CacheError('unsafe or mismatched NPY payload size/type')
164
+
165
+
166
+ class CouplingCache:
167
+ """Explicit directory ownership prefix cwt3d-<SHA256>.npz.
168
+
169
+ Default age 7 days, size 256 MiB. Size limits bound each expanded entry and
170
+ explicit prune's total retained storage. readonly forbids every write/delete.
171
+ Concurrent misses may duplicate work; atomic replace keeps complete snapshots,
172
+ not guaranteed concurrent success. Windows open readers may block replacement
173
+ with PermissionError/WinError 5. Callers must serialize access or retry errors;
174
+ no cross-process lock or automatic retry is provided.
175
+ Private temp files are always in directory and cleaned on normal failure.
176
+ """
177
+ def __init__(self, directory, *, readonly=False, max_age=DEFAULT_MAX_AGE,
178
+ max_bytes=DEFAULT_MAX_BYTES):
179
+ self.directory = Path(directory)
180
+ self.readonly = bool(readonly)
181
+ if max_age is not None and (isinstance(max_age, bool) or not np.isfinite(max_age) or max_age < 0):
182
+ raise ValueError('max_age must be None or finite nonnegative seconds')
183
+ if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
184
+ raise ValueError('max_bytes must be a positive integer')
185
+ self.max_age, self.max_bytes = max_age, max_bytes
186
+
187
+ def key(self, mode, period_um, pc_layers, **options):
188
+ return sha256(_json(_document(mode, period_um, pc_layers, options)).encode()).hexdigest()
189
+
190
+ def document(self, mode, period_um, pc_layers, **options):
191
+ """Canonical JSON-compatible provenance for explicit store calls."""
192
+ return _document(mode, period_um, pc_layers, options)
193
+
194
+ def path(self, key):
195
+ if not isinstance(key, str) or re.fullmatch('[0-9a-f]{64}', key) is None:
196
+ raise ValueError('key must be lowercase SHA256 hex')
197
+ return self.directory/f'cwt3d-{key}.npz'
198
+
199
+ def _writable(self):
200
+ if self.readonly:
201
+ raise PermissionError('cache is readonly')
202
+
203
+ def load(self, key):
204
+ path = self.path(key)
205
+ if not path.exists():
206
+ return None
207
+ if path.is_symlink():
208
+ raise CacheError('cache entry must not be a symlink')
209
+ if self.max_age is not None and time.time()-path.stat().st_mtime > self.max_age:
210
+ return None
211
+ try:
212
+ _check_archive(path, self.max_bytes)
213
+ with np.load(path, allow_pickle=False) as data:
214
+ if set(data.files) != {*_ARRAY_NAMES, 'manifest'}:
215
+ raise CacheError('unexpected array names')
216
+ raw = data['manifest']
217
+ if raw.dtype != np.uint8 or raw.ndim != 1:
218
+ raise CacheError('manifest must be JSON bytes')
219
+ manifest = json.loads(raw.tobytes())
220
+ if manifest['schema'] != SCHEMA or manifest['key'] != key:
221
+ raise CacheError('cache schema/key mismatch')
222
+ if sha256(_json(manifest['document']).encode()).hexdigest() != key:
223
+ raise CacheError('cache key document mismatch')
224
+ if (manifest['document']['algorithm'] != algorithm_digest()
225
+ or manifest['document']['numpy'] != np.__version__
226
+ or manifest['document']['scipy'] != scipy.__version__
227
+ or manifest['document']['byteorder'] != sys.byteorder):
228
+ return None
229
+ arrays = {name: data[name] for name in _ARRAY_NAMES}
230
+ if any(_array(a) != manifest['arrays'][name] for name,a in arrays.items()):
231
+ raise CacheError('array checksum/shape/dtype mismatch')
232
+ metadata = manifest['metadata']
233
+ check = sha256(_json(metadata).encode()).hexdigest()
234
+ if check != manifest['metadata_sha256']:
235
+ raise CacheError('metadata checksum mismatch')
236
+ _validate_metadata(metadata, manifest['document'])
237
+ return CouplingResult(_pack(**arrays), **{k:_tuple(v) for k,v in metadata.items()})
238
+ except CacheError:
239
+ raise
240
+ except (OSError, ValueError, KeyError, TypeError, AttributeError, OverflowError,
241
+ zipfile.BadZipFile, EOFError, RuntimeError) as exc:
242
+ raise CacheError('invalid coupling cache entry') from exc
243
+
244
+ def store(self, key, result, *, document):
245
+ self._writable()
246
+ target = self.path(key)
247
+
248
+ if sha256(_json(document).encode()).hexdigest() != key:
249
+ raise CacheError('store document/key mismatch')
250
+ arrays = {name: getattr(result, name) for name in _ARRAY_NAMES}
251
+ metadata = {f.name: getattr(result, f.name) for f in fields(result) if f.name != '_arrays'}
252
+ _validate_metadata(metadata, document)
253
+ manifest = {'schema': SCHEMA, 'key': key, 'document': document,
254
+ 'metadata': metadata, 'metadata_sha256':sha256(_json(metadata).encode()).hexdigest(),
255
+ 'arrays': {name:_array(a) for name,a in arrays.items()}}
256
+ raw = _json(manifest).encode()
257
+ if sum(a.nbytes for a in arrays.values())+len(raw)+16384 > self.max_bytes:
258
+ raise CacheError('entry exceeds cache byte budget')
259
+ self.directory.mkdir(parents=True, exist_ok=True)
260
+ temp = None
261
+ try:
262
+ with tempfile.NamedTemporaryFile(dir=self.directory, prefix='.cwt3d-', suffix='.tmp', delete=False) as stream:
263
+ temp = Path(stream.name)
264
+ np.savez(stream, manifest=np.frombuffer(raw, dtype=np.uint8), **arrays)
265
+ stream.flush()
266
+ os.fsync(stream.fileno())
267
+ os.replace(temp, target)
268
+ finally:
269
+ if temp is not None:
270
+ temp.unlink(missing_ok=True)
271
+
272
+ def build(self, mode, period_um, pc_layers, **options):
273
+ document = _document(mode, period_um, pc_layers, options)
274
+ key = sha256(_json(document).encode()).hexdigest()
275
+ result = self.load(key)
276
+ if result is not None:
277
+ return result
278
+ self._writable()
279
+ result = build_coupling(mode, period_um, pc_layers, **options)
280
+ self.store(key, result, document=document)
281
+ return result
282
+
283
+ def clear(self, key):
284
+ self._writable()
285
+ self.path(key).unlink(missing_ok=True)
286
+
287
+ def prune(self):
288
+ """Delete recognized owned entries, expired then oldest.
289
+
290
+ Recognition checks ZIP/NPY layout and schema/key document, not array or
291
+ metadata checksums/provenance. Thus recognized checksum-damaged entries
292
+ can be deleted by age/size policy. Unrecognized files, symlinks and
293
+ crash-left temp files are retained; max_bytes counts recognized files only.
294
+ """
295
+ self._writable()
296
+ entries = []
297
+ if not self.directory.exists():
298
+ return []
299
+ for path in self.directory.iterdir():
300
+ match = re.fullmatch(r'cwt3d-([0-9a-f]{64})\.npz', path.name)
301
+ if not match or path.is_symlink() or not path.is_file():
302
+ continue
303
+ try:
304
+ _check_archive(path, max(self.max_bytes, DEFAULT_MAX_BYTES))
305
+ with np.load(path, allow_pickle=False) as data:
306
+ manifest = json.loads(data['manifest'].tobytes())
307
+ key = match[1]
308
+ if (manifest['schema'] != SCHEMA or manifest['key'] != key or
309
+ sha256(_json(manifest['document']).encode()).hexdigest() != key):
310
+ continue
311
+ except (OSError, ValueError, KeyError, TypeError, AttributeError,
312
+ OverflowError, zipfile.BadZipFile, EOFError, RuntimeError):
313
+ continue
314
+ stat = path.stat()
315
+ entries.append((stat.st_mtime, stat.st_size, key, path))
316
+ total = sum(e[1] for e in entries)
317
+ removed = []
318
+ now = time.time()
319
+ for mtime, size, key, path in sorted(entries):
320
+ if (self.max_age is not None and now-mtime > self.max_age) or total > self.max_bytes:
321
+ path.unlink()
322
+ total -= size
323
+ removed.append(key)
324
+ return removed
cwt3d/conventions.py ADDED
@@ -0,0 +1,128 @@
1
+ """Shared mathematical conventions for the square-lattice TE reference core.
2
+
3
+ Fields use exp(+i*omega*t) and exp(-i*beta0*(m*x+n*y)). Fourier
4
+ coefficients use the positive exponent and the unit-cell average (1/area).
5
+ The four TE amplitudes are (Rx, Sx, Ry, Sy): the x-travelling waves
6
+ contribute to Ey and the y-travelling waves to Ex, with no extra sign.
7
+ Thus Ey = Rx*exp(-i*beta0*x) + Sx*exp(+i*beta0*x), and
8
+ Ex = Ry*exp(-i*beta0*y) + Sy*exp(+i*beta0*y), before any common
9
+ longitudinal profile. Its normalization belongs to the future waveguide API.
10
+
11
+ Internal lengths are micrometres (um); wavenumbers and amplitude losses
12
+ are um^-1, never implicitly cm^-1. E(s)=E(0)*exp(-alpha*s) defines
13
+ positive amplitude loss alpha; power decays as exp(-2*alpha*s).
14
+ Negative alpha denotes gain. No eigenvalue-to-alpha rule is imposed here.
15
+ """
16
+
17
+ from dataclasses import dataclass
18
+ from typing import Literal
19
+
20
+ import numpy as np
21
+ from numpy.typing import ArrayLike, NDArray
22
+
23
+ TIME_EXPONENT_SIGN = 1
24
+ SPATIAL_EXPONENT_SIGN = -1
25
+ FOURIER_COEFFICIENT_EXPONENT_SIGN = 1
26
+ INTERNAL_LENGTH_UNIT = "um"
27
+ INTERNAL_INVERSE_LENGTH_UNIT = "um^-1"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class BasicWave:
32
+ """Immutable harmonic index and transverse electric-field component."""
33
+
34
+ name: str
35
+ m: int
36
+ n: int
37
+ electric_component: Literal["Ex", "Ey"]
38
+
39
+
40
+ BASIC_WAVES = (
41
+ BasicWave("Rx", 1, 0, "Ey"),
42
+ BasicWave("Sx", -1, 0, "Ey"),
43
+ BasicWave("Ry", 0, 1, "Ex"),
44
+ BasicWave("Sy", 0, -1, "Ex"),
45
+ )
46
+ BASIC_WAVE_ORDER = tuple(wave.name for wave in BASIC_WAVES)
47
+
48
+
49
+ def time_phase(omega_rad_per_time: ArrayLike, t: ArrayLike):
50
+ """Return exp(+i*omega*t); omega and t must use matching time units."""
51
+ return np.exp(1j * TIME_EXPONENT_SIGN * np.asarray(omega_rad_per_time) * np.asarray(t))
52
+
53
+
54
+ def spatial_phase(m: int, n: int, *, beta0_per_um: ArrayLike,
55
+ x_um: ArrayLike, y_um: ArrayLike):
56
+ """Return exp(-i*beta0*(m*x+n*y)) with NumPy broadcasting.
57
+
58
+ Coordinates must be in um and beta0 in um^-1; m,n are the signed
59
+ integer harmonic indices. This is a basis phase, not a Fourier solver.
60
+ """
61
+ argument = np.asarray(beta0_per_um) * (m * np.asarray(x_um) + n * np.asarray(y_um))
62
+ return np.exp(1j * SPATIAL_EXPONENT_SIGN * argument)
63
+
64
+
65
+ def fourier_coefficient_phase(m: int, n: int, *, beta0_per_um: ArrayLike,
66
+ x_um: ArrayLike, y_um: ArrayLike):
67
+ """Return the positive-exponent weight for the (m,n) coefficient.
68
+
69
+ Multiply the field by this weight and average over one unit cell.
70
+ Coordinates and beta0 use um and um^-1; no integration is done here.
71
+ """
72
+ argument = np.asarray(beta0_per_um) * (m * np.asarray(x_um) + n * np.asarray(y_um))
73
+ return np.exp(1j * FOURIER_COEFFICIENT_EXPONENT_SIGN * argument)
74
+
75
+
76
+ def _real_finite(value: ArrayLike) -> NDArray[np.float64]:
77
+ array = np.asarray(value)
78
+ if array.dtype.kind not in "iuf":
79
+ raise TypeError("Expected real numeric values, not boolean, complex or text")
80
+ array = array.astype(np.float64)
81
+ if not np.all(np.isfinite(array)):
82
+ raise ValueError("Values must be finite")
83
+ return array
84
+
85
+
86
+ def _scaled(value: ArrayLike, scale: float) -> float | NDArray[np.float64]:
87
+ with np.errstate(over="raise", invalid="raise"):
88
+ result = _real_finite(value) * scale
89
+ return float(result) if result.ndim == 0 else result
90
+
91
+
92
+ def convert_length(value: ArrayLike, *, from_unit: Literal["um", "cm"],
93
+ to_unit: Literal["um", "cm"]) -> float | NDArray[np.float64]:
94
+ """Convert real finite lengths between explicit 'um' and 'cm'.
95
+
96
+ Both units are required keywords; inverse-length/unknown units fail
97
+ rather than being guessed. Scalars return float; arrays return float64
98
+ arrays. Signed coordinates are allowed. Input arrays are not modified.
99
+ """
100
+ scales = {"um": 1.0, "cm": 1e4} # length in internal um
101
+ if from_unit not in scales or to_unit not in scales:
102
+ raise ValueError("Length units must be 'um' or 'cm'")
103
+ return _scaled(value, scales[from_unit] / scales[to_unit])
104
+
105
+
106
+ def convert_inverse_length(value: ArrayLike, *,
107
+ from_unit: Literal["um^-1", "cm^-1"],
108
+ to_unit: Literal["um^-1", "cm^-1"]) -> float | NDArray[np.float64]:
109
+ """Convert real finite wavenumbers/losses, preserving their sign.
110
+
111
+ Explicit unit strings prevent confusing lengths with inverse lengths:
112
+ 1 um^-1 = 1e4 cm^-1. Scalars return float; arrays return float64
113
+ arrays. This does not convert amplitude loss into power loss.
114
+ """
115
+ scales = {"um^-1": 1.0, "cm^-1": 1e-4}
116
+ if from_unit not in scales or to_unit not in scales:
117
+ raise ValueError("Inverse-length units must be 'um^-1' or 'cm^-1'")
118
+ return _scaled(value, scales[from_unit] / scales[to_unit])
119
+
120
+
121
+ def amplitude_loss_to_power_loss(alpha_per_um: ArrayLike) -> float | NDArray[np.float64]:
122
+ """Return power loss 2*alpha in um^-1 from amplitude loss in um^-1.
123
+
124
+ E(s)=E(0)*exp(-alpha*s), so |E(s)|^2=|E(0)|^2*exp(-2*alpha*s).
125
+ Signed real finite scalar/array inputs are supported; negative is gain.
126
+ Convert cm^-1 explicitly with convert_inverse_length before calling.
127
+ """
128
+ return _scaled(alpha_per_um, 2.0)
cwt3d/coupling.py ADDED
@@ -0,0 +1,186 @@
1
+ """Lossless square-lattice TE reference coupling, all C terms in um^-1.
2
+
3
+ V=(Rx,Sx,Ry,Sy), harmonics=(+x,-x,+y,-y), fields=(y,y,x,x).
4
+ Liang thesis (3.27)-(3.35), extended with LOCAL receiver/source xi in
5
+ multiple PC layers. No averaging of xi before applying the Green operator.
6
+ No legacy imports, output files, material-loss truncation or hidden redesign.
7
+ """
8
+ from dataclasses import dataclass
9
+ import numpy as np
10
+ from .geometry import CircleHole, RightTriangleHole, SampledPermittivity, _integer
11
+ from .fourier import shape_fourier, fft_fourier
12
+ from .greens import LayeredGreen, PreparedLayeredGreen
13
+ from .quadrature import mode_overlap, green_overlap
14
+ from .waveguide import TEMode, _positive_scalar
15
+
16
+
17
+ def _pack(**arrays):
18
+ return tuple((name,a.shape,a.dtype.str,a.tobytes())
19
+ for name,value in arrays.items() for a in [np.asarray(value)])
20
+
21
+
22
+ class _Snapshots:
23
+ def __getattr__(self,name):
24
+ for key,shape,dtype,data in self._arrays:
25
+ if name == key:
26
+ return np.ndarray(shape,dtype=dtype,buffer=data)
27
+ raise AttributeError(name)
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CouplingResult(_Snapshots):
32
+ """Independent bytes-backed array headers on EVERY read.
33
+
34
+ C1D=kappa feedback; Crad=zeta; C2D=chi including -k0²/(2 beta0).
35
+ C2D_green and C2D_local separate mu and negative-nu contributions.
36
+ mu/nu have axes (auxiliary, receiver layer, source basic wave), and
37
+ varsigma has (auxiliary, receiver layer, component x/y, source wave).
38
+ They are dimensionless receiver-layer integrated fields, NOT averaged xi.
39
+ Summing the receiver-layer axis gives thesis (3.28)/(3.29)/(3.27).
40
+ I,J,K are layer overlaps; K radial axis corresponds to radial_orders
41
+ (zero is radiation). xi is the Fourier table with indices xi_orders.
42
+ No a-priori quadrature error estimate: order/cutoff are explicit metadata.
43
+ """
44
+ _arrays: tuple
45
+ period_um: float
46
+ beta0: float
47
+ k0: float
48
+ relative_detuning: float
49
+ cutoff: int
50
+ quadrature_order: int
51
+ pc_layers: tuple
52
+ radial_orders: tuple
53
+ all_aux_evanescent: bool
54
+ units: str = 'um^-1'
55
+ fourier_order: int = 64
56
+ backend: str = 'reference'
57
+ sampling_shapes: tuple = ()
58
+ # Immutable canonical input snapshot; empty for manually constructed results.
59
+ # Not an authentication signature against deliberate dataclass forgery.
60
+ provenance: str = ''
61
+
62
+
63
+ def build_coupling(mode, period_um, pc_layers, *, cutoff=1,
64
+ quadrature_order=12, fourier_order=64, beta_rtol=1e-8,
65
+ backend='reference'):
66
+ """Build physical reference C using a normalized TEMode.
67
+
68
+ pc_layers maps guide epsilon indices 1..len(eps)-2 to a CircleHole,
69
+ RightTriangleHole or SampledPermittivity. Others have xi=0; empty mapping
70
+ is legal and gives C=0. Each geometry's mean MUST match guide epsilon.
71
+ Scalar period is explicitly square and must match mode.beta within
72
+ beta_rtol (default 1e-8 relative). A relaxed tolerance records detuning
73
+ but does not add a detuning diagonal or alter period/k0. This is the
74
+ near-Bragg coupling operator, not a self-consistent frequency solve.
75
+ Quadrature is fixed-order per material interval. Kernels/overlaps reused
76
+ by m²+n², never recomputed per matrix entry. Scalar TE/nonmagnetic only.
77
+ Propagating auxiliaries allowed; C2D Hermiticity then not promised.
78
+ backend='reference' is the unchanged dense per-pair regression anchor;
79
+ 'prepared' uses one pole check/LU per radial kernel and batched source RHS,
80
+ with identical Fourier/quadrature orders and same-layer Duffy integration.
81
+ fourier_order and sampled grid shapes are retained in result metadata.
82
+ """
83
+ if not isinstance(mode,TEMode):
84
+ raise TypeError('mode must be a normalized TEMode')
85
+ if backend not in ('reference', 'prepared'):
86
+ raise ValueError("backend must be 'reference' or 'prepared'")
87
+ a = _positive_scalar(period_um,'period_um')
88
+ D = _integer(cutoff,'cutoff')
89
+ Q = _integer(quadrature_order,'quadrature_order')
90
+ F = _integer(fourier_order,'fourier_order')
91
+ if isinstance(beta_rtol,(bool,np.bool_)) or not np.isscalar(beta_rtol) or not np.isfinite(beta_rtol) or beta_rtol < 0:
92
+ raise ValueError('beta_rtol must be finite and nonnegative')
93
+ b = 2*np.pi/a
94
+ if not np.isfinite(b) or b <= 0 or not np.isfinite(b*b) or b*b == 0:
95
+ raise ValueError('beta0 or beta0 squared outside resolved finite range')
96
+ detuning = (mode.beta-b)/b
97
+ if abs(detuning) > beta_rtol:
98
+ raise ValueError('mode.beta does not match beta0=2*pi/period_um within beta_rtol')
99
+ guide = mode.guide
100
+ if guide.period_um is not None and not np.isclose(guide.period_um,a,rtol=1e-12,atol=0):
101
+ raise ValueError('period_um differs from guide fixed period')
102
+ layers = dict(pc_layers)
103
+ for l,h in layers.items():
104
+ if isinstance(l,(bool,np.bool_)) or not isinstance(l,(int,np.integer)) or not 1 <= l <= len(guide.eps)-2:
105
+ raise ValueError('PC layer indices must be finite internal epsilon indices')
106
+ if not isinstance(h,(CircleHole,RightTriangleHole,SampledPermittivity)):
107
+ raise TypeError('Unsupported PC geometry')
108
+ if not np.allclose(h.period_um,(a,a),rtol=1e-12,atol=0):
109
+ raise ValueError('geometry must have the specified square period')
110
+ material = h.values if isinstance(h,SampledPermittivity) else np.array([h.eps_background,h.eps_hole])
111
+ if np.any(material.imag != 0) or np.any(material.real <= 0):
112
+ raise ValueError('PC materials must be positive lossless epsilon')
113
+ mean = material.mean() if isinstance(h,SampledPermittivity) else h.mean_epsilon
114
+ if not np.isclose(mean,guide.eps[l],rtol=1e-11,atol=1e-12):
115
+ raise ValueError('geometry mean epsilon does not match averaged waveguide')
116
+ # Lazy import avoids a module-initialization cycle with the optional cache.
117
+ # Snapshot actual inputs, not a caller-supplied key or inferred Fourier table.
118
+ from .cache import _document, _json
119
+ provenance = _json(_document(mode, a, layers, dict(
120
+ cutoff=cutoff, quadrature_order=quadrature_order, fourier_order=fourier_order,
121
+ beta_rtol=beta_rtol, backend=backend)))
122
+ ids = tuple(sorted(layers))
123
+ basic = np.array([(1,0),(-1,0),(0,1),(0,-1)])
124
+ aux = [(m,n) for m in range(-D,D+1) for n in range(-D,D+1) if m*m+n*n > 1]
125
+ radial = (0,*sorted({m*m+n*n for m,n in aux}))
126
+ orders = np.array([(m,n) for m in range(-D-1,D+2) for n in range(-D-1,D+2)],dtype=int)
127
+ lookup = {tuple(o):i for i,o in enumerate(orders)}
128
+ xi = np.empty((len(ids),len(orders)),complex)
129
+ for i,l in enumerate(ids):
130
+ h = layers[l]
131
+ xi[i] = (fft_fourier(h,orders) if isinstance(h,SampledPermittivity)
132
+ else shape_fourier(h,orders,quadrature_order=F))
133
+ xi[i,lookup[(0,0)]] = 0 # mean is already in the waveguide
134
+ def coeff(order):
135
+ return xi[:,lookup[tuple(order)]]
136
+ edges = guide.interfaces_um
137
+ bounds = [(edges[l-1],edges[l]) for l in ids]
138
+ I = np.array([mode_overlap(mode,t,Q) for t in bounds])
139
+ J = I/np.array([guide.eps[l] for l in ids])
140
+ K = np.empty((len(radial),len(ids),len(ids)),complex)
141
+ for index,r2 in enumerate(radial):
142
+ kernel_type = LayeredGreen if backend == 'reference' else PreparedLayeredGreen
143
+ kernel = kernel_type.from_structure(guide,b*np.sqrt(r2))
144
+ for i,left in enumerate(bounds):
145
+ for j,right in enumerate(bounds):
146
+ K[index,i,j] = green_overlap(mode,left,right,kernel,Q)
147
+ f = -guide.k0**2/(2*b)
148
+ c1,cr,cg,cl = [np.zeros((4,4),complex) for _ in range(4)]
149
+ for i,p in enumerate(basic):
150
+ for j,r in enumerate(basic):
151
+ if j == (i^1):
152
+ c1[i,j] = f*np.dot(coeff(p-r),I)
153
+ if i//2 == j//2:
154
+ cr[i,j] = f*guide.k0**2*(coeff(p)@K[0]@coeff(-r))
155
+ # Eminus=k0² G A xi Theta; Eplus=-B xi Theta/epsilon.
156
+ # Ex=(n Eminus+m Eplus)/r²; Ey=(-m Eminus+n Eplus)/r².
157
+ mus, nus, fields = [], [], []
158
+ for m,n in aux:
159
+ r2 = m*m+n*n
160
+ source = np.stack([coeff(np.array([m,n])-r) for r in basic],axis=1)
161
+ receiver = np.stack([coeff(p-np.array([m,n])) for p in basic])
162
+ mu = guide.k0**2*(K[radial.index(r2)]@source)
163
+ nu = -J[:,None]*source # retain (3.29)'s minus sign
164
+ A,B = np.array([-m,-m,n,n]),np.array([n,n,m,m])
165
+ transverse,longitudinal = np.array([-m,-m,n,n]),np.array([n,n,m,m])
166
+ mus.append(mu)
167
+ nus.append(nu)
168
+ # varsigma is integral of Theta* E_(x,y) over EACH receiver layer,
169
+ # before multiplying that layer's receiver xi; axes (layer, x/y, j).
170
+ fields.append(np.stack([(n*mu*A+m*nu*B)/r2,
171
+ (-m*mu*A+n*nu*B)/r2],axis=1))
172
+ cg += f*(receiver@mu)*transverse[:,None]*A[None,:]/r2
173
+ cl += f*(receiver@nu)*longitudinal[:,None]*B[None,:]/r2
174
+ c2 = cg+cl
175
+ total = c1+cr+c2
176
+ if not np.isfinite(total).all():
177
+ raise ValueError('coupling outside finite numeric range')
178
+ return CouplingResult(_pack(C1D=c1,Crad=cr,C2D=c2,C2D_green=cg,C2D_local=cl,
179
+ C=total,I=I,J=J,K=K,xi=xi,xi_orders=orders,
180
+ aux_orders=np.array(aux,dtype=int),
181
+ mu=np.array(mus),nu=np.array(nus),varsigma=np.array(fields)),
182
+ a,b,guide.k0,float(detuning),D,Q,ids,radial,
183
+ bool(all(b*b*r > guide.k0**2*max(guide.eps) for r in radial[1:])),
184
+ fourier_order=F, backend=backend, provenance=provenance,
185
+ sampling_shapes=tuple((l,layers[l].values.shape) for l in ids
186
+ if isinstance(layers[l],SampledPermittivity)))
@@ -0,0 +1,16 @@
1
+ """Finite-domain staggered layout, sparse pencil, and targeted eigensolver."""
2
+ from .grid import StaggeredGrid, WaveFields
3
+ from .assembly import FinitePencil, assemble_pencil
4
+ from .eigensolver import (
5
+ EigenResult, EigenSolveError, FactorizationError, solve_generalized, solve_finite,
6
+ )
7
+ from .fields import FiniteMode, reconstruct_field, reconstruct_mode, reconstruct_modes
8
+ from .tracking import ClusterMatch, ModeTracking, track_modes
9
+
10
+ __all__ = [
11
+ 'StaggeredGrid', 'WaveFields', 'FinitePencil', 'assemble_pencil',
12
+ 'EigenResult', 'EigenSolveError', 'FactorizationError',
13
+ 'solve_generalized', 'solve_finite',
14
+ 'FiniteMode', 'reconstruct_field', 'reconstruct_mode', 'reconstruct_modes',
15
+ 'ClusterMatch', 'ModeTracking', 'track_modes',
16
+ ]