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
43 changes: 39 additions & 4 deletions lib/core/database/database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1779,6 +1779,10 @@ class DiverSettings extends Table {
// Auto site matching sensitivity (v76): strict | balanced | relaxed
TextColumn get siteMatchSensitivity =>
text().withDefault(const Constant('balanced'))();
// Read cylinder end pressure at surfacing rather than at the end of the
// recording (v165, issue #1092).
BoolColumn get trimTankPressureAtSurfacing =>
boolean().withDefault(const Constant(true))();
// Dive profile chart defaults
TextColumn get defaultRightAxisMetric =>
text().withDefault(const Constant('temperature'))();
Expand Down Expand Up @@ -3480,10 +3484,13 @@ class AppDatabase extends _$AppDatabase {
// media item in the dive when its capture time is wrong (issue #1090).
// Renumbered from 162, which #731 landed past while this branch was open.
164,
// v165 is deliberately absent, not missing: it is claimed by issue #1092
// (PR #1290, diver_settings.trim_tank_pressure_at_surfacing) on a branch
// that is still open. This ladder is monotonic and unique, not
// contiguous, so do not "fix" the gap by renumbering downwards.
// v165: diver_settings.trim_tank_pressure_at_surfacing, which decides
// whether an import reads cylinder end pressure at the moment of
// surfacing rather than at the end of the recording (issue #1092).
// Renumbered from 163, which #731 landed on main while this branch
// was open. Main reserved this number while the branch was open, so it
// lands here without renumbering.
165,
// v166: diver_settings.place_name_language, the synced language used for
// reverse-geocoded country/region/town/body of water (issue #1187).
// Renumbered from 162, which #731 landed past while this branch was open.
Expand Down Expand Up @@ -5008,6 +5015,26 @@ class AppDatabase extends _$AppDatabase {
}
}

/// v165: trim_tank_pressure_at_surfacing on diver_settings (issue #1092).
/// Dive computers keep recording after the diver surfaces, so the last
/// pressure in the profile is not the pressure at the end of the dive. On
/// by default, because the reading it prefers can only ever be the higher,
/// earlier one.
Future<void> _assertSurfacingPressureColumn() async {
final cols = await customSelect(
"PRAGMA table_info('diver_settings')",
).get();
if (cols.isEmpty) return;
final names = cols.map((c) => c.read<String>('name')).toSet();
if (!names.contains('trim_tank_pressure_at_surfacing')) {
await customStatement(
'ALTER TABLE diver_settings ADD COLUMN trim_tank_pressure_at_surfacing '
'INTEGER NOT NULL DEFAULT 1 '
'CHECK (trim_tank_pressure_at_surfacing IN (0, 1))',
);
}
}

/// v166: place_name_language on diver_settings (issue #1187). Defaults to
/// 'en', the language every pre-v166 row was geocoded in (issue #214).
Future<void> _assertPlaceNameLanguageColumn() async {
Expand Down Expand Up @@ -8665,6 +8692,11 @@ class AppDatabase extends _$AppDatabase {
await _assertMediaManualElapsedColumn();
}
if (from < 164) await reportProgress();
// v165: trim_tank_pressure_at_surfacing on diver_settings (#1092).
if (from < 165) {
await _assertSurfacingPressureColumn();
}
if (from < 165) await reportProgress();
// v166: place_name_language on diver_settings (issue #1187).
if (from < 166) {
await _assertPlaceNameLanguageColumn();
Expand Down Expand Up @@ -8880,6 +8912,9 @@ class AppDatabase extends _$AppDatabase {
// #1090; same parallel-branch version-collision self-heal). The
// media row mapper reads it on every hydration.
await _assertMediaManualElapsedColumn();
// v165 backstop: re-assert diver_settings.trim_tank_pressure_at_
// surfacing (issue #1092; same parallel-branch collision self-heal).
await _assertSurfacingPressureColumn();

// v168 backstop: re-assert buddies.is_favorite (issue #638). A
// database that arrives by restore or sync-adopt never runs
Expand Down
136 changes: 136 additions & 0 deletions lib/core/profile/surfacing_pressure.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/// Depth below which a diver counts as being on the surface, in meters.
///
/// Matches Subsurface's long-standing `SURFACE_THRESHOLD` of 750 mm, so a
/// dive imported from either app agrees on where the dive ended.
const double kSurfaceThresholdMeters = 0.75;

/// How far a reported end pressure may sit from the last post-surfacing
/// reading and still count as having come from it, in bar.
///
/// Sources quantize pressure before converting it: Shearwater logs units of
/// 2 psi (about 0.14 bar), and exported files often round to whole psi or bar.
/// The artifact this guards against is measured in tens of bar, so a tolerance
/// this small cannot let one through.
const double kPressureMatchToleranceBar = 0.5;

/// One profile sample, reduced to what the surfacing rule needs: when it was
/// taken, how deep the diver was, and what each cylinder read at that instant.
///
/// [tankPressuresBar] is keyed by cylinder index and is empty for a sample that
/// carries no transmitter reading.
class SurfacingProfilePoint {
const SurfacingProfilePoint({
required this.timeSeconds,
required this.depthMeters,
this.tankPressuresBar = const {},
});

final int timeSeconds;
final double depthMeters;
final Map<int, double> tankPressuresBar;
}

/// What one cylinder read on either side of the surfacing moment.
class SurfacingTankReading {
const SurfacingTankReading({
required this.atSurfacing,
required this.lastAfterSurfacing,
});

/// The cylinder's most recent reading at or before surfacing: the pressure
/// it actually held at the end of the dive.
final double atSurfacing;

/// The cylinder's last reading in the post-surfacing tail, or null when the
/// recording stopped at the surface.
final double? lastAfterSurfacing;
}

/// What each cylinder read on either side of the moment the diver surfaced,
/// keyed by cylinder index.
///
/// Dive computers keep recording for a while after the diver reaches the
/// surface, so the last reading in the profile is not the reading at the end of
/// the dive. On a rebreather whose oxygen cylinder feeds a constant mass flow
/// orifice, closing the valve topside leaves the hose bleeding down through
/// that orifice, and the tail of the recording can shed most of the cylinder's
/// apparent contents (issue #1092).
///
/// Surfacing is the last sample deeper than [kSurfaceThresholdMeters], so a
/// diver who drops back down after a surface break is measured from the final
/// descent. Each cylinder is read independently and carries its most recent
/// value forward, because transmitters report on their own cadence and the
/// surfacing sample may hold no reading for a given cylinder.
///
/// Cylinders with no reading at or before surfacing are left out: there is
/// nothing to correct a reported end pressure with. Returns an empty map when
/// the profile never went below the threshold or carries no pressure at all.
/// Sample order in [points] does not matter.
Map<int, SurfacingTankReading> surfacingTankReadings(
List<SurfacingProfilePoint> points,
) {
int? surfacingTime;
for (final p in points) {
if (p.depthMeters > kSurfaceThresholdMeters &&
(surfacingTime == null || p.timeSeconds > surfacingTime)) {
surfacingTime = p.timeSeconds;
}
}
if (surfacingTime == null) {
return const {};
}

final atSurfacing = <int, double>{};
final atSurfacingTime = <int, int>{};
final afterSurfacing = <int, double>{};
final afterSurfacingTime = <int, int>{};
for (final p in points) {
final surfaced = p.timeSeconds > surfacingTime;
final values = surfaced ? afterSurfacing : atSurfacing;
final times = surfaced ? afterSurfacingTime : atSurfacingTime;
for (final entry in p.tankPressuresBar.entries) {
final seen = times[entry.key];
if (seen == null || p.timeSeconds >= seen) {
values[entry.key] = entry.value;
times[entry.key] = p.timeSeconds;
}
}
}

return {
for (final entry in atSurfacing.entries)
entry.key: SurfacingTankReading(
atSurfacing: entry.value,
lastAfterSurfacing: afterSurfacing[entry.key],
),
};
}

/// The end pressure to record for a cylinder, given what the source reported
/// and what the profile read around surfacing.
///
/// Only a reported pressure that matches the last post-surfacing reading is
/// corrected. That match is the evidence that the source simply took the last
/// sample it saw and so inherited the post-surfacing bleed-down. A source that
/// read its end pressure from anywhere else -- a log header, or a transmitter
/// that dropped out before the diver surfaced -- is left alone, because its
/// value has an origin this rule knows nothing about.
///
/// Beyond that, the correction only ever raises [reportedBar], and a source
/// that reported nothing keeps reporting nothing rather than gaining a
/// fabricated value.
double? trimEndPressureBar({
required double? reportedBar,
required SurfacingTankReading? reading,
}) {
if (reportedBar == null || reading == null) {
return reportedBar;
}
final tail = reading.lastAfterSurfacing;
if (tail == null ||
(tail - reportedBar).abs() > kPressureMatchToleranceBar ||
reading.atSurfacing <= reportedBar) {
return reportedBar;
}
return reading.atSurfacing;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ import 'package:submersion/features/dive_computer/data/services/parsed_tank_reso
import 'package:submersion/features/dive_computer/domain/entities/downloaded_dive.dart';

/// Convert a Pigeon ParsedDive to the app's DownloadedDive format.
DownloadedDive parsedDiveToDownloaded(pigeon.ParsedDive parsed) {
///
/// [trimAtSurfacing] carries the diver's preference for reading cylinder end
/// pressure at the moment of surfacing rather than at the end of the recording
/// (issue #1092); see [resolveParsedTanks].
DownloadedDive parsedDiveToDownloaded(
pigeon.ParsedDive parsed, {
bool trimAtSurfacing = true,
}) {
// Some computers (e.g. Shearwater) don't provide top-level min/max
// temperature — derive from profile samples when missing.
final sampleTemps = parsed.samples
Expand Down Expand Up @@ -86,7 +93,7 @@ DownloadedDive parsedDiveToDownloaded(pigeon.ParsedDive parsed) {
.toList(),
// Gas-mix linking, tankless synthesis, and gas-switch derivation live in
// the shared resolver so the download and reparse paths cannot drift apart.
tanks: resolveParsedTanks(parsed),
tanks: resolveParsedTanks(parsed, trimAtSurfacing: trimAtSurfacing),
gasSwitches: resolveGasSwitches(parsed),
events: parsed.events
.map(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon;
import 'package:submersion/core/constants/enums.dart';
import 'package:submersion/core/profile/surfacing_pressure.dart';
import 'package:submersion/features/dive_computer/domain/entities/downloaded_dive.dart';

/// Resolve a parsed dive's gas mixes to concrete cylinders, shared by the
Expand All @@ -12,8 +13,17 @@ import 'package:submersion/features/dive_computer/domain/entities/downloaded_div
/// "first gas mix" fallback both mislabeled the transmitter and dropped any gas
/// used without one (e.g. a deco bottle). Gases with no tank become pressureless
/// cylinders.
List<DownloadedTank> resolveParsedTanks(pigeon.ParsedDive parsed) =>
_resolveCylinders(parsed).tanks;
///
/// When [trimAtSurfacing] is set, each cylinder's end pressure is read at the
/// moment the diver surfaced rather than at the end of the recording. Dive
/// computers keep logging topside, and a rebreather oxygen cylinder feeding a
/// constant mass flow orifice bleeds down through it once the valve is closed,
/// so the computer's own end pressure can be a small fraction of what was
/// actually left at the end of the dive (issue #1092).
List<DownloadedTank> resolveParsedTanks(
pigeon.ParsedDive parsed, {
bool trimAtSurfacing = true,
}) => _resolveCylinders(parsed, trimAtSurfacing: trimAtSurfacing).tanks;

/// Derive the dive's gas switches from per-sample gas-mix transitions, keyed by
/// the cylinder index assigned by [resolveParsedTanks].
Expand All @@ -26,7 +36,10 @@ List<DownloadedTank> resolveParsedTanks(pigeon.ParsedDive parsed) =>
/// a different gas mix becomes a [GasSwitchEvent] pointing at the cylinder that
/// holds that gas.
List<GasSwitchEvent> resolveGasSwitches(pigeon.ParsedDive parsed) {
final gasIndexToTankIndex = _resolveCylinders(parsed).gasIndexToTankIndex;
final gasIndexToTankIndex = _resolveCylinders(
parsed,
trimAtSurfacing: false,
).gasIndexToTankIndex;
if (gasIndexToTankIndex.isEmpty) {
return const [];
}
Expand Down Expand Up @@ -81,7 +94,10 @@ class _ResolvedCylinders {
const _ResolvedCylinders(this.tanks, this.gasIndexToTankIndex);
}

_ResolvedCylinders _resolveCylinders(pigeon.ParsedDive parsed) {
_ResolvedCylinders _resolveCylinders(
pigeon.ParsedDive parsed, {
required bool trimAtSurfacing,
}) {
final gasMixes = parsed.gasMixes;
final gasIndexToTankIndex = <int, int>{};

Expand Down Expand Up @@ -111,6 +127,11 @@ _ResolvedCylinders _resolveCylinders(pigeon.ParsedDive parsed) {
}

// Gas indices are positions into gasMixes (every bridge sets GasMix.index == i).
// Scanned only once there are tank records to correct: a tankless dive
// synthesizes pressureless cylinders that have no end pressure to trim.
final surfacingReadings = trimAtSurfacing
? surfacingTankReadings(_surfacingPoints(parsed.samples))
: const <int, SurfacingTankReading>{};
final result = <DownloadedTank>[];
final consumed = <int>{};

Expand All @@ -130,7 +151,10 @@ _ResolvedCylinders _resolveCylinders(pigeon.ParsedDive parsed) {
o2Percent: o2,
hePercent: he,
startPressure: tank.startPressureBar,
endPressure: tank.endPressureBar,
endPressure: trimEndPressureBar(
reportedBar: tank.endPressureBar,
reading: surfacingReadings[tank.index],
),
volumeLiters: tank.volumeLiters,
role: _inferRole(tank.usage, o2, he),
),
Expand Down Expand Up @@ -248,3 +272,17 @@ int _firstFreeIndex(pigeon.ParsedDive parsed) {
}
return maxIndex + 1;
}

/// 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.
List<SurfacingProfilePoint> _surfacingPoints(List<pigeon.ProfileSample> 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 {},
),
];
13 changes: 11 additions & 2 deletions lib/features/dive_computer/data/services/reparse_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ class ReparseService {
final AppDatabase db;
final _uuid = const Uuid();

ReparseService({required this.db});
/// The diver's preference for reading cylinder end pressure at the moment of
/// surfacing rather than at the end of the recording (issue #1092). Reparse
/// is how an already-imported dive picks the rule up, so it has to agree
/// with the live download path.
final bool trimTankPressureAtSurfacing;

ReparseService({required this.db, this.trimTankPressureAtSurfacing = true});

/// Apply a freshly parsed dive to the database, updating only
/// computer-authored fields and preserving user-authored fields.
Expand Down Expand Up @@ -672,7 +678,10 @@ class ReparseService {
// Gas-mix linking and tankless synthesis (computers that report gas
// mixes but no tank records) live in the shared resolver so this path
// cannot drift from the live-download mapper.
for (final tank in resolveParsedTanks(parsed)) {
for (final tank in resolveParsedTanks(
parsed,
trimAtSurfacing: trimTankPressureAtSurfacing,
)) {
newTankOrders.add(tank.index);

final existing = existingByOrder[tank.index];
Expand Down
Loading