diff --git a/lib/features/earthquake/data/eew_ml_max_source.dart b/lib/features/earthquake/data/eew_ml_max_source.dart new file mode 100644 index 000000000..6abcec3e3 --- /dev/null +++ b/lib/features/earthquake/data/eew_ml_max_source.dart @@ -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> { + EewMlMaxSource(this._source, this._model); + + final RealtimeSource> _source; + final MlIntensityEstimator _model; + + @override + Future>> 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 _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 value) => _source.timestampOf(value); + + @override + bool sameData(List? a, List? 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(); +} diff --git a/lib/features/earthquake/domain/eew_town_levels.dart b/lib/features/earthquake/domain/eew_town_levels.dart index c5b046d88..cde1a4a3a 100644 --- a/lib/features/earthquake/domain/eew_town_levels.dart +++ b/lib/features/earthquake/domain/eew_town_levels.dart @@ -52,3 +52,20 @@ Future<({Map 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 levels) { + int? highest; + for (final level in levels.values) { + if (level <= 0) continue; + if (highest == null || level > highest) highest = level; + } + return highest; +} diff --git a/lib/features/earthquake/earthquake_providers.dart b/lib/features/earthquake/earthquake_providers.dart index 04bfea2f0..a55cd8d44 100644 --- a/lib/features/earthquake/earthquake_providers.dart +++ b/lib/features/earthquake/earthquake_providers.dart @@ -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'; @@ -57,8 +58,9 @@ List 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), @@ -89,7 +91,7 @@ List earthquakeProviders(SharedDeps deps) { poll: api.getEewRealtime, ) as RealtimeSource>; final eewChannel = RealtimeChannel>( - source: eewSource, + source: EewMlMaxSource(eewSource, mlIntensity), clock: deps.serverClock, elapsed: SystemElapsed(), ticker: const SystemTicker(), diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index c553f3db2..bd7d498e2 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -106,6 +106,12 @@ class _ReportReplayPageState extends State { /// digits four times out of five. Assigned only when the second changes. final ValueNotifier _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 _paintedMaxLevel = ValueNotifier(null); + void _syncClockSecond() { final second = _session.clock.now().millisecondsSinceEpoch ~/ 1000; if (second == _clockSecond.value) return; @@ -151,6 +157,7 @@ class _ReportReplayPageState extends State { context.read().clock, widget.replayTimestamp, cwaOnly: () => cwaOnly.enabled, + mlIntensity: context.read(), )..start(); _session.eew.addListener(_syncAnnouncement); _startTicker(); @@ -281,6 +288,7 @@ class _ReportReplayPageState extends State { _speechSettings?.removeListener(_syncAnnouncement); _announcement?.dispose(); _clockSecond.dispose(); + _paintedMaxLevel.dispose(); _session.dispose(); super.dispose(); } @@ -304,6 +312,7 @@ class _ReportReplayPageState extends State { tick: _tick, clock: _session.clock, eewIndex: _eewIndex, + paintedMaxLevel: _paintedMaxLevel, ), ), Positioned( @@ -348,10 +357,13 @@ class _ReportReplayPageState extends State { // 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( @@ -359,6 +371,13 @@ class _ReportReplayPageState extends State { 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 @@ -464,6 +483,7 @@ class _ReplayMap extends StatefulWidget { required this.tick, required this.clock, required this.eewIndex, + required this.paintedMaxLevel, }); final TremStationRepository stationRepository; @@ -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 paintedMaxLevel; + @override State<_ReplayMap> createState() => _ReplayMapState(); } @@ -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), @@ -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 = []; estimate.levels.forEach((code, level) { if (level > 0) { diff --git a/lib/features/earthquake/replay_session.dart b/lib/features/earthquake/replay_session.dart index 372f47281..cc5646124 100644 --- a/lib/features/earthquake/replay_session.dart +++ b/lib/features/earthquake/replay_session.dart @@ -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'; @@ -44,6 +46,7 @@ class ReplaySession { Clock serverClock, int replayTimestamp, { required bool Function() cwaOnly, + MlIntensityEstimator? mlIntensity, }) { final api = EarthquakeApi(apiClient); final clock = ReplayClock( @@ -58,7 +61,12 @@ class ReplaySession { label: 'rts-replay', ); final eewChannel = RealtimeChannel>( - 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(), diff --git a/lib/features/map/presentation/layers/rts_layer.dart b/lib/features/map/presentation/layers/rts_layer.dart index 5808bff91..a1a51c95b 100644 --- a/lib/features/map/presentation/layers/rts_layer.dart +++ b/lib/features/map/presentation/layers/rts_layer.dart @@ -216,6 +216,11 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { /// Bumped on every feed notification, so the ranking card rebuilds with it. final ValueNotifier _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 _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 @@ -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), @@ -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 = []; estimate.levels.forEach((code, level) { if (level > 0) { @@ -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 []).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 diff --git a/lib/shared/widgets/intensity_legend.dart b/lib/shared/widgets/intensity_legend.dart index c0a689347..02fccec5d 100644 --- a/lib/shared/widgets/intensity_legend.dart +++ b/lib/shared/widgets/intensity_legend.dart @@ -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). diff --git a/test/features/earthquake/data/eew_ml_max_source_test.dart b/test/features/earthquake/data/eew_ml_max_source_test.dart new file mode 100644 index 000000000..d1345a4e7 --- /dev/null +++ b/test/features/earthquake/data/eew_ml_max_source_test.dart @@ -0,0 +1,96 @@ +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/realtime/realtime_source.dart'; +import 'package:dpip/features/earthquake/data/eew_ml_max_source.dart'; +import 'package:dpip/features/earthquake/domain/eew.dart'; +import 'package:dpip/features/earthquake/domain/eew_town_levels.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Source extends RealtimeSource> { + _Source(this.alerts); + + final List alerts; + + @override + Future>> fetch() async => Ok(alerts); + + @override + DateTime? timestampOf(List value) => null; +} + +class _Model implements MlIntensityEstimator { + _Model({required this.ready, this.levels}); + + @override + bool ready; + final Map? levels; + int prepareCalls = 0; + + @override + Future prepare() async { + prepareCalls++; + return ready; + } + + @override + Future?> townLevels(EewInfo info) async => levels; +} + +Eew _alert({int max = 7, int status = 0}) => Eew( + agency: 'cwa', + id: 'test', + serial: 1, + status: status, + isFinal: false, + info: EewInfo( + time: 1700000000000, + longitude: 121.5, + latitude: 23.5, + depth: 10, + magnitude: 6, + location: 'Test', + max: max, + ), +); + +void main() { + test('replaces the wire maximum with the ML nationwide maximum', () async { + final model = _Model(ready: true, levels: {'100': 3, '630': 5, '970': 1}); + final result = await EewMlMaxSource( + _Source([_alert(max: 7)]), + model, + ).fetch(); + + expect(result.valueOrNull!.single.info.max, 5); + }); + + test('publishes immediately and starts loading an unready model', () async { + final model = _Model(ready: false, levels: {'100': 5}); + final result = await EewMlMaxSource( + _Source([_alert(max: 7)]), + model, + ).fetch(); + + expect(result.valueOrNull!.single.info.max, 7); + expect(model.prepareCalls, 1); + }); + + test('keeps the wire maximum when ML cannot score the solution', () async { + final model = _Model(ready: true); + final result = await EewMlMaxSource( + _Source([_alert(max: 7)]), + model, + ).fetch(); + + expect(result.valueOrNull!.single.info.max, 7); + }); + + test('does not attach an estimate to a cancelled warning', () async { + final model = _Model(ready: true, levels: {'100': 5}); + final result = await EewMlMaxSource( + _Source([_alert(max: 7, status: 3)]), + model, + ).fetch(); + + expect(result.valueOrNull!.single.info.max, 7); + }); +} diff --git a/test/features/earthquake/domain/eew_town_levels_test.dart b/test/features/earthquake/domain/eew_town_levels_test.dart index ad37497f3..38420cac2 100644 --- a/test/features/earthquake/domain/eew_town_levels_test.dart +++ b/test/features/earthquake/domain/eew_town_levels_test.dart @@ -5,6 +5,10 @@ /// may be missing, still loading, or fail to score one quake — and in every /// one of those cases an alert must still colour the island rather than leave /// it blank. +/// +/// The legend's ceiling is the other property: whatever the wash paints has to +/// have a row beside it, and the wash is scored here rather than taken from the +/// warning's reported maximum. library; import 'package:dpip/core/models/lat_lng.dart'; @@ -64,4 +68,10 @@ void main() { expect(estimate.levels['970']!, greaterThan(estimate.levels['100']!)); } }); + + test('the highest painted level ignores townships left transparent', () { + expect(highestPaintedLevel(const {}), isNull); + expect(highestPaintedLevel(const {'970': 0, '100': 0}), isNull); + expect(highestPaintedLevel(const {'970': 7, '100': 3, '630': 0}), 7); + }); } diff --git a/test/features/earthquake/presentation/pages/report_replay_page_test.dart b/test/features/earthquake/presentation/pages/report_replay_page_test.dart index 243a8429f..f1901bb52 100644 --- a/test/features/earthquake/presentation/pages/report_replay_page_test.dart +++ b/test/features/earthquake/presentation/pages/report_replay_page_test.dart @@ -687,6 +687,7 @@ void main() { ChangeNotifierProvider.value( value: RegionStore(settings), ), + Provider.value(value: _Model()), ], child: MaterialApp.router( locale: const Locale('en'), @@ -864,6 +865,7 @@ void main() { ChangeNotifierProvider.value( value: RegionStore(settings), ), + Provider.value(value: _Model()), ], child: MaterialApp.router( locale: const Locale('en'),