[phasor] add temporal_phasor and temporal_phasor_scale
This commit is contained in:
parent
318c43d62d
commit
c0b41752e1
6 changed files with 323 additions and 67 deletions
|
|
@ -139,8 +139,8 @@ def main():
|
||||||
print(f'{grid.shape=}')
|
print(f'{grid.shape=}')
|
||||||
|
|
||||||
dt = dx * 0.99 / numpy.sqrt(3)
|
dt = dx * 0.99 / numpy.sqrt(3)
|
||||||
ee = numpy.zeros_like(epsilon, dtype=dtype)
|
ee = numpy.zeros_like(epsilon, dtype=complex)
|
||||||
hh = numpy.zeros_like(epsilon, dtype=dtype)
|
hh = numpy.zeros_like(epsilon, dtype=complex)
|
||||||
|
|
||||||
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
||||||
|
|
||||||
|
|
@ -149,25 +149,25 @@ def main():
|
||||||
[cpml_params(axis=dd, polarity=pp, dt=dt, thickness=pml_thickness, epsilon_eff=n_air ** 2)
|
[cpml_params(axis=dd, polarity=pp, dt=dt, thickness=pml_thickness, epsilon_eff=n_air ** 2)
|
||||||
for pp in (-1, +1)]
|
for pp in (-1, +1)]
|
||||||
for dd in range(3)]
|
for dd in range(3)]
|
||||||
update_E, update_H = updates_with_cpml(cpml_params=pml_params, dt=dt, dxes=dxes, epsilon=epsilon)
|
update_E, update_H = updates_with_cpml(cpml_params=pml_params, dt=dt, dxes=dxes, epsilon=epsilon, dtype=complex)
|
||||||
|
|
||||||
# sample_interval = numpy.floor(1 / (2 * 1 / wl * dt)).astype(int)
|
# sample_interval = numpy.floor(1 / (2 * 1 / wl * dt)).astype(int)
|
||||||
# print(f'Save time interval would be {sample_interval} * dt = {sample_interval * dt:3g}')
|
# print(f'Save time interval would be {sample_interval} * dt = {sample_interval * dt:3g}')
|
||||||
|
|
||||||
|
|
||||||
# Source parameters and function. The pulse normalization is kept outside
|
# Build the pulse directly at the current half-steps and normalize that
|
||||||
# accumulate_phasor(); the helper only performs the Fourier sum.
|
# scalar waveform so its extracted temporal phasor is exactly 1 at omega.
|
||||||
source_phasor, delay = gaussian_packet(wl=wl, dwl=100, dt=dt, turn_on=1e-5)
|
source_phasor, _delay = gaussian_packet(wl=wl, dwl=100, dt=dt, turn_on=1e-5)
|
||||||
aa, cc, ss = source_phasor(numpy.arange(max_t))
|
aa, cc, ss = source_phasor(numpy.arange(max_t) + 0.5)
|
||||||
srca_real = aa * cc
|
source_waveform = aa * (cc + 1j * ss)
|
||||||
src_maxt = numpy.argwhere(numpy.diff(aa < 1e-5))[-1]
|
|
||||||
assert aa[src_maxt - 1] >= 1e-5
|
|
||||||
phasor_norm = dt / (aa * cc * cc).sum()
|
|
||||||
|
|
||||||
Jph = numpy.zeros_like(epsilon, dtype=complex)
|
|
||||||
Jph[1, *(grid.shape // 2)] = epsilon[1, *(grid.shape // 2)]
|
|
||||||
omega = 2 * numpy.pi / wl
|
omega = 2 * numpy.pi / wl
|
||||||
|
pulse_scale = fdtd.temporal_phasor_scale(source_waveform, omega, dt, offset_steps=0.5)[0]
|
||||||
|
|
||||||
|
j_source = numpy.zeros_like(epsilon, dtype=complex)
|
||||||
|
j_source[1, *(grid.shape // 2)] = epsilon[1, *(grid.shape // 2)]
|
||||||
|
jph = numpy.zeros((1, *epsilon.shape), dtype=complex)
|
||||||
eph = numpy.zeros((1, *epsilon.shape), dtype=complex)
|
eph = numpy.zeros((1, *epsilon.shape), dtype=complex)
|
||||||
|
hph = numpy.zeros((1, *epsilon.shape), dtype=complex)
|
||||||
|
|
||||||
# #### Run a bunch of iterations ####
|
# #### Run a bunch of iterations ####
|
||||||
output_file = h5py.File('simulation_output.h5', 'w')
|
output_file = h5py.File('simulation_output.h5', 'w')
|
||||||
|
|
@ -175,10 +175,10 @@ def main():
|
||||||
for tt in range(max_t):
|
for tt in range(max_t):
|
||||||
update_E(ee, hh, epsilon)
|
update_E(ee, hh, epsilon)
|
||||||
|
|
||||||
if tt < src_maxt:
|
# Electric-current injection uses E -= dt * J / epsilon, which is the
|
||||||
# Electric-current injection uses E -= dt * J / epsilon, which is
|
# same sign convention used by the matching FDFD right-hand side.
|
||||||
# the same sign convention used by the matching FDFD right-hand side.
|
j_step = pulse_scale * source_waveform[tt] * j_source
|
||||||
ee[1, *(grid.shape // 2)] -= srca_real[tt]
|
ee -= dt * j_step / epsilon
|
||||||
update_H(ee, hh)
|
update_H(ee, hh)
|
||||||
|
|
||||||
avg_rate = (tt + 1) / (time.perf_counter() - start)
|
avg_rate = (tt + 1) / (time.perf_counter() - start)
|
||||||
|
|
@ -186,7 +186,7 @@ def main():
|
||||||
|
|
||||||
if tt % 200 == 0:
|
if tt % 200 == 0:
|
||||||
print(f'iteration {tt}: average {avg_rate} iterations per sec')
|
print(f'iteration {tt}: average {avg_rate} iterations per sec')
|
||||||
E_energy_sum = (ee * ee * epsilon).sum()
|
E_energy_sum = (ee.conj() * ee * epsilon).sum().real
|
||||||
print(f'{E_energy_sum=}')
|
print(f'{E_energy_sum=}')
|
||||||
|
|
||||||
# Save field slices
|
# Save field slices
|
||||||
|
|
@ -194,21 +194,12 @@ def main():
|
||||||
print(f'saving E-field at iteration {tt}')
|
print(f'saving E-field at iteration {tt}')
|
||||||
output_file[f'/E_t{tt}'] = ee[:, :, :, ee.shape[3] // 2]
|
output_file[f'/E_t{tt}'] = ee[:, :, :, ee.shape[3] // 2]
|
||||||
|
|
||||||
fdtd.accumulate_phasor(
|
fdtd.accumulate_phasor_j(jph, omega, dt, j_step, tt)
|
||||||
eph,
|
fdtd.accumulate_phasor_e(eph, omega, dt, ee, tt + 1)
|
||||||
omega,
|
fdtd.accumulate_phasor_h(hph, omega, dt, hh, tt + 1)
|
||||||
dt,
|
|
||||||
ee,
|
|
||||||
tt,
|
|
||||||
# The pulse is delayed relative to t=0, so the extracted phasor
|
|
||||||
# needs the same phase offset in its sample times.
|
|
||||||
offset_steps=0.5 - delay / dt,
|
|
||||||
# accumulate_phasor() already multiplies by dt, so pass the
|
|
||||||
# discrete-sum normalization without its extra dt factor.
|
|
||||||
weight=phasor_norm / dt,
|
|
||||||
)
|
|
||||||
|
|
||||||
Eph = eph[0]
|
Eph = eph[0]
|
||||||
|
Jph = jph[0]
|
||||||
b = -1j * omega * Jph
|
b = -1j * omega * Jph
|
||||||
dxes_fdfd = copy.deepcopy(dxes)
|
dxes_fdfd = copy.deepcopy(dxes)
|
||||||
for pp in (-1, +1):
|
for pp in (-1, +1):
|
||||||
|
|
|
||||||
|
|
@ -266,8 +266,8 @@ def main2():
|
||||||
print(f'{grid.shape=}')
|
print(f'{grid.shape=}')
|
||||||
|
|
||||||
dt = dx * 0.99 / numpy.sqrt(3)
|
dt = dx * 0.99 / numpy.sqrt(3)
|
||||||
ee = numpy.zeros_like(epsilon, dtype=dtype)
|
ee = numpy.zeros_like(epsilon, dtype=complex)
|
||||||
hh = numpy.zeros_like(epsilon, dtype=dtype)
|
hh = numpy.zeros_like(epsilon, dtype=complex)
|
||||||
|
|
||||||
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
dxes = [grid.dxyz, grid.autoshifted_dxyz()]
|
||||||
|
|
||||||
|
|
@ -276,25 +276,25 @@ def main2():
|
||||||
[cpml_params(axis=dd, polarity=pp, dt=dt, thickness=pml_thickness, epsilon_eff=n_cladding ** 2)
|
[cpml_params(axis=dd, polarity=pp, dt=dt, thickness=pml_thickness, epsilon_eff=n_cladding ** 2)
|
||||||
for pp in (-1, +1)]
|
for pp in (-1, +1)]
|
||||||
for dd in range(3)]
|
for dd in range(3)]
|
||||||
update_E, update_H = updates_with_cpml(cpml_params=pml_params, dt=dt, dxes=dxes, epsilon=epsilon)
|
update_E, update_H = updates_with_cpml(cpml_params=pml_params, dt=dt, dxes=dxes, epsilon=epsilon, dtype=complex)
|
||||||
|
|
||||||
# sample_interval = numpy.floor(1 / (2 * 1 / wl * dt)).astype(int)
|
# sample_interval = numpy.floor(1 / (2 * 1 / wl * dt)).astype(int)
|
||||||
# print(f'Save time interval would be {sample_interval} * dt = {sample_interval * dt:3g}')
|
# print(f'Save time interval would be {sample_interval} * dt = {sample_interval * dt:3g}')
|
||||||
|
|
||||||
|
|
||||||
# Source parameters and function. The phasor helper only performs the
|
# Sample the pulse at the current half-steps and normalize that scalar
|
||||||
# Fourier accumulation; the pulse normalization stays explicit here.
|
# waveform so the extracted temporal phasor is exactly 1 at omega.
|
||||||
source_phasor, delay = gaussian_packet(wl=wl, dwl=100, dt=dt, turn_on=1e-5)
|
source_phasor, _delay = gaussian_packet(wl=wl, dwl=100, dt=dt, turn_on=1e-5)
|
||||||
aa, cc, ss = source_phasor(numpy.arange(max_t))
|
aa, cc, ss = source_phasor(numpy.arange(max_t) + 0.5)
|
||||||
srca_real = aa * cc
|
source_waveform = aa * (cc + 1j * ss)
|
||||||
src_maxt = numpy.argwhere(numpy.diff(aa < 1e-5))[-1]
|
|
||||||
assert aa[src_maxt - 1] >= 1e-5
|
|
||||||
phasor_norm = dt / (aa * cc * cc).sum()
|
|
||||||
|
|
||||||
Jph = numpy.zeros_like(epsilon, dtype=complex)
|
|
||||||
Jph[1, *(grid.shape // 2)] = epsilon[1, *(grid.shape // 2)]
|
|
||||||
omega = 2 * numpy.pi / wl
|
omega = 2 * numpy.pi / wl
|
||||||
|
pulse_scale = fdtd.temporal_phasor_scale(source_waveform, omega, dt, offset_steps=0.5)[0]
|
||||||
|
|
||||||
|
j_source = numpy.zeros_like(epsilon, dtype=complex)
|
||||||
|
j_source[1, *(grid.shape // 2)] = epsilon[1, *(grid.shape // 2)]
|
||||||
|
jph = numpy.zeros((1, *epsilon.shape), dtype=complex)
|
||||||
eph = numpy.zeros((1, *epsilon.shape), dtype=complex)
|
eph = numpy.zeros((1, *epsilon.shape), dtype=complex)
|
||||||
|
hph = numpy.zeros((1, *epsilon.shape), dtype=complex)
|
||||||
|
|
||||||
# #### Run a bunch of iterations ####
|
# #### Run a bunch of iterations ####
|
||||||
output_file = h5py.File('simulation_output.h5', 'w')
|
output_file = h5py.File('simulation_output.h5', 'w')
|
||||||
|
|
@ -302,17 +302,17 @@ def main2():
|
||||||
for tt in range(max_t):
|
for tt in range(max_t):
|
||||||
update_E(ee, hh, epsilon)
|
update_E(ee, hh, epsilon)
|
||||||
|
|
||||||
if tt < src_maxt:
|
# Electric-current injection uses E -= dt * J / epsilon, which is the
|
||||||
# Electric-current injection uses E -= dt * J / epsilon, which is
|
# sign convention matched by the FDFD source term -1j * omega * J.
|
||||||
# the sign convention matched by the FDFD source term -1j * omega * J.
|
j_step = pulse_scale * source_waveform[tt] * j_source
|
||||||
ee[1, *(grid.shape // 2)] -= srca_real[tt]
|
ee -= dt * j_step / epsilon
|
||||||
update_H(ee, hh)
|
update_H(ee, hh)
|
||||||
|
|
||||||
avg_rate = (tt + 1) / (time.perf_counter() - start)
|
avg_rate = (tt + 1) / (time.perf_counter() - start)
|
||||||
|
|
||||||
if tt % 200 == 0:
|
if tt % 200 == 0:
|
||||||
print(f'iteration {tt}: average {avg_rate} iterations per sec')
|
print(f'iteration {tt}: average {avg_rate} iterations per sec')
|
||||||
E_energy_sum = (ee * ee * epsilon).sum()
|
E_energy_sum = (ee.conj() * ee * epsilon).sum().real
|
||||||
print(f'{E_energy_sum=}')
|
print(f'{E_energy_sum=}')
|
||||||
|
|
||||||
# Save field slices
|
# Save field slices
|
||||||
|
|
@ -320,21 +320,12 @@ def main2():
|
||||||
print(f'saving E-field at iteration {tt}')
|
print(f'saving E-field at iteration {tt}')
|
||||||
output_file[f'/E_t{tt}'] = ee[:, :, :, ee.shape[3] // 2]
|
output_file[f'/E_t{tt}'] = ee[:, :, :, ee.shape[3] // 2]
|
||||||
|
|
||||||
fdtd.accumulate_phasor(
|
fdtd.accumulate_phasor_j(jph, omega, dt, j_step, tt)
|
||||||
eph,
|
fdtd.accumulate_phasor_e(eph, omega, dt, ee, tt + 1)
|
||||||
omega,
|
fdtd.accumulate_phasor_h(hph, omega, dt, hh, tt + 1)
|
||||||
dt,
|
|
||||||
ee,
|
|
||||||
tt,
|
|
||||||
# The pulse is delayed relative to t=0, so the extracted phasor must
|
|
||||||
# apply the same delay to its sample times.
|
|
||||||
offset_steps=0.5 - delay / dt,
|
|
||||||
# accumulate_phasor() already contributes dt, so remove the extra dt
|
|
||||||
# from the externally computed normalization.
|
|
||||||
weight=phasor_norm / dt,
|
|
||||||
)
|
|
||||||
|
|
||||||
Eph = eph[0]
|
Eph = eph[0]
|
||||||
|
Jph = jph[0]
|
||||||
b = -1j * omega * Jph
|
b = -1j * omega * Jph
|
||||||
dxes_fdfd = copy.deepcopy(dxes)
|
dxes_fdfd = copy.deepcopy(dxes)
|
||||||
for pp in (-1, +1):
|
for pp in (-1, +1):
|
||||||
|
|
|
||||||
|
|
@ -146,8 +146,11 @@ of the time-domain fields.
|
||||||
|
|
||||||
`accumulate_phasor` in `meanas.fdtd.phasor` performs the phase accumulation for one
|
`accumulate_phasor` in `meanas.fdtd.phasor` performs the phase accumulation for one
|
||||||
or more target frequencies, while leaving source normalization and simulation-loop
|
or more target frequencies, while leaving source normalization and simulation-loop
|
||||||
policy to the caller. Convenience wrappers `accumulate_phasor_e`,
|
policy to the caller. `temporal_phasor(...)` and `temporal_phasor_scale(...)`
|
||||||
`accumulate_phasor_h`, and `accumulate_phasor_j` apply the usual Yee time offsets.
|
apply the same Fourier sum to a scalar waveform, which is useful when a pulsed
|
||||||
|
source envelope must be normalized before being applied to a point source or
|
||||||
|
mode source. Convenience wrappers `accumulate_phasor_e`, `accumulate_phasor_h`,
|
||||||
|
and `accumulate_phasor_j` apply the usual Yee time offsets.
|
||||||
The helpers accumulate
|
The helpers accumulate
|
||||||
|
|
||||||
$$ \Delta_t \sum_l w_l e^{-i \omega t_l} f_l $$
|
$$ \Delta_t \sum_l w_l e^{-i \omega t_l} f_l $$
|
||||||
|
|
@ -232,4 +235,6 @@ from .phasor import (
|
||||||
accumulate_phasor_e as accumulate_phasor_e,
|
accumulate_phasor_e as accumulate_phasor_e,
|
||||||
accumulate_phasor_h as accumulate_phasor_h,
|
accumulate_phasor_h as accumulate_phasor_h,
|
||||||
accumulate_phasor_j as accumulate_phasor_j,
|
accumulate_phasor_j as accumulate_phasor_j,
|
||||||
|
temporal_phasor as temporal_phasor,
|
||||||
|
temporal_phasor_scale as temporal_phasor_scale,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ The usual Yee offsets are:
|
||||||
- `accumulate_phasor_h(..., step=l)` for `H_{l + 1/2}`
|
- `accumulate_phasor_h(..., step=l)` for `H_{l + 1/2}`
|
||||||
- `accumulate_phasor_j(..., step=l)` for `J_{l + 1/2}`
|
- `accumulate_phasor_j(..., step=l)` for `J_{l + 1/2}`
|
||||||
|
|
||||||
|
`temporal_phasor(...)` and `temporal_phasor_scale(...)` apply the same Fourier
|
||||||
|
sum to a 1D scalar waveform. They are useful for normalizing a pulsed source
|
||||||
|
before that scalar waveform is applied to a point source or spatial mode source.
|
||||||
|
|
||||||
These helpers do not choose warmup/accumulation windows or pulse-envelope
|
These helpers do not choose warmup/accumulation windows or pulse-envelope
|
||||||
normalization. They also do not impose a current sign convention. In this
|
normalization. They also do not impose a current sign convention. In this
|
||||||
codebase, electric-current injection normally appears as `E -= dt * J / epsilon`,
|
codebase, electric-current injection normally appears as `E -= dt * J / epsilon`,
|
||||||
|
|
@ -46,6 +50,15 @@ def _normalize_weight(
|
||||||
raise ValueError(f'weight must be scalar or have shape {omega_shape}, got {weight_array.shape}')
|
raise ValueError(f'weight must be scalar or have shape {omega_shape}, got {weight_array.shape}')
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_temporal_samples(
|
||||||
|
samples: ArrayLike,
|
||||||
|
) -> NDArray[numpy.complexfloating]:
|
||||||
|
sample_array = numpy.asarray(samples, dtype=complex)
|
||||||
|
if sample_array.ndim != 1 or sample_array.size == 0:
|
||||||
|
raise ValueError('samples must be a non-empty 1D sequence')
|
||||||
|
return sample_array
|
||||||
|
|
||||||
|
|
||||||
def accumulate_phasor(
|
def accumulate_phasor(
|
||||||
accumulator: NDArray[numpy.complexfloating],
|
accumulator: NDArray[numpy.complexfloating],
|
||||||
omegas: float | complex | Sequence[float | complex] | NDArray,
|
omegas: float | complex | Sequence[float | complex] | NDArray,
|
||||||
|
|
@ -87,6 +100,59 @@ def accumulate_phasor(
|
||||||
return accumulator
|
return accumulator
|
||||||
|
|
||||||
|
|
||||||
|
def temporal_phasor(
|
||||||
|
samples: ArrayLike,
|
||||||
|
omegas: float | complex | Sequence[float | complex] | NDArray,
|
||||||
|
dt: float,
|
||||||
|
*,
|
||||||
|
start_step: int = 0,
|
||||||
|
offset_steps: float = 0.0,
|
||||||
|
) -> NDArray[numpy.complexfloating]:
|
||||||
|
"""
|
||||||
|
Fourier-project a 1D temporal waveform onto one or more angular frequencies.
|
||||||
|
|
||||||
|
The returned quantity is
|
||||||
|
|
||||||
|
dt * sum(exp(-1j * omega * t_step) * samples[step_index])
|
||||||
|
|
||||||
|
where `t_step = (start_step + step_index + offset_steps) * dt`.
|
||||||
|
"""
|
||||||
|
if dt <= 0:
|
||||||
|
raise ValueError('dt must be positive')
|
||||||
|
|
||||||
|
omega_array = _normalize_omegas(omegas)
|
||||||
|
sample_array = _normalize_temporal_samples(samples)
|
||||||
|
steps = start_step + numpy.arange(sample_array.size, dtype=float) + offset_steps
|
||||||
|
phase = numpy.exp(-1j * omega_array[:, None] * (steps[None, :] * dt))
|
||||||
|
return dt * (phase @ sample_array)
|
||||||
|
|
||||||
|
|
||||||
|
def temporal_phasor_scale(
|
||||||
|
samples: ArrayLike,
|
||||||
|
omegas: float | complex | Sequence[float | complex] | NDArray,
|
||||||
|
dt: float,
|
||||||
|
*,
|
||||||
|
start_step: int = 0,
|
||||||
|
offset_steps: float = 0.0,
|
||||||
|
target: ArrayLike = 1.0,
|
||||||
|
) -> NDArray[numpy.complexfloating]:
|
||||||
|
"""
|
||||||
|
Return the scalar multiplier that gives a desired temporal phasor response.
|
||||||
|
|
||||||
|
The returned scale satisfies
|
||||||
|
|
||||||
|
temporal_phasor(scale * samples, omegas, dt, ...) == target
|
||||||
|
|
||||||
|
for each target frequency. The result keeps a leading frequency axis even
|
||||||
|
when `omegas` is scalar.
|
||||||
|
"""
|
||||||
|
response = temporal_phasor(samples, omegas, dt, start_step=start_step, offset_steps=offset_steps)
|
||||||
|
target_array = _normalize_weight(target, response.shape)
|
||||||
|
if numpy.any(numpy.abs(response) <= numpy.finfo(float).eps):
|
||||||
|
raise ValueError('cannot normalize a waveform with zero temporal phasor response')
|
||||||
|
return target_array / response
|
||||||
|
|
||||||
|
|
||||||
def accumulate_phasor_e(
|
def accumulate_phasor_e(
|
||||||
accumulator: NDArray[numpy.complexfloating],
|
accumulator: NDArray[numpy.complexfloating],
|
||||||
omegas: float | complex | Sequence[float | complex] | NDArray,
|
omegas: float | complex | Sequence[float | complex] | NDArray,
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,33 @@ def test_phasor_accumulator_matches_delayed_weighted_example_pattern() -> None:
|
||||||
assert_close(accumulator[0], expected)
|
assert_close(accumulator[0], expected)
|
||||||
|
|
||||||
|
|
||||||
def test_phasor_accumulator_validation_reset_and_squeeze() -> None:
|
def test_temporal_phasor_matches_direct_sum_for_offset_waveform() -> None:
|
||||||
|
omegas = numpy.array([0.75, 1.25])
|
||||||
|
dt = 0.2
|
||||||
|
samples = numpy.array([1.0 + 0.5j, 0.5 - 0.25j, -0.75 + 0.1j])
|
||||||
|
|
||||||
|
result = fdtd.temporal_phasor(samples, omegas, dt, start_step=2, offset_steps=0.5)
|
||||||
|
|
||||||
|
expected = numpy.zeros(omegas.shape, dtype=complex)
|
||||||
|
for idx, omega in enumerate(omegas):
|
||||||
|
for step, sample in enumerate(samples, start=2):
|
||||||
|
expected[idx] += dt * numpy.exp(-1j * omega * ((step + 0.5) * dt)) * sample
|
||||||
|
|
||||||
|
assert_close(result, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_temporal_phasor_scale_normalizes_waveform_to_target_response() -> None:
|
||||||
|
omega = 0.75
|
||||||
|
dt = 0.2
|
||||||
|
delay = 0.6
|
||||||
|
samples = numpy.arange(5, dtype=float) + 1.0
|
||||||
|
scale = fdtd.temporal_phasor_scale(samples, omega, dt, offset_steps=0.5 - delay / dt, target=0.5)
|
||||||
|
normalized = fdtd.temporal_phasor(scale[0] * samples, omega, dt, offset_steps=0.5 - delay / dt)
|
||||||
|
|
||||||
|
assert_close(normalized[0], 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_phasor_accumulator_validation_reset_and_temporal_validation() -> None:
|
||||||
with pytest.raises(ValueError, match='dt must be positive'):
|
with pytest.raises(ValueError, match='dt must be positive'):
|
||||||
fdtd.accumulate_phasor(numpy.zeros((1, 2, 2), dtype=complex), [1.0], 0.0, numpy.ones((2, 2)), 0)
|
fdtd.accumulate_phasor(numpy.zeros((1, 2, 2), dtype=complex), [1.0], 0.0, numpy.ones((2, 2)), 0)
|
||||||
|
|
||||||
|
|
@ -109,6 +135,12 @@ def test_phasor_accumulator_validation_reset_and_squeeze() -> None:
|
||||||
accumulator.fill(0)
|
accumulator.fill(0)
|
||||||
assert_close(accumulator, 0.0)
|
assert_close(accumulator, 0.0)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='samples must be a non-empty 1D sequence'):
|
||||||
|
fdtd.temporal_phasor(numpy.ones((2, 2)), [1.0], 0.2)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='cannot normalize a waveform with zero temporal phasor response'):
|
||||||
|
fdtd.temporal_phasor_scale(numpy.zeros(4), [1.0], 0.2)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def _continuous_wave_case() -> ContinuousWaveCase:
|
def _continuous_wave_case() -> ContinuousWaveCase:
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from functools import lru_cache
|
||||||
import numpy
|
import numpy
|
||||||
|
|
||||||
from .. import fdfd, fdtd
|
from .. import fdfd, fdtd
|
||||||
|
from ..fdtd.misc import gaussian_packet
|
||||||
from ..fdmath import vec, unvec
|
from ..fdmath import vec, unvec
|
||||||
from ..fdfd import functional, scpml, waveguide_3d
|
from ..fdfd import functional, scpml, waveguide_3d
|
||||||
|
|
||||||
|
|
@ -11,6 +12,8 @@ from ..fdfd import functional, scpml, waveguide_3d
|
||||||
DT = 0.25
|
DT = 0.25
|
||||||
PERIOD_STEPS = 64
|
PERIOD_STEPS = 64
|
||||||
OMEGA = 2 * numpy.pi / (PERIOD_STEPS * DT)
|
OMEGA = 2 * numpy.pi / (PERIOD_STEPS * DT)
|
||||||
|
WAVELENGTH = 2 * numpy.pi / OMEGA
|
||||||
|
PULSE_DWL = 4.0
|
||||||
CPML_THICKNESS = 3
|
CPML_THICKNESS = 3
|
||||||
WARMUP_PERIODS = 9
|
WARMUP_PERIODS = 9
|
||||||
ACCUMULATION_PERIODS = 9
|
ACCUMULATION_PERIODS = 9
|
||||||
|
|
@ -94,6 +97,40 @@ class WaveguideScatteringResult:
|
||||||
return float(abs(self.transmitted_flux_td - self.transmitted_flux_fd) / abs(self.transmitted_flux_fd))
|
return float(abs(self.transmitted_flux_td - self.transmitted_flux_fd) / abs(self.transmitted_flux_fd))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class PulsedWaveguideCalibrationResult:
|
||||||
|
e_ph: numpy.ndarray
|
||||||
|
h_ph: numpy.ndarray
|
||||||
|
j_ph: numpy.ndarray
|
||||||
|
j_target: numpy.ndarray
|
||||||
|
e_fdfd: numpy.ndarray
|
||||||
|
h_fdfd: numpy.ndarray
|
||||||
|
overlap_td: complex
|
||||||
|
overlap_fd: complex
|
||||||
|
flux_td: float
|
||||||
|
flux_fd: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def j_rel_err(self) -> float:
|
||||||
|
return float(numpy.linalg.norm(vec(self.j_ph - self.j_target)) / numpy.linalg.norm(vec(self.j_target)))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def overlap_rel_err(self) -> float:
|
||||||
|
return float(abs(self.overlap_td - self.overlap_fd) / abs(self.overlap_fd))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def overlap_mag_rel_err(self) -> float:
|
||||||
|
return float(abs(abs(self.overlap_td) - abs(self.overlap_fd)) / abs(self.overlap_fd))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def overlap_phase_deg(self) -> float:
|
||||||
|
return float(abs(numpy.degrees(numpy.angle(self.overlap_td / self.overlap_fd))))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def flux_rel_err(self) -> float:
|
||||||
|
return float(abs(self.flux_td - self.flux_fd) / abs(self.flux_fd))
|
||||||
|
|
||||||
|
|
||||||
def _build_uniform_dxes(shape: tuple[int, int, int, int]) -> list[list[numpy.ndarray]]:
|
def _build_uniform_dxes(shape: tuple[int, int, int, int]) -> list[list[numpy.ndarray]]:
|
||||||
return [[numpy.ones(shape[axis + 1]) for axis in range(3)] for _ in range(2)]
|
return [[numpy.ones(shape[axis + 1]) for axis in range(3)] for _ in range(2)]
|
||||||
|
|
||||||
|
|
@ -142,6 +179,14 @@ def _build_cpml_params() -> list[list[dict[str, numpy.ndarray | float]]]:
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_complex_pulse_waveform(total_steps: int) -> tuple[numpy.ndarray, complex]:
|
||||||
|
source_phasor, _delay = gaussian_packet(wl=WAVELENGTH, dwl=PULSE_DWL, dt=DT, turn_on=1e-5)
|
||||||
|
aa, cc, ss = source_phasor(numpy.arange(total_steps) + 0.5)
|
||||||
|
waveform = aa * (cc + 1j * ss)
|
||||||
|
scale = fdtd.temporal_phasor_scale(waveform, OMEGA, DT, offset_steps=0.5)[0]
|
||||||
|
return waveform, scale
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=2)
|
@lru_cache(maxsize=2)
|
||||||
def _run_straight_waveguide_case(variant: str) -> WaveguideCalibrationResult:
|
def _run_straight_waveguide_case(variant: str) -> WaveguideCalibrationResult:
|
||||||
assert variant in ('stretched', 'base')
|
assert variant in ('stretched', 'base')
|
||||||
|
|
@ -386,6 +431,112 @@ def _run_width_step_scattering_case() -> WaveguideScatteringResult:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _run_pulsed_straight_waveguide_case() -> PulsedWaveguideCalibrationResult:
|
||||||
|
epsilon = _build_epsilon()
|
||||||
|
base_dxes = _build_base_dxes()
|
||||||
|
stretched_dxes = _build_stretched_dxes(base_dxes)
|
||||||
|
|
||||||
|
source_mode = waveguide_3d.solve_mode(
|
||||||
|
0,
|
||||||
|
omega=OMEGA,
|
||||||
|
dxes=base_dxes,
|
||||||
|
axis=0,
|
||||||
|
polarity=1,
|
||||||
|
slices=SOURCE_SLICES,
|
||||||
|
epsilon=epsilon,
|
||||||
|
)
|
||||||
|
j_mode = waveguide_3d.compute_source(
|
||||||
|
E=source_mode['E'],
|
||||||
|
wavenumber=source_mode['wavenumber'],
|
||||||
|
omega=OMEGA,
|
||||||
|
dxes=base_dxes,
|
||||||
|
axis=0,
|
||||||
|
polarity=1,
|
||||||
|
slices=SOURCE_SLICES,
|
||||||
|
epsilon=epsilon,
|
||||||
|
)
|
||||||
|
monitor_mode = waveguide_3d.solve_mode(
|
||||||
|
0,
|
||||||
|
omega=OMEGA,
|
||||||
|
dxes=base_dxes,
|
||||||
|
axis=0,
|
||||||
|
polarity=1,
|
||||||
|
slices=MONITOR_SLICES,
|
||||||
|
epsilon=epsilon,
|
||||||
|
)
|
||||||
|
overlap_e = waveguide_3d.compute_overlap_e(
|
||||||
|
E=monitor_mode['E'],
|
||||||
|
wavenumber=monitor_mode['wavenumber'],
|
||||||
|
dxes=base_dxes,
|
||||||
|
axis=0,
|
||||||
|
polarity=1,
|
||||||
|
slices=MONITOR_SLICES,
|
||||||
|
omega=OMEGA,
|
||||||
|
)
|
||||||
|
|
||||||
|
update_e, update_h = fdtd.updates_with_cpml(cpml_params=_build_cpml_params(), dt=DT, dxes=base_dxes, epsilon=epsilon, dtype=complex)
|
||||||
|
|
||||||
|
e_field = numpy.zeros_like(epsilon, dtype=complex)
|
||||||
|
h_field = numpy.zeros_like(epsilon, dtype=complex)
|
||||||
|
e_accumulator = numpy.zeros((1, *SHAPE), dtype=complex)
|
||||||
|
h_accumulator = numpy.zeros((1, *SHAPE), dtype=complex)
|
||||||
|
j_accumulator = numpy.zeros((1, *SHAPE), dtype=complex)
|
||||||
|
|
||||||
|
total_steps = (WARMUP_PERIODS + ACCUMULATION_PERIODS) * PERIOD_STEPS
|
||||||
|
waveform, pulse_scale = _build_complex_pulse_waveform(total_steps)
|
||||||
|
|
||||||
|
for step in range(total_steps):
|
||||||
|
update_e(e_field, h_field, epsilon)
|
||||||
|
|
||||||
|
j_step = pulse_scale * waveform[step] * j_mode
|
||||||
|
e_field -= DT * j_step / epsilon
|
||||||
|
|
||||||
|
fdtd.accumulate_phasor_j(j_accumulator, OMEGA, DT, j_step, step)
|
||||||
|
fdtd.accumulate_phasor_e(e_accumulator, OMEGA, DT, e_field, step + 1)
|
||||||
|
|
||||||
|
update_h(e_field, h_field)
|
||||||
|
|
||||||
|
fdtd.accumulate_phasor_h(h_accumulator, OMEGA, DT, h_field, step + 1)
|
||||||
|
|
||||||
|
e_ph = e_accumulator[0]
|
||||||
|
h_ph = h_accumulator[0]
|
||||||
|
j_ph = j_accumulator[0]
|
||||||
|
|
||||||
|
e_fdfd = unvec(
|
||||||
|
fdfd.solvers.generic(
|
||||||
|
J=vec(j_ph),
|
||||||
|
omega=OMEGA,
|
||||||
|
dxes=stretched_dxes,
|
||||||
|
epsilon=vec(epsilon),
|
||||||
|
matrix_solver_opts={'atol': 1e-10, 'rtol': 1e-7},
|
||||||
|
),
|
||||||
|
SHAPE[1:],
|
||||||
|
)
|
||||||
|
h_fdfd = functional.e2h(OMEGA, stretched_dxes)(e_fdfd)
|
||||||
|
|
||||||
|
overlap_td = vec(e_ph) @ vec(overlap_e).conj()
|
||||||
|
overlap_fd = vec(e_fdfd) @ vec(overlap_e).conj()
|
||||||
|
|
||||||
|
poynting_td = functional.poynting_e_cross_h(stretched_dxes)(e_ph, h_ph.conj())
|
||||||
|
poynting_fd = functional.poynting_e_cross_h(stretched_dxes)(e_fdfd, h_fdfd.conj())
|
||||||
|
flux_td = float(0.5 * poynting_td[0, MONITOR_SLICES[0], :, :].real.sum())
|
||||||
|
flux_fd = float(0.5 * poynting_fd[0, MONITOR_SLICES[0], :, :].real.sum())
|
||||||
|
|
||||||
|
return PulsedWaveguideCalibrationResult(
|
||||||
|
e_ph=e_ph,
|
||||||
|
h_ph=h_ph,
|
||||||
|
j_ph=j_ph,
|
||||||
|
j_target=j_mode,
|
||||||
|
e_fdfd=e_fdfd,
|
||||||
|
h_fdfd=h_fdfd,
|
||||||
|
overlap_td=overlap_td,
|
||||||
|
overlap_fd=overlap_fd,
|
||||||
|
flux_td=flux_td,
|
||||||
|
flux_fd=flux_fd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_straight_waveguide_base_variant_outperforms_stretched_variant() -> None:
|
def test_straight_waveguide_base_variant_outperforms_stretched_variant() -> None:
|
||||||
base_result = _run_straight_waveguide_case('base')
|
base_result = _run_straight_waveguide_case('base')
|
||||||
stretched_result = _run_straight_waveguide_case('stretched')
|
stretched_result = _run_straight_waveguide_case('stretched')
|
||||||
|
|
@ -434,3 +585,23 @@ def test_width_step_waveguide_fdtd_fdfd_modal_powers_and_flux_agree() -> None:
|
||||||
assert result.reflected_overlap_mag_rel_err < 0.03
|
assert result.reflected_overlap_mag_rel_err < 0.03
|
||||||
assert result.transmitted_flux_rel_err < 0.01
|
assert result.transmitted_flux_rel_err < 0.01
|
||||||
assert result.reflected_flux_rel_err < 0.01
|
assert result.reflected_flux_rel_err < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_pulsed_straight_waveguide_fdtd_fdfd_overlap_flux_and_source_agree() -> None:
|
||||||
|
result = _run_pulsed_straight_waveguide_case()
|
||||||
|
|
||||||
|
assert numpy.isfinite(result.e_ph).all()
|
||||||
|
assert numpy.isfinite(result.h_ph).all()
|
||||||
|
assert numpy.isfinite(result.j_ph).all()
|
||||||
|
assert numpy.isfinite(result.e_fdfd).all()
|
||||||
|
assert numpy.isfinite(result.h_fdfd).all()
|
||||||
|
assert abs(result.overlap_td) > 0
|
||||||
|
assert abs(result.overlap_fd) > 0
|
||||||
|
assert abs(result.flux_td) > 0
|
||||||
|
assert abs(result.flux_fd) > 0
|
||||||
|
|
||||||
|
assert result.j_rel_err < 1e-9
|
||||||
|
assert result.overlap_mag_rel_err < 0.01
|
||||||
|
assert result.flux_rel_err < 0.03
|
||||||
|
assert result.overlap_rel_err < 0.01
|
||||||
|
assert result.overlap_phase_deg < 0.5
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue