Skip to content
Open
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
Binary file added docs/pr-1978/tissue-loading-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-1978/tissue-loading-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Use the `Fixed` column as a working checkbox:
| Rebreather dive fields (`setpointLow/High/Deco`, `SCR` config, diluent gas, loop O2, scrubber, loop volume) | [ ] | High | Yes | Yes | No |
| Tank role / material metadata | [ ] | High | Yes | Yes | No[^1] |
| Dive-level `cns` / `otu` | [x] | Medium | Yes | Yes | Yes |
| Sample `n2Load` / dive-level computer tissue snapshot (`computerTissue`: algorithm, start/end N2 and CNS) | [x] | Medium | Yes | N/A | N/A |
| Dive-level deco metadata (`decoAlgorithm`, `GF low/high`, conservatism) | [ ] | Medium | Yes | No | Partial |
| Profile events / markers | [ ] | Medium | Yes | Yes | Partial |
| Source provenance snapshot (`DiveDataSources`) | [ ] | Medium | Yes | No | Yes |
Expand Down
1 change: 1 addition & 0 deletions lib/core/constants/profile_metrics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -212,4 +212,5 @@ typedef MetricSourceInfo = ({
MetricDataSource cnsActual,
MetricDataSource decoStopActual,
MetricDataSource gtrActual,
MetricDataSource gf99Actual,
});
41 changes: 40 additions & 1 deletion lib/core/database/database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,12 @@ class Dives extends Table {
text().nullable()(); // "buhlmann", "vpm", "rgbm", "dciem"
IntColumn get decoConservatism =>
integer().nullable()(); // Personal adjustment (0=neutral)
// Tissue state the dive computer itself reported (v225): the JSON of
// ComputerTissueSnapshot.toJson, or null when the source carried none.
// Drift replaces this getter with a generated field at runtime.
// coverage:ignore-start
TextColumn get computerTissueJson => text().nullable()();
// coverage:ignore-end
// Dive computer that logged this dive (for display/export, separate from computerId relation)
TextColumn get diveComputerModel => text().nullable()();
TextColumn get diveComputerSerial => text().nullable()();
Expand Down Expand Up @@ -4946,10 +4952,16 @@ class AppDatabase extends _$AppDatabase {
// Renumbered from 223, which buddy profile links took while this was
// in review.
224,
// v225: dives.computer_tissue_json, the tissue state a dive computer
// reports for the dive (import of Garmin, Shearwater, Suunto, Ratio and
// UDDF tissue data). Additive nullable column, no backfill. Takes 225,
// not 224: main shipped 224 (media fact clocks) while this branch was
// open, and a rung at or below the shipped version never runs its
// onUpgrade step, so this one sits above it.
225,
// v226: media.cloud_asset_id, the PhotoKit cloud identifier (media sync
// program spec 6.2). Column only; the one-time backfill runs after a
// sync, not here. Additive and nullable, so the floor stays at 224.
// 225 is held by PR #1978 (tissue loading import).
226,
];

Expand Down Expand Up @@ -6488,6 +6500,20 @@ class AppDatabase extends _$AppDatabase {
}
}

/// v225: dives.computer_tissue_json. Idempotent, so it is safe to call
/// from both onUpgrade and the beforeOpen backstop, and a no-op when the
/// table does not exist yet.
Future<void> _assertComputerTissueColumn() async {
final cols = await customSelect("PRAGMA table_info('dives')").get();
if (cols.isEmpty) return;
final names = cols.map((c) => c.read<String>('name')).toSet();
if (!names.contains('computer_tissue_json')) {
await customStatement(
'ALTER TABLE dives ADD COLUMN computer_tissue_json TEXT',
);
}
}

/// v126: emergency_chambers table + emergency card settings columns.
/// Idempotent so it is safe to call from both onUpgrade and the
/// beforeOpen backstop.
Expand Down Expand Up @@ -12542,6 +12568,14 @@ class AppDatabase extends _$AppDatabase {
await _backfillMediaFactClocks();
}
if (from < 224) await reportProgress();
// v225: dives.computer_tissue_json. Column-only rung, no backfill:
// null reads as "the computer reported no tissue state". Takes 225,
// not 224: main shipped 224 (media fact clocks) while this branch
// was open.
if (from < 225) {
await _assertComputerTissueColumn();
}
if (from < 225) await reportProgress();
// v226: media.cloud_asset_id. Column only, no backfill.
if (from < 226) {
await _assertMediaCloudAssetIdColumn();
Expand Down Expand Up @@ -12929,6 +12963,11 @@ class AppDatabase extends _$AppDatabase {
// arrives by restore or sync-adopt without them would throw on the
// first read.
await _assertBuddyProfileDiveLinkColumns();

// v225 backstop: re-assert dives.computer_tissue_json. Every dive
// read selects the whole row, so a database that arrives by restore
// or sync-adopt without it would throw on the first read.
await _assertComputerTissueColumn();
// v182 backstop: re-assert the packed profile series tables, then
// pack any dive that still has legacy rows and no series row. A
// schema-version collision with a parallel branch skips the rung on
Expand Down
8 changes: 7 additions & 1 deletion lib/core/database/legacy_sample_staging.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ String _sqlType(ProfileFieldKind kind) => switch (kind) {
ProfileFieldKind.runLengthString => 'TEXT',
};

/// The legacy `dive_profiles` columns: identity plus every codec field.
/// The legacy `dive_profiles` columns: identity plus every codec v1 field.
///
/// Deliberately v1, not the newest table. These staging tables mirror the
/// row-per-sample `dive_profiles` table an older peer still publishes, and
/// that table only ever had the v1 columns: no peer below the v183 floor can
/// send `gf99` or `n2_load`, and the packer encodes staged rows with the
/// current codec anyway (the missing v2 columns simply pack as null).
final List<String> _legacyProfileColumns = [
'id',
'dive_id',
Expand Down
7 changes: 7 additions & 0 deletions lib/core/services/export/uddf/uddf_export_builders.dart
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,13 @@ class UddfExportBuilders {
if (point.ppO2 != null) {
builder.element('ppo2', nest: point.ppO2.toString());
}
// Computer-reported GF99 (UDDF 3.2 waypoint child).
if (point.gf99 != null) {
builder.element(
'gradientfactor',
nest: point.gf99.toString(),
);
}
},
);
}
Expand Down
9 changes: 9 additions & 0 deletions lib/core/services/export/uddf/uddf_export_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,15 @@ class UddfExportService {
}
}
}
// Computer-reported GF99, as Shearwater
// Cloud writes it, so a round trip keeps
// the recorded value.
if (point.gf99 != null) {
builder.element(
'gradientfactor',
nest: point.gf99.toString(),
);
}
},
);
}
Expand Down
11 changes: 11 additions & 0 deletions lib/core/services/export/uddf/uddf_full_import_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:submersion/core/services/logger_service.dart';
import 'package:submersion/core/services/export/models/uddf_import_result.dart';
import 'package:submersion/core/services/export/uddf/uddf_buddy_roles.dart';
import 'package:submersion/core/services/export/uddf/uddf_dump_codec.dart';
import 'package:submersion/core/services/export/uddf/uddf_gradient_factor.dart';
import 'package:submersion/core/services/export/uddf/uddf_import_parsers.dart';
import 'package:submersion/features/universal_import/data/services/import_site_location.dart';
import 'package:submersion/core/services/export/uddf/uddf_normalizer.dart';
Expand Down Expand Up @@ -2512,6 +2513,16 @@ class UddfFullImportService {
point['ndl'] = UddfImportParsers.parseUddfInt(ndlText);
}

// Shearwater Cloud and Subsurface write the computer's GF99 on each
// waypoint. Only set when present, so a sample without one carries
// no key rather than a null.
final gf99 = parseUddfGradientFactorPercent(
UddfImportParsers.getElementText(waypoint, 'gradientfactor'),
);
if (gf99 != null) {
point['gf99'] = gf99;
}

final decoStop = waypoint.findElements('decostop').firstOrNull;
if (decoStop != null) {
final kind = decoStop.getAttribute('kind')?.trim().toLowerCase();
Expand Down
21 changes: 21 additions & 0 deletions lib/core/services/export/uddf/uddf_gradient_factor.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/// Reads a UDDF waypoint `<gradientfactor>` as GF99, a whole percent.
///
/// Shearwater Cloud and Subsurface write whole percents ("0", "63"). Some
/// writers use a fraction of one instead ("0.63"); a value with a decimal
/// point in (0, 1] is read as such and scaled to percent. Nothing is
/// clamped, so a supersaturated 120 stays 120. Blank, non-numeric and
/// non-finite text read as null.
int? parseUddfGradientFactorPercent(String? text) {
if (text == null) return null;
final trimmed = text.trim();
if (trimmed.isEmpty) return null;

final asInt = int.tryParse(trimmed);
if (asInt != null) return asInt;

final asDouble = double.tryParse(trimmed);
if (asDouble == null || !asDouble.isFinite) return null;

final isFraction = trimmed.contains('.') && asDouble > 0 && asDouble <= 1.0;
return isFraction ? (asDouble * 100).round() : asDouble.round();
}
9 changes: 9 additions & 0 deletions lib/core/services/export/uddf/uddf_import_service.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:xml/xml.dart';

import 'package:submersion/core/services/logger_service.dart';
import 'package:submersion/core/services/export/uddf/uddf_gradient_factor.dart';
import 'package:submersion/core/services/export/uddf/uddf_import_parsers.dart';
import 'package:submersion/features/universal_import/data/services/import_site_location.dart';
import 'package:submersion/features/dive_log/domain/entities/dive.dart';
Expand Down Expand Up @@ -695,6 +696,14 @@ class UddfImportService {
point['heartRate'] = UddfImportParsers.parseUddfInt(heartRateText);
}

// Computer-reported GF99 (whole percent), set only when present.
final gf99 = parseUddfGradientFactorPercent(
_getElementText(waypoint, 'gradientfactor'),
);
if (gf99 != null) {
point['gf99'] = gf99;
}

if (point.containsKey('timestamp') && point.containsKey('depth')) {
profile.add(point);
}
Expand Down
9 changes: 9 additions & 0 deletions lib/core/services/suunto_cloud/suunto_dive_parser.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import 'dart:math' as math;

import 'package:submersion/core/services/suunto_cloud/suunto_cloud_event_map.dart';
import 'package:submersion/core/services/suunto_cloud/suunto_tissue_parser.dart';
import 'package:submersion/features/dive_computer/domain/entities/downloaded_dive.dart';
import 'package:submersion/features/dive_log/domain/entities/computer_tissue_snapshot.dart';

/// A dive parsed from a Suunto export, plus the device identity fields
/// needed to resolve/create the owning [DiveComputer] record (kept separate
Expand All @@ -16,6 +18,11 @@ class SuuntoParsedDive {

final DownloadedDive dive;

/// Dive-level tissue state the computer reported in the SML header
/// (`Header.Diving.StartTissue` / `EndTissue` / `Algorithm`), if any.
/// Lives on [dive] so the shared import pipeline persists it.
ComputerTissueSnapshot? get computerTissue => dive.computerTissue;

/// Suunto's internal device codename (e.g. "Vaasa"), already mapped to a
/// commercial product line name (e.g. "Suunto Nautic") for display.
final String? deviceName;
Expand Down Expand Up @@ -96,6 +103,7 @@ class SuuntoDiveParser {
final diving = header['Diving'] as Map<String, dynamic>?;
final gfLow = (diving?['GfLow'] as num?)?.round();
final gfHigh = (diving?['GfHigh'] as num?)?.round();
final computerTissue = diving == null ? null : parseSuuntoTissue(diving);

final tanks = _buildTanks(diving, profileResult.gasSwitchOrder);

Expand Down Expand Up @@ -125,6 +133,7 @@ class SuuntoDiveParser {
gfLow: gfLow,
gfHigh: gfHigh,
decoAlgorithm: (gfLow != null && gfHigh != null) ? 'buhlmann' : null,
computerTissue: computerTissue,
events: profileResult.events,
);

Expand Down
15 changes: 15 additions & 0 deletions lib/core/services/suunto_cloud/suunto_sml_normalizer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ class SuuntoSmlNormalizer {

static const int _suuntoActivityScuba = 51;

/// DiveHeader fields hoisted verbatim onto `header['Diving']`.
static const List<String> _diveHeaderTissueKeys = [
'StartTissue',
'EndTissue',
'Algorithm',
];

/// Throws [SuuntoApiException] if [json] isn't a recognizable dive export
/// (neither cloud 'sml' nor app 'DeviceLog' shape, or not a dive activity).
static SuuntoDiveExport parse(Map<String, dynamic> json) {
Expand Down Expand Up @@ -117,6 +124,14 @@ class SuuntoSmlNormalizer {
diving['GfLow'] = diveHeader['LowGf'];
diving['GfHigh'] = diveHeader['HighGf'];
}
// The dive-level tissue state (per-compartment tensions, CNS/OTU, RGBM
// factors) and the deco model name sit in the app's DeviceLog schema
// under Header.Diving already; the cloud shape keeps them on the
// DiveHeader, so carry them across under the same names for the
// tissue parser.
for (final key in _diveHeaderTissueKeys) {
if (diveHeader[key] != null) diving[key] = diveHeader[key];
}
if (diving.isNotEmpty) header['Diving'] = diving;

// The surface GPS pair (Start = entry, Stop = exit, both in radians)
Expand Down
80 changes: 80 additions & 0 deletions lib/core/services/suunto_cloud/suunto_tissue_parser.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import 'package:submersion/core/utils/number_utils.dart';
import 'package:submersion/features/dive_log/domain/entities/computer_tissue_snapshot.dart';

/// Reads the dive-level tissue state a Suunto SML header carries under
/// `Header.Diving` (`StartTissue`, `EndTissue`, `Algorithm`) into a
/// [ComputerTissueSnapshot].
///
/// Suunto reports per-compartment tensions in Pascal (9 compartments on the
/// HelO2 / D-series RGBM, 15 on the EON Fused2 RGBM); they come out in bar
/// with five decimals. `CNS` (and its `OLF` stand-in on older models) is a
/// 0-1 fraction and comes out in percent; `OTU` and the two RGBM factors are
/// kept as reported.
///
/// Two encodings of the compartment arrays exist and both are accepted: a
/// plain numeric list (the app's DeviceLog JSON export), or -- in JSON
/// derived from the SML XML -- a `{"Pressure": [...]}` object or a list of
/// `{"Pressure": n}` maps.
///
/// A state with no numeric content is skipped; the result is null when
/// neither state has anything, even if `Algorithm` is present. Never throws.
ComputerTissueSnapshot? parseSuuntoTissue(Map<String, dynamic> diving) {
final start = _stateOf(diving['StartTissue']);
final end = _stateOf(diving['EndTissue']);
if (start == null && end == null) return null;

final algorithm = diving['Algorithm'];
return ComputerTissueSnapshot(
algorithm: algorithm is String && algorithm.isNotEmpty ? algorithm : null,
start: start,
end: end,
);
}

const double _pascalPerBar = 100000;

ComputerTissueState? _stateOf(Object? value) {
if (value is! Map) return null;
final tissue = Map<String, dynamic>.from(value);

final cns = asDoubleOrNull(tissue['CNS']) ?? asDoubleOrNull(tissue['OLF']);
final state = ComputerTissueState(
n2Bar: _tensionsBar(tissue['Nitrogen']),
heBar: _tensionsBar(tissue['Helium']),
cnsPercent: cns == null ? null : cns * 100,
otu: asDoubleOrNull(tissue['OTU']),
rgbmNitrogen: asDoubleOrNull(tissue['RgbmNitrogen']),
rgbmHelium: asDoubleOrNull(tissue['RgbmHelium']),
);

final hasContent =
state.n2Bar != null ||
state.heBar != null ||
state.cnsPercent != null ||
state.otu != null ||
state.rgbmNitrogen != null ||
state.rgbmHelium != null;
return hasContent ? state : null;
}

/// Per-compartment tensions in bar, or null when [value] holds none.
///
/// Compartment order is positional, so a list with any non-numeric element
/// reads as null as a whole rather than as a shorter list.
List<double>? _tensionsBar(Object? value) {
if (value is Map) return _tensionsBar(value['Pressure']);
if (value is! List || value.isEmpty) return null;

final bars = <double>[];
for (final element in value) {
final pascal = asDoubleOrNull(
element is Map ? element['Pressure'] : element,
);
if (pascal == null) return null;
bars.add(_pascalToBar(pascal));
}
return List.unmodifiable(bars);
}

/// Pascal to bar, kept to five decimals (whole-Pascal precision).
double _pascalToBar(double pascal) => pascal.round() / _pascalPerBar;
5 changes: 5 additions & 0 deletions lib/features/dive_3d/application/tissue_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import 'package:submersion/features/settings/presentation/providers/settings_pro

/// The per-sample decompression status series for a dive's active source -
/// the same data that feeds the 2D tissue heat map.
///
/// Always the app's own Buhlmann recompute. An imported
/// `Dive.computerTissue` snapshot is dive-level (start and end only) and its
/// compartment model differs by computer, so it cannot seed this per-sample
/// scene and is deliberately not used here.
final tissueDecoStatusesProvider =
FutureProvider.family<List<DecoStatus>, String>((ref, diveId) async {
final analysis = await ref.watch(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,7 @@ class DiveImportService {
gfLow: dive.gfLow,
gfHigh: dive.gfHigh,
decoConservatism: dive.decoConservatism,
computerTissue: dive.computerTissue,
diveMode: dive.diveMode,
diluentO2: dive.diluentO2,
diluentHe: dive.diluentHe,
Expand Down Expand Up @@ -860,6 +861,7 @@ class DiveImportService {
gfLow: dive.gfLow,
gfHigh: dive.gfHigh,
decoConservatism: dive.decoConservatism,
computerTissue: dive.computerTissue,
diveMode: dive.diveMode,
diluentO2: dive.diluentO2,
diluentHe: dive.diluentHe,
Expand Down
Loading
Loading