From 6526066584c0e647e18796ff4d91628b42814beb Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:06:18 +0200 Subject: [PATCH 1/5] fix(dive-computer): resolve CCR transmitter cylinders from every transmitter reading A sample's pressureBar/tankIndex pair holds only the last transmitter it reported, which on a Shearwater CCR is always the oxygen transmitter. Read every transmitter from tankPressuresBar instead, resolve oxygen and diluent cylinders by their usage tag before the breathed-gas rule, and give gases without a transmitter the sensorless roles. Refs #2318 --- .../data/services/parsed_tank_resolver.dart | 167 ++++++++---- ...d_tank_resolver_ccr_transmitters_test.dart | 239 ++++++++++++++++++ 2 files changed, 361 insertions(+), 45 deletions(-) create mode 100644 test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart diff --git a/lib/features/dive_computer/data/services/parsed_tank_resolver.dart b/lib/features/dive_computer/data/services/parsed_tank_resolver.dart index 2240309027..542dd9f1ed 100644 --- a/lib/features/dive_computer/data/services/parsed_tank_resolver.dart +++ b/lib/features/dive_computer/data/services/parsed_tank_resolver.dart @@ -127,7 +127,9 @@ _ResolvedCylinders _resolveCylinders( return const _ResolvedCylinders([], {}); } final tanks = []; - final roles = _inferSensorlessRoles(gasMixes, parsed.diveMode); + final roles = _inferSensorlessRoles(gasMixes, [ + for (var i = 0; i < gasMixes.length; i++) i, + ], parsed.diveMode); for (var i = 0; i < gasMixes.length; i++) { final g = gasMixes[i]; gasIndexToTankIndex[i] = g.index; @@ -195,18 +197,27 @@ _ResolvedCylinders _resolveCylinders( // Pressureless cylinder for any gas not on a transmitter; indices sit above // every real tank/sample index so they never capture per-sample pressure. + // These are sensorless cylinders, so they get the same roles a tankless dive + // gives its gases: the gas's own usage tag first, then on CCR the bailout + // ranking (issue #2318). + final unclaimed = [ + for (var i = 0; i < gasMixes.length; i++) + if (!consumed.contains(i)) i, + ]; + final unclaimedRoles = _inferSensorlessRoles( + gasMixes, + unclaimed, + parsed.diveMode, + ); var nextIndex = _firstFreeIndex(parsed); - for (var i = 0; i < gasMixes.length; i++) { - if (consumed.contains(i)) { - continue; - } + for (final i in unclaimed) { gasIndexToTankIndex[i] = nextIndex; result.add( DownloadedTank( index: nextIndex++, o2Percent: gasMixes[i].o2Percent, hePercent: gasMixes[i].hePercent, - role: _inferRole(null, gasMixes[i].o2Percent, gasMixes[i].hePercent), + role: unclaimedRoles[i], ), ); } @@ -236,7 +247,9 @@ String _inferRole(int? usage, double o2Percent, double hePercent) { return TankRole.backGas.name; } -/// Role for each of [gasMixes], in order, on a sensorless (tankless) dive. +/// Role for each gas in [indices] (positions into [gasMixes]) that has no +/// transmitter: every gas on a tankless dive, or the gases left unclaimed by +/// the transmitters on one that has tank records. Keyed by gas index. /// /// A gas whose usage the computer reported directly on the gas mix itself /// (`dc_gasmix_t.usage`, independent of any tank/transmitter record) is @@ -260,13 +273,14 @@ String _inferRole(int? usage, double o2Percent, double hePercent) { /// /// On any other recognized dive mode, a gas with no reported usage keeps /// [_inferRole]'s original single-threshold heuristic, unaffected by this. -List _inferSensorlessRoles( +Map _inferSensorlessRoles( List gasMixes, + List indices, String? diveMode, ) { - final roles = List.filled(gasMixes.length, null); + final roles = {}; final unranked = []; - for (var i = 0; i < gasMixes.length; i++) { + for (final i in indices) { final g = gasMixes[i]; switch (g.usage) { case 1: // DC_USAGE_OXYGEN @@ -285,7 +299,7 @@ List _inferSensorlessRoles( final g = gasMixes[i]; roles[i] = _inferRole(null, g.o2Percent, g.hePercent); } - return [for (final role in roles) role!]; + return roles; } if (unranked.isNotEmpty) { @@ -304,14 +318,14 @@ List _inferSensorlessRoles( } } for (final i in unranked) { - if (roles[i] != null) continue; + if (roles.containsKey(i)) continue; roles[i] = gasMixes[i].o2Percent >= 41.0 ? TankRole.deco.name : TankRole.stage.name; } } - return [for (final role in roles) role!]; + return roles; } /// Whether two gas percentages are the same value within floating-point @@ -324,7 +338,7 @@ bool _nearlyEqualPercent(double a, double b) => (a - b).abs() < 1e-6; /// The gas-mix index (position in [gasMixes]) for [tank], preferring the gas /// actually breathed on it. Returns null when there are no gas mixes, or for -/// a CCR oxygen supply tank that matched neither of the first two rules. +/// a CCR oxygen supply tank the computer gave no gas mix of its own. int? _resolveTankGasIndex( pigeon.TankInfo tank, List samples, @@ -333,49 +347,87 @@ int? _resolveTankGasIndex( if (gasMixes.isEmpty) { return null; } + final linked = tank.gasMixIndex >= 0 && tank.gasMixIndex < gasMixes.length + ? tank.gasMixIndex + : null; + // A CCR supply cylinder is never breathed the way rule 1 below measures it: + // on the loop the active gas is always the diluent, so every transmitter + // reporting during the dive would be credited with it, and the oxygen + // cylinder came out as the diluent (issue #2318). Its usage tag decides. + switch (tank.usage) { + case 1: // DC_USAGE_OXYGEN + // The computer's own link, else a gas mix it tagged as oxygen (some + // computers report one without index-linking the tank, caught in review + // on #1972). Otherwise left gasless so the caller applies the 100% O2 + // default (#726): Shearwater never links it and never tags a gas oxygen. + if (linked != null) return linked; + final oxygenGasIndex = gasMixes.indexWhere((g) => g.usage == 1); + return oxygenGasIndex >= 0 ? oxygenGasIndex : null; + case 2: // DC_USAGE_DILUENT + // The computer's own link, else the diluent actually breathed. Only + // when the computer tagged no gas as a diluent do the generic rules + // below apply. + final diluent = linked ?? _breathedDiluentIndex(samples, gasMixes); + if (diluent != null) return diluent; + } // 1. The gas breathed on this transmitter (per-sample DC_SAMPLE_GASMIX). final breathed = _dominantGasIndex(tank.index, samples, gasMixes.length); if (breathed != null) { return breathed; } // 2. The computer's own tank->gas link, when it set one (non-Shearwater). - if (tank.gasMixIndex >= 0 && tank.gasMixIndex < gasMixes.length) { - return tank.gasMixIndex; - } - // A CCR oxygen supply cylinder is never "breathed" in the OC sense rule 1 - // tracks, and libdivecomputer's Shearwater parser never links it to a gas - // mix (rule 2), so falling through to rule 3 below would mislabel pure O2 - // as whatever gas happens to be first (#726). Some other computers DO - // report an explicit usage-tagged gas mix for it without index-linking the - // tank to it, though -- match that by its usage tag first, so it's - // consumed here rather than synthesized a second time as an unclaimed gas - // mix (caught in review on #1972). Only when no such entry exists is it - // left gasless, so the caller can apply the correct 100% O2 default. - if (tank.usage == 1 /* DC_USAGE_OXYGEN */ ) { - final oxygenGasIndex = gasMixes.indexWhere((g) => g.usage == 1); - return oxygenGasIndex >= 0 ? oxygenGasIndex : null; + if (linked != null) { + return linked; } // 3. Last resort: the dive's primary (first) mix -- never a hardcoded air // default, which would mislabel an EAN dive. return 0; } -/// The most frequent gas-mix index among the pressure samples of [tankIndex], -/// or null when none of that tank's samples carry a gas mix. +/// The diluent-tagged gas mix (`usage == 2`) breathed in the most samples, or +/// the first diluent-tagged one when none was breathed. Null when the computer +/// tagged no gas mix as a diluent. +/// +/// Shearwater reports every enabled diluent, not only the one used, and its +/// gas list puts the open-circuit gases first, so neither "the first gas" nor +/// "the first diluent" is safe on its own. +int? _breathedDiluentIndex( + List samples, + List gasMixes, +) { + bool isDiluent(int? i) => + i != null && i >= 0 && i < gasMixes.length && gasMixes[i].usage == 2; + final breathed = _mostFrequent([ + for (final s in samples) + if (isDiluent(s.gasMixIndex)) s.gasMixIndex!, + ]); + if (breathed != null) { + return breathed; + } + final first = gasMixes.indexWhere((g) => g.usage == 2); + return first >= 0 ? first : null; +} + +/// The most frequent gas-mix index among the samples in which [tankIndex] +/// reported a pressure, or null when none of them carries a gas mix. int? _dominantGasIndex( int tankIndex, List samples, int gasCount, -) { +) => _mostFrequent([ + for (final s in samples) + if (s.gasMixIndex case final gasIndex? + when gasIndex >= 0 && + gasIndex < gasCount && + _sampleTankReadings(s).containsKey(tankIndex)) + gasIndex, +]); + +/// The most frequent value in [values], or null when it is empty. +int? _mostFrequent(List values) { final counts = {}; - for (final s in samples) { - final gasIndex = s.gasMixIndex; - if (s.tankIndex == tankIndex && - gasIndex != null && - gasIndex >= 0 && - gasIndex < gasCount) { - counts[gasIndex] = (counts[gasIndex] ?? 0) + 1; - } + for (final value in values) { + counts[value] = (counts[value] ?? 0) + 1; } if (counts.isEmpty) { return null; @@ -406,20 +458,45 @@ int _firstFreeIndex(pigeon.ParsedDive parsed) { if (tankIndex != null && tankIndex > maxIndex) { maxIndex = tankIndex; } + final perTank = s.tankPressuresBar; + if (perTank != null && perTank.length - 1 > maxIndex) { + maxIndex = perTank.length - 1; + } } return maxIndex + 1; } +/// Every transmitter reading [sample] carries, keyed by tank index. +/// +/// libdivecomputer reports one pressure per air-integrated transmitter, so a +/// sample can carry several, and `pressureBar`/`tankIndex` hold only the last +/// of them (on a Shearwater CCR, always the oxygen transmitter). Reading the +/// pair alone credited that one transmitter with everything (issue #2318). +/// `tankPressuresBar` is the complete record; the pair remains the fallback +/// for sources that never report more than one tank per sample, the same rule +/// `groupPressuresByTank` applies to the stored pressure series. +Map _sampleTankReadings(pigeon.ProfileSample sample) { + final perTank = sample.tankPressuresBar; + if (perTank != null) { + return { + for (var index = 0; index < perTank.length; index++) + if (perTank[index] case final pressure?) index: pressure, + }; + } + final pressure = sample.pressureBar; + final tankIndex = sample.tankIndex; + return pressure != null && tankIndex != null + ? {tankIndex: pressure} + : const {}; +} + /// Reduce libdivecomputer samples to the depth-plus-pressure points the -/// surfacing rule reads. A sample carries at most one transmitter reading, so -/// each point holds either one entry or none. +/// surfacing rule reads, with every transmitter the sample carries. List _surfacingPoints(List s) => [ for (final sample in s) SurfacingProfilePoint( timeSeconds: sample.timeSeconds, depthMeters: sample.depthMeters, - tankPressuresBar: sample.pressureBar != null && sample.tankIndex != null - ? {sample.tankIndex!: sample.pressureBar!} - : const {}, + tankPressuresBar: _sampleTankReadings(sample), ), ]; diff --git a/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart b/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart new file mode 100644 index 0000000000..538ec40776 --- /dev/null +++ b/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart @@ -0,0 +1,239 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; +import 'package:submersion/features/dive_computer/data/services/parsed_tank_resolver.dart'; +import 'package:submersion/features/dive_computer/domain/entities/downloaded_dive.dart'; + +/// Issue #2318: a Shearwater CCR dive with a diluent and an oxygen transmitter. +/// +/// Both transmitters report in the same sample, but a sample's +/// `pressureBar`/`tankIndex` pair only holds the LAST reading, which on the +/// Petrel 3 is always the oxygen transmitter. Every reading is in +/// `tankPressuresBar`. These tests model the real download: gas list in the +/// parser's order (OC slots first, so OC1 99/0 is gas 0), the whole dive on the +/// loop breathing the diluent, and both transmitters in almost every sample. +void main() { + // DC_GASMIX_UNKNOWN: Shearwater never links a tank to a gas mix. + const unknownGasMixIndex = 4294967295; + const oxygenUsage = 1; // DC_USAGE_OXYGEN + const diluentUsage = 2; // DC_USAGE_DILUENT + + // Gas list as the Shearwater parser reports it for this setup. + const oc1Deco = 0; // OC1 99/0 + const oc5Bailout = 1; // OC5 15/55 + const dil2 = 2; // DIL2 15/55, the breathed diluent + final gasMixes = [ + pigeon.GasMix(index: oc1Deco, o2Percent: 99.0, hePercent: 0.0), + pigeon.GasMix(index: oc5Bailout, o2Percent: 15.0, hePercent: 55.0), + pigeon.GasMix( + index: dil2, + o2Percent: 15.0, + hePercent: 55.0, + usage: diluentUsage, + ), + ]; + + // Tank 0 is the transmitter named "D1", tank 1 the one named "O2". The + // reported end pressures are the last tail samples, as libdivecomputer + // reports them. + final tanks = [ + pigeon.TankInfo( + index: 0, + gasMixIndex: unknownGasMixIndex, + startPressureBar: 128.9, + endPressureBar: 13.4, + usage: diluentUsage, + ), + pigeon.TankInfo( + index: 1, + gasMixIndex: unknownGasMixIndex, + startPressureBar: 107.7, + endPressureBar: 29.5, + usage: oxygenUsage, + ), + ]; + + /// A loop sample carrying both transmitters, oxygen reported last. + pigeon.ProfileSample bothReport( + int t, + double depth, + double diluentBar, + double oxygenBar, + ) => pigeon.ProfileSample( + timeSeconds: t, + depthMeters: depth, + pressureBar: oxygenBar, + tankIndex: 1, + tankPressuresBar: [diluentBar, oxygenBar], + gasMixIndex: dil2, + ); + + pigeon.ParsedDive ccrDive(List samples) => + pigeon.ParsedDive( + fingerprint: 'test', + dateTimeYear: 2026, + dateTimeMonth: 9, + dateTimeDay: 12, + dateTimeHour: 8, + dateTimeMinute: 56, + dateTimeSecond: 33, + maxDepthMeters: 61.9, + avgDepthMeters: 30.0, + durationSeconds: 3900, + diveMode: 'ccr', + samples: samples, + tanks: tanks, + gasMixes: gasMixes, + events: const [], + ); + + /// Both transmitters in every sample, the oxygen transmitter always last, so + /// the diluent transmitter never owns a sample's tankIndex. On the diver's + /// 21.09.2026 dive this imported the diluent cylinder as 99/0. + pigeon.ParsedDive oxygenAlwaysLast() => ccrDive([ + bothReport(0, 0.0, 128.9, 107.7), + bothReport(600, 40.0, 120.0, 90.0), + bothReport(1800, 61.9, 100.0, 60.0), + bothReport(3600, 1.2, 77.6, 35.0), // last sample below the surface + bothReport(3700, 0.0, 40.0, 31.0), + bothReport(3800, 0.0, 13.4, 29.5), // post-surfacing tail + ]); + + DownloadedTank tankAt(List tanks, int index) => + tanks.firstWhere((t) => t.index == index); + + group('CCR dive with a diluent and an oxygen transmitter (#2318)', () { + test('the oxygen cylinder is pure O2, not the breathed diluent', () { + final oxygen = tankAt(resolveParsedTanks(oxygenAlwaysLast()), 1); + expect(oxygen.role, 'oxygenSupply'); + expect(oxygen.o2Percent, 100.0); + expect(oxygen.hePercent, 0.0); + }); + + test('the diluent cylinder carries the breathed diluent even when it ' + 'never owns a sample tankIndex', () { + final diluent = tankAt(resolveParsedTanks(oxygenAlwaysLast()), 0); + expect(diluent.role, 'diluent'); + expect(diluent.o2Percent, 15.0); + expect(diluent.hePercent, 55.0); + }); + + test('the dive-level diluent follows the diluent cylinder', () { + final diluent = resolveDiluentGas(resolveParsedTanks(oxygenAlwaysLast())); + expect(diluent, isNotNull); + expect(diluent!.o2, 15.0); + expect(diluent.he, 55.0); + }); + + test('gases without a transmitter keep their CCR roles: the open circuit ' + 'bottom gas is bailout, 99% is deco', () { + final resolved = resolveParsedTanks(oxygenAlwaysLast()); + expect(resolved, hasLength(4)); + final withoutTransmitter = resolved.where((t) => t.index > 1).toList(); + expect( + withoutTransmitter.firstWhere((t) => t.o2Percent == 99.0).role, + 'deco', + ); + expect( + withoutTransmitter.firstWhere((t) => t.o2Percent == 15.0).role, + 'bailout', + ); + }); + + test('both transmitters are trimmed to their reading at surfacing', () { + final resolved = resolveParsedTanks(oxygenAlwaysLast()); + expect(tankAt(resolved, 0).endPressure, 77.6); + expect(tankAt(resolved, 1).endPressure, 35.0); + }); + + test('without the surfacing trim both keep the reported end pressure', () { + final resolved = resolveParsedTanks( + oxygenAlwaysLast(), + trimAtSurfacing: false, + ); + expect(tankAt(resolved, 0).endPressure, 13.4); + expect(tankAt(resolved, 1).endPressure, 29.5); + }); + + test('the most-breathed diluent wins when several are programmed', () { + // DIL1 21/0 is programmed and enabled but never breathed. + final parsed = pigeon.ParsedDive( + fingerprint: 'test', + dateTimeYear: 2026, + dateTimeMonth: 9, + dateTimeDay: 12, + dateTimeHour: 8, + dateTimeMinute: 56, + dateTimeSecond: 33, + maxDepthMeters: 61.9, + avgDepthMeters: 30.0, + durationSeconds: 3900, + diveMode: 'ccr', + samples: [ + bothReport(0, 0.0, 128.9, 107.7), + bothReport(600, 40.0, 120.0, 90.0), + ], + tanks: tanks, + gasMixes: [ + ...gasMixes, + pigeon.GasMix( + index: 3, + o2Percent: 21.0, + hePercent: 0.0, + usage: diluentUsage, + ), + ], + events: const [], + ); + final diluent = tankAt(resolveParsedTanks(parsed), 0); + expect(diluent.o2Percent, 15.0); + expect(diluent.hePercent, 55.0); + }); + + test('no gas switch is derived for a dive that stays on the loop', () { + expect(resolveGasSwitches(oxygenAlwaysLast()), isEmpty); + }); + }); + + group('open circuit with two transmitters in one sample', () { + test('both sidemount cylinders are labeled with the breathed gas', () { + // Before #2318 only the last-reported transmitter was attributed a gas; + // the other fell back to the first gas mix (here the 50% deco gas). + final parsed = pigeon.ParsedDive( + fingerprint: 'test', + dateTimeYear: 2026, + dateTimeMonth: 9, + dateTimeDay: 12, + dateTimeHour: 8, + dateTimeMinute: 56, + dateTimeSecond: 33, + maxDepthMeters: 30.0, + avgDepthMeters: 20.0, + durationSeconds: 3000, + diveMode: 'oc', + samples: [ + for (final t in [0, 600, 1200, 1800]) + pigeon.ProfileSample( + timeSeconds: t, + depthMeters: 20.0, + pressureBar: 180.0, + tankIndex: 1, + tankPressuresBar: const [190.0, 180.0], + gasMixIndex: 1, + ), + ], + tanks: [ + pigeon.TankInfo(index: 0, gasMixIndex: unknownGasMixIndex), + pigeon.TankInfo(index: 1, gasMixIndex: unknownGasMixIndex), + ], + gasMixes: [ + pigeon.GasMix(index: 0, o2Percent: 50.0, hePercent: 0.0), + pigeon.GasMix(index: 1, o2Percent: 32.0, hePercent: 0.0), + ], + events: const [], + ); + final resolved = resolveParsedTanks(parsed); + expect(tankAt(resolved, 0).o2Percent, 32.0); + expect(tankAt(resolved, 1).o2Percent, 32.0); + }); + }); +} From 8c71837caa1a74859ff864337ab1e13a340ef408 Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:08:37 +0200 Subject: [PATCH 2/5] refactor(dive-computer): use a null-aware map entry for transmitter readings Refs #2318 --- .../dive_computer/data/services/parsed_tank_resolver.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/dive_computer/data/services/parsed_tank_resolver.dart b/lib/features/dive_computer/data/services/parsed_tank_resolver.dart index 542dd9f1ed..878a444889 100644 --- a/lib/features/dive_computer/data/services/parsed_tank_resolver.dart +++ b/lib/features/dive_computer/data/services/parsed_tank_resolver.dart @@ -480,7 +480,7 @@ Map _sampleTankReadings(pigeon.ProfileSample sample) { if (perTank != null) { return { for (var index = 0; index < perTank.length; index++) - if (perTank[index] case final pressure?) index: pressure, + index: ?perTank[index], }; } final pressure = sample.pressureBar; From c3cdbb0ac7962c38f3a5c24ff73e18d18b5e6624 Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:12:07 +0200 Subject: [PATCH 3/5] fix(dive-computer): keep tankIndex attribution for the breathed-gas rule Crediting every reporting transmitter with the breathed gas overrode a computer's own tank->gas link. The CCR fix does not need it: oxygen and diluent cylinders resolve by usage. Per-transmitter readings stay for the surfacing trim. Put the breathed diluent first among the unclaimed gases so the dive-level diluent is the one used, not the first programmed one. Refs #2318 --- .../data/services/parsed_tank_resolver.dart | 25 +++++-- ...d_tank_resolver_ccr_transmitters_test.dart | 75 +++++++++++++++++-- 2 files changed, 87 insertions(+), 13 deletions(-) diff --git a/lib/features/dive_computer/data/services/parsed_tank_resolver.dart b/lib/features/dive_computer/data/services/parsed_tank_resolver.dart index 878a444889..430f4860ed 100644 --- a/lib/features/dive_computer/data/services/parsed_tank_resolver.dart +++ b/lib/features/dive_computer/data/services/parsed_tank_resolver.dart @@ -199,10 +199,15 @@ _ResolvedCylinders _resolveCylinders( // every real tank/sample index so they never capture per-sample pressure. // These are sensorless cylinders, so they get the same roles a tankless dive // gives its gases: the gas's own usage tag first, then on CCR the bailout - // ranking (issue #2318). + // ranking (issue #2318). The breathed diluent goes first, so a dive whose + // diluent has no transmitter still gets the one used, not merely the first + // programmed one, from [resolveDiluentGas]. + final breathedDiluent = _breathedDiluentIndex(parsed.samples, gasMixes); final unclaimed = [ + if (breathedDiluent != null && !consumed.contains(breathedDiluent)) + breathedDiluent, for (var i = 0; i < gasMixes.length; i++) - if (!consumed.contains(i)) i, + if (!consumed.contains(i) && i != breathedDiluent) i, ]; final unclaimedRoles = _inferSensorlessRoles( gasMixes, @@ -408,8 +413,13 @@ int? _breathedDiluentIndex( return first >= 0 ? first : null; } -/// The most frequent gas-mix index among the samples in which [tankIndex] -/// reported a pressure, or null when none of them carries a gas mix. +/// The most frequent gas-mix index among the pressure samples of [tankIndex], +/// or null when none of that tank's samples carry a gas mix. +/// +/// Deliberately keyed on the sample's own `tankIndex` rather than on every +/// transmitter the sample carries: a transmitter reports all dive long, so +/// crediting every reporting tank would hand each one the dive's main gas and +/// override a computer's own tank->gas link (review on #2318). int? _dominantGasIndex( int tankIndex, List samples, @@ -417,9 +427,7 @@ int? _dominantGasIndex( ) => _mostFrequent([ for (final s in samples) if (s.gasMixIndex case final gasIndex? - when gasIndex >= 0 && - gasIndex < gasCount && - _sampleTankReadings(s).containsKey(tankIndex)) + when s.tankIndex == tankIndex && gasIndex >= 0 && gasIndex < gasCount) gasIndex, ]); @@ -471,7 +479,8 @@ int _firstFreeIndex(pigeon.ParsedDive parsed) { /// libdivecomputer reports one pressure per air-integrated transmitter, so a /// sample can carry several, and `pressureBar`/`tankIndex` hold only the last /// of them (on a Shearwater CCR, always the oxygen transmitter). Reading the -/// pair alone credited that one transmitter with everything (issue #2318). +/// pair alone left every other transmitter without a reading at surfacing, so +/// the diluent kept its post-surfacing bleed-down (issue #2318). /// `tankPressuresBar` is the complete record; the pair remains the fallback /// for sources that never report more than one tank per sample, the same rule /// `groupPressuresByTank` applies to the stored pressure series. diff --git a/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart b/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart index 538ec40776..f5c45d3029 100644 --- a/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart +++ b/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart @@ -194,10 +194,75 @@ void main() { }); }); + group('CCR dive with only an oxygen transmitter', () { + test('the dive-level diluent is the breathed one, not the first ' + 'programmed one', () { + // DIL1 21/0 is enabled but never breathed; DIL2 15/55 is on the loop. + // Neither has a transmitter, so both become pressureless cylinders. + final parsed = pigeon.ParsedDive( + fingerprint: 'test', + dateTimeYear: 2026, + dateTimeMonth: 9, + dateTimeDay: 12, + dateTimeHour: 8, + dateTimeMinute: 56, + dateTimeSecond: 33, + maxDepthMeters: 61.9, + avgDepthMeters: 30.0, + durationSeconds: 3900, + diveMode: 'ccr', + samples: [ + for (final t in [0, 600, 1200]) + pigeon.ProfileSample( + timeSeconds: t, + depthMeters: 30.0, + pressureBar: 100.0, + tankIndex: 0, + tankPressuresBar: const [100.0], + gasMixIndex: 2, + ), + ], + tanks: [ + pigeon.TankInfo( + index: 0, + gasMixIndex: unknownGasMixIndex, + startPressureBar: 107.7, + usage: oxygenUsage, + ), + ], + gasMixes: [ + pigeon.GasMix(index: 0, o2Percent: 99.0, hePercent: 0.0), + pigeon.GasMix( + index: 1, + o2Percent: 21.0, + hePercent: 0.0, + usage: diluentUsage, + ), + pigeon.GasMix( + index: 2, + o2Percent: 15.0, + hePercent: 55.0, + usage: diluentUsage, + ), + ], + events: const [], + ); + final resolved = resolveParsedTanks(parsed); + expect(tankAt(resolved, 0).o2Percent, 100.0); + final diluent = resolveDiluentGas(resolved); + expect(diluent, isNotNull); + expect(diluent!.o2, 15.0); + expect(diluent.he, 55.0); + }); + }); + group('open circuit with two transmitters in one sample', () { - test('both sidemount cylinders are labeled with the breathed gas', () { - // Before #2318 only the last-reported transmitter was attributed a gas; - // the other fell back to the first gas mix (here the 50% deco gas). + test("a computer's own tank->gas link survives a transmitter that " + 'reports all dive long', () { + // Tank 0 is linked to the 50% deco gas and reports in every sample, but + // tank 1 is always the last reading, so tank 0 never owns a sample's + // tankIndex. Crediting every reporting transmitter with the breathed + // gas would override that link with the 32% bottom gas. final parsed = pigeon.ParsedDive( fingerprint: 'test', dateTimeYear: 2026, @@ -222,7 +287,7 @@ void main() { ), ], tanks: [ - pigeon.TankInfo(index: 0, gasMixIndex: unknownGasMixIndex), + pigeon.TankInfo(index: 0, gasMixIndex: 0), pigeon.TankInfo(index: 1, gasMixIndex: unknownGasMixIndex), ], gasMixes: [ @@ -232,7 +297,7 @@ void main() { events: const [], ); final resolved = resolveParsedTanks(parsed); - expect(tankAt(resolved, 0).o2Percent, 32.0); + expect(tankAt(resolved, 0).o2Percent, 50.0); expect(tankAt(resolved, 1).o2Percent, 32.0); }); }); From 3c455c628ced877801679454c86187e162e2ab1f Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:14:39 +0200 Subject: [PATCH 4/5] fix(dive-computer): rank CCR bailout against every untagged gas A bailout cylinder on its own transmitter is claimed before the unclaimed gases are ranked, so the leanest remaining gas was promoted to Bailout. Rank against every gas with no reported usage and assign only the unclaimed ones. Refs #2318 --- .../data/services/parsed_tank_resolver.dart | 24 ++++++++-- ...d_tank_resolver_ccr_transmitters_test.dart | 48 +++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/lib/features/dive_computer/data/services/parsed_tank_resolver.dart b/lib/features/dive_computer/data/services/parsed_tank_resolver.dart index 430f4860ed..9f9b1fcb84 100644 --- a/lib/features/dive_computer/data/services/parsed_tank_resolver.dart +++ b/lib/features/dive_computer/data/services/parsed_tank_resolver.dart @@ -267,7 +267,8 @@ String _inferRole(int? usage, double o2Percent, double hePercent) { /// For a dive recognized as CCR, the gases left with no reported usage are /// the open-circuit bailout candidates and are ranked against each other /// instead of scored in isolation: -/// 1. Bottom gas: the lowest O2 percentage among them becomes +/// 1. Bottom gas: the lowest O2 percentage among every gas of the dive with no +/// reported usage (one on a transmitter included) becomes /// [TankRole.bailout]; a tie is broken by the higher helium percentage, /// and a further tie gives Bailout to every still-tied gas. A gas that /// only loses the helium tie-break gets no automatic Bailout role and @@ -287,6 +288,7 @@ Map _inferSensorlessRoles( final unranked = []; for (final i in indices) { final g = gasMixes[i]; + // Keep in step with _hasReportedUsage. switch (g.usage) { case 1: // DC_USAGE_OXYGEN roles[i] = TankRole.oxygenSupply.name; @@ -308,17 +310,26 @@ Map _inferSensorlessRoles( } if (unranked.isNotEmpty) { - final lowestO2 = unranked + // Ranked against every gas of the dive with no reported usage, including + // one a transmitter claimed: a bailout cylinder on its own transmitter is + // still the bottom gas, and leaving it out would promote the leanest + // remaining gas (say a 50% deco gas) to Bailout (review on #2318). + final candidates = [ + for (var i = 0; i < gasMixes.length; i++) + if (!_hasReportedUsage(gasMixes[i])) i, + ]; + final lowestO2 = candidates .map((i) => gasMixes[i].o2Percent) .reduce((a, b) => a < b ? a : b); - final atLowestO2 = unranked.where( + final atLowestO2 = candidates.where( (i) => _nearlyEqualPercent(gasMixes[i].o2Percent, lowestO2), ); final highestHeAtLowestO2 = atLowestO2 .map((i) => gasMixes[i].hePercent) .reduce((a, b) => a > b ? a : b); for (final i in atLowestO2) { - if (_nearlyEqualPercent(gasMixes[i].hePercent, highestHeAtLowestO2)) { + if (unranked.contains(i) && + _nearlyEqualPercent(gasMixes[i].hePercent, highestHeAtLowestO2)) { roles[i] = TankRole.bailout.name; } } @@ -333,6 +344,11 @@ Map _inferSensorlessRoles( return roles; } +/// Whether the computer tagged [gas] with a usage that fixes its role +/// (oxygen, diluent or sidemount), taking it out of the bailout ranking. +bool _hasReportedUsage(pigeon.GasMix gas) => + gas.usage == 1 || gas.usage == 2 || gas.usage == 3; + /// Whether two gas percentages are the same value within floating-point /// noise. Each of the four platform converters independently computes /// `fraction * 100.0` from the native `dc_gasmix_t`, so two mixes the diver diff --git a/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart b/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart index f5c45d3029..8fbde417b9 100644 --- a/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart +++ b/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart @@ -256,6 +256,54 @@ void main() { }); }); + group('CCR dive with a bailout cylinder on its own transmitter', () { + test('a leaner deco gas without a transmitter stays deco, not bailout', () { + final parsed = pigeon.ParsedDive( + fingerprint: 'test', + dateTimeYear: 2026, + dateTimeMonth: 9, + dateTimeDay: 12, + dateTimeHour: 8, + dateTimeMinute: 56, + dateTimeSecond: 33, + maxDepthMeters: 61.9, + avgDepthMeters: 30.0, + durationSeconds: 3900, + diveMode: 'ccr', + samples: [ + for (final t in [0, 600, 1200]) + pigeon.ProfileSample( + timeSeconds: t, + depthMeters: 30.0, + pressureBar: 100.0, + tankIndex: 1, + tankPressuresBar: const [120.0, 100.0, 200.0], + gasMixIndex: 2, + ), + ], + tanks: [ + ...tanks, + // An untagged third transmitter on the 15/55 bailout cylinder, + // linked by the computer to its gas. + pigeon.TankInfo(index: 2, gasMixIndex: 1, startPressureBar: 200.0), + ], + gasMixes: [ + pigeon.GasMix(index: 0, o2Percent: 50.0, hePercent: 0.0), + pigeon.GasMix(index: 1, o2Percent: 15.0, hePercent: 55.0), + pigeon.GasMix( + index: 2, + o2Percent: 15.0, + hePercent: 55.0, + usage: diluentUsage, + ), + ], + events: const [], + ); + final resolved = resolveParsedTanks(parsed); + expect(resolved.firstWhere((t) => t.o2Percent == 50.0).role, 'deco'); + }); + }); + group('open circuit with two transmitters in one sample', () { test("a computer's own tank->gas link survives a transmitter that " 'reports all dive long', () { From 9738c41494a4d90ba1e7efef1fd51587a1b27b54 Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:28:22 +0200 Subject: [PATCH 5/5] fix(dive-computer): trim a legacy reading without a tank index as tank 0 Match groupPressuresByTank, which stores such a reading on tank 0, so the surfacing trim does not drop it. Also covers the first-diluent fallback. Refs #2318 --- .../data/services/parsed_tank_resolver.dart | 6 +- ...d_tank_resolver_ccr_transmitters_test.dart | 66 +++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/lib/features/dive_computer/data/services/parsed_tank_resolver.dart b/lib/features/dive_computer/data/services/parsed_tank_resolver.dart index 9f9b1fcb84..90c0b4bf87 100644 --- a/lib/features/dive_computer/data/services/parsed_tank_resolver.dart +++ b/lib/features/dive_computer/data/services/parsed_tank_resolver.dart @@ -508,11 +508,9 @@ Map _sampleTankReadings(pigeon.ProfileSample sample) { index: ?perTank[index], }; } + // A reading without a tank index belongs to tank 0, as in the stored series. final pressure = sample.pressureBar; - final tankIndex = sample.tankIndex; - return pressure != null && tankIndex != null - ? {tankIndex: pressure} - : const {}; + return pressure != null ? {sample.tankIndex ?? 0: pressure} : const {}; } /// Reduce libdivecomputer samples to the depth-plus-pressure points the diff --git a/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart b/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart index 8fbde417b9..82d293623d 100644 --- a/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart +++ b/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart @@ -189,6 +189,30 @@ void main() { expect(diluent.hePercent, 55.0); }); + test('a diluent cylinder falls back to the first diluent when no diluent ' + 'was breathed', () { + final parsed = pigeon.ParsedDive( + fingerprint: 'test', + dateTimeYear: 2026, + dateTimeMonth: 9, + dateTimeDay: 12, + dateTimeHour: 8, + dateTimeMinute: 56, + dateTimeSecond: 33, + maxDepthMeters: 61.9, + avgDepthMeters: 30.0, + durationSeconds: 3900, + diveMode: 'ccr', + samples: const [], + tanks: tanks, + gasMixes: gasMixes, + events: const [], + ); + final diluent = tankAt(resolveParsedTanks(parsed), 0); + expect(diluent.o2Percent, 15.0); + expect(diluent.hePercent, 55.0); + }); + test('no gas switch is derived for a dive that stays on the loop', () { expect(resolveGasSwitches(oxygenAlwaysLast()), isEmpty); }); @@ -304,6 +328,48 @@ void main() { }); }); + group('legacy single-reading samples', () { + test('a reading without a tank index is trimmed as tank 0, like the ' + 'stored pressure series', () { + pigeon.ProfileSample legacy(int t, double depth, double bar) => + pigeon.ProfileSample( + timeSeconds: t, + depthMeters: depth, + pressureBar: bar, + gasMixIndex: 0, + ); + final parsed = pigeon.ParsedDive( + fingerprint: 'test', + dateTimeYear: 2026, + dateTimeMonth: 9, + dateTimeDay: 12, + dateTimeHour: 8, + dateTimeMinute: 56, + dateTimeSecond: 33, + maxDepthMeters: 30.0, + avgDepthMeters: 20.0, + durationSeconds: 3000, + samples: [ + legacy(0, 0.0, 200.0), + legacy(1200, 30.0, 120.0), + legacy(2400, 1.2, 60.0), // last sample below the surface + legacy(2500, 0.0, 20.0), // post-surfacing tail + ], + tanks: [ + pigeon.TankInfo( + index: 0, + gasMixIndex: 0, + startPressureBar: 200.0, + endPressureBar: 20.0, + ), + ], + gasMixes: [pigeon.GasMix(index: 0, o2Percent: 100.0, hePercent: 0.0)], + events: const [], + ); + expect(resolveParsedTanks(parsed).single.endPressure, 60.0); + }); + }); + group('open circuit with two transmitters in one sample', () { test("a computer's own tank->gas link survives a transmitter that " 'reports all dive long', () {