# Design Document: 3D EFA Semi-Major Axis Normalization

## Overview

**Purpose**: This feature adds semi-major axis based normalization as an alternative scale factor for 3D Elliptic Fourier Analysis, enabling researchers to use a normalization convention consistent with 2D EFA.

**Users**: Morphometrics researchers using `EllipticFourierAnalysis` with `n_dim=3` and `norm=True` will gain the option to normalize by the semi-major axis length `a₁` instead of the default ellipse area `sqrt(π·a₁·b₁)`.

**Impact**: Extends `EllipticFourierAnalysis.__init__` with a new `norm_method` parameter. Existing behavior is preserved by default.

### Goals
- Provide semi-major axis normalization for 3D EFA, aligning with the 2D convention
- Maintain full backward compatibility (default = area-based normalization)
- Follow sklearn conventions for parameter placement and discoverability

### Non-Goals
- Area-based normalization for 2D EFA (separate feature, noted in `_normalize_2d` TODO)
- Changes to `_compute_ellipse_geometry_3d` (shared infrastructure, unchanged)
- Changes to inverse transform logic (DC zeroing behavior is scale-factor-independent)

## Architecture

### Existing Architecture Analysis

The normalization pipeline flows through three internal methods:

```
transform() → _transform_single_3d() → _normalize_3d()
```

The `_normalize_3d` method performs 4 steps:
1. **Rescaling** by `sqrt(π·a₁·b₁)` ← only this step changes
2. **Reorientation** via ZXZ Euler angles
3. **Phase shift** by `k·φ₁`
4. **Direction correction** (sign of y-sine component)

The inverse transform (`_inverse_transform_single_3d`) only checks `norm` to decide whether to zero DC components — it has no dependency on the scale factor used.

### Architecture Pattern & Boundary Map

**Architecture Integration**:
- Selected pattern: Parameter-driven strategy — the `norm_method` value selects the scale computation within `_normalize_3d`
- Domain boundaries: All changes are contained within `EllipticFourierAnalysis` class and its private methods
- Existing patterns preserved: sklearn `__init__` parameter convention, private method delegation, `_compute_ellipse_geometry_3d` returns `(phi, a, b, alpha, beta, gamma)` unchanged
- Steering compliance: Domain-driven subpackage organization, private implementation files, sklearn contract

### Technology Stack

| Layer | Choice / Version | Role in Feature | Notes |
|-------|------------------|-----------------|-------|
| Core | numpy >= 1.20 | Array operations | No change |
| Framework | scikit-learn >= 1.5 | BaseEstimator contract | `get_params`/`set_params` auto-discovers `norm_method` |

No new dependencies required.

## System Flows

```mermaid
flowchart TD
    A[transform norm=True] --> B[_transform_single_3d]
    B --> C[_normalize_3d]
    C --> D{self.norm_method}
    D -->|area| E[scale = sqrt pi a1 b1]
    D -->|semi_major_axis| F[scale = a1]
    E --> G[Apply scale reorientation phase direction]
    F --> G
    G --> H[Return normalized coefficients + geometry]
```

## Requirements Traceability

| Requirement | Summary | Components | Interfaces |
|-------------|---------|------------|------------|
| 1.1 | Use `a₁` as scale factor | `_normalize_3d` | — |
| 1.2 | Same reorientation/phase/direction steps | `_normalize_3d` | — |
| 1.3 | Canonical first harmonic (semi-major = 1) | `_normalize_3d` | — |
| 2.1 | Accept normalization method parameter | `__init__` | `norm_method: str` |
| 2.2 | Default to area-based | `__init__` | `norm_method="area"` |
| 2.3 | Use `a₁` when semi_major_axis selected | `_normalize_3d` | — |
| 2.4 | Validate parameter | `__init__` | `ValueError` |
| 3.1 | Return `a₁` as scale | `_normalize_3d`, `_transform_single_3d` | return value |
| 3.2 | Same orientation params regardless of method | `_compute_ellipse_geometry_3d` | unchanged |
| 4.1 | Inverse transform zeroes DC (unchanged) | `_inverse_transform_single_3d` | unchanged |
| 4.2 | Round-trip consistency | transform + inverse_transform | unchanged |
| 5.1 | 2D behavior unchanged | `_normalize_2d` | unchanged |
| 5.2 | Default 3D output identical | `_normalize_3d` | `norm_method="area"` default |
| 5.3 | No API removal or rename | all public methods | unchanged |
| 6.1-6.6 | Test coverage | test file | — |

## Components and Interfaces

| Component | Domain | Intent | Req Coverage | Key Dependencies | Contracts |
|-----------|--------|--------|--------------|------------------|-----------|
| `EllipticFourierAnalysis.__init__` | Core | Accept `norm_method` parameter | 2.1, 2.2, 2.4 | BaseEstimator (P0) | Service |
| `EllipticFourierAnalysis._normalize_3d` | Core | Branch scale factor by method | 1.1-1.3, 2.3, 3.1 | `_compute_ellipse_geometry_3d` (P0) | Service |
| `EllipticFourierAnalysis._transform_single_3d` | Core | Thread `norm_method` to `_normalize_3d` | 1.1, 3.1 | `_normalize_3d` (P0) | — |

### Core Layer

#### `EllipticFourierAnalysis.__init__`

| Field | Detail |
|-------|--------|
| Intent | Accept and validate `norm_method` parameter |
| Requirements | 2.1, 2.2, 2.4 |

**Responsibilities & Constraints**
- Store `norm_method` as instance attribute for use by `_normalize_3d`
- Validate `norm_method` against allowed values on construction
- Default to `"area"` to preserve backward compatibility

**Contracts**: Service [x]

##### Service Interface

```python
def __init__(
    self,
    n_harmonics: int = 20,
    n_dim: int = 2,
    n_jobs: Optional[int] = None,
    verbose: int = 0,
    norm_method: str = "area",
) -> None:
    """
    Parameters
    ----------
    norm_method : str, default="area"
        Normalization method for 3D EFA coefficients when ``norm=True``.
        Only affects ``n_dim=3``.

        - ``"area"``: Scale by ``sqrt(pi * a1 * b1)`` where ``a1`` and ``b1``
          are the semi-major and semi-minor axis lengths of the 1st harmonic
          ellipse (Godefroy et al. 2012).
        - ``"semi_major_axis"``: Scale by the semi-major axis length ``a1``
          of the 1st harmonic ellipse, consistent with the 2D normalization
          convention (Kuhl & Giardina 1982).

    Raises
    ------
    ValueError
        If ``norm_method`` is not one of ``"area"`` or ``"semi_major_axis"``.
    """
```

- Preconditions: `norm_method in {"area", "semi_major_axis"}`
- Postconditions: `self.norm_method` is set
- Invariants: `get_params()` includes `norm_method`

**Implementation Notes**
- Validation: Raise `ValueError` with message listing valid options if `norm_method` is invalid
- The parameter is placed after `verbose` to minimize disruption to existing positional usage

#### `EllipticFourierAnalysis._normalize_3d`

| Field | Detail |
|-------|--------|
| Intent | Compute scale factor based on `self.norm_method` |
| Requirements | 1.1, 1.2, 1.3, 2.3, 3.1 |

**Responsibilities & Constraints**
- Branch at the scale computation step only
- All other normalization steps (reorientation, phase shift, direction correction) remain identical
- Return the same tuple structure regardless of method

**Contracts**: Service [x]

##### Service Interface

```python
def _normalize_3d(
    self,
    an: np.ndarray,
    bn: np.ndarray,
    cn: np.ndarray,
    dn: np.ndarray,
    en: np.ndarray,
    fn: np.ndarray,
) -> tuple[
    np.ndarray, np.ndarray, np.ndarray,  # An, Bn, Cn
    np.ndarray, np.ndarray, np.ndarray,  # Dn, En, Fn
    float, float, float,                  # alpha, beta, gamma
    float, float,                         # phi, scale
]:
    """
    Scale computation depends on ``self.norm_method``:
    - ``"area"``: ``scale = sqrt(pi * a1 * b1)``
    - ``"semi_major_axis"``: ``scale = a1``
    """
```

- Preconditions: `self.norm_method` is validated (done in `__init__`)
- Postconditions: Normalized coefficients where the first harmonic is canonical under the selected scale
- Invariants: Return signature is unchanged; `scale` value reflects the selected method

**Implementation Notes**
- The branch point is at the existing lines 601-603 (`area1 = np.pi * a1 * b1; scale = np.sqrt(area1)`)
- For `"semi_major_axis"`: replace with `scale = a1`
- No changes to steps 2-4 of the normalization algorithm

#### `EllipticFourierAnalysis._transform_single_3d`

| Field | Detail |
|-------|--------|
| Intent | Thread `norm_method` to `_normalize_3d` via `self` |
| Requirements | 1.1, 3.1 |

**Responsibilities & Constraints**
- No parameter changes needed — `_normalize_3d` accesses `self.norm_method` directly
- The call at line 551-553 remains `self._normalize_3d(an, bn, cn, dn, en, fn)` unchanged

**Implementation Notes**
- No modification needed to `_transform_single_3d` since `_normalize_3d` is an instance method with access to `self.norm_method`

## Data Models

No data model changes. The coefficient array layout `(6*(n_harmonics+1),)` and the optional orientation/scale appendix `[alpha, beta, gamma, phi, scale]` remain structurally identical. The `scale` value changes semantically when `norm_method="semi_major_axis"`.

## Error Handling

### Error Categories and Responses

**User Errors**:
- Invalid `norm_method` value → `ValueError` with message: `"norm_method must be 'area' or 'semi_major_axis', got '{value}'"`
- Raised in `__init__`, consistent with `n_dim` validation pattern

No new system or business logic errors. Existing degenerate ellipse handling (`a1 < 1e-15`) applies equally to both methods.

## Testing Strategy

### Unit Tests

| Test | Requirement | Description |
|------|-------------|-------------|
| `test_norm_method_validation` | 2.4 | Invalid `norm_method` raises `ValueError` |
| `test_norm_method_default_unchanged` | 5.2 | Default `norm_method="area"` produces identical output to current code |
| `test_semi_major_axis_canonical` | 1.3, 6.5 | Normalized first harmonic has semi-major axis length = 1.0 |
| `test_semi_major_axis_translation_invariance` | 6.1 | Invariant under 3D translation |
| `test_semi_major_axis_scale_invariance` | 6.2 | Invariant under uniform scaling |
| `test_semi_major_axis_rotation_invariance` | 6.3 | Invariant under 3D rotation |
| `test_semi_major_axis_startpoint_invariance` | 6.4 | Approximately invariant under cyclic permutation |
| `test_area_regression` | 6.6 | Area-based normalization produces bit-identical results to baseline |

### Test Implementation Strategy

Existing invariance tests (`test_translation_invariance`, `test_scale_invariance`, `test_rotation_invariance`, `test_startpoint_shift_invariance`) can be parameterized with `@pytest.mark.parametrize("norm_method", ["area", "semi_major_axis"])` to cover both methods. The regression test captures a snapshot of current area-based output and verifies it remains unchanged.
