Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Dive Planner Phase 5: Contingencies — 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:** One-tap deviation plans (+depth / +time / both) with ghost overlays on the canvas, lost-deco-gas schedules, turn-pressure per bottom tank, and rock-bottom (min-gas) validation — the spec's "Contingencies (Phase 5)".

**Architecture:** A `ContingencyService` derives modified `DivePlan`s (deeper/longer bottom, tank removed) and runs them through the existing `PlanEngine`; nothing new in the engine core. Turn pressure and min-gas land as fields on `PlanTankUsage` plus one new `PlanIssueType.minGasViolation`. Editing state gains the contingency config (deltas, turn rule) like Phase 4's setpoints. Branch: stacked on `worktree-dive-planner-phase4-ccr` (PR #488). Global constraints as Phases 3–4.

---

### Task 1: `ContingencyService` — deviations + lost gas

**Files:** Create `lib/features/planner/domain/services/contingency_service.dart`; Test `test/features/planner/contingency_service_test.dart`.

```dart
class DeviationOutcome { final String key; // 'deeper' | 'longer' | 'both'
final domain.DivePlan plan; final PlanOutcome outcome; }
class LostGasOutcome { final DiveTank tank; final PlanOutcome outcome; }
class ContingencyService {
const ContingencyService({PlanEngineConfig config});
List<DeviationOutcome> deviations(domain.DivePlan plan);
List<LostGasOutcome> lostGas(domain.DivePlan plan); // OC only; [] for CCR
}
```
- Deviations (skip when no segments): `deeper` = every bottom segment's depth + `deviationDepthDelta` (descent segments' end depth likewise when they feed the deepened bottom — implement as: any segment start/end depth that EQUALS the plan max depth gets +delta); `longer` = every bottom segment + `deviationTimeMinutes`; `both` = both edits. Each computed via `PlanEngine.compute`.
- Lost gas: for each `deco`/`stage`-role tank, a plan without it (and without segments referencing it — bottom segments never do) → outcome. Empty list for CCR (bailout covers loop loss) and when no such tanks.

- [ ] Tests: deeper deviation has `maxDepth == base + delta` and `totalDecoSeconds > base`; longer likewise; lost EAN50 on a trimix plan lengthens deco vs base and its stops at ≤ 22 m no longer breathe EAN50; CCR plan → `lostGas` empty; deltas honored from the plan fields.
- [ ] Implement; format+commit `feat(planner): contingency service for deviation and lost-gas plans`.

---

### Task 2: Turn pressure + min-gas in PlanEngine

**Files:** Modify `plan_outcome.dart` (PlanTankUsage +`turnPressureBar`, +`minGasBar`; PlanIssueType +`minGasViolation`), `plan_engine.dart`, `plan_results_sheet.dart` (message case); ARBs (+`plannerCanvas_issue_minGas`, `plannerCanvas_gas_turnAt`, `plannerCanvas_gas_minGas`); Test extend `plan_engine_issues_test.dart`.

- `PlanEngineConfig` += `buddyFactor = 2.0` (two divers share the rock-bottom ascent).
- Turn pressure (OC, rule non-null, bottom/backGas-role tanks with startPressure): `usable = start − reserve`, `turn = start − usable × fraction` where fraction = allUsable 1.0 / halves 0.5 / thirds 1/3 / custom `turnPressureFraction ?? 1/3`.
- Min gas (OC, backGas-role tanks): liters for a direct stressed ascent from `plan.maxDepth` on that tank's gas — schedule from maxDepth with `FixedAscentGas(tank gas)` at END-OF-BOTTOM tissue state is overkill; per rock-bottom convention use the NO-DECO emergency form: 1 min at max depth + ascent at `plan.ascentRate` to surface, at `sacStressedEffective × buddyFactor`, pressure-integrated — then `minGasBar` via the tank's volume (ideal-bar conversion is the field convention: liters / volume). Issue `minGasViolation` (alert) when `remainingPressure < minGasBar`.
- [ ] Tests: thirds rule on 200 bar start / 50 reserve → turn at 150; minGas positive and violation fires for a small tank on a deep plan, absent for a big one; CCR plans get neither.
- [ ] Format+commit `feat(planner): turn pressure and rock-bottom validation`.

---

### Task 3: Editing state + mapper contingency config

**Files:** `plan_result.dart` (DivePlanState += `deviationDepthDelta` (double, default 5), `deviationTimeMinutes` (int, default 5), `turnPressureRule` (TurnPressureRule?), `turnPressureFraction` (double?)), notifier `updateContingencies({...})`, mapper both ways; extend mapper test.
- [ ] Round-trip tests; format+commit `feat(planner): contingency config in the planner editing state`.

---

### Task 4: Canvas UI — ghost overlays, contingency sections, settings

**Files:** `plan_canvas_providers.dart` (extract `buildCanvasSeries({segments, outcome})` top-level; `planDeviationsProvider`, `selectedDeviationProvider = StateProvider<String?>`, `deviationGhostSeriesProvider`), `plan_canvas_chart.dart` (ghost `LineChartBarData`, grey dashed), new `contingency_chips.dart` (selector row: Base / +Xm / +X′ / both), `plan_results_sheet.dart` (Deviations + Lost gas sections with mini runtime tables; turn/min-gas on gas rows), new `contingency_settings_section.dart` (two numeric fields + rule dropdown, shown in plan settings for OC), page wiring; l10n (~8 keys, all locales); widget tests.
- [ ] Tests: selecting a deviation adds a second (ghost) line bar; deviations section lists three tables for an OC deco plan; gas row shows `turn @` when a rule is set.
- [ ] Format+commit `feat(planner): contingency overlays and tables on the canvas`.

---

### Task 5: Verification sweep

- [ ] `flutter analyze` clean; format stable; `flutter test test/core/deco/ test/features/planner/ test/features/dive_planner/` green; gen-l10n zero untranslated.

**Out of scope:** slate export of contingency tables (Phase 7); per-stop lost-gas re-switching UI; cave same-way-back.
31 changes: 30 additions & 1 deletion lib/features/dive_planner/domain/entities/plan_result.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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;
show PlanMode, TurnPressureRule;

/// Types of warnings that can occur during dive planning.
enum PlanWarningType {
Expand Down Expand Up @@ -509,6 +509,14 @@ class DivePlanState extends Equatable {
/// Depth below which the high setpoint is in force; null = default 10 m.
final double? setpointSwitchDepth;

/// Contingency deviation deltas (Phase 5).
final double deviationDepthDelta;
final int deviationTimeMinutes;

/// Turn-pressure rule for penetration planning; null = none.
final TurnPressureRule? turnPressureRule;
final double? turnPressureFraction;

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

Expand Down Expand Up @@ -540,6 +548,10 @@ class DivePlanState extends Equatable {
this.setpointLow,
this.setpointHigh,
this.setpointSwitchDepth,
this.deviationDepthDelta = 5.0,
this.deviationTimeMinutes = 5,
this.turnPressureRule,
this.turnPressureFraction,
this.reservePressure = kDefaultReservePressureBar,
this.notes = '',
this.isDirty = false,
Expand Down Expand Up @@ -591,6 +603,11 @@ class DivePlanState extends Equatable {
double? setpointLow,
double? setpointHigh,
double? setpointSwitchDepth,
double? deviationDepthDelta,
int? deviationTimeMinutes,
TurnPressureRule? turnPressureRule,
double? turnPressureFraction,
bool clearTurnPressureRule = false,
double? reservePressure,
String? notes,
bool? isDirty,
Expand Down Expand Up @@ -624,6 +641,14 @@ class DivePlanState extends Equatable {
setpointSwitchDepth: clearSetpoints
? null
: (setpointSwitchDepth ?? this.setpointSwitchDepth),
deviationDepthDelta: deviationDepthDelta ?? this.deviationDepthDelta,
deviationTimeMinutes: deviationTimeMinutes ?? this.deviationTimeMinutes,
turnPressureRule: clearTurnPressureRule
? null
: (turnPressureRule ?? this.turnPressureRule),
turnPressureFraction: clearTurnPressureRule
? null
: (turnPressureFraction ?? this.turnPressureFraction),
reservePressure: reservePressure ?? this.reservePressure,
notes: notes ?? this.notes,
isDirty: isDirty ?? this.isDirty,
Expand All @@ -649,6 +674,10 @@ class DivePlanState extends Equatable {
setpointLow,
setpointHigh,
setpointSwitchDepth,
deviationDepthDelta,
deviationTimeMinutes,
turnPressureRule,
turnPressureFraction,
reservePressure,
notes,
isDirty,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,25 @@ class DivePlanNotifier extends StateNotifier<DivePlanState> {
);
}

/// Update contingency configuration; only supplied values change.
void updateContingencies({
double? depthDelta,
int? timeMinutes,
domain.TurnPressureRule? turnRule,
double? turnFraction,
bool clearTurnRule = false,
}) {
state = state.copyWith(
deviationDepthDelta: depthDelta,
deviationTimeMinutes: timeMinutes,
turnPressureRule: turnRule,
turnPressureFraction: turnFraction,
clearTurnPressureRule: clearTurnRule,
isDirty: true,
updatedAt: DateTime.now(),
);
}

// --------------------------------------------------------------------------
// Repetitive Dive Support
// --------------------------------------------------------------------------
Expand Down
11 changes: 11 additions & 0 deletions lib/features/planner/domain/entities/plan_outcome.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ enum PlanIssueType {
gasOut,
ndlExceededNoDecoGas,
noBailoutCarried,
minGasViolation,
}

/// One issue found while computing a plan.
Expand Down Expand Up @@ -137,12 +138,20 @@ class PlanTankUsage extends Equatable {
final double percentUsed;
final bool reserveViolation;

/// Turn pressure per the plan's turn-pressure rule (bottom tanks only).
final double? turnPressureBar;

/// Rock-bottom minimum gas for a stressed shared ascent (bottom tanks).
final double? minGasBar;

const PlanTankUsage({
required this.tankId,
required this.litersUsed,
this.remainingPressure,
required this.percentUsed,
this.reserveViolation = false,
this.turnPressureBar,
this.minGasBar,
});

@override
Expand All @@ -152,6 +161,8 @@ class PlanTankUsage extends Equatable {
remainingPressure,
percentUsed,
reserveViolation,
turnPressureBar,
minGasBar,
];
}

Expand Down
137 changes: 137 additions & 0 deletions lib/features/planner/domain/services/contingency_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import 'package:submersion/core/constants/enums.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'
as domain;
import 'package:submersion/features/planner/domain/entities/plan_outcome.dart';
import 'package:submersion/features/planner/domain/services/plan_engine.dart';

/// A "what if it goes deeper/longer" variant of the base plan.
class DeviationOutcome {
/// 'deeper' | 'longer' | 'both'
final String key;
final domain.DivePlan plan;
final PlanOutcome outcome;

const DeviationOutcome({
required this.key,
required this.plan,
required this.outcome,
});
}

/// The schedule that results from losing one deco/stage cylinder.
class LostGasOutcome {
final DiveTank tank;
final PlanOutcome outcome;

const LostGasOutcome({required this.tank, required this.outcome});
}

/// Derives contingency variants of a plan and runs them through the
/// PlanEngine: the classic slate trio (+depth, +time, both) and one
/// lost-gas schedule per carried deco/stage cylinder.
class ContingencyService {
final PlanEngineConfig config;

const ContingencyService({this.config = const PlanEngineConfig()});

PlanEngine get _engine => PlanEngine(config: config);

/// The three deviation keys, in slate order.
static const deviationKeys = ['deeper', 'longer', 'both'];

/// The deeper / longer / both variants (empty when the plan has no
/// segments).
List<DeviationOutcome> deviations(domain.DivePlan plan) {
if (plan.segments.isEmpty) return const [];
return [for (final key in deviationKeys) deviationFor(plan, key)!];
}

/// A single deviation variant by [key] ('deeper' | 'longer' | 'both'), or
/// null when the plan has no segments. Lets callers (the chart ghost) run
/// just the one variant the user selected instead of all three.
DeviationOutcome? deviationFor(domain.DivePlan plan, String key) {
if (plan.segments.isEmpty) return null;
final variant = switch (key) {
'deeper' => _deepened(plan),
'longer' => _lengthened(plan),
_ => _lengthened(_deepened(plan)),
};
return DeviationOutcome(
key: key,
plan: variant,
outcome: _engine.compute(variant),
);
}

/// One outcome per lost deco/stage cylinder. Empty for CCR plans (loop
/// loss is the bailout solver's job) and when no such cylinder is carried.
List<LostGasOutcome> lostGas(domain.DivePlan plan) {
if (plan.mode == domain.PlanMode.ccr || plan.segments.isEmpty) {
return const [];
}
final results = <LostGasOutcome>[];
for (final tank in plan.tanks) {
if (tank.role != TankRole.deco && tank.role != TankRole.stage) continue;
final remaining = plan.tanks.where((t) => t.id != tank.id).toList();
// Nothing left to breathe — a lost-gas schedule would be meaningless.
if (remaining.isEmpty) continue;
// Any user segment that breathed the lost cylinder is remapped onto a
// fallback (prefer back gas). Without this the contingency would still
// "breathe" the lost gas and its consumption would go unaccounted, since
// the engine only reports usage for tanks present in plan.tanks.
final fallback = remaining.firstWhere(
(t) => t.role == TankRole.backGas,
orElse: () => remaining.first,
);
final without = plan.copyWith(
tanks: remaining,
segments: [
for (final segment in plan.segments)
segment.tankId == tank.id
? segment.copyWith(tankId: fallback.id, gasMix: fallback.gasMix)
: segment,
],
);
results.add(
LostGasOutcome(tank: tank, outcome: _engine.compute(without)),
);
}
return results;
}

/// Depths equal to the plan's max depth grow by the deviation delta —
/// the bottom deepens and the descent that feeds it follows.
domain.DivePlan _deepened(domain.DivePlan plan) {
final maxDepth = plan.maxDepth;
final delta = plan.deviationDepthDelta;
PlanSegment deepen(PlanSegment segment) {
var changed = segment;
if ((segment.startDepth - maxDepth).abs() < 0.01) {
changed = changed.copyWith(startDepth: segment.startDepth + delta);
}
if ((segment.endDepth - maxDepth).abs() < 0.01) {
changed = changed.copyWith(endDepth: segment.endDepth + delta);
}
return changed;
}

return plan.copyWith(segments: plan.segments.map(deepen).toList());
}

/// Bottom segments grow by the deviation minutes.
domain.DivePlan _lengthened(domain.DivePlan plan) {
final extraSeconds = plan.deviationTimeMinutes * 60;
return plan.copyWith(
segments: [
for (final segment in plan.segments)
segment.type == SegmentType.bottom
? segment.copyWith(
durationSeconds: segment.durationSeconds + extraSeconds,
)
: segment,
],
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ domain.DivePlan divePlanFromState(
clearSetpointHigh: state.setpointHigh == null,
setpointSwitchDepth: state.setpointSwitchDepth,
clearSetpointSwitchDepth: state.setpointSwitchDepth == null,
deviationDepthDelta: state.deviationDepthDelta,
deviationTimeMinutes: state.deviationTimeMinutes,
turnPressureRule: state.turnPressureRule,
clearTurnPressureRule: state.turnPressureRule == null,
turnPressureFraction: state.turnPressureFraction,
clearTurnPressureFraction: state.turnPressureFraction == null,
gfLow: state.gfLow,
gfHigh: state.gfHigh,
sacBottom: state.sacRate,
Expand All @@ -63,6 +69,10 @@ DivePlanState stateFromDivePlan(domain.DivePlan plan) {
setpointLow: plan.setpointLow,
setpointHigh: plan.setpointHigh,
setpointSwitchDepth: plan.setpointSwitchDepth,
deviationDepthDelta: plan.deviationDepthDelta,
deviationTimeMinutes: plan.deviationTimeMinutes,
turnPressureRule: plan.turnPressureRule,
turnPressureFraction: plan.turnPressureFraction,
gfLow: plan.gfLow,
gfHigh: plan.gfHigh,
sacRate: plan.sacBottom,
Expand Down
Loading