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
74 changes: 74 additions & 0 deletions lib/features/earthquake/data/eew_ml_max_source.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import 'dart:async';

import 'package:dpip/core/error/failure.dart';
import 'package:dpip/core/error/result.dart';
import 'package:dpip/core/realtime/realtime_source.dart';
import 'package:dpip/features/earthquake/domain/eew.dart';
import 'package:dpip/features/earthquake/domain/eew_town_levels.dart';
import 'package:flutter/foundation.dart' show listEquals;

/// Replaces each EEW payload's agency-reported `max` with the ML model's
/// nationwide township maximum before the alert reaches any app consumer.
///
/// The raw value remains available while the model loads or when it cannot
/// score a solution. Loading never delays delivery of a safety-critical alert;
/// the channel's next fetch publishes the ML value once the model is ready.
class EewMlMaxSource extends RealtimeSource<List<Eew>> {
EewMlMaxSource(this._source, this._model);

final RealtimeSource<List<Eew>> _source;
final MlIntensityEstimator _model;

@override
Future<Result<List<Eew>>> fetch() async {
final result = await _source.fetch();
final alerts = result.valueOrNull;
if (alerts == null || !alerts.any((alert) => !alert.isCancelled)) {
return result;
}
if (!_model.ready) {
unawaited(_model.prepare());
return result;
}
return switch (result) {
Ok(:final value, :final stale) => Ok(
await Future.wait(value.map(_withMlMaximum)),
stale: stale,
),
Err(:final failure) => Err(failure),
};
}

Future<Eew> _withMlMaximum(Eew alert) async {
if (alert.isCancelled) return alert;
final levels = await _model.townLevels(alert.info);
if (levels == null || levels.isEmpty) return alert;
final maximum = levels.values.reduce((a, b) => a > b ? a : b);
return alert.copyWith(info: alert.info.copyWith(max: maximum));
}

@override
DateTime? timestampOf(List<Eew> value) => _source.timestampOf(value);

@override
bool sameData(List<Eew>? a, List<Eew>? b) => listEquals(a, b);

@override
bool get servingFallback => _source.servingFallback;

@override
bool isIgnorableFailure(Failure failure) =>
_source.isIgnorableFailure(failure);

@override
void pause() => _source.pause();

@override
void resume() => _source.resume();

@override
void networkChanged() => _source.networkChanged();

@override
void dispose() => _source.dispose();
}
17 changes: 17 additions & 0 deletions lib/features/earthquake/domain/eew_town_levels.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,20 @@ Future<({Map<String, int> levels, bool fromModel})> eewTownLevels(
fromModel: false,
);
}

/// The highest level in [levels] that the wash actually paints, or null when
/// it paints none (every township at 0 is transparent).
///
/// This is what the legend's ceiling has to follow, not the warning's reported
/// `max`: [eewTownLevels] scores townships itself — the ML model, or the
/// formula until it loads — and can put one higher than the round number the
/// alert reports. A colour painted on the map with no row beside it is read as
/// some other intensity, which is the one thing a legend may not do.
int? highestPaintedLevel(Map<String, int> levels) {
int? highest;
for (final level in levels.values) {
if (level <= 0) continue;
if (highest == null || level > highest) highest = level;
}
return highest;
}
8 changes: 5 additions & 3 deletions lib/features/earthquake/earthquake_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'package:dpip/core/realtime/realtime_notifier.dart';
import 'package:dpip/core/realtime/realtime_source.dart';
import 'package:dpip/core/realtime/ticker.dart';
import 'package:dpip/features/earthquake/data/earthquake_api.dart';
import 'package:dpip/features/earthquake/data/eew_ml_max_source.dart';
import 'package:dpip/features/earthquake/data/eew_realtime_source.dart';
import 'package:dpip/features/earthquake/data/eew_repository_impl.dart';
import 'package:dpip/features/earthquake/data/ml_intensity_service.dart';
Expand Down Expand Up @@ -57,8 +58,9 @@ List<SingleChildWidget> earthquakeProviders(SharedDeps deps) {
// Held by the 強震監視器 while it is on screen: only then does the RTS feed
// need every frame, rather than the alerting ones the stream's sleep sends.
final rtsLiveDemand = RtsLiveDemand();
// ML v1 township estimates. Nothing is fetched or built here: the monitor
// calls `prepare` the first time it opens.
// ML v1 township estimates. The EEW source starts loading it on the first
// alert, then republishes that alert with the model's nationwide maximum;
// the monitor reuses the same service for its township wash.
final mlIntensity = MlIntensityService(
client: deps.apiClient,
store: MlModelStore(deps.database.durable),
Expand Down Expand Up @@ -89,7 +91,7 @@ List<SingleChildWidget> earthquakeProviders(SharedDeps deps) {
poll: api.getEewRealtime,
) as RealtimeSource<List<Eew>>;
final eewChannel = RealtimeChannel<List<Eew>>(
source: eewSource,
source: EewMlMaxSource(eewSource, mlIntensity),
clock: deps.serverClock,
elapsed: SystemElapsed(),
ticker: const SystemTicker(),
Expand Down
30 changes: 28 additions & 2 deletions lib/features/earthquake/presentation/pages/report_replay_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ class _ReportReplayPageState extends State<ReportReplayPage> {
/// digits four times out of five. Assigned only when the second changes.
final ValueNotifier<int> _clockSecond = ValueNotifier(0);

/// The highest level the replay's wash paints — the legend's ceiling. The
/// estimate belongs to the map ([_ReplayMapState._updateAreaFill]; the
/// legend lives up here beside the back button), so it is written back the
/// same way [_tick] travels down.
final ValueNotifier<int?> _paintedMaxLevel = ValueNotifier(null);

void _syncClockSecond() {
final second = _session.clock.now().millisecondsSinceEpoch ~/ 1000;
if (second == _clockSecond.value) return;
Expand Down Expand Up @@ -151,6 +157,7 @@ class _ReportReplayPageState extends State<ReportReplayPage> {
context.read<RealtimeService>().clock,
widget.replayTimestamp,
cwaOnly: () => cwaOnly.enabled,
mlIntensity: context.read<MlIntensityEstimator>(),
)..start();
_session.eew.addListener(_syncAnnouncement);
_startTicker();
Expand Down Expand Up @@ -281,6 +288,7 @@ class _ReportReplayPageState extends State<ReportReplayPage> {
_speechSettings?.removeListener(_syncAnnouncement);
_announcement?.dispose();
_clockSecond.dispose();
_paintedMaxLevel.dispose();
_session.dispose();
super.dispose();
}
Expand All @@ -304,6 +312,7 @@ class _ReportReplayPageState extends State<ReportReplayPage> {
tick: _tick,
clock: _session.clock,
eewIndex: _eewIndex,
paintedMaxLevel: _paintedMaxLevel,
),
),
Positioned(
Expand Down Expand Up @@ -348,17 +357,27 @@ class _ReportReplayPageState extends State<ReportReplayPage> {
// not whether the user asked to see it.
CollapsibleMapLegend(
legend: ListenableBuilder(
listenable: _session.eew,
listenable: Listenable.merge([
_session.eew,
_paintedMaxLevel,
]),
builder: (context, _) {
final alerts = _session.eew.alerts.uncancelled;
final maxEewLevel = alerts.isEmpty
final alertMax = alerts.isEmpty
? null
: alerts
.map(
(alert) =>
alert.info.max.clamp(0, 9),
)
.reduce((a, b) => a > b ? a : b);
final paintedMax = _paintedMaxLevel.value;
final maxEewLevel = alertMax == null
? paintedMax
: paintedMax == null ||
alertMax >= paintedMax
? alertMax
: paintedMax;
return MapLegendCard(
child: IntensityLegend(
mode: alerts.isNotEmpty
Expand Down Expand Up @@ -464,6 +483,7 @@ class _ReplayMap extends StatefulWidget {
required this.tick,
required this.clock,
required this.eewIndex,
required this.paintedMaxLevel,
});

final TremStationRepository stationRepository;
Expand All @@ -490,6 +510,10 @@ class _ReplayMap extends StatefulWidget {
/// showing, not always the newest.
final int eewIndex;

/// Where [_ReplayMapState] reports the highest level its wash paints, for
/// the legend the page draws above it.
final ValueNotifier<int?> paintedMaxLevel;

@override
State<_ReplayMap> createState() => _ReplayMapState();
}
Expand Down Expand Up @@ -1059,6 +1083,7 @@ class _ReplayMapState extends State<_ReplayMap> {
// with no alert up it paints nothing, so the OSM detailed ground —
// which mounts directly beneath it — keeps showing. The grey island is
// [countyFillLayerId]'s job, restored just above.
widget.paintedMaxLevel.value = null;
await controller.setLayerProperties(
townFillLayerId,
const FillLayerProperties(fillColor: '#00000000', fillOpacity: 0),
Expand All @@ -1077,6 +1102,7 @@ class _ReplayMapState extends State<_ReplayMap> {
// A newer alert, serial or the model itself took over while this one
// was being scored — its own update paints, not this stale one.
if (!mounted || key != _fillEewKey) return;
widget.paintedMaxLevel.value = highestPaintedLevel(estimate.levels);
final entries = <Object>[];
estimate.levels.forEach((code, level) {
if (level > 0) {
Expand Down
10 changes: 9 additions & 1 deletion lib/features/earthquake/replay_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import 'package:dpip/core/realtime/realtime_config.dart';
import 'package:dpip/core/realtime/replay_clock.dart';
import 'package:dpip/core/realtime/ticker.dart';
import 'package:dpip/features/earthquake/data/earthquake_api.dart';
import 'package:dpip/features/earthquake/data/eew_ml_max_source.dart';
import 'package:dpip/features/earthquake/data/eew_replay_source.dart';
import 'package:dpip/features/earthquake/data/rts_replay_source.dart';
import 'package:dpip/features/earthquake/domain/eew.dart';
import 'package:dpip/features/earthquake/domain/eew_town_levels.dart';
import 'package:dpip/features/earthquake/domain/rts.dart';
import 'package:dpip/features/earthquake/presentation/eew_realtime_controller.dart';
import 'package:dpip/features/earthquake/presentation/rts_realtime_controller.dart';
Expand Down Expand Up @@ -44,6 +46,7 @@ class ReplaySession {
Clock serverClock,
int replayTimestamp, {
required bool Function() cwaOnly,
MlIntensityEstimator? mlIntensity,
}) {
final api = EarthquakeApi(apiClient);
final clock = ReplayClock(
Expand All @@ -58,7 +61,12 @@ class ReplaySession {
label: 'rts-replay',
);
final eewChannel = RealtimeChannel<List<Eew>>(
source: EewReplaySource(api, clock, cwaOnly: cwaOnly),
source: mlIntensity == null
? EewReplaySource(api, clock, cwaOnly: cwaOnly)
: EewMlMaxSource(
EewReplaySource(api, clock, cwaOnly: cwaOnly),
mlIntensity,
),
clock: serverClock,
elapsed: SystemElapsed(),
ticker: const SystemTicker(),
Expand Down
17 changes: 15 additions & 2 deletions lib/features/map/presentation/layers/rts_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,11 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer {
/// Bumped on every feed notification, so the ranking card rebuilds with it.
final ValueNotifier<int> _rankingTick = ValueNotifier(0);

/// The highest level the current wash paints — the legend must include it
/// even when the active alerts' model-derived maximum belongs to another
/// warning.
final ValueNotifier<int?> _paintedMaxLevel = ValueNotifier(null);

/// Brightness captured from the last context-bearing call ([buildLegend] /
/// [buildSheet]) — the render/data-push methods get no `BuildContext`, but
/// the intensity icons come in light/dark artwork and the area fill needs
Expand Down Expand Up @@ -905,6 +910,7 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer {
// with no alert up it paints nothing, so the OSM detailed ground —
// which mounts directly beneath it — keeps showing. The grey island is
// [countyFillLayerId]'s job, restored just above.
_paintedMaxLevel.value = null;
await controller.setLayerProperties(
townFillLayerId,
const FillLayerProperties(fillColor: '#00000000', fillOpacity: 0),
Expand All @@ -923,6 +929,7 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer {
// A newer alert, serial or the model itself took over while this one
// was being scored — its own update paints, not this stale one.
if (key != _fillEewKey) return;
_paintedMaxLevel.value = highestPaintedLevel(estimate.levels);
final entries = <Object>[];
estimate.levels.forEach((code, level) {
if (level > 0) {
Expand Down Expand Up @@ -964,14 +971,20 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer {
Widget buildLegend(BuildContext context) {
_captureBrightness(context);
return ListenableBuilder(
listenable: _eew,
listenable: Listenable.merge([_eew, _paintedMaxLevel]),
builder: (context, _) {
final alerts = (_eew.state.data ?? const <Eew>[]).uncancelled;
final maxEewLevel = alerts.isEmpty
final alertMax = alerts.isEmpty
? null
: alerts
.map((alert) => alert.info.max.clamp(0, 9))
.reduce((a, b) => a > b ? a : b);
final paintedMax = _paintedMaxLevel.value;
final maxEewLevel = alertMax == null
? paintedMax
: paintedMax == null || alertMax >= paintedMax
? alertMax
: paintedMax;
return MapLegendCard(
child: IntensityLegend(
mode: _eewActive
Expand Down
5 changes: 5 additions & 0 deletions lib/shared/widgets/intensity_legend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ class IntensityLegend extends StatelessWidget {
/// Highest discrete EEW level to show (`0`–`9`). Null shows the full scale.
/// Levels above it carry no information for the current warning and are
/// omitted to preserve map space.
///
/// This includes the maximum of the *wash*, which may differ from an
/// incoming warning's original reported maximum.
/// A level the map paints but this scale omits would be read as another
/// intensity.
final int? maxEewLevel;

/// Height of one scale cell / gradient step (px).
Expand Down
Loading
Loading