8000 fix(cbio): finish a holed history and give the stored range a real window by shroominic · Pull Request #206 · shroominic/OpenGlucose · GitHub
[go: up one dir, main page]

Skip to content
Closed
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
15 changes: 15 additions & 0 deletions docs/testing/cbio-gs1-app-live.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ when the sensor's own newest record stamp agrees with the app's clock to within
hero says `Sensor clock unsynced · ordered by sensor index, not by clock`
instead of a placeholder time.

The stored-history caption uses the same anchor:

- `10067 readings stored · sensor minutes 1–10067 · 02:34–19:02` when the
anchor covers the range, with the window in the device's local zone;
- `… · 3 positions not received` appended whenever the stored positions have
holes, counted from the range they span;
- without an anchor the caption stays exactly as captured above: count and
sensor minutes only, with the unsynced line beside it.

Ingest also answers one question only: `inProgress` is true while the sensor is
still pushing and false once the fetch has caught up with the front it offered -
a close on the settle (idle) timer is that evidence, so a hole in the middle no
longer pins a finished fetch to "Fetching sensor history" for the rest of the
session. The hole stays visible in the positions and the caption.

The timestamps therefore inherit the clock this app set on the sensor and its
drift, which the surface states as the ±1 minute of one stored record. The
capture above predates the anchor and shows the unsynced copy; a device
Expand Down
5 changes: 4 additions & 1 deletion openhealth/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1171,7 +1171,10 @@ class _DashboardHeroCardState extends State<_DashboardHeroCard> {
if (isCbioSnapshot(snapshot)) ...<Widget>[
const SizedBox(height: 6),
Text(
cbioStoredRangeText(snapshot.history),
cbioStoredRangeText(
snapshot.history,
anchor: cbioAnchorForSnapshot(snapshot),
),
key: const ValueKey<String>('cbioStoredRange'),
style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFFC7E4DD),
Expand Down
36 changes: 30 additions & 6 deletions openhealth/lib/src/session_presentation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -532,10 +532,15 @@ String? cbioProvisionalValueText(CgmReading? reading) {
return (raw / 10).toStringAsFixed(1);
}

/// What the app actually stored for this sensor: how many records and which
/// sensor positions they cover. Positions are the protocol's own `index`
/// counter, which advances one per stored minute; it is never a wall clock.
String cbioStoredRangeText(Iterable<CgmReading> readings) {
/// What the app actually stored for this sensor: how many records, which sensor
/// positions they cover, the local window the anchor gives that range, and how
/// many positions never arrived. Positions are the protocol's own `index`
/// counter, which advances one per stored minute and is never a wall clock on
/// its own: the clock comes from [anchor] when the session holds one.
String cbioStoredRangeText(
Iterable<CgmReading> readings, {
CbioIndexTimeAnchor? anchor,
}) {
final positions = <int>[
for (final reading in readings)
if (reading.sensorMinute != null) reading.sensorMinute!,
Expand All @@ -544,8 +549,27 @@ String cbioStoredRangeText(Iterable<CgmReading> readings) {
return '${readings.length} readings stored';
}
positions.sort();
return '${readings.length} readings stored · '
'sensor minutes ${positions.first}–${positions.last}';
final oldest = positions.first;
final newest = positions.last;
final missing = newest - oldest + 1 - positions.length;
return <String>[
'${readings.length} readings stored',
'sensor minutes $oldest–$newest',
if (anchor != null && anchor.coversIndex(oldest))
_anchorWindowText(anchor, oldest, newest),
if (missing > 0)
missing == 1
? '1 position not received'
: '$missing positions not received',
].join(' · ');
}

/// The local window the anchor gives the stored range, oldest to newest.
String _anchorWindowText(CbioIndexTimeAnchor anchor, int oldest, int newest) {
final format = DateFormat('HH:mm');
final from = format.format(anchor.timeForIndex(oldest).toLocal());
final to = format.format(anchor.timeForIndex(newest).toLocal());
return '$from–$to';
}

/// The index-to-clock anchor a GS1 session published, or null when it has none.
Expand Down
64 changes: 64 additions & 0 deletions openhealth/test/cbio_clock_anchor_surface_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ List<CgmReading> _readings({required bool anchored}) => <CgmReading>[
),
];

CgmReading _position(int index) => CgmReading(
valueMgdl: 100 + index.toDouble(),
source: CgmRecordSource.raw,
sensorMinute: index,
recordedAt: null,
rawValue: 55,
isDisplayProvisional: true,
);

CgmSessionSnapshot _snapshot({
required bool anchored,
bool covered = true,
Expand Down Expand Up @@ -218,6 +227,61 @@ void main() {
await tester.pump();
});

test(
'the caption names the anchored window and the positions that never came',
() {
final local = DateFormat('HH:mm');
final anchored = _snapshot(anchored: true);
expect(
cbioStoredRangeText(
anchored.history,
anchor: cbioAnchorForSnapshot(anchored),
),
'3 readings stored \u00b7 sensor minutes 10065\u201310067 \u00b7 '
'${local.format(_anchorInstant.subtract(const Duration(minutes: 2)).toLocal())}'
'\u2013${local.format(_anchorInstant.toLocal())}',
);
expect(
cbioStoredRangeText(anchored.history),
'3 readings stored \u00b7 sensor minutes 10065\u201310067',
reason: 'without an anchor the positions stay the only range',
);

final holed = _snapshot(
anchored: false,
history: <CgmReading>[
for (final index in <int>[10065, 10066, 10068]) _position(index),
],
);
expect(
cbioStoredRangeText(holed.history),
'3 readings stored \u00b7 sensor minutes 10065\u201310068 \u00b7 '
'1 position not received',
);
},
);

testWidgets('the hero caption carries the anchored window', (tester) async {
final local = DateFormat('HH:mm');
final (controller, preferences) = await _controllerFor(
_snapshot(anchored: true),
);
await _pumpApp(tester, controller, preferences);

expect(
tester
.widget<Text>(find.byKey(const ValueKey<String>('cbioStoredRange')))
.data,
'3 readings stored \u00b7 sensor minutes 10065\u201310067 \u00b7 '
'${local.format(_anchorInstant.subtract(const Duration(minutes: 2)).toLocal())}'
'\u2013${local.format(_anchorInstant.toLocal())}',
);

await tester.pumpWidget(const SizedBox.shrink());
controller.dispose();
await tester.pump();
});

testWidgets(
'the hero never shows a placeholder clock for an unanchored GS1',
(
Expand Down
44 changes: 34 additions & 10 deletions packages/cgm_cbio/lib/src/cbio_glucose_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ final class CbioGlucoseSession implements CgmSession {
int? _clockReferenceEpochSeconds;
CbioIndexTimeAnchor? _anchor;
bool _anchorLogged = false;
bool _closedOnIdle = false;
bool _catchUpOpen = false;
bool _budgetExhausted = false;
bool _closing = false;
Expand Down Expand Up @@ -461,6 +462,7 @@ final class CbioGlucoseSession implements CgmSession {

void _beginHistory(int startIndex) {
_catchUpOpen = false;
_closedOnIdle = false;
_catchUpTimer?.cancel();
_catchUpTimer = null;
_setPhase(
Expand All @@ -486,13 +488,21 @@ final class CbioGlucoseSession implements CgmSession {
return;
}
_historyIdleTimer?.cancel();
_historyIdleTimer = Timer(timing.historyIdleWindow, _finishHistory);
_historyIdleTimer = Timer(
timing.historyIdleWindow,
() => _finishHistory(closedOnIdle: true),
);
}

void _finishHistory() {
void _finishHistory({bool closedOnIdle = false}) {
if (_closing) {
return;
}
// Closing on the idle timer means the sensor stopped offering records
// before the window deadline, which is the only counter-free evidence that
// the fetch reached the front. A deadline close has to fall back on the
// freshness check below.
_closedOnIdle = _closedOnIdle || closedOnIdle;
_historyDeadlineTimer?.cancel();
_historyDeadlineTimer = null;
_historyIdleTimer?.cancel();
Expand Down Expand Up @@ -740,26 +750,40 @@ final class CbioGlucoseSession implements CgmSession {
}
}

bool get _atLiveEdge {
if (_archive.length == 0 || _archive.hasGap) {
/// Whether the fetch has caught up with what the sensor offered.
///
/// `inProgress` answers one question: is there more of what the sensor pushed
/// still coming. A hole in the middle describes the range, not the fetch, and
/// is reported by the positions and the caption, so it no longer pins the
/// sync open for the rest of the session.
bool get _historyCaughtUp {
if (_archive.length == 0) {
return false;
}
if (_closedOnIdle) {
return true;
}
// A deadline close means the window ran out before the sensor stopped
// offering, so the newest stored record has to sit at the app's own live
// edge. This is a freshness check, never a rendered clock: the anchored
// instant is used when the session holds one, and the bare counter is only
// a fallback for "did anything arrive in the last few minutes".
if (_archive.oldestIndex != _historyStartIndex) {
return false;
}
final newestIndex = _archive.newestIndex;
final newestTime = _archive.newestTime;
if (newestTime == null) {
if (newestIndex == null || newestTime == null) {
return false;
}
final newest = DateTime.fromMillisecondsSinceEpoch(
newestTime * 1000,
isUtc: true,
);
final newest =
_anchor?.timeForIndex(newestIndex) ??
DateTime.fromMillisecondsSinceEpoch(newestTime * 1000, isUtc: true);
return _clock().toUtc().difference(newest).abs() <= timing.liveEdgeWindow;
}

CgmHistorySyncState get _historySyncState => CgmHistorySyncState(
inProgress: !_atLiveEdge || _catchUpOpen,
inProgress: !_historyCaughtUp || _catchUpOpen,
storedCount: _archive.length,
totalAvailable: _archive.newestIndex ?? 0,
latestStoredOffset: _archive.newestIndex,
Expand Down
55 changes: 55 additions & 0 deletions packages/cgm_cbio/test/cbio_glucose_session_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1081,4 +1081,59 @@ void main() {
},
);
});

group('CbioGlucoseSession live edge', () {
test('a holed archive still finishes ingesting at the live edge', () async {
final connection = _FakeConnection();
final transport = _FakeTransport(connection);
final now = DateTime.now().toUtc();
final base = now.millisecondsSinceEpoch ~/ 1000 - 180;
await _defaultResponder(
connection,
rawBatches: <List<int>>[
// Positions 1-3 arrive, then the sensor skips position 4 and keeps
// pushing to its live edge. The hole stays visible in the positions
// the session publishes (#188); it must not pin the fetch to
// "in progress" for the rest of the session.
_rawBatch(
startIndex: 1,
baseEpochSeconds: base,
baseReindex: 3,
currents: <int>[64, 70, 75],
),
_rawBatch(
startIndex: 5,
baseEpochSeconds: base + 240,
baseReindex: 5,
currents: <int>[80],
),
],
);

final session = CbioGlucoseSession(
sensor: _sensor,
transport: transport,
credentials: _syntheticSource,
timing: _fastTiming,
clock: () => now,
);
await session.initialize();
await _pumpUntil(() => session.currentSnapshot.history.length == 4);
await _pumpUntil(
() =>
session.currentSnapshot.stage == CgmSyncStage.ready &&
session.currentSnapshot.historySync.inProgress == false,
);

final snapshot = session.currentSnapshot;
expect(snapshot.historySync.storedCount, 4);
expect(
snapshot.history.map((reading) => reading.sensorMinute),
<int>[1, 2, 3, 5],
reason: 'the hole at position 4 is still visible in the positions',
);
expect(_phaseOf(snapshot), CbioSessionPhase.live);
await session.disconnect();
});
});
}
0