Note

このページは Jupyter Notebook から生成されました。 ノートブックをダウンロード (.ipynb)

ScalarField クラス入門

Open In Colab

このノートブックでは、gwexpyScalarField クラスの基本的な使い方を学びます。

ScalarField とは?

ScalarField は、時間と3次元空間の4次元データを扱うための特殊なクラスです。物理場の時空間構造を表現し、以下の機能を提供します:

  • 軸0(時間軸): 時間ドメイン ↔ 周波数ドメインの変換

  • 軸1-3(空間軸): 実空間 ↔ K空間(波数空間)の変換

  • 4D構造の保持: スライシングしても常に4次元を維持

  • バッチ操作: FieldListFieldDict による複数フィールドの一括処理

  • 信号処理: PSD計算、周波数-空間マッピング、相互相関、コヒーレンスマップ

セットアップ

必要なライブラリをインポートします。

[1]:
# SWIGLAL
import warnings

warnings.filterwarnings("ignore", "Wswiglal-redir-stdio")
warnings.filterwarnings("ignore", category=UserWarning)

import matplotlib.pyplot as plt
import numpy as np
from astropy import units as u

from gwexpy.fields import FieldDict, FieldList, ScalarField

# for Set
np.random.seed(42)

1. ScalarField の初期化とメタデータ

ScalarField オブジェクトを作成し、そのメタデータを確認します。

[2]:
# 4DData Create(10 points × 8×8×8 Space )
nt, nx, ny, nz = 10, 8, 8, 8
data = np.random.randn(nt, nx, ny, nz)

# AxisCoordinates Define
t = np.arange(nt) * 0.1 * u.s
x = np.arange(nx) * 0.5 * u.m
y = np.arange(ny) * 0.5 * u.m
z = np.arange(nz) * 0.5 * u.m

# ScalarField Create
field = ScalarField(
    data,
    unit=u.V,
    axis0=t,
    axis1=x,
    axis2=y,
    axis3=z,
    axis_names=["t", "x", "y", "z"],
    axis0_domain="time",
    space_domain="real",
)

print(f"Shape: {field.shape}")
print(f"Unit: {field.unit}")
print(f"Axis names: {field.axis_names}")
print(f"Axis0 domain: {field.axis0_domain}")
print(f"Space domains: {field.space_domains}")
Shape: (10, 8, 8, 8)
Unit: V
Axis names: ('t', 'x', 'y', 'z')
Axis0 domain: time
Space domains: {'x': 'real', 'y': 'real', 'z': 'real'}

メタデータの確認

  • axis0_domain: 軸0のドメイン(”time” または “frequency”)

  • space_domains: 各空間軸のドメイン(”real” または “k”)

  • axis_names: 各軸の名前

これらのメタデータは、FFT変換時に自動的に更新されます。

2. 4D構造を保持するスライシング

ScalarField の重要な特徴は、スライシングしても常に4次元を維持することです。 整数インデックスを使っても、自動的に長さ1のスライスに変換されます。

[3]:
# Slice( ndarray 3D )
sliced = field[0, :, :, :]

print(f"Original shape: {field.shape}")
print(f"Sliced shape: {sliced.shape}") # (1, 8, 8, 8) - 4D !
print(f"Type: {type(sliced)}") # ScalarField
print(f"Axis names preserved: {sliced.axis_names}")
Original shape: (10, 8, 8, 8)
Sliced shape: (1, 8, 8, 8)
Type: <class 'gwexpy.fields.scalar.ScalarField'>
Axis names preserved: ('t', 'x', 'y', 'z')
[4]:
# Axis
sliced_multi = field[0, 1, 2, 3]

print(f"Multi-sliced shape: {sliced_multi.shape}") # (1, 1, 1, 1) - 4D
print(f"Value: {sliced_multi.value}")
Multi-sliced shape: (1, 1, 1, 1)
Value: [[[[-0.51827022]]]]

この挙動により、ScalarFieldオブジェクトの一貫性が保たれ、メタデータ(軸名やドメイン情報)が失われることがありません。

3. 時間-周波数変換(軸0のFFT)

fft_time()ifft_time() メソッドを使って、時間軸を周波数軸に変換できます。 GWpy の TimeSeries.fft() と同じ正規化を採用しています。

[5]:
# time / duration ScalarField Create(Sine wave)
t_dense = np.arange(128) * 0.01 * u.s
x_small = np.arange(4) * 1.0 * u.m
signal_freq = 10.0 # Hz

# 10 Hz Sine wave Space
data_signal = np.sin(2 * np.pi * signal_freq * t_dense.value)[:, None, None, None]
data_signal = np.tile(data_signal, (1, 4, 4, 4))

field_time = ScalarField(
    data_signal,
    unit=u.V,
    axis0=t_dense,
    axis1=x_small,
    axis2=x_small.copy(),
    axis3=x_small.copy(),
    axis_names=["t", "x", "y", "z"],
    axis0_domain="time",
    space_domain="real",
)

# FFTRun
field_freq = field_time.fft_time()

print(f"Time domain shape: {field_time.shape}")
print(f"Frequency domain shape: {field_freq.shape}")
print(f"Axis0 domain changed: {field_time.axis0_domain}{field_freq.axis0_domain}")
Time domain shape: (128, 4, 4, 4)
Frequency domain shape: (65, 4, 4, 4)
Axis0 domain changed: time → frequency
[6]:
# Frequency Plot(1points x,y,z )
# Note: Slice 4D 、squeeze() Dimension Reduction
spectrum = np.abs(field_freq[:, 0, 0, 0].value).squeeze()
freqs = field_freq._axis0_index.value

plt.figure(figsize=(10, 4))
plt.plot(freqs, spectrum, "b-", linewidth=2)
plt.axvline(
    signal_freq, color="r", linestyle="--", label=f"{signal_freq} Hz (Input signal)"
)
plt.xlabel("Frequency [Hz]")
plt.ylabel("Amplitude [V]")
plt.title("FFT Spectrum (ScalarField)")
plt.xlim(0, 50)
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# PeakFrequency Verify
peak_idx = np.argmax(spectrum)
peak_freq = freqs[peak_idx]
print(f"Peak frequency: {peak_freq:.2f} Hz (expected: {signal_freq} Hz)")
../../../../_images/web_ja_user_guide_tutorials_field_scalar_intro_12_0.png
Peak frequency: 10.16 Hz (expected: 10.0 Hz)

逆FFT(周波数 → 時間)

ifft_time() で元の時間ドメインに戻すことができます。

[7]:
# FFT
field_reconstructed = field_freq.ifft_time()

# Signal Ratiocomparison
# Note: 4D do 、squeeze() 1Dimension
original = field_time[:, 0, 0, 0].value.squeeze()
reconstructed = field_reconstructed[:, 0, 0, 0].value.squeeze()

plt.figure(figsize=(10, 4))
plt.plot(t_dense.value, original, "b-", label="Original", alpha=0.7)
plt.plot(t_dense.value, reconstructed.real, "r--", label="Reconstructed", alpha=0.7)
plt.xlabel("Time [s]")
plt.ylabel("Amplitude [V]")
plt.title("IFFT: Frequency → Time")
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# Error Verify
error = np.abs(original - reconstructed.real)
print(f"Max reconstruction error: {np.max(error):.2e} V")
../../../../_images/web_ja_user_guide_tutorials_field_scalar_intro_14_0.png
Max reconstruction error: 4.44e-16 V

4. 実空間-K空間変換(空間軸のFFT)

fft_space()ifft_space() を使って、空間軸を波数空間(K空間)に変換できます。 角波数 k = 2π / λ の符号付きFFTを使用します。

[8]:
# Space Period havingData Create
nx, ny, nz = 16, 16, 16
x_grid = np.arange(nx) * 0.5 * u.m
y_grid = np.arange(ny) * 0.5 * u.m
z_grid = np.arange(nz) * 0.5 * u.m

# XDirection Wavelength 4m Sine wave
wavelength = 4.0 # m
k_expected = 2 * np.pi / wavelength # rad/m

data_spatial = np.sin(2 * np.pi * x_grid.value / wavelength)[None, :, None, None]
data_spatial = np.tile(data_spatial, (4, 1, ny, nz))

field_real = ScalarField(
    data_spatial,
    unit=u.V,
    axis0=np.arange(4) * 0.1 * u.s,
    axis1=x_grid,
    axis2=y_grid,
    axis3=z_grid,
    axis_names=["t", "x", "y", "z"],
    axis0_domain="time",
    space_domain="real",
)

# XAxis FFT
field_kx = field_real.fft_space(axes=["x"])

print(f"Original space domains: {field_real.space_domains}")
print(f"After fft_space: {field_kx.space_domains}")
print(f"Axis names: {field_kx.axis_names}")
Original space domains: {'x': 'real', 'y': 'real', 'z': 'real'}
After fft_space: {'y': 'real', 'z': 'real', 'kx': 'k'}
Axis names: ('t', 'kx', 'y', 'z')
[9]:
# KSpace Plot
# Note: squeeze() DimensionReduction
kx_spectrum = np.abs(field_kx[0, :, 0, 0].value).squeeze()
kx_values = field_kx._axis1_index.value

plt.figure(figsize=(10, 4))
plt.plot(kx_values, kx_spectrum, "b-", linewidth=2)
plt.axvline(k_expected, color="r", linestyle="--", label=f"k = {k_expected:.2f} rad/m")
plt.axvline(-k_expected, color="r", linestyle="--")
plt.xlabel("Wavenumber kx [rad/m]")
plt.ylabel("Amplitude")
plt.title("Spatial FFT: Real → K space")
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# PeakWavenumber Verify
peak_idx = np.argmax(kx_spectrum)
peak_k = kx_values[peak_idx]
print(f"Peak wavenumber: {peak_k:.2f} rad/m (expected: ±{k_expected:.2f} rad/m)")
../../../../_images/web_ja_user_guide_tutorials_field_scalar_intro_17_0.png
Peak wavenumber: 1.57 rad/m (expected: ±1.57 rad/m)

波長の計算

K空間では、wavelength() メソッドで波長を計算できます。

[10]:
# Wavelength Calculate
wavelengths = field_kx.wavelength("kx")

print(f"Wavelength at k={k_expected:.2f}: {2 * np.pi / k_expected:.2f} m")
print(
    f"Calculated wavelengths range: {wavelengths.value[wavelengths.value > 0].min():.2f} - {wavelengths.value[wavelengths.value < np.inf].max():.2f} m"
)
Wavelength at k=1.57: 4.00 m
Calculated wavelengths range: 1.00 - 8.00 m

全空間軸のFFT

axes パラメータを省略すると、全空間軸をまとめてFFTできます。

[11]:
# SpaceAxis FFT
field_k_all = field_real.fft_space()
print(f"All spatial axes in K space: {field_k_all.space_domains}")

# FFT Restore
field_real_back = field_k_all.ifft_space()
print(f"Back to real space: {field_real_back.space_domains}")

# ReconstructionError
reconstruction_error = np.max(np.abs(field_real.value - field_real_back.value))
print(f"Max reconstruction error: {reconstruction_error:.2e}")
All spatial axes in K space: {'kx': 'k', 'ky': 'k', 'kz': 'k'}
Back to real space: {'x': 'real', 'y': 'real', 'z': 'real'}
Max reconstruction error: 2.29e-16

5. FieldList と FieldDict によるバッチ操作

複数の ScalarField オブジェクトをまとめて処理するには、FieldList または FieldDict を使用します。

FieldList

リスト形式で複数のフィールドを管理し、一括でFFT操作を適用できます。

[12]:
# 3 AmplitudehavingScalarField Create
amplitudes = [1.0, 2.0, 3.0]
fields = []

for amp in amplitudes:
    data_temp = amp * np.random.randn(8, 4, 4, 4)
    field_temp = ScalarField(
        data_temp,
        unit=u.V,
        axis0=np.arange(8) * 0.1 * u.s,
        axis1=np.arange(4) * 0.5 * u.m,
        axis2=np.arange(4) * 0.5 * u.m,
        axis3=np.arange(4) * 0.5 * u.m,
        axis_names=["t", "x", "y", "z"],
        axis0_domain="time",
        space_domain="real",
    )
    fields.append(field_temp)

# FieldList Create
field_list = FieldList(fields, validate=True)
print(f"Number of fields: {len(field_list)}")
Number of fields: 3
[13]:
# Batch time / durationFFT Run
field_list_freq = field_list.fft_time_all()

print("All fields transformed to frequency domain:")
for i, field in enumerate(field_list_freq):
    print(f" Field {i}: axis0_domain = {field.axis0_domain}")
All fields transformed to frequency domain:
 Field 0: axis0_domain = frequency
 Field 1: axis0_domain = frequency
 Field 2: axis0_domain = frequency

FieldDict

辞書形式で名前付きフィールドを管理します。

[14]:
# Named Dictionary Create
field_dict = FieldDict(
    {"channel_A": fields[0], "channel_B": fields[1], "channel_C": fields[2]},
    validate=True,
)

print(f"Field names: {list(field_dict.keys())}")
Field names: ['channel_A', 'channel_B', 'channel_C']
[15]:
# Batch SpaceFFT Run
field_dict_k = field_dict.fft_space_all(axes=["x", "y"])

for name, field in field_dict_k.items():
    print(f"{name}: {field.space_domains}")
channel_A: {'z': 'real', 'kx': 'k', 'ky': 'k'}
channel_B: {'z': 'real', 'kx': 'k', 'ky': 'k'}
channel_C: {'z': 'real', 'kx': 'k', 'ky': 'k'}

6. 信号処理と解析

物理場の解析において重要な信号処理手法として、以下を実演します:

  1. PSD計算: 時間・空間方向のパワースペクトル密度

  2. 周波数-空間マッピング: 空間的に周波数がどう変化するか可視化

  3. 相互相関: 信号の伝搬遅延の推定

  4. コヒーレンスマップ: 空間的なコヒーレンスの可視化

[16]:
# Data Generate
# Signal: 30 Hz Sine wave x=1m fromPropagation
# Noise: GaussianNoise
fs = 256 * u.Hz
duration = 4 * u.s
t = np.arange(int((fs * duration).to_value(u.dimensionless_unscaled))) / fs.value
x = np.linspace(0, 5, 20) # 5m, 20points
y = np.linspace(0, 2, 5) # 2m, 5points
z = np.array([0]) # z=0

shape = (len(t), len(x), len(y), len(z))
data_sig = np.random.normal(0, 0.1, size=shape)

source_freq = 30.0
source_signal = np.sin(2 * np.pi * source_freq * t)
v_prop = 10.0 # 10 m/s
source_pos = 1.0

for i, xi in enumerate(x):
    dist = abs(xi - source_pos)
    delay = dist / v_prop
    shift = int(delay * fs.value)
    sig_delayed = np.roll(source_signal, shift)
    amp = 1.0 / (1.0 + dist)
    data_sig[:, i, :, 0] += amp * sig_delayed[:, np.newaxis]

field_sig = ScalarField(
    data_sig,
    unit=u.V,
    axis0=t * u.s,
    axis1=x * u.m,
    axis2=y * u.m,
    axis3=z * u.m,
    name="Environmental Field",
)
print(field_sig)
ScalarField([[[[ 0.08684844],
               [ 0.36739322],
               [ 0.20264566],
               [ 0.24368612],
               [ 0.29709332]],

              [[-0.42828167],
               [-0.2788319 ],
               [-0.63601112],
               [-0.43725569],
               [-0.45766302]],

              [[-0.50234469],
               [-0.39848957],
               [-0.33841909],
               [-0.30672809],
               [-0.35290641]],

              ...,

              [[-0.24582327],
               [-0.36331307],
               [-0.35576048],
               [-0.30664571],
               [-0.29873314]],

              [[-0.25188483],
               [-0.10460112],
               [-0.20704192],
               [-0.17437447],
               [-0.06699974]],

              [[-0.01857532],
               [ 0.03160235],
               [ 0.09463293],
               [-0.13609984],
               [-0.18043362]]],


             [[[ 0.4965515 ],
               [ 0.51114119],
               [ 0.66836178],
               [ 0.47774925],
               [ 0.54391374]],

              [[ 0.00870382],
               [ 0.05797381],
               [ 0.19497677],
               [-0.1841482 ],
               [ 0.06961271]],

              [[-0.44467682],
               [-0.63003207],
               [-0.4931782 ],
               [-0.72115671],
               [-0.66144024]],

              ...,

              [[-0.26696884],
               [-0.02335534],
               [-0.2955512 ],
               [ 0.03365938],
               [-0.16675011]],

              [[-0.01148699],
               [ 0.02693153],
               [ 0.23230027],
               [-0.12005181],
               [-0.1327343 ]],

              [[ 0.0960591 ],
               [ 0.17476004],
               [-0.00103139],
               [ 0.17377965],
               [ 0.34090044]]],


             [[[ 0.48591277],
               [ 0.44150349],
               [ 0.40895599],
               [ 0.5606232 ],
               [ 0.38818876]],

              [[ 0.59176168],
               [ 0.60045806],
               [ 0.43279717],
               [ 0.40597058],
               [ 0.51018807]],

              [[-0.48896672],
               [-0.67904575],
               [-0.65110209],
               [-0.73068132],
               [-0.55754008]],

              ...,

              [[-0.00889405],
               [ 0.0246277 ],
               [-0.00934052],
               [-0.28479022],
               [ 0.00294349]],

              [[-0.05809312],
               [ 0.21557047],
               [ 0.08898029],
               [ 0.19601207],
               [-0.00748609]],

              [[ 0.31327382],
               [ 0.04965958],
               [ 0.12931156],
               [ 0.09634759],
               [ 0.02072857]]],


             ...,


             [[[-0.47088405],
               [-0.44093954],
               [-0.50808846],
               [-0.53825154],
               [-0.52270623]],

              [[-0.27016794],
               [-0.05885403],
               [-0.03014553],
               [-0.11945106],
               [-0.22574645]],

              [[ 0.67186813],
               [ 0.65758934],
               [ 0.61808806],
               [ 0.68006628],
               [ 0.64916691]],

              ...,

              [[ 0.33427533],
               [ 0.1238872 ],
               [ 0.23921193],
               [ 0.13288922],
               [ 0.17767688]],

              [[-0.09293398],
               [ 0.04599217],
               [-0.04333049],
               [-0.14249861],
               [-0.14613857]],

              [[-0.17523055],
               [-0.08070457],
               [-0.3736138 ],
               [-0.21547623],
               [-0.24905171]]],


             [[[-0.33979978],
               [-0.52516958],
               [-0.43117016],
               [-0.55575497],
               [-0.30922288]],

              [[-0.57172495],
               [-0.36253384],
               [-0.47323134],
               [-0.56822764],
               [-0.41788518]],

              [[ 0.60000133],
               [ 0.69520425],
               [ 0.58067915],
               [ 0.59044121],
               [ 0.60903589]],

              ...,

              [[ 0.09738152],
               [-0.03127321],
               [ 0.10558964],
               [ 0.02092155],
               [ 0.12403775]],

              [[-0.17854237],
               [-0.15159656],
               [-0.13889632],
               [-0.10361625],
               [-0.07109176]],

              [[-0.30326966],
               [-0.18659539],
               [-0.17075845],
               [ 0.12720352],
               [-0.09463939]]],


             [[[-0.13293935],
               [-0.26827271],
               [-0.16340608],
               [ 0.0859768 ],
               [-0.29470039]],

              [[-0.59042514],
               [-0.43746394],
               [-0.34756977],
               [-0.58880771],
               [-0.50722801]],

              [[ 0.19803234],
               [ 0.1693735 ],
               [ 0.13092929],
               [ 0.23941187],
               [ 0.22039804]],

              ...,

              [[-0.17123032],
               [-0.04474158],
               [-0.08665946],
               [-0.00431457],
               [-0.00958998]],

              [[-0.12704775],
               [-0.18034525],
               [-0.24545939],
               [-0.26021225],
               [-0.27972791]],

              [[-0.10539607],
               [ 0.00241909],
               [ 0.01135068],
               [-0.2382848 ],
               [-0.06614197]]]],
            unit: V,
            name: Environmental Field,
            epoch: None,
            channel: None,
            axis_names: ['t', 'x', 'y', 'z'],
            axis0_name: t,
            axis1_name: x,
            axis2_name: y,
            axis3_name: z,
            axis0_index: [0.00000000e+00 3.90625000e-03 7.81250000e-03 ... 3.98828125e+00
                           3.99218750e+00 3.99609375e+00] s,
            axis1_index: [0.         0.26315789 0.52631579 0.78947368 1.05263158
                           1.31578947 1.57894737 1.84210526 2.10526316 2.36842105
                           2.63157895 2.89473684 3.15789474 3.42105263 3.68421053
                           3.94736842 4.21052632 4.47368421 4.73684211 5.        ] m,
            axis2_index: [0.  0.5 1.  1.5 2. ] m,
            axis3_index: [0.] m,
            axis0_domain: time,
            space_domains: {'x': 'real', 'y': 'real', 'z': 'real'},
            axis0_offset: None)

パワースペクトル密度 (PSD)

特定の位置でのPSDを計算し、周波数成分を確認します。

[17]:
# SourcePosition(x=1m) Far(x=4m) PSD Ratiocomparison
psd_source = field_sig.psd(point_or_region=(1.0 * u.m, 0.0 * u.m, 0.0 * u.m))
psd_far = field_sig.psd(point_or_region=(4.0 * u.m, 0.0 * u.m, 0.0 * u.m))

plt.figure(figsize=(10, 6))
psd_source.plot(label="Source (x=1m)")
psd_far.plot(label="Far (x=4m)")
plt.xscale("log")
plt.yscale("log")
plt.title("PSD at Different Locations")
plt.legend()
plt.show()
<Figure size 1000x600 with 0 Axes>
../../../../_images/web_ja_user_guide_tutorials_field_scalar_intro_32_1.png
../../../../_images/web_ja_user_guide_tutorials_field_scalar_intro_32_2.png

周波数-空間マッピング(近日公開)

注意: plot_freq_space() 可視化メソッドは今後のリリースで提供予定です。

現時点では、標準のmatplotlibを使用して周波数-空間データを手動で抽出・プロット可能です:

# 異なる空間位置でのPSDを抽出
# (手動可視化例 - v0.2.0で提供)

このセクションは、可視化APIが実装され次第更新されます。

[18]:
# plot_freq_space() Visualizationmethod v0.2.0
#
# ( ):
# field_sig.plot_freq_space(
# space_axis="x",
# fixed_coords={"y": 0.0 * u.m, "z": 0.0 * u.m},
# freq_range=(10, 100),
# log_scale=True,
# )

print(" : Frequency-SpaceVisualization will be added in a future release")
 : Frequency-SpaceVisualization will be added in a future release

相互相関と遅延推定

基準点(ソース位置)と他の位置との相互相関を計算し、信号の伝搬遅延を可視化します。

[19]:
# plot_cross_correlation() Visualizationmethod v0.2.0
#
# ( ):
# x=1.0m (Source) pointsas
# ref_point = (1.0 * u.m, 0.0 * u.m, 0.0 * u.m)
#
# # CrossCorrelation Calculate Plot
# lags, corrs = field_sig.plot_cross_correlation(
# ref_point=ref_point,
# scan_axis="x",
# fixed_coords={"y": 0.0 * u.m, "z": 0.0 * u.m},
# max_lag=0.5,
# )

print(" : CrossCorrelationVisualization will be added in a future release")
 : CrossCorrelationVisualization will be added in a future release

傾きがV字型になっていることから、信号が x=1.0m から両側に伝搬していることがわかります。

コヒーレンスマップ

特定周波数(ここでは30Hz)における空間的なコヒーレンスを表示します。

[20]:
# plot_coherence_map() Visualizationmethod v0.2.0
#
# ( ):
# field_sig.plot_coherence_map(
# target_freq=30.0,
# ref_point=ref_point,
# scan_axis="x",
# fixed_coords={"y": 0.0 * u.m, "z": 0.0 * u.m},
# )

print(" : CoherenceMapVisualization will be added in a future release")
 : CoherenceMapVisualization will be added in a future release

6. 数値的不変量の検証

FFT変換の正確性を検証するため、往復変換(round-trip)が元のデータを再構成できることを確認します。

[21]:
# time / durationFFT Invariantcheck: ifft_time(fft_time(f)) ≈ f
np.random.seed(42)
data_test = np.random.randn(64, 4, 4, 4)
field_test = ScalarField(
    data_test,
    unit=u.V,
    axis0=np.arange(64) * 0.01 * u.s,
    axis1=np.arange(4) * u.m,
    axis2=np.arange(4) * u.m,
    axis3=np.arange(4) * u.m,
    axis_names=["t", "x", "y", "z"],
    axis0_domain="time",
    space_domain="real",
)

# Round-trip
field_roundtrip = field_test.fft_time().ifft_time()

# Check
max_error_time = np.max(np.abs(field_test.value - field_roundtrip.value.real))
print(f"time / durationFFT Round-trip max error: {max_error_time:.2e}")
assert max_error_time < 1e-10, "Time FFT round-trip failed!"
print("✅ time / durationFFTInvariantcheck: PASSED")
time / durationFFT Round-trip max error: 9.99e-16
✅ time / durationFFTInvariantcheck: PASSED