Per-Angle Scaling Modes

Learning Objectives

By the end of this section you will understand:

  • Why per-angle corrections are needed in XPCS

  • The four per-angle modes and their parameter counts

  • When to use each mode

  • How to configure per-angle modes for NLSQ and CMC (note: fourier is NLSQ-only)

  • How to diagnose and fix degeneracy issues

Why Per-Angle Scaling Matters

In a real XPCS experiment, the speckle contrast \(\beta(\phi)\) and incoherent background offset can vary with azimuthal angle \(\phi\). This happens because:

  • Different detector segments have different quantum efficiencies

  • The coherent flux illuminates pixels differently across azimuthal positions

  • Multiple scattering contributions can vary with scattering direction

  • Beamstop shadows affect some angular ranges

Without correcting for these variations, the optimizer will absorb angular anisotropies into the physical parameters (\(D_0\), \(\dot\gamma_0\)), producing biased and physically meaningless results.

The anti-degeneracy system in homodyne addresses this by introducing per-angle contrast and offset corrections.

The Four Modes

Per-angle scaling is controlled by per_angle_mode in both the NLSQ and CMC configuration sections.

constant

What it does:

  1. Estimates per-angle contrast and offset using quantile analysis

  2. Fixes these values — they are not optimized

Parameter count: +0 (total: 3 for static, 7 for laminar_flow)

When to use:

  • When you want the fastest possible fit

  • When contrast/offset variations are known from calibration

  • For initial exploration before enabling auto

optimization:
  nlsq:
    anti_degeneracy:
      per_angle_mode: "constant"

Note

constant mode produces a different fixed value per angle based on quantile estimation. Unlike auto, it does not average across angles.

individual

What it does:

Treats contrast and offset as independent free parameters for each angle. Each angle gets its own \(\beta_i\) and \(\text{offset}_i\), all optimized simultaneously.

Parameter count: +2×n_phi (total: 3+2n_phi for static)

Example (static, 23 angles): 3 + 2×23 = 49 parameters

When to use:

  • When angles are physically isolated (e.g., different sample regions)

  • With very few angles (n_phi ≤ 2)

  • For detailed angular analysis of optical effects

Warning

individual mode with many angles creates a large parameter space. For n_phi ≥ 5, the optimizer may struggle with degeneracy between per-angle contrast/offset and physical parameters. Use auto instead.

optimization:
  nlsq:
    anti_degeneracy:
      per_angle_mode: "individual"

fourier

What it does:

Models the angular variation of contrast and offset as a truncated Fourier series with K=2:

\[\beta(\phi) = a_0 + \sum_{k=1}^{K} \left[a_k \cos(k\phi) + b_k \sin(k\phi)\right]\]

Parameter count: +4K+2 = +10 for K=2 (total: 13 for static)

When to use:

  • When angular variation is smooth and physically expected (e.g., beam path)

  • When you want to regularize the angular dependence

  • For experiments with strong azimuthal anisotropy

optimization:
  nlsq:
    anti_degeneracy:
      per_angle_mode: "fourier"

Configuration Examples

NLSQ + CMC with matching per_angle_mode:

Note

CMC supports auto, constant, constant_averaged, and individual per-angle modes. The fourier mode is NLSQ-only and is not valid for CMC. If you use fourier in NLSQ, switch to auto or individual for CMC.

Always set the same per_angle_mode in both NLSQ and CMC to ensure that the NLSQ warm-start priors match the CMC parameterization:

optimization:
  nlsq:
    anti_degeneracy:
      per_angle_mode: "auto"   # NLSQ setting
  cmc:
    per_angle_mode: "auto"     # CMC setting (must match NLSQ)
    constant_scaling_threshold: 3   # Use averaged mode when n_phi >= 3

Advanced: custom threshold for auto mode:

optimization:
  cmc:
    per_angle_mode: "auto"
    constant_scaling_threshold: 5  # Switch to averaged mode only for n_phi >= 5

Parameter Count Summary

Mode

Extra Params

static (23 angles)

laminar_flow (23 angles)

laminar_flow (1 angle)

constant

0

3

7

7

auto

2

5

9

7 (falls back to individual for n_phi < 3)

individual

2×n_phi = 46

49

53

9

fourier (K=2)

10

13

17

17

Diagnosing Degeneracy

Signs that degeneracy is present (per-angle mode not working):

1. Parameters at bounds:

import numpy as np

bounds_lower = config.get_parameter_bounds()[0]
bounds_upper = config.get_parameter_bounds()[1]

for i, (val, lo, hi) in enumerate(zip(
    result.parameters, bounds_lower, bounds_upper
)):
    tol = 1e-4 * (hi - lo)
    if abs(val - lo) < tol or abs(val - hi) < tol:
        print(f"WARNING: param[{i}] = {val:.4g} at bound [{lo}, {hi}]")

2. Suspiciously large D₀ with small contrast:

If \(D_0\) is much larger than physically expected AND the contrast is near zero, this suggests degeneracy: the optimizer is absorbing the signal into \(D_0\) and zeroing out the contrast.

3. Angular dependence of residuals:

If residuals (model - data) show systematic angular patterns (all angles in one quadrant have positive residuals), per-angle corrections are insufficient. Switch from constant to auto mode.

4. NLSQ vs CMC disagreement > 20%:

Large discrepancies between NLSQ and CMC estimates often indicate degeneracy that NLSQ resolved by finding a local minimum.

Fixing Degeneracy Issues

Approach 1: Switch to ``auto`` mode (most effective)

optimization:
  nlsq:
    anti_degeneracy:
      per_angle_mode: "auto"   # Was "constant" or not set

Approach 2: Provide tight initial estimates

If you know the contrast from calibration measurements:

parameter_space:
  contrast:
    initial: 0.15    # From beamline calibration
    bounds: [0.05, 0.4]

Approach 3: Multi-start optimization

Use Latin Hypercube Sampling to explore parameter space:

from homodyne.optimization.nlsq import fit_nlsq_multistart, MultiStartConfig

result = fit_nlsq_multistart(
    data, config,
    ms_config=MultiStartConfig(n_starts=30, use_lhs=True),
)

Approach 4: CMC with informative priors

CMC with informative priors from calibration prevents the optimizer from exploring degenerate regions:

parameter_space:
  contrast:
    prior: "normal"
    prior_mean: 0.15
    prior_std: 0.05

See Also