2025, Oct 20 07:18

Гауссов пучок и метод углового спектра: как выбрать шаг по z

Разбор ошибки в методе углового спектра на БПФ: слишком малый шаг по z накапливает численную погрешность. Показаны пример кода, метрика и оптимальный выбор dz.

Распространение углового спектра на основе БПФ — надежный способ эволюции поперечного оптического поля на расстоянии, но он может подвести, когда сетка, края и величина шага вступают в конфликт. Обычно добавляют поглощающие границы и разбивают путь на мелкие шаги, чтобы поле не упиралось в края области. Если при больших дистанциях профиль начинает расходиться с аналитическим решением, подозрение часто падает на фильтр. Но в рассмотренном ниже случае фильтр ни при чем: нестабильность возникает из‑за слишком малого шага по z, который заставляет выполнять слишком много пар БПФ/обратное БПФ и накапливает численную ошибку.

Постановка задачи

Цель — стартовать с гауссова поля, дискретизированного при z = z0, распространить его на расстояние d и сравнить численный результат в плоскости z = z0 + d с аналитическим гауссовым пучком в той же плоскости. Для удержания энергии вдали от границ используется пошаговое распространение с поглощающей маской. На относительно коротких дистанциях совпадение хорошее. На длинных — когда пучок приближается к краю — численное решение заметно отклоняется от аналитического, даже при гладкой маске.

Пример кода, воспроизводящий расхождение

Фрагмент ниже демонстрирует основную логику: определение гауссова пучка, одношаговый распространитель в пространственно‑частотной области, многократный запуск с поглощением у края и тест, сравнивающий результат в целевой плоскости с аналитикой. Имена переменных на английском, логика соответствует описанию.

import numpy as np
from numpy.fft import fft2, ifft2, fftfreq
from numpy import exp, pi, sqrt

arctg = np.arctan2

class BeamCore:
  def __init__(self, zR: float, lambda0: float):
    self.zR = zR
    self.lambda0 = lambda0
    self.k = 2 * pi / self.lambda0
    self.w0 = sqrt(self.lambda0 * self.zR / pi)
    self.theta0 = self.w0 / self.zR
    self.a0 = sqrt(2 / (pi * self.w0))
    self.i0 = self.a0 * self.a0.conjugate()

  def w(self, z: np.array):
    return self.w0 * sqrt(1 + (z / self.zR)**2)

  def R(self, z: np.array):
    z_safe = np.where(z == 0.0, 1e-20, z)
    rval = z_safe * (1 + (self.zR / z_safe)**2)
    rval = np.where(z == 0.0, np.inf, rval)
    return rval

  def gouy(self, z: np.array):
    return arctg(z, self.zR)

class GaussianBeam(BeamCore):
  def __init__(self, zR: float, lambda0: float):
    super().__init__(zR, lambda0)

  def field(self, z: np.array = 0.0, r: np.array = 0.0, phi: np.array = 0.0) -> np.array:
    z = np.asarray(z, dtype=float)
    w = self.w(z)
    R = self.R(z)
    zeta = self.gouy(z)
    U = self.a0 * (self.w0 / w) * exp(-r**2 / w**2 + (-self.k * z - self.k * (r**2) / (2 * R) + zeta) * 1.0j)
    return U

def step_propagate(U, dz, kz):
  UF = fft2(U)
  if dz > 0.0:
    UFprop = UF * exp(1j * kz * dz).conj()
  elif dz < 0.0:
    UFprop = UF * exp(1j * kz * (-dz))
  Uout = ifft2(UFprop)
  return Uout

def propagate_sliced(U, zdist, x1d, y1d, k0, edge_mask):
  nx, ny = U.shape
  dx = (x1d[-1] - x1d[0]) / (nx - 1)
  dy = (y1d[-1] - y1d[0]) / (ny - 1)
  fx = fftfreq(nx, d=dx)
  fy = fftfreq(ny, d=dy)
  FX, FY = np.meshgrid(fx, fy)
  kx = 2 * pi * FX
  ky = 2 * pi * FY
  kz = sqrt(k0**2 - kx**2 - ky**2 + 0j)

  Ustep = U
  while zdist != 0.0:
    U_faded = Ustep * edge_mask
    dz = 0.00005  # очень маленький шаг
    dz = min(dz, zdist)
    Ustep = step_propagate(U_faded, dz, kz)
    zdist -= dz
  return Ustep

# Параметры
lambda0 = 800 * (10**-9)
zR = pi * (10**(-3)) / 2
beam = GaussianBeam(zR, lambda0)
w0 = beam.w0
k0 = beam.k

# Поглощающая маска на большем поле
pad = 500
grid = 1000 + 2 * pad
X = np.linspace(-8 * w0, 8 * w0, grid)
Y = np.linspace(-8 * w0, 8 * w0, grid)
XX, YY = np.meshgrid(X, Y)
r_mask = sqrt(XX**2 + YY**2)

def gauss_edge(r, pad_size):
  sigma = 1
  r_shift = (r - 4 * w0) / w0
  return exp(-(r_shift**2) / (2 * sigma**2))

edge_mask = np.where(r_mask > 4 * w0, gauss_edge(r_mask, pad), 1)

# Рабочая сетка для поля и распространения
x = np.linspace(-8 * w0, 8 * w0, 2000)
y = np.linspace(-8 * w0, 8 * w0, 2000)
Xg, Yg = np.meshgrid(x, y)
r = sqrt(Xg**2 + Yg**2)
phi = arctg(Yg, Xg)

# Начальное поле при z = zR
U0 = beam.field(zR, r, phi)

# Распространяем на 5 * zR, чтобы попасть в z = 6 * zR
U_num = propagate_sliced(U0, 5 * zR, x, y, k0, edge_mask)

# Аналитическое поле при z = 6 * zR
U_ref = beam.field(6 * zR, r, phi)

# Простая метрика расстояния по пикселям
def pix_distance(a: np.array, b: np.array):
  v = abs((a - b).flatten())
  return np.dot(v, v) / len(v)

err = pix_distance(U_num, U_ref)
print(err)

С столь малым шагом отклонение на больших расстояниях велико. Поле стартует корректно, края приглушены, но итоговый профиль не совпадает с аналитическим гауссианом, как должен бы.

Что на самом деле идет не так

Проблема не в поглощающем слое по краю. Ломается всё из‑за слишком малого шага распространения. Каждый шаг делает БПФ и обратное БПФ. Когда шагов неоправданно много, повторные преобразования накапливают численную погрешность. Эта погрешность суммируется на всей траектории и начинает доминировать, когда пучок расширяется.

Решение: увеличить шаг распространения

Увеличение шага уменьшает число пар БПФ/обратное БПФ, и накопленная ошибка падает. В этой конфигурации использование dz = 0.001 дает заметно лучшее совпадение с аналитикой в целевой плоскости.

def propagate_sliced(U, zdist, x1d, y1d, k0, edge_mask):
  nx, ny = U.shape
  dx = (x1d[-1] - x1d[0]) / (nx - 1)
  dy = (y1d[-1] - y1d[0]) / (ny - 1)
  fx = fftfreq(nx, d=dx)
  fy = fftfreq(ny, d=dy)
  FX, FY = np.meshgrid(fx, fy)
  kx = 2 * pi * FX
  ky = 2 * pi * FY
  kz = sqrt(k0**2 - kx**2 - ky**2 + 0j)

  Ustep = U
  while zdist != 0.0:
    U_faded = Ustep * edge_mask
    dz = 0.001  # больший шаг здесь улучшает результат
    dz = min(dz, zdist)
    Ustep = step_propagate(U_faded, dz, kz)
    zdist -= dz
  return Ustep

Оценить улучшение можно той же функцией «пиксельного» расстояния. При dz = 0.001 расстояние около 2.35, тогда как при dz = 0.00005 оно подскакивает примерно до 64.49. Меняйте dz и смотрите, как ведет себя метрика на вашей дальности.

Почему это важно

В методе углового спектра шаг — это не просто удобный параметр. Он определяет, сколько спектральных преобразований вы выполните. Если шаг слишком мал, метод тратит точность на избыточные обновления. Даже при гладкой поглощающей маске и достаточно большой сетке слишком мелкий шаг может определить весь бюджет ошибки, просто вынуждая делать слишком много циклов БПФ/обратное БПФ.

Вывод

Если пошаговый распространитель на БПФ на дальних дистанциях уходит от аналитического гауссиана, не спешите винить поглощение на краю. Сначала проверьте шаг по z. В приведенной настройке увеличение dz с 0.00005 до 0.001 резко сокращает рассогласование, что подтверждает простая метрика по пикселям. Начните с более крупного шага, сверяйтесь с аналитическим профилем и уже затем уточняйте параметры под вашу дистанцию и сетку.

Материал основан на вопросе на StackOverflow от Henrique Guerra и ответе от Henrique Guerra.