# Design Document: 3D EFA Normalization

## Overview

**Purpose**: This feature implements 3D Elliptic Fourier Analysis normalization following Godefroy et al. (2012), enabling size-invariant, orientation-invariant, and starting-point-invariant shape comparison of three-dimensional closed curves.

**Users**: Morphometrics researchers comparing 3D outline shapes (biological specimens, anatomical structures) across specimens acquired at different scales, orientations, and starting points.

**Impact**: Replaces the `NotImplementedError` stub in `_normalize_3d` with a full implementation. Fixes two pre-existing bugs in the 3D forward transform (arc-length parameterization and DC component calculation).

### Goals

- Implement Godefroy et al. (2012) 4-step normalization: rescaling, reorientation, phase shift, direction correction
- Fix arc-length bug (missing dz²) and DC component bug (missing dt weighting) in 3D transform
- Support `return_orientation_scale=True` for 3D with Euler angles and scale
- Maintain full backward compatibility with existing 2D workflows
- Provide comprehensive invariance tests (translation, rotation, scaling, starting-point shift)

### Non-Goals

- Alternative normalization strategies (PCA-based, Zhang-Fiume dominant coefficient)
- Visualization or plotting of 3D normalized shapes (use existing `plot` module)
- Normalization for open curves or surfaces (out of scope)
- Changes to `__init__.py` public exports (no new public classes or functions)

## Architecture

### Existing Architecture Analysis

The `EllipticFourierAnalysis` class (L35-639) follows the sklearn Transformer pattern with private methods organized by dimension:

- 2D methods: `_transform_single_2d`, `_normalize_2d`, `_inverse_transform_single_2d`
- 3D methods: `_transform_single_3d`, `_normalize_3d` (stub), `_inverse_transform_single_3d`
- Module-level utilities: `rotation_matrix_2d`, `_cse`, `_sse`

The public `transform` method dispatches to dimension-specific private methods. Normalization is called within each `_transform_single_Xd` method. This architecture is preserved unchanged.

### Architecture Pattern & Boundary Map

**Selected pattern**: In-place extension of existing module (see `research.md` for alternatives evaluation)

**Domain boundaries**: All changes confined to `ktch/harmonic/_elliptic_fourier_analysis.py` and its test file. No cross-subpackage changes.

**Existing patterns preserved**:

- Private `_normalize_Xd` instance methods for normalization logic
- Module-level utility functions for math primitives
- sklearn Transformer API contract unchanged
- Parallel dispatch via `Parallel`/`delayed` unchanged

**New components**:

- `rotation_matrix_3d_euler_zxz()`: Module-level utility (alongside `rotation_matrix_2d`)
- `_compute_ellipse_geometry_3d()`: Module-level utility for geometric parameter extraction
- `_normalize_3d()`: Instance method replacing the stub

### Technology Stack

| Layer | Choice / Version | Role in Feature | Notes |
|-------|------------------|-----------------|-------|
| Core | numpy >= 1.20 | Array operations, linear algebra | Already a dependency |
| Core | scipy >= 1.7 | None (not used directly) | Available if needed for cross-validation |

No new dependencies. All computations use numpy only.

## System Flows

### 3D Normalization Flow (within `_transform_single_3d`)

```mermaid
flowchart TD
    A[Input: X coords, t param] --> B[Compute differences dx dy dz]
    B --> C[Arc-length: dt = sqrt dx2+dy2+dz2]
    C --> D[Fourier expansion: cse sse]
    D --> E[Raw coefficients: an bn cn dn en fn]
    E --> F{norm?}
    F -->|False| G[Return raw coefficients]
    F -->|True| H[_normalize_3d]
    H --> H1[Extract 1st harmonic geometry: phi a b alpha beta gamma]
    H1 --> H2[Step 1: Rescale by 1 over sqrt pi a1 b1]
    H2 --> H3[Step 2: Reorient by Omega1 inverse]
    H3 --> H4[Step 3: Phase shift by phi1]
    H4 --> H5[Step 4: Direction fix if Ys1 lt 0]
    H5 --> I{return_orientation_scale?}
    I -->|False| J[Return normalized coefficients]
    I -->|True| K[Append alpha beta gamma phi scale]
    K --> J
```

## Requirements Traceability

| Requirement | Summary | Components | Interfaces |
|-------------|---------|------------|------------|
| 1.1 | Normalize 3D coefficients via 1st harmonic | `_normalize_3d`, `_compute_ellipse_geometry_3d` | `_normalize_3d` signature |
| 1.2 | Plane alignment + axis alignment + scale | `_normalize_3d`, `rotation_matrix_3d_euler_zxz` | Internal steps |
| 1.3 | Canonical 1st harmonic after normalization | `_normalize_3d` direction fix step | Tested via invariance |
| 1.4 | Consistent normalization across all harmonics | `_normalize_3d` loop over harmonics | Tested via invariance |
| 2.1 | Append orientation/scale when requested | `_transform_single_3d` | 5 appended values |
| 2.2 | Return Euler angles + scale | `_normalize_3d` return tuple | (alpha, beta, gamma, phi, scale) |
| 2.3 | Document parameterization in docstring | Docstring update | N/A |
| 3.1 | Inverse transform norm=True zeroes DC | `_inverse_transform_single_3d` | Existing behavior preserved |
| 3.2 | Inverse transform norm=False uses raw DC | `_inverse_transform_single_3d` | Existing behavior preserved |
| 3.3 | Round-trip consistency | N/A (test-only) | Tested via round-trip test |
| 4.1 | Arc-length includes dz | `_transform_single_3d` bug fix | L493 |
| 4.2 | Corrected dt in _transform_single_3d | `_transform_single_3d` bug fix | L493 |
| 5.1 | 2D behavior unchanged | No 2D code modified | Existing tests pass |
| 5.2 | 3D norm=False unchanged (except arc-length fix) | `_transform_single_3d` | Existing test_transform_3d_exact |
| 5.3 | Public API signature unchanged | All public methods | No signature changes |
| 6.1-6.6 | Test suite | New test functions | pytest |

## Components and Interfaces

| Component | Domain | Intent | Req Coverage | Key Dependencies | Contracts |
|-----------|--------|--------|--------------|------------------|-----------|
| `_normalize_3d` | EFA class | 4-step 3D normalization | 1.1-1.4, 2.1-2.2 | `_compute_ellipse_geometry_3d`, `rotation_matrix_3d_euler_zxz` | Service |
| `_compute_ellipse_geometry_3d` | Module utility | Extract geometric params from coefficients | 1.1, 1.2 | numpy | Service |
| `rotation_matrix_3d_euler_zxz` | Module utility | Build 3x3 rotation from Euler ZXZ angles | 1.2 | numpy | Service |
| `_transform_single_3d` (modified) | EFA class | Bug fixes + normalization integration | 4.1, 4.2, 2.1 | `_normalize_3d` | Service |

### Harmonic Analysis Layer

#### `_normalize_3d`

| Field | Detail |
|-------|--------|
| Intent | Normalize 3D EFA coefficients following Godefroy et al. (2012) 4-step algorithm |
| Requirements | 1.1, 1.2, 1.3, 1.4, 2.1, 2.2 |

**Responsibilities & Constraints**

- Receives raw coefficient arrays (an, bn, cn, dn, en, fn) each of length (n_harmonics+1)
- Extracts geometric parameters of the 1st harmonic ellipse
- Applies rescaling, reorientation, phase shift, and direction correction to all harmonics
- Returns normalized coefficient arrays + orientation/scale parameters

**Dependencies**

- Inbound: `_transform_single_3d` — calls after Fourier expansion (P0)
- Outbound: `_compute_ellipse_geometry_3d` — geometric parameter extraction (P0)
- Outbound: `rotation_matrix_3d_euler_zxz` — 3D rotation matrix construction (P0)

**Contracts**: Service [x]

##### Service Interface

```python
def _normalize_3d(
    self,
    an: np.ndarray,  # shape (n_harmonics+1,)
    bn: np.ndarray,
    cn: np.ndarray,
    dn: np.ndarray,
    en: np.ndarray,
    fn: np.ndarray,
) -> tuple[
    np.ndarray,  # An normalized
    np.ndarray,  # Bn normalized
    np.ndarray,  # Cn normalized
    np.ndarray,  # Dn normalized
    np.ndarray,  # En normalized
    np.ndarray,  # Fn normalized
    float,       # alpha (Euler angle)
    float,       # beta (Euler angle)
    float,       # gamma (Euler angle)
    float,       # phi (phase)
    float,       # scale (sqrt(A1))
]:
    ...
```

- **Preconditions**: All input arrays have the same length. Index 0 contains DC components. Indices 1+ contain harmonic coefficients.
- **Postconditions**: Normalized arrays have same shape as input. After normalization, the 1st harmonic defines an ellipse aligned with the X-Y plane with semi-major axis along X. Scale is normalized by √(πa₁b₁).
- **Invariants**: Normalization is deterministic for the same input. The transformation is invertible given (α, β, γ, φ, scale).

**Implementation Notes**

- Algorithm follows Godefroy et al. (2012) §3.1.1–§3.1.4 exactly
- Step 1 (Rescaling): Compute A₁ = π·a₁·b₁; divide all coefficients by √A₁
- Step 2 (Reorientation): Build Ω₁ from (α₁, β₁, γ₁); apply Ω₁⁻¹ to each harmonic's 3×2 coefficient matrix
- Step 3 (Phase shift): Apply 2×2 rotation by φ₁ to (cos, sin) columns of each harmonic
- Step 4 (Direction): If Y_{s₁} < 0 after reorientation, negate all sine coefficient columns
- Gimbal lock guard: When β₁ ≈ 0 or π (within 1e-10), set γ₁ = 0 and compute α₁ from combined angle

#### `_compute_ellipse_geometry_3d`

| Field | Detail |
|-------|--------|
| Intent | Extract geometric parameters (φ, a, b, α, β, γ) from 6 Fourier coefficients of a single harmonic |
| Requirements | 1.1, 1.2 |

**Responsibilities & Constraints**

- Implements Godefroy et al. (2012) §2, Eqs. 4–12
- Enforces uniqueness constraints: φ ∈ ]−π/4, π/4[, a > 0, b > 0, β ∈ [0, π]
- Returns the unique solution among the 32 possible parameter sets

**Dependencies**

- External: numpy — trigonometric functions, arctan2 (P0)

**Contracts**: Service [x]

##### Service Interface

```python
def _compute_ellipse_geometry_3d(
    xc: float, xs: float,
    yc: float, ys: float,
    zc: float, zs: float,
) -> tuple[float, float, float, float, float, float]:
    """Compute geometric parameters of a 3D ellipse from Fourier coefficients.

    Parameters
    ----------
    xc, xs, yc, ys, zc, zs : float
        Cosine and sine Fourier coefficients for x, y, z coordinates.

    Returns
    -------
    phi : float
        Phase angle in ]−π/4, π/4[.
    a : float
        Semi-major axis length (a > 0).
    b : float
        Semi-minor axis length (b > 0).
    alpha : float
        First Euler angle (ZXZ convention).
    beta : float
        Second Euler angle, β ∈ [0, π].
    gamma : float
        Third Euler angle (ZXZ convention).
    """
```

- **Preconditions**: At least one of (xc, xs, yc, ys, zc, zs) is nonzero.
- **Postconditions**: a >= b > 0. φ ∈ ]−π/4, π/4[. β ∈ [0, π].
- **Invariants**: Same coefficients always produce the same unique parameter set.

#### `rotation_matrix_3d_euler_zxz`

| Field | Detail |
|-------|--------|
| Intent | Construct 3×3 rotation matrix from ZXZ Euler angles |
| Requirements | 1.2 |

**Responsibilities & Constraints**

- Implements Ω = R_γ · R_β · R_α per Godefroy et al. (2012) Fig. 1
- Module-level function (alongside existing `rotation_matrix_2d`)
- Pure numpy computation

**Contracts**: Service [x]

##### Service Interface

```python
def rotation_matrix_3d_euler_zxz(
    alpha: float, beta: float, gamma: float
) -> np.ndarray:
    """Construct 3x3 rotation matrix from ZXZ Euler angles.

    The rotation is composed as Omega = R_gamma @ R_beta @ R_alpha,
    following the convention in Godefroy et al. (2012) Fig. 1.

    Parameters
    ----------
    alpha, beta, gamma : float
        ZXZ Euler angles in radians.

    Returns
    -------
    rotation_matrix : np.ndarray of shape (3, 3)
        Orthogonal rotation matrix.
    """
```

- **Preconditions**: Angles are finite floats.
- **Postconditions**: Output is a 3×3 orthogonal matrix with determinant +1.
- **Invariants**: `rotation_matrix_3d_euler_zxz(0, 0, 0)` returns the identity matrix.

#### `_transform_single_3d` (Modified)

| Field | Detail |
|-------|--------|
| Intent | Bug fixes and normalization integration for 3D forward transform |
| Requirements | 4.1, 4.2, 2.1 |

**Modifications**:

1. **Arc-length fix (4.1)**: Change `dt = np.sqrt(dx**2 + dy**2)` to `dt = np.sqrt(dx**2 + dy**2 + dz**2)` at L493
2. **DC component fix**: Change `a0 = 2 / T * np.sum(X_arr[1:, 0])` to `a0 = 2 * np.sum(X_arr[1:, 0] * dt) / T` (and same for c0, e0) at L530-532
3. **Normalization call**: `_normalize_3d` returns normalized coefficients + (alpha, beta, gamma, phi, scale)
4. **Orientation/scale append**: When `return_orientation_scale=True`, append [alpha, beta, gamma, phi, scale] to output array

**Implementation Notes**

- Existing signature and dispatch flow unchanged
- `norm=True` now calls the implemented `_normalize_3d` instead of raising NotImplementedError
- `return_orientation_scale=True` appends 5 values (vs 2 for 2D): [alpha, beta, gamma, phi, scale]

## Data Models

### Coefficient Array Layout

**3D raw coefficients** (norm=False): shape `(6 * (n_harmonics + 1),)`

```
[a_0, a_1, ..., a_N, b_0, b_1, ..., b_N, c_0, c_1, ..., c_N, d_0, d_1, ..., d_N, e_0, e_1, ..., e_N, f_0, f_1, ..., f_N]
```

**3D normalized coefficients** (norm=True, return_orientation_scale=False): same shape

```
[A_0, A_1, ..., A_N, B_0, B_1, ..., B_N, C_0, C_1, ..., C_N, D_0, D_1, ..., D_N, E_0, E_1, ..., E_N, F_0, F_1, ..., F_N]
```

**3D with orientation/scale** (norm=True, return_orientation_scale=True): shape `(6 * (n_harmonics + 1) + 5,)`

```
[A_0, ..., F_N, alpha, beta, gamma, phi, scale]
```

### Per-Harmonic Coefficient Matrix

For harmonic k, the 3×2 matrix representation used internally:

```
C_k = [[an[k], bn[k]],   ← x coefficients
       [cn[k], dn[k]],   ← y coefficients
       [en[k], fn[k]]]   ← z coefficients
```

Normalization transforms each C_k as:

```
C'_k = (1/scale) * Omega1_inv @ C_k @ R_phase(phi1)
```

where `R_phase` is a 2×2 rotation matrix and `Omega1_inv` is the inverse of the 1st harmonic's 3×3 orientation matrix.

## Error Handling

### Error Categories

| Error | Trigger | Response | Requirement |
|-------|---------|----------|-------------|
| `ValueError` | `n_dim` not in (2, 3) | Raise immediately in `__init__` | Existing |
| Degenerate 1st harmonic | All 1st harmonic coefficients near zero | Raise `ValueError` with descriptive message | 1.1 |
| Gimbal lock | β₁ ≈ 0 or π | Silently resolve: set γ=0, compute α from combined angle | 1.2 |

No new exception types needed. Existing `ValueError` pattern is sufficient.

## Testing Strategy

### Unit Tests

Tests added to `ktch/harmonic/tests/test_elliptic_fourier_analysis.py`:

1. **`test_normalize_3d_translation_invariance`** (6.1): Apply random translation to 3D outline; verify normalized coefficients match within tolerance.
2. **`test_normalize_3d_scale_invariance`** (6.2): Scale 3D outline by random factor; verify normalized coefficients match.
3. **`test_normalize_3d_rotation_invariance`** (6.3): Apply random 3D rotation to outline; verify normalized coefficients match.
4. **`test_normalize_3d_startpoint_invariance`** (6.4): Cyclically shift outline points; verify normalized coefficients match.
5. **`test_normalize_3d_round_trip`** (6.5): Forward transform (norm=True) then inverse transform (norm=True); verify shape consistency via Wasserstein distance (following existing `test_orientation_and_scale_2d` pattern).
6. **`test_arc_length_3d_corrected`** (6.6): Create 3D curve where z variation matters; verify dt includes dz².
7. **`test_normalize_3d_orientation_scale_return`** (2.1): Verify `return_orientation_scale=True` produces correct output shape and recoverable parameters.
8. **`test_normalize_3d_canonical_first_harmonic`** (1.3): After normalization, verify the 1st harmonic defines an ellipse in the XY-plane with predictable structure.
9. **`test_backward_compat_2d`** (5.1): Run existing 2D tests unmodified; all pass.
10. **`test_backward_compat_3d_norm_false`** (5.2): Run existing `test_transform_3d_exact`; passes (note: existing test uses explicit `t`, so arc-length fix does not affect it).

### Test Data Generation Strategy

Synthetic 3D test outlines are generated by constructing Fourier series with known coefficients, then reconstructing coordinates via inverse transform. This allows:

- Known ground truth for coefficient comparison
- Controlled application of translation, rotation, scaling, and starting-point shifts
- Verification of geometric parameter extraction

A reference 3D ellipse with known Euler angles is used as a basic sanity check for `_compute_ellipse_geometry_3d` and `rotation_matrix_3d_euler_zxz`.

### Performance

No performance tests needed. Normalization adds O(n_harmonics) computation per sample, which is negligible compared to the Fourier expansion itself.
