# Gap Analysis: 3d-efa-normalization-semi-major-axis

## Executive Summary

This feature adds semi-major axis based normalization as an alternative to the existing area-based normalization for 3D EFA. The scope is narrow: the change affects only the **scale factor** computation and the **API surface for method selection**. All other normalization steps (reorientation, phase shift, direction correction) and the helper function `_compute_ellipse_geometry_3d` remain unchanged.

---

## 1. Current State Investigation

### Key Files

| File | Role | Lines |
|------|------|-------|
| `ktch/harmonic/_elliptic_fourier_analysis.py` | Main implementation | ~930 |
| `ktch/harmonic/tests/test_elliptic_fourier_analysis.py` | Tests | ~1060 |
| `ktch/harmonic/__init__.py` | Public API re-export | 33 |

### Relevant Code Sections

| Component | Location | Description |
|-----------|----------|-------------|
| `EllipticFourierAnalysis.__init__` | L94-107 | Constructor: `n_harmonics`, `n_dim`, `n_jobs`, `verbose` |
| `transform` | L114-198 | Public API entry point; `norm: bool = True` parameter |
| `_transform_single_3d` | L458-562 | Per-specimen 3D transform; calls `_normalize_3d` at L551-553 |
| `_normalize_3d` | L564-664 | 4-step normalization; **scale = sqrt(π·a₁·b₁)** at L601-603 |
| `_normalize_2d` | L353-404 | 2D normalization; **scale = semi-major axis length** at L384 |
| `_compute_ellipse_geometry_3d` | L799-926 | Returns `(phi, a, b, alpha, beta, gamma)` — unchanged |
| `_inverse_transform_single_3d` | L666-714 | 3D inverse; only checks `if norm:` to zero DC |
| `_transform_single_2d` | L263-351 | Per-specimen 2D transform; calls `_normalize_2d` |

### Scale Factor Comparison

| Dimension | Current scale | Formula |
|-----------|---------------|---------|
| 2D | Semi-major axis length | `sqrt(a_s² + c_s²)` |
| 3D | Ellipse area root | `sqrt(π·a₁·b₁)` |
| 3D (proposed) | Semi-major axis length | `a₁` |

### Architecture Patterns

- `norm` is currently `bool` — passed through `transform` → `_transform_single_3d` → `_normalize_3d`
- `_normalize_3d` returns 11 values: 6 coefficient arrays + 5 geometry parameters
- The return value `scale` is appended to output when `return_orientation_scale=True`
- `inverse_transform` does **not** receive the normalization method — it only checks `norm` to decide whether to zero DC components
- No existing parameter governs normalization method selection

### Conventions

- All parameters are set in `__init__` (sklearn contract) or passed to `transform`/`inverse_transform`
- `norm` is a `transform`-level parameter (not `__init__`)
- sklearn `BaseEstimator` requires `__init__` params to match `get_params()` exactly

---

## 2. Requirement-to-Asset Map

| Requirement | Existing Asset | Gap |
|-------------|---------------|-----|
| **R1**: Semi-major axis normalization | `_normalize_3d` exists; only scale factor changes | **Minor**: Replace `sqrt(π·a·b)` with `a₁` when method selected |
| **R2**: Method selection parameter | No existing parameter for normalization method | **Missing**: Need new parameter in API |
| **R3**: Return scale for semi-major axis | `return_orientation_scale` pipeline exists | **Minor**: Return `a₁` instead of `sqrt(π·a·b)` |
| **R4**: Inverse transform compatibility | `_inverse_transform_single_3d` exists | **None**: Inverse only zeros DC when `norm=True`, no scale dependency |
| **R5**: Backward compatibility | All existing code | **Constraint**: Default must remain area-based |
| **R6**: Testing | Invariance test patterns exist | **Minor**: Duplicate existing tests with semi-major axis method param |

---

## 3. Implementation Approach Options

### Option A: Extend `_normalize_3d` with method parameter

**What changes**:
1. Add `norm_method` parameter to `_normalize_3d(self, ..., norm_method="area")`
2. Branch on `norm_method` at L601-603: `if norm_method == "semi_major_axis": scale = a1; else: scale = sqrt(π·a₁·b₁)`
3. Thread `norm_method` through: `transform` → `_transform_single_3d` → `_normalize_3d`
4. No change to `_compute_ellipse_geometry_3d`, inverse transform, or 2D paths

**Files modified**: 1 file (`_elliptic_fourier_analysis.py`) + 1 test file

**Trade-offs**:
- ✅ Minimal code change (~15 lines of production code)
- ✅ Scale factor is the only varying logic; no branching elsewhere
- ✅ Clear backward compatibility (default = "area")
- ❌ `norm` as `bool` + separate `norm_method` is slightly awkward

### Option B: Extend `norm` parameter to accept string values

**What changes**:
1. Change `norm` type from `bool` to `bool | str` (e.g., `norm=True` for default, `norm="semi_major_axis"`)
2. Treat `norm=True` as the current default (area-based for 3D, semi-major for 2D)
3. `norm="semi_major_axis"` selects semi-major axis normalization
4. No new parameter needed; reuse existing parameter

**Files modified**: 1 file (`_elliptic_fourier_analysis.py`) + 1 test file

**Trade-offs**:
- ✅ No new parameter in API — cleaner for sklearn contract
- ✅ Natural extension: `norm=True` means "default normalization"
- ❌ Overloaded type (`bool | str`) is less clean for type hints
- ❌ Interpretation of `norm=True` changes contextually (2D vs 3D) — already the case though

### Option C: Add `norm_method` as `__init__` parameter

**What changes**:
1. Add `norm_method: str = "area"` to `__init__` (alongside `n_harmonics`, `n_dim`, etc.)
2. Store as instance attribute, used when `norm=True`
3. Thread through `_transform_single_3d` → `_normalize_3d`
4. Follows sklearn convention: configuration in `__init__`, data in `transform`

**Files modified**: 1 file (`_elliptic_fourier_analysis.py`) + 1 test file

**Trade-offs**:
- ✅ Follows sklearn convention: `__init__` for hyperparameters, `transform` for data
- ✅ Clean type: `norm_method: str = "area"` with `norm: bool = True`
- ✅ Works with `get_params()`/`set_params()` and sklearn pipeline tools
- ✅ Backward compatible: default `"area"` preserves existing behavior
- ❌ One more constructor parameter (minor)

---

## 4. Implementation Complexity & Risk

**Effort: S (1-3 days)**
- Core change is 2-3 lines in `_normalize_3d` (branch on scale factor)
- API threading is mechanical (~10 lines across `__init__`, `transform`, `_transform_single_3d`)
- Tests can be parameterized copies of existing invariance tests

**Risk: Low**
- Familiar technology (pure numpy, no new dependencies)
- Clear, well-tested codebase with round-trip validation patterns
- No architectural changes; extends established patterns
- Default behavior unchanged (backward compatible)

---

## 5. Recommendations for Design Phase

### Preferred Approach

**Option C** (Add `norm_method` as `__init__` parameter) is recommended:
- Best alignment with sklearn convention (hyperparameters in `__init__`)
- Cleanest type signature
- Works seamlessly with `Pipeline`, `GridSearchCV`, etc.
- `norm_method` naming is descriptive and unambiguous

### Key Design Decisions

1. **Parameter naming**: `norm_method` vs `normalization_method` vs `scale_type`
2. **Valid values**: `"area"` and `"semi_major_axis"` — consider whether to also support `"major_axis"` as alias
3. **2D behavior**: Should `norm_method` affect 2D normalization too? (Currently 2D already uses semi-major axis — adding area-based 2D normalization is the TODO in line 358, but is out of scope)
4. **Docstring updates**: Document the two methods and their mathematical definitions

### Research Items

- None required. The mathematical foundation is straightforward (change one line in the scale computation). All infrastructure exists.

### Test Strategy

- Parameterize existing 3D invariance tests (`test_translation_invariance`, `test_scale_invariance`, `test_rotation_invariance`, `test_startpoint_shift_invariance`) with `@pytest.mark.parametrize("norm_method", ["area", "semi_major_axis"])`
- Add specific test: normalized first harmonic semi-major axis == 1.0
- Add regression test: `norm_method="area"` produces identical results to current code
