Skip to content
Merged
106 changes: 106 additions & 0 deletions docs/superpowers/plans/2026-07-05-dive-planner-phase4-ccr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Dive Planner Phase 4: CCR Planning + Bailout — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Real CCR planning — constant-ppO2 loading AND loop deco schedules (removing the "CCR schedules breathe OC" Phase 1 limitation), setpoint low/high with a depth switch, O2/diluent consumption, a worst-case bailout solver with scrub-to-bailout readout, and the canvas UI for all of it.

**Architecture (the key move):** the constant-ppO2 loop is expressed as a **depth-dependent effective-fraction `AscentGasPlan`** (`CcrLoopAscentGas`): at depth d, the loop's inspired partial pressures (via `ClosedCircuit.inspiredAt`) divide by alveolar pressure to give effective fN2/fHe — so the UNCHANGED Bühlmann schedule machinery computes loop deco exactly at constant-depth stops. No engine-core edits. Branch: stacked on `worktree-dive-planner-phase3-canvas` (PR #486).

**Spec:** "CCR and bailout (Phase 4)" section. Global constraints as Phase 3 (l10n all locales, units, format/analyze, commit per task, no Co-Authored-By).

---

### Task 1: `CcrLoopAscentGas` (core engine, additive)

**Files:** Create `lib/core/deco/ascent/ccr_loop_ascent_gas.dart`; Test `test/core/deco/ccr_loop_ascent_gas_test.dart`.

```dart
class CcrLoopAscentGas extends AscentGasPlan {
CcrLoopAscentGas({required DiveEnvironment environment,
required double setpointLow, required double setpointHigh,
required double switchDepth, // > switchDepth => high setpoint
required double diluentFO2, double diluentFHe = 0.0});
double setpointAt(double depthMeters); // depth > switchDepth ? high : low
@override AscentGas gasForDepth(double depthMeters); // effective fractions
@override List<double> switchDepthsBetween(double deeper, double shallower);
// returns [switchDepth] when strictly crossed (setpoint change = gas change)
}
```
`gasForDepth`: `amb = env.pressureAtDepth(d)`, `pAlv = max(amb - waterVaporPressure, 0)`, inspired = `ClosedCircuit(setpoint: setpointAt(d), diluentFO2, diluentFHe).inspiredAt(amb)`, return `AscentGas(fN2: inspired.pN2/pAlv, fHe: inspired.pHe/pAlv)` (guard pAlv<=0 → all-zero). `breakGasForDepth` stays null (loop gas is never pure O2 at stop depths under a 1.3 setpoint, and air breaks don't apply to loop deco).

- [ ] Tests (python3-computed): effective fN2 at 40 m, SP 1.3, Tx18/45 diluent equals `pN2/pAlv` from the Task-4 Phase-1 vector (1.6412207/4.9373); fractions at 3 m (SP clamped) are ~0; `setpointAt` honors switchDepth on both sides; `switchDepthsBetween(21, 3)` with switchDepth 10 returns `[10.0]`, `(9, 3)` returns `[]`; round-trip: `calculateSegment(breathing: ClosedCircuit(...))` equals `calculateSegment(fN2: gasForDepth(d).fN2, fHe: ...)` tensions at the same constant depth (proves the equivalence the design rests on).
- [ ] Implement; deco suite green; format+commit `feat(deco): CCR loop as a depth-dependent ascent gas plan`.

---

### Task 2: PlanEngine CCR mode (loading, loop deco, consumption)

**Files:** Modify `plan_engine.dart`, `dive_plan.dart` (effective-setpoint getters); Test `test/features/planner/plan_engine_ccr_test.dart`.

- `domain.DivePlan`: `double get effectiveSetpointLow => setpointLow ?? 0.7;` `effectiveSetpointHigh => setpointHigh ?? 1.3;` `effectiveSetpointSwitchDepth => setpointSwitchDepth ?? 10.0;`
- `PlanEngineConfig`: add `o2MetabolicRateLpm = 1.0`, `loopVolumeLiters = 6.0`.
- `PlanEngine.compute` when `plan.mode == PlanMode.ccr`:
- Segment loading breathes `ClosedCircuit(setpoint: setpointAt(segment.avgDepth), diluentFO2/FHe: segment gas)`; NDL at segment end likewise.
- Ascent plan = `CcrLoopAscentGas` (diluent = last segment's gas); schedule/TTS therefore run on the loop.
- Consumption: O2 = `o2MetabolicRateLpm × runtime-minutes(incl. deco)` charged to the `TankRole.oxygenSupply` tank when present (else uncharged); diluent = `loopVolumeLiters × (pressureAt(maxDepth) − surface)` charged to the `TankRole.diluent` tank when present, else the first segment's tank. Bailout-role tanks consume NOTHING in the main plan. OC-role SAC consumption paths skipped for ccr.
- Issues: END/density already evaluate segment gas (= diluent) ✓; add `ndlExceededNoDecoGas`-analog: when ccr, deco, and NO `TankRole.bailout` tank → reuse `ndlExceededNoDecoGas` with a bailout-specific message? NO — add enum value `noBailoutCarried` (alert). Remove the "computed as OC" doc note.
- [ ] Tests: CCR Tx18/45 60 m/25 min SP .7/1.3/10 → stops exist and total deco is LESS than the same plan computed as OC on the diluent (higher loop ppO2 shallow); O2 liters == rate×runtime within 1 L; diluent charged to diluent-role tank; bailout tank untouched; `noBailoutCarried` raised without a bailout tank and cleared with one; OC plans byte-identical to Phase 3 behavior (existing parity test still green).
- [ ] Format+commit `feat(planner): CCR loop deco schedules and consumption in PlanEngine`.

---

### Task 3: Editing state + mapper carry mode/setpoints

**Files:** Modify `plan_result.dart` (DivePlanState), `dive_planner_providers.dart` (notifier), `dive_plan_state_mapper.dart`; extend `test/features/planner/dive_plan_state_mapper_test.dart`.

- `DivePlanState` += `PlanMode mode` (default `PlanMode.oc`), `double? setpointLow/setpointHigh/setpointSwitchDepth` (+copyWith with clear flags, props). Import `dive_plan.dart` for `PlanMode` (no cycle: dive_plan imports only plan_segment).
- Notifier: `updateMode(PlanMode)`, `updateSetpoints({double? low, double? high, double? switchDepth})` (isDirty+updatedAt as siblings do).
- Mapper: state→plan carries mode/setpoints (drop them from the `existing`-preserved set); plan→state restores them.
- [ ] Tests: mode/setpoints round-trip state→plan→state; a CCR plan loaded then saved keeps setpoints without `existing`.
- [ ] Format+commit `feat(planner): CCR mode and setpoints in the planner editing state`.

---

### Task 4: Bailout solver + provider

**Files:** Create `lib/features/planner/domain/services/bailout_solver.dart`; provider in `plan_canvas_providers.dart`; Test `test/features/planner/bailout_solver_test.dart`.

```dart
class BailoutPoint { final int runtimeSeconds; final double depthMeters;
final int ttsSeconds; final double litersRequired; }
class BailoutOutcome { final List<BailoutPoint> points;
final BailoutPoint worstCase; final double availableLiters;
bool get sufficient => worstCase.litersRequired <= availableLiters;
BailoutPoint nearest(double runtimeSeconds); }
class BailoutSolver {
const BailoutSolver({PlanEngineConfig config = const PlanEngineConfig()});
BailoutOutcome? solve(domain.DivePlan plan); // null: not ccr / no bailout tanks / no segments
}
```
Solve: CCR-load minute-by-minute through the user segments (depth linearly interpolated inside each segment; sample interval `max(60, totalSeconds ~/ 40)` seconds — bounded ≤ ~40 samples, plus always the exact end-of-bottom sample). At each sample: OC schedule from that depth on `OptimalOcAscentGas(bailout-role tanks, ppO2Deco)`, TTS, and liters = `sacStressedEffective × Σ(minutes × pressureAt(depth))` over travel legs (plan.ascentRate) + stops + final surfacing. `availableLiters` = Σ `gasVolume(tank)` over bailout tanks. Worst case = max liters.
Provider: `final planBailoutProvider = Provider<BailoutOutcome?>` watching state + config (compute only when ccr).

- [ ] Tests: square 60 m/25 min CCR plan with one AL80 EAN50 bailout → worstCase at (or within one sample of) end-of-bottom; litersRequired monotonic non-decreasing across the bottom phase; `sufficient` flips when the bailout tank shrinks (3 L) vs a pair of large tanks; returns null for OC plans and when no bailout tanks.
- [ ] Format+commit `feat(planner): worst-case bailout solver`.

---

### Task 5: Canvas CCR UI + l10n

**Files:** Modify `plan_canvas_page.dart` (badge toggle, CCR settings section in the settings sheet), `plan_results_sheet.dart` (+bailout section), `plan_canvas_chart.dart` (scrub readout appends bailout TTS); Create `lib/features/planner/presentation/widgets/ccr_settings_section.dart`; ARBs (en + 10 locales); Test `test/features/planner/ccr_ui_test.dart`.

- Badge: `PlanChip(label: mode name uppercased)` wrapped in InkWell → `updateMode(toggled)`.
- `CcrSettingsSection` (shown in the settings sheet when ccr): three numeric `TextFormField`s (low/high in bar, switch depth in display units) writing `updateSetpoints`; own controllers in a StatefulWidget (dialog-controller-dispose rule: owned by State.dispose).
- Results sheet: when ccr and `planBailoutProvider` non-null: header `plannerCanvas_bailout_title`, rows worst-case (RT′ @ depth), bailout TTS, required vs available liters (error color + `plannerCanvas_bailout_insufficient` row when short).
- Scrub readout: when ccr and outcome non-null, append ` · BO {tts}′` from `nearest(scrubTime)` (`plannerCanvas_scrub_bailout` key).
- l10n keys (~8): `plannerCanvas_bailout_available`, `_insufficient`, `_required`, `_title`, `_tts`, `_worstCase`, `plannerCanvas_ccr_setpointHigh`, `_setpointLow`, `_switchDepth`, `plannerCanvas_scrub_bailout` — en + all 10 locales, gen-l10n zero untranslated.
- [ ] Widget tests: badge toggles state mode; CCR plan with bailout tank shows the bailout section values; insufficient case renders the error row.
- [ ] Format+commit `feat(planner): CCR canvas controls and bailout readouts`.

---

### Task 6: Verification sweep

- [ ] `flutter analyze` clean; `dart format .` stable; `flutter test test/core/deco/ test/features/planner/ test/features/dive_planner/` green; commit anything outstanding.

**Out of scope:** per-stop manual setpoint overrides; measured-ppO2 loading; SCR planning UI; bailout contingency TABLES on the slate (Phase 7); persisted o2-rate/loop-volume settings (engine-config defaults for now).
61 changes: 61 additions & 0 deletions lib/core/deco/ascent/ccr_loop_ascent_gas.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import 'package:submersion/core/deco/ascent/ascent_gas_plan.dart';
import 'package:submersion/core/deco/constants/buhlmann_coefficients.dart';
import 'package:submersion/core/deco/entities/breathing_config.dart';
import 'package:submersion/core/deco/entities/dive_environment.dart';

/// The closed-circuit loop expressed as a depth-dependent ascent gas.
///
/// At a constant depth the loop's inspired partial pressures (constant-ppO2
/// via [ClosedCircuit]) divide by the alveolar pressure to give EXACT
/// effective inert fractions — so the unchanged Bühlmann stop-search
/// machinery (which multiplies fractions back by alveolar pressure)
/// reproduces constant-ppO2 loading at every stop. Ascent legs use the
/// fraction at the leg's deeper end, the same approximation open-circuit
/// legs already make.
class CcrLoopAscentGas extends AscentGasPlan {
CcrLoopAscentGas({
required this.environment,
required this.setpointLow,
required this.setpointHigh,
required this.switchDepth,
required this.diluentFO2,
this.diluentFHe = 0.0,
});

final DiveEnvironment environment;
final double setpointLow;
final double setpointHigh;

/// Deeper than this the loop runs [setpointHigh]; at or above it,
/// [setpointLow].
final double switchDepth;
final double diluentFO2;
final double diluentFHe;

/// Setpoint in force at [depthMeters].
double setpointAt(double depthMeters) =>
depthMeters > switchDepth ? setpointHigh : setpointLow;

@override
AscentGas gasForDepth(double depthMeters) {
final ambient = environment.pressureAtDepth(depthMeters);
final pAlv = ambient - waterVaporPressure;
if (pAlv <= 0) return const AscentGas(fN2: 0, fHe: 0);
final inspired = ClosedCircuit(
setpoint: setpointAt(depthMeters),
diluentFO2: diluentFO2,
diluentFHe: diluentFHe,
).inspiredAt(ambient);
return AscentGas(fN2: inspired.pN2 / pAlv, fHe: inspired.pHe / pAlv);
}

@override
List<double> switchDepthsBetween(double deeperDepth, double shallowerDepth) {
// A setpoint change is a gas change: split ascent legs at the switch.
if (switchDepth > shallowerDepth + 1e-9 &&
switchDepth < deeperDepth - 1e-9) {
return [switchDepth];
}
return const [];
}
}
31 changes: 31 additions & 0 deletions lib/features/dive_planner/domain/entities/plan_result.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import 'package:equatable/equatable.dart';
import 'package:submersion/core/deco/entities/tissue_compartment.dart';
import 'package:submersion/features/dive_log/domain/entities/dive.dart';
import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart';
import 'package:submersion/features/planner/domain/entities/dive_plan.dart'
show PlanMode;

/// Types of warnings that can occur during dive planning.
enum PlanWarningType {
Expand Down Expand Up @@ -497,6 +499,16 @@ class DivePlanState extends Equatable {
/// Altitude above sea level in meters (for altitude diving).
final double? altitude;

/// Breathing mode (open circuit or CCR).
final PlanMode mode;

/// CCR setpoints in bar; null = the engine's defaults (0.7 / 1.3).
final double? setpointLow;
final double? setpointHigh;

/// Depth below which the high setpoint is in force; null = default 10 m.
final double? setpointSwitchDepth;

/// Reserve pressure in bar.
final double reservePressure;

Expand Down Expand Up @@ -524,6 +536,10 @@ class DivePlanState extends Equatable {
this.initialTissueState,
this.siteId,
this.altitude,
this.mode = PlanMode.oc,
this.setpointLow,
this.setpointHigh,
this.setpointSwitchDepth,
this.reservePressure = kDefaultReservePressureBar,
this.notes = '',
this.isDirty = false,
Expand Down Expand Up @@ -571,6 +587,10 @@ class DivePlanState extends Equatable {
List<TissueCompartment>? initialTissueState,
String? siteId,
double? altitude,
PlanMode? mode,
double? setpointLow,
double? setpointHigh,
double? setpointSwitchDepth,
double? reservePressure,
String? notes,
bool? isDirty,
Expand All @@ -580,6 +600,7 @@ class DivePlanState extends Equatable {
bool clearInitialTissueState = false,
bool clearSiteId = false,
bool clearAltitude = false,
bool clearSetpoints = false,
}) {
return DivePlanState(
id: id ?? this.id,
Expand All @@ -597,6 +618,12 @@ class DivePlanState extends Equatable {
: (initialTissueState ?? this.initialTissueState),
siteId: clearSiteId ? null : (siteId ?? this.siteId),
altitude: clearAltitude ? null : (altitude ?? this.altitude),
mode: mode ?? this.mode,
setpointLow: clearSetpoints ? null : (setpointLow ?? this.setpointLow),
setpointHigh: clearSetpoints ? null : (setpointHigh ?? this.setpointHigh),
setpointSwitchDepth: clearSetpoints
? null
: (setpointSwitchDepth ?? this.setpointSwitchDepth),
reservePressure: reservePressure ?? this.reservePressure,
notes: notes ?? this.notes,
isDirty: isDirty ?? this.isDirty,
Expand All @@ -618,6 +645,10 @@ class DivePlanState extends Equatable {
initialTissueState,
siteId,
altitude,
mode,
setpointLow,
setpointHigh,
setpointSwitchDepth,
reservePressure,
notes,
isDirty,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,26 @@ class DivePlanNotifier extends StateNotifier<DivePlanState> {
);
}

/// Switch between open circuit and CCR.
void updateMode(domain.PlanMode mode) {
state = state.copyWith(
mode: mode,
isDirty: true,
updatedAt: DateTime.now(),
);
}

/// Update CCR setpoints; only supplied values change.
void updateSetpoints({double? low, double? high, double? switchDepth}) {
state = state.copyWith(
setpointLow: low,
setpointHigh: high,
setpointSwitchDepth: switchDepth,
isDirty: true,
updatedAt: DateTime.now(),
);
}

// --------------------------------------------------------------------------
// Repetitive Dive Support
// --------------------------------------------------------------------------
Expand Down
5 changes: 5 additions & 0 deletions lib/features/planner/domain/entities/dive_plan.dart
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ class DivePlan extends Equatable {
this.tanks = const [],
});

/// CCR setpoints with the spec defaults (0.7 shallow, 1.3 below 10 m).
double get effectiveSetpointLow => setpointLow ?? 0.7;
double get effectiveSetpointHigh => setpointHigh ?? 1.3;
double get effectiveSetpointSwitchDepth => setpointSwitchDepth ?? 10.0;

/// Deco SAC: explicit value or the 0.8x-of-bottom default.
double get sacDecoEffective => sacDeco ?? sacBottom * 0.8;

Expand Down
1 change: 1 addition & 0 deletions lib/features/planner/domain/entities/plan_outcome.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ enum PlanIssueType {
gasReserveViolation,
gasOut,
ndlExceededNoDecoGas,
noBailoutCarried,
}

/// One issue found while computing a plan.
Expand Down
Loading