diff --git a/Nuvolari/app/assets/mock/frames/1768478400000.png b/Nuvolari/app/assets/mock/frames/1768478400000.png new file mode 100644 index 0000000..f8c1bb7 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768478400000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768478700000.png b/Nuvolari/app/assets/mock/frames/1768478700000.png new file mode 100644 index 0000000..65a6629 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768478700000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768479000000.png b/Nuvolari/app/assets/mock/frames/1768479000000.png new file mode 100644 index 0000000..27837b8 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768479000000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768479300000.png b/Nuvolari/app/assets/mock/frames/1768479300000.png new file mode 100644 index 0000000..e96967c Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768479300000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768479600000.png b/Nuvolari/app/assets/mock/frames/1768479600000.png new file mode 100644 index 0000000..883bb26 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768479600000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768479900000.png b/Nuvolari/app/assets/mock/frames/1768479900000.png new file mode 100644 index 0000000..b427ae7 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768479900000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768480200000.png b/Nuvolari/app/assets/mock/frames/1768480200000.png new file mode 100644 index 0000000..750e585 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768480200000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768480500000.png b/Nuvolari/app/assets/mock/frames/1768480500000.png new file mode 100644 index 0000000..0cf625e Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768480500000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768480800000.png b/Nuvolari/app/assets/mock/frames/1768480800000.png new file mode 100644 index 0000000..b191091 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768480800000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768481100000.png b/Nuvolari/app/assets/mock/frames/1768481100000.png new file mode 100644 index 0000000..998b689 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768481100000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768481400000.png b/Nuvolari/app/assets/mock/frames/1768481400000.png new file mode 100644 index 0000000..5d2eac9 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768481400000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768481700000.png b/Nuvolari/app/assets/mock/frames/1768481700000.png new file mode 100644 index 0000000..fb9bd1c Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768481700000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768482000000.png b/Nuvolari/app/assets/mock/frames/1768482000000.png new file mode 100644 index 0000000..6a906a8 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768482000000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768482300000.png b/Nuvolari/app/assets/mock/frames/1768482300000.png new file mode 100644 index 0000000..c166966 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768482300000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768482600000.png b/Nuvolari/app/assets/mock/frames/1768482600000.png new file mode 100644 index 0000000..fbae767 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768482600000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768482900000.png b/Nuvolari/app/assets/mock/frames/1768482900000.png new file mode 100644 index 0000000..caace6e Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768482900000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768483200000.png b/Nuvolari/app/assets/mock/frames/1768483200000.png new file mode 100644 index 0000000..7ded163 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768483200000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768483500000.png b/Nuvolari/app/assets/mock/frames/1768483500000.png new file mode 100644 index 0000000..63bb411 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768483500000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768483800000.png b/Nuvolari/app/assets/mock/frames/1768483800000.png new file mode 100644 index 0000000..1810c03 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768483800000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768484100000.png b/Nuvolari/app/assets/mock/frames/1768484100000.png new file mode 100644 index 0000000..78cbc66 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768484100000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768484400000.png b/Nuvolari/app/assets/mock/frames/1768484400000.png new file mode 100644 index 0000000..c4d7eeb Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768484400000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768484700000.png b/Nuvolari/app/assets/mock/frames/1768484700000.png new file mode 100644 index 0000000..ad5e44a Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768484700000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768485000000.png b/Nuvolari/app/assets/mock/frames/1768485000000.png new file mode 100644 index 0000000..2db577b Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768485000000.png differ diff --git a/Nuvolari/app/assets/mock/frames/1768485300000.png b/Nuvolari/app/assets/mock/frames/1768485300000.png new file mode 100644 index 0000000..dcaa0d0 Binary files /dev/null and b/Nuvolari/app/assets/mock/frames/1768485300000.png differ diff --git a/Nuvolari/app/assets/mock/manifest.json b/Nuvolari/app/assets/mock/manifest.json new file mode 100644 index 0000000..5e43980 --- /dev/null +++ b/Nuvolari/app/assets/mock/manifest.json @@ -0,0 +1,148 @@ +{ + "region": "piemonte", + "product": "VMI", + "generatedAt": 1768485300000, + "bbox": [ + 6.55, + 43.95, + 9.3, + 46.55 + ], + "crs": "EPSG:3857", + "frames": [ + { + "ts": 1768478400000, + "url": "frames/1768478400000.png" + }, + { + "ts": 1768478700000, + "url": "frames/1768478700000.png" + }, + { + "ts": 1768479000000, + "url": "frames/1768479000000.png" + }, + { + "ts": 1768479300000, + "url": "frames/1768479300000.png" + }, + { + "ts": 1768479600000, + "url": "frames/1768479600000.png" + }, + { + "ts": 1768479900000, + "url": "frames/1768479900000.png" + }, + { + "ts": 1768480200000, + "url": "frames/1768480200000.png" + }, + { + "ts": 1768480500000, + "url": "frames/1768480500000.png" + }, + { + "ts": 1768480800000, + "url": "frames/1768480800000.png" + }, + { + "ts": 1768481100000, + "url": "frames/1768481100000.png" + }, + { + "ts": 1768481400000, + "url": "frames/1768481400000.png" + }, + { + "ts": 1768481700000, + "url": "frames/1768481700000.png" + }, + { + "ts": 1768482000000, + "url": "frames/1768482000000.png" + }, + { + "ts": 1768482300000, + "url": "frames/1768482300000.png" + }, + { + "ts": 1768482600000, + "url": "frames/1768482600000.png" + }, + { + "ts": 1768482900000, + "url": "frames/1768482900000.png" + }, + { + "ts": 1768483200000, + "url": "frames/1768483200000.png" + }, + { + "ts": 1768483500000, + "url": "frames/1768483500000.png" + }, + { + "ts": 1768483800000, + "url": "frames/1768483800000.png" + }, + { + "ts": 1768484100000, + "url": "frames/1768484100000.png" + }, + { + "ts": 1768484400000, + "url": "frames/1768484400000.png" + }, + { + "ts": 1768484700000, + "url": "frames/1768484700000.png" + }, + { + "ts": 1768485000000, + "url": "frames/1768485000000.png" + }, + { + "ts": 1768485300000, + "url": "frames/1768485300000.png" + } + ], + "legend": { + "unit": "dBZ", + "stops": [ + { + "value": 5, + "color": "#4FA3D1" + }, + { + "value": 10, + "color": "#2E7DBE" + }, + { + "value": 20, + "color": "#35A64A" + }, + { + "value": 30, + "color": "#E9D22B" + }, + { + "value": 35, + "color": "#EC8B2A" + }, + { + "value": 40, + "color": "#D63B28" + }, + { + "value": 45, + "color": "#A61E1E" + }, + { + "value": 50, + "color": "#B028B0" + } + ] + }, + "attribution": "Dati dimostrativi generati da tool/generate_mock_frames.py" +} diff --git a/Nuvolari/app/lib/data/radar/arpa_radar_source.dart b/Nuvolari/app/lib/data/radar/arpa_radar_source.dart new file mode 100644 index 0000000..793207e --- /dev/null +++ b/Nuvolari/app/lib/data/radar/arpa_radar_source.dart @@ -0,0 +1,38 @@ +import 'dart:typed_data'; + +import 'radar_manifest.dart'; +import 'radar_source.dart'; + +/// Placeholder for direct ARPA Piemonte radar (HDF5 ODIM, 5-minute cadence from +/// the Bric della Croce and Monte Settepani C-band radars). +/// +/// **Disabled.** Access requires an authorization from ARPA Piemonte that this +/// project does not have and has not requested. The class exists so the shape +/// of the seam is visible and the region config can name the adapter, not +/// because it is nearly ready. +/// +/// When authorization arrives, the data is CC BY 4.0 and must be credited as +/// "Fonte: Arpa Piemonte - www.arpa.piemonte.it". +/// +/// Every method throws. Constructing it is not an error — the region config may +/// legitimately list it as unavailable — but using it is, and failing loudly +/// beats silently serving something else. +class ArpaRadarSource implements RadarSource { + const ArpaRadarSource(); + + static const String _reason = + 'the ARPA Piemonte radar adapter is disabled: it needs an authorization ' + 'from ARPA that this project does not hold'; + + @override + Future getLatestManifest() async => + throw const RadarUnavailableException(_reason); + + @override + Future> getFrames() async => + throw const RadarUnavailableException(_reason); + + @override + Future loadFrameBytes(RadarFrame frame) async => + throw const RadarUnavailableException(_reason); +} diff --git a/Nuvolari/app/lib/data/radar/mock_radar_source.dart b/Nuvolari/app/lib/data/radar/mock_radar_source.dart new file mode 100644 index 0000000..5ee0b6d --- /dev/null +++ b/Nuvolari/app/lib/data/radar/mock_radar_source.dart @@ -0,0 +1,86 @@ +import 'dart:typed_data'; + +import 'package:flutter/services.dart' show AssetBundle, rootBundle; + +import 'radar_manifest.dart'; +import 'radar_source.dart'; + +/// Radar frames bundled in the app, for demo mode and for tests. +/// +/// This is a real adapter, not test scaffolding. It is what lets the app run on +/// a fresh clone with no network and no credentials, which in turn means the +/// timeline, the prefetching, the cache eviction and the degraded states are +/// all exercisable offline. +/// +/// The assets are generated by `tool/generate_mock_frames.py` and deliberately +/// carry the same manifest shape the Python worker publishes, so nothing here +/// is a special case. +class MockRadarSource implements RadarSource { + MockRadarSource({this.bundle, this.manifestAsset = defaultManifestAsset}); + + static const String defaultManifestAsset = 'assets/mock/manifest.json'; + + /// Overridden in tests; null means the real asset bundle. + final AssetBundle? bundle; + + /// Asset path of the manifest; frame paths are resolved relative to it. + final String manifestAsset; + + AssetBundle get _assets => bundle ?? rootBundle; + + /// Parsed once and reused: the assets cannot change while the app runs, and + /// re-parsing on every timeline tick would be pure waste. + RadarManifest? _cached; + + @override + Future getLatestManifest() async { + final cached = _cached; + if (cached != null) return cached; + + final String source; + try { + source = await _assets.loadString(manifestAsset); + } on Object catch (error) { + throw RadarUnavailableException( + 'mock manifest asset $manifestAsset could not be read', + cause: error, + ); + } + + try { + return _cached = RadarManifest.parse(source); + } on FormatException catch (error) { + // A malformed bundled asset is a packaging bug, but it still reaches the + // caller as unavailability so demo mode degrades like any other source. + throw RadarUnavailableException( + 'mock manifest asset $manifestAsset is malformed', + cause: error, + ); + } + } + + @override + Future> getFrames() async => + (await getLatestManifest()).frames; + + @override + Future loadFrameBytes(RadarFrame frame) async { + final key = resolveAssetPath(frame); + try { + final data = await _assets.load(key); + return data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); + } on Object catch (error) { + throw RadarUnavailableException( + 'mock frame asset $key could not be read', + cause: error, + ); + } + } + + /// Resolves a frame's manifest-relative path against [manifestAsset]. + String resolveAssetPath(RadarFrame frame) { + final separator = manifestAsset.lastIndexOf('/'); + if (separator < 0) return frame.path; + return '${manifestAsset.substring(0, separator + 1)}${frame.path}'; + } +} diff --git a/Nuvolari/app/lib/data/radar/radar_manifest.dart b/Nuvolari/app/lib/data/radar/radar_manifest.dart new file mode 100644 index 0000000..b36f5b6 --- /dev/null +++ b/Nuvolari/app/lib/data/radar/radar_manifest.dart @@ -0,0 +1,252 @@ +import 'dart:convert'; +import 'dart:ui' show Color; + +import '../../core/region/geo.dart'; + +/// One radar image in the timeline. +class RadarFrame { + const RadarFrame({required this.timestamp, required this.path}); + + factory RadarFrame.fromJson(Map json) { + final ts = json['ts']; + if (ts is! int) { + throw const FormatException('frame ts must be an integer epoch in ms'); + } + final url = json['url']; + if (url is! String || url.isEmpty) { + throw const FormatException('frame url must be a non-empty string'); + } + return RadarFrame( + timestamp: DateTime.fromMillisecondsSinceEpoch(ts, isUtc: true), + path: url, + ); + } + + /// When the radar sweep was taken, in UTC. + final DateTime timestamp; + + /// Location of the PNG, relative to the manifest that listed it, so the whole + /// published tree can be moved between hosts without rewriting the frames. + final String path; + + @override + String toString() => 'RadarFrame(${timestamp.toIso8601String()}, $path)'; +} + +/// One step of the colour ramp, at and above [value]. +class LegendStop { + const LegendStop({required this.value, required this.color}); + + factory LegendStop.fromJson(Map json) { + final value = json['value']; + if (value is! num) { + throw const FormatException('legend stop value must be a number'); + } + final color = json['color']; + if (color is! String) { + throw const FormatException('legend stop color must be a string'); + } + return LegendStop(value: value.toDouble(), color: _parseHexColor(color)); + } + + final double value; + final Color color; +} + +/// The colour ramp used to render the frames, published alongside them. +/// +/// The legend travels with the data rather than living as a constant in the +/// app: the worker chose these colours when it rendered the PNGs, so a +/// hardcoded copy here could drift and mislabel what the user is looking at. +class RadarLegend { + const RadarLegend({required this.unit, required this.stops}); + + factory RadarLegend.fromJson(Map json) { + final unit = json['unit']; + if (unit is! String || unit.isEmpty) { + throw const FormatException('legend unit must be a non-empty string'); + } + final rawStops = json['stops']; + if (rawStops is! List || rawStops.isEmpty) { + throw const FormatException('legend stops must be a non-empty list'); + } + + final stops = rawStops + .map((entry) { + if (entry is! Map) { + throw const FormatException('legend stop must be an object'); + } + return LegendStop.fromJson(entry); + }) + .toList(growable: false); + + for (var i = 1; i < stops.length; i++) { + if (stops[i].value <= stops[i - 1].value) { + throw FormatException( + 'legend stops must ascend: ${stops[i - 1].value} then ${stops[i].value}', + ); + } + } + + return RadarLegend(unit: unit, stops: stops); + } + + /// Physical unit of the values, for example `dBZ`. + final String unit; + + /// Ascending by [LegendStop.value]. + final List stops; + + double get minimumValue => stops.first.value; + + /// The colour for [value], or null below the lowest stop — which means "no + /// precipitation", and must render as transparent rather than as the first + /// colour of the ramp. + Color? colorFor(double value) { + if (value < stops.first.value) return null; + var chosen = stops.first.color; + for (final stop in stops) { + if (value >= stop.value) { + chosen = stop.color; + } else { + break; + } + } + return chosen; + } +} + +/// The index of published radar frames, as written by the worker. +/// +/// Shape is documented in docs/architecture.md and produced by both +/// tool/generate_mock_frames.py and the Python worker, so the mock and live +/// adapters parse exactly the same document. +class RadarManifest { + const RadarManifest({ + required this.regionId, + required this.product, + required this.generatedAt, + required this.bounds, + required this.frames, + required this.legend, + required this.attribution, + }); + + /// The projection the frames are rendered in. + /// + /// The map overlays each PNG on a lat/lng quad, which only lines up if the + /// image is already in Web Mercator. Anything else would render visibly + /// skewed, so it is rejected at parse time. + static const String requiredCrs = 'EPSG:3857'; + + factory RadarManifest.fromJson(Map json) { + final crs = json['crs']; + if (crs != requiredCrs) { + throw FormatException( + 'manifest crs must be $requiredCrs, got ${crs ?? 'nothing'}', + ); + } + + final generatedAt = json['generatedAt']; + if (generatedAt is! int) { + throw const FormatException('generatedAt must be an integer epoch in ms'); + } + + final rawFrames = json['frames']; + if (rawFrames is! List) { + throw const FormatException('frames must be a list'); + } + final frames = rawFrames.map((entry) { + if (entry is! Map) { + throw const FormatException('frame must be an object'); + } + return RadarFrame.fromJson(entry); + }).toList()..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + + final legend = json['legend']; + if (legend is! Map) { + throw const FormatException('legend must be an object'); + } + + final region = json['region']; + if (region is! String || region.isEmpty) { + throw const FormatException('region must be a non-empty string'); + } + final product = json['product']; + if (product is! String || product.isEmpty) { + throw const FormatException('product must be a non-empty string'); + } + final attribution = json['attribution']; + if (attribution is! String || attribution.isEmpty) { + throw const FormatException( + 'attribution must be a non-empty string: the frames carry a credit ' + 'obligation and the manifest is where it travels', + ); + } + + return RadarManifest( + regionId: region, + product: product, + generatedAt: DateTime.fromMillisecondsSinceEpoch( + generatedAt, + isUtc: true, + ), + bounds: GeoBounds.fromJson(json['bbox']), + frames: List.unmodifiable(frames), + legend: RadarLegend.fromJson(legend), + attribution: attribution, + ); + } + + factory RadarManifest.parse(String source) { + final decoded = jsonDecode(source); + if (decoded is! Map) { + throw const FormatException('manifest must be a JSON object'); + } + return RadarManifest.fromJson(decoded); + } + + final String regionId; + + /// Radar-DPC product code the frames were rendered from, for example `VMI`. + final String product; + + final DateTime generatedAt; + + /// Geographic extent of every frame; all frames share it. + final GeoBounds bounds; + + /// Ascending by timestamp. + final List frames; + + final RadarLegend legend; + + /// Credit line for the frames, shown wherever they are. + final String attribution; + + bool get isEmpty => frames.isEmpty; + + RadarFrame? get newestFrame => frames.isEmpty ? null : frames.last; + + /// How stale the newest frame is relative to [now]. + /// + /// Radar shown without its age is radar the user will assume is current, so + /// every view that renders a frame also renders this. + Duration ageAt(DateTime now) { + final newest = newestFrame; + if (newest == null) return Duration.zero; + return now.toUtc().difference(newest.timestamp); + } +} + +Color _parseHexColor(String value) { + final hex = value.startsWith('#') ? value.substring(1) : value; + if (hex.length != 6 && hex.length != 8) { + throw FormatException('colour must be #RRGGBB or #AARRGGBB, got $value'); + } + final parsed = int.tryParse(hex, radix: 16); + if (parsed == null) { + throw FormatException('colour is not hexadecimal: $value'); + } + return Color(hex.length == 6 ? 0xFF000000 | parsed : parsed); +} diff --git a/Nuvolari/app/lib/data/radar/radar_source.dart b/Nuvolari/app/lib/data/radar/radar_source.dart new file mode 100644 index 0000000..a3777b8 --- /dev/null +++ b/Nuvolari/app/lib/data/radar/radar_source.dart @@ -0,0 +1,48 @@ +import 'dart:typed_data'; + +import 'radar_manifest.dart'; + +/// A source of radar frames. +/// +/// The three implementations differ only in where the bytes come from, never in +/// what they mean: all of them parse the same manifest document, so switching +/// source is configuration rather than a separate code path. +/// +/// See `MockRadarSource` (offline demo), `DpcRadarSource` (live, via our CDN) +/// and `ArpaRadarSource` (disabled stub). +abstract interface class RadarSource { + /// The most recent published index of frames. + /// + /// Throws [RadarUnavailableException] when the manifest cannot be reached or + /// read. Callers are expected to fall back to the last good manifest rather + /// than showing an empty map. + Future getLatestManifest(); + + /// The frames of the latest manifest, ascending by timestamp. + Future> getFrames(); + + /// The PNG bytes for [frame]. + /// + /// Throws [RadarUnavailableException] if that single frame is missing. A gap + /// in the middle of a timeline is normal — the worker can be mid-publish — so + /// callers hold the previous frame instead of blanking the map. + Future loadFrameBytes(RadarFrame frame); +} + +/// Radar data could not be obtained. +/// +/// Deliberately one exception type for every cause — offline, HTTP error, +/// malformed document, adapter disabled — because the app's response is the +/// same in all of them: keep showing the last good frame and say how old it is. +/// The [cause] is kept for logging, not for branching. +class RadarUnavailableException implements Exception { + const RadarUnavailableException(this.message, {this.cause}); + + final String message; + final Object? cause; + + @override + String toString() => cause == null + ? 'RadarUnavailableException: $message' + : 'RadarUnavailableException: $message ($cause)'; +} diff --git a/Nuvolari/app/pubspec.yaml b/Nuvolari/app/pubspec.yaml index c2c7254..a6824cc 100644 --- a/Nuvolari/app/pubspec.yaml +++ b/Nuvolari/app/pubspec.yaml @@ -38,3 +38,7 @@ flutter: assets: - assets/regions/ + # Demo radar frames, generated by tool/generate_mock_frames.py. They make + # the app runnable offline with no credentials. + - assets/mock/ + - assets/mock/frames/ diff --git a/Nuvolari/app/test/data/radar/mock_radar_source_test.dart b/Nuvolari/app/test/data/radar/mock_radar_source_test.dart new file mode 100644 index 0000000..47974e7 --- /dev/null +++ b/Nuvolari/app/test/data/radar/mock_radar_source_test.dart @@ -0,0 +1,187 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nuvolari/core/region/region_config.dart'; +import 'package:nuvolari/data/radar/arpa_radar_source.dart'; +import 'package:nuvolari/data/radar/mock_radar_source.dart'; +import 'package:nuvolari/data/radar/radar_manifest.dart'; +import 'package:nuvolari/data/radar/radar_source.dart'; + +/// Serves the repository's real asset files, since `flutter test` does not +/// populate the asset bundle itself. +class _DiskAssetBundle extends CachingAssetBundle { + @override + Future load(String key) async { + final file = File(key); + if (!file.existsSync()) { + throw FlutterError('asset not found: $key'); + } + return ByteData.sublistView(Uint8List.fromList(file.readAsBytesSync())); + } +} + +class _EmptyAssetBundle extends CachingAssetBundle { + @override + Future load(String key) async => + throw FlutterError('asset not found: $key'); +} + +class _FixedAssetBundle extends CachingAssetBundle { + _FixedAssetBundle(this.content); + + final String content; + + @override + Future load(String key) async => + ByteData.sublistView(Uint8List.fromList(utf8.encode(content))); +} + +void main() { + group('MockRadarSource against the bundled assets', () { + late MockRadarSource source; + + setUp(() { + source = MockRadarSource(bundle: _DiskAssetBundle()); + }); + + test('parses the generated manifest', () async { + final manifest = await source.getLatestManifest(); + + expect(manifest.regionId, 'piemonte'); + expect(manifest.product, 'VMI'); + expect(manifest.frames, isNotEmpty); + expect(manifest.legend.unit, 'dBZ'); + }); + + test('frames are evenly spaced and ascending', () async { + final frames = await source.getFrames(); + + expect(frames.length, greaterThanOrEqualTo(12)); + for (var i = 1; i < frames.length; i++) { + expect( + frames[i].timestamp.difference(frames[i - 1].timestamp), + const Duration(minutes: 5), + reason: 'gap before frame $i', + ); + } + }); + + test('every frame in the manifest actually exists', () async { + final frames = await source.getFrames(); + + for (final frame in frames) { + final bytes = await source.loadFrameBytes(frame); + expect(bytes, isNotEmpty, reason: '${frame.path} is empty'); + // PNG magic number, so a truncated or misnamed file is caught here + // rather than as a blank overlay on the map. + expect( + bytes.sublist(0, 8), + orderedEquals([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), + reason: '${frame.path} is not a PNG', + ); + } + }); + + // The demo frames are drawn for the region's extent. If the two drifted + // apart the overlay would sit off the map with nothing to signal it. + test('the manifest bbox matches the region config', () async { + final manifest = await source.getLatestManifest(); + final region = RegionConfig.parse( + File('assets/regions/piemonte.json').readAsStringSync(), + ); + + expect(manifest.bounds, region.bounds); + }); + + test('resolves frame paths relative to the manifest', () { + final resolved = source.resolveAssetPath( + RadarFrame( + timestamp: DateTime.utc(2026), + path: 'frames/1768478400000.png', + ), + ); + + expect(resolved, 'assets/mock/frames/1768478400000.png'); + }); + + test('parses the manifest once and reuses it', () async { + final first = await source.getLatestManifest(); + final second = await source.getLatestManifest(); + + expect(identical(first, second), isTrue); + }); + }); + + group('MockRadarSource failure handling', () { + // Demo mode degrades exactly like a live source: the caller sees + // unavailability, not a packaging error it cannot act on. + test('reports a missing manifest as unavailable', () async { + final source = MockRadarSource(bundle: _EmptyAssetBundle()); + + await expectLater( + source.getLatestManifest(), + throwsA(isA()), + ); + }); + + test('reports a malformed manifest as unavailable', () async { + final source = MockRadarSource(bundle: _FixedAssetBundle('{"crs": 1}')); + + await expectLater( + source.getLatestManifest(), + throwsA(isA()), + ); + }); + + test('reports a missing frame as unavailable', () async { + final source = MockRadarSource(bundle: _EmptyAssetBundle()); + + await expectLater( + source.loadFrameBytes( + RadarFrame(timestamp: DateTime.utc(2026), path: 'frames/gone.png'), + ), + throwsA(isA()), + ); + }); + }); + + // The adapter is named by configuration but has no authorization behind it. + // Failing loudly is the point: silently serving something else would misreport + // where the data came from. + group('ArpaRadarSource', () { + const source = ArpaRadarSource(); + + test('every entry point refuses', () async { + await expectLater( + source.getLatestManifest(), + throwsA(isA()), + ); + await expectLater( + source.getFrames(), + throwsA(isA()), + ); + await expectLater( + source.loadFrameBytes( + RadarFrame(timestamp: DateTime.utc(2026), path: 'x.png'), + ), + throwsA(isA()), + ); + }); + + test('says why it is disabled', () async { + await expectLater( + source.getLatestManifest(), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('authorization'), + ), + ), + ); + }); + }); +} diff --git a/Nuvolari/app/test/data/radar/radar_manifest_test.dart b/Nuvolari/app/test/data/radar/radar_manifest_test.dart new file mode 100644 index 0000000..e4c2f89 --- /dev/null +++ b/Nuvolari/app/test/data/radar/radar_manifest_test.dart @@ -0,0 +1,233 @@ +import 'dart:convert'; +import 'dart:ui' show Color; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:nuvolari/data/radar/radar_manifest.dart'; + +Map validManifest() => { + 'region': 'piemonte', + 'product': 'VMI', + 'generatedAt': 1768478400000, + 'bbox': [6.55, 43.95, 9.30, 46.55], + 'crs': 'EPSG:3857', + 'frames': [ + {'ts': 1768478400000, 'url': 'frames/a.png'}, + {'ts': 1768478700000, 'url': 'frames/b.png'}, + ], + 'legend': { + 'unit': 'dBZ', + 'stops': [ + {'value': 5, 'color': '#4FA3D1'}, + {'value': 30, 'color': '#E9D22B'}, + ], + }, + 'attribution': 'Radar-DPC — CC BY-SA', +}; + +RadarManifest parseWith(void Function(Map doc) mutate) { + final doc = validManifest(); + mutate(doc); + return RadarManifest.fromJson(doc); +} + +void main() { + group('RadarManifest.fromJson', () { + test('parses a complete manifest', () { + final manifest = RadarManifest.fromJson(validManifest()); + + expect(manifest.regionId, 'piemonte'); + expect(manifest.product, 'VMI'); + expect(manifest.bounds.west, 6.55); + expect(manifest.frames, hasLength(2)); + expect(manifest.legend.unit, 'dBZ'); + expect(manifest.attribution, contains('Radar-DPC')); + expect(manifest.generatedAt.isUtc, isTrue); + }); + + test('frame timestamps are UTC', () { + final manifest = RadarManifest.fromJson(validManifest()); + + expect(manifest.frames.first.timestamp.isUtc, isTrue); + expect( + manifest.frames.first.timestamp.millisecondsSinceEpoch, + 1768478400000, + ); + }); + + test('sorts frames by timestamp regardless of publication order', () { + final manifest = parseWith((doc) { + doc['frames'] = [ + {'ts': 1768478700000, 'url': 'frames/b.png'}, + {'ts': 1768478400000, 'url': 'frames/a.png'}, + ]; + }); + + expect( + manifest.frames.map((frame) => frame.path), + orderedEquals(['frames/a.png', 'frames/b.png']), + ); + }); + + // The map overlays each PNG on a lat/lng quad, which only lines up if the + // image is already in Web Mercator. A different CRS renders visibly skewed + // with no error, so it has to be caught here. + test('rejects a manifest that is not in EPSG:3857', () { + expect( + () => parseWith((doc) => doc['crs'] = 'EPSG:4326'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('EPSG:3857'), + ), + ), + ); + }); + + test('rejects a manifest with no CRS at all', () { + expect( + () => parseWith((doc) => doc.remove('crs')), + throwsFormatException, + ); + }); + + // The frames are a derived product of CC BY-SA data. Publishing them + // without the credit travelling alongside is a licence breach, so a + // manifest that omits it must not load. + test('rejects a manifest with no attribution', () { + expect( + () => parseWith((doc) => doc.remove('attribution')), + throwsFormatException, + ); + expect( + () => parseWith((doc) => doc['attribution'] = ''), + throwsFormatException, + ); + }); + + test('rejects a non-integer timestamp', () { + expect( + () => parseWith((doc) { + doc['frames'] = [ + {'ts': '1768478400000', 'url': 'frames/a.png'}, + ]; + }), + throwsFormatException, + ); + }); + + test('accepts an empty frame list', () { + final manifest = parseWith((doc) => doc['frames'] = []); + + expect(manifest.isEmpty, isTrue); + expect(manifest.newestFrame, isNull); + expect(manifest.ageAt(DateTime.now()), Duration.zero); + }); + }); + + group('RadarManifest.ageAt', () { + test('measures from the newest frame', () { + final manifest = RadarManifest.fromJson(validManifest()); + final now = DateTime.fromMillisecondsSinceEpoch( + 1768478700000 + Duration.millisecondsPerMinute * 12, + isUtc: true, + ); + + expect(manifest.ageAt(now), const Duration(minutes: 12)); + }); + + test('handles a local-time argument', () { + final manifest = RadarManifest.fromJson(validManifest()); + final now = DateTime.fromMillisecondsSinceEpoch( + 1768478700000 + Duration.millisecondsPerMinute * 5, + ); + + expect(manifest.ageAt(now), const Duration(minutes: 5)); + }); + }); + + group('RadarLegend', () { + late RadarLegend legend; + + setUp(() { + legend = RadarManifest.fromJson(validManifest()).legend; + }); + + test('parses #RRGGBB as opaque', () { + expect(legend.stops.first.color, const Color(0xFF4FA3D1)); + }); + + test('picks the highest stop at or below the value', () { + expect(legend.colorFor(5), const Color(0xFF4FA3D1)); + expect(legend.colorFor(29.9), const Color(0xFF4FA3D1)); + expect(legend.colorFor(30), const Color(0xFFE9D22B)); + expect(legend.colorFor(120), const Color(0xFFE9D22B)); + }); + + // Below the lowest stop means "no precipitation", which must be transparent + // rather than the first colour of the ramp — otherwise a dry region renders + // as light drizzle everywhere. + test('returns null below the lowest stop', () { + expect(legend.colorFor(4.9), isNull); + expect(legend.colorFor(-10), isNull); + expect(legend.minimumValue, 5); + }); + + test('rejects stops that do not ascend', () { + expect( + () => parseWith((doc) { + doc['legend'] = { + 'unit': 'dBZ', + 'stops': [ + {'value': 30, 'color': '#E9D22B'}, + {'value': 5, 'color': '#4FA3D1'}, + ], + }; + }), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('ascend'), + ), + ), + ); + }); + + test('rejects an empty stop list', () { + expect( + () => parseWith((doc) { + doc['legend'] = { + 'unit': 'dBZ', + 'stops': [], + }; + }), + throwsFormatException, + ); + }); + + test('rejects a malformed colour', () { + expect( + () => parseWith((doc) { + doc['legend'] = { + 'unit': 'dBZ', + 'stops': [ + {'value': 5, 'color': 'blue'}, + ], + }; + }), + throwsFormatException, + ); + }); + }); + + group('RadarManifest.parse', () { + test('parses JSON text', () { + expect(RadarManifest.parse(jsonEncode(validManifest())).product, 'VMI'); + }); + + test('rejects JSON that is not an object', () { + expect(() => RadarManifest.parse('[]'), throwsFormatException); + }); + }); +} diff --git a/Nuvolari/tool/generate_mock_frames.py b/Nuvolari/tool/generate_mock_frames.py new file mode 100644 index 0000000..e0fa2b5 --- /dev/null +++ b/Nuvolari/tool/generate_mock_frames.py @@ -0,0 +1,175 @@ +"""Generate the synthetic radar frames that back MockRadarSource. + +MockRadarSource is a first-class adapter, not test scaffolding: it is what makes +the app runnable offline, with no credentials, on a fresh clone. Its frames have +to be committed, so this script exists to say where they came from and to let +them be regenerated rather than hand-edited. + +The output deliberately mimics the real contract the Python worker will publish +in milestone 8 — same manifest shape, same relative frame URLs, same legend — so +swapping MockRadarSource for DpcRadarSource is a configuration change and not a +different code path. + +Pure standard library: no Pillow, no numpy. The PNG writer below is about thirty +lines because a dependency for this would be a dependency for everyone building +the app. + +Usage: + python tool/generate_mock_frames.py +""" + +from __future__ import annotations + +import json +import math +import pathlib +import struct +import zlib + +# Piedmont, matching app/assets/regions/piemonte.json. Kept in sync by +# test/data/radar/mock_radar_source_test.dart, which compares the two. +BBOX = (6.55, 43.95, 9.30, 46.55) + +WIDTH = 512 +HEIGHT = 512 +FRAME_COUNT = 24 +FRAME_INTERVAL_MINUTES = 5 + +# A fixed instant, so regenerating the frames produces identical bytes and the +# committed assets do not churn on every run. 2026-01-15 12:00:00 UTC. +BASE_TIMESTAMP_MS = 1768478400000 + +OUT_DIR = pathlib.Path(__file__).resolve().parent.parent / "app" / "assets" / "mock" + +# Reflectivity colour ramp, in dBZ. Published in the manifest so the app draws +# the legend from the data rather than from a hardcoded copy that could drift. +LEGEND_STOPS: list[tuple[float, str]] = [ + (5, "#4FA3D1"), + (10, "#2E7DBE"), + (20, "#35A64A"), + (30, "#E9D22B"), + (35, "#EC8B2A"), + (40, "#D63B28"), + (45, "#A61E1E"), + (50, "#B028B0"), +] + +MIN_DBZ = LEGEND_STOPS[0][0] + + +def hex_to_rgb(value: str) -> tuple[int, int, int]: + value = value.lstrip("#") + return int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16) + + +def colour_for(dbz: float) -> tuple[int, int, int, int]: + """Maps a reflectivity value to RGBA, transparent below the lowest stop.""" + if dbz < MIN_DBZ: + return (0, 0, 0, 0) + + chosen = LEGEND_STOPS[0][1] + for threshold, colour in LEGEND_STOPS: + if dbz >= threshold: + chosen = colour + else: + break + + r, g, b = hex_to_rgb(chosen) + # Fade the weakest returns so the edge of a cell does not look like a wall. + alpha = 140 if dbz < 10 else 215 + return (r, g, b, alpha) + + +def write_png(path: pathlib.Path, pixels: list[bytes]) -> None: + """Writes an 8-bit RGBA PNG. `pixels` is one bytes object per row.""" + + def chunk(kind: bytes, data: bytes) -> bytes: + return ( + struct.pack(">I", len(data)) + + kind + + data + + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) + ) + + raw = b"".join(b"\x00" + row for row in pixels) # filter type 0 per scanline + png = ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", WIDTH, HEIGHT, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"") + ) + path.write_bytes(png) + + +def reflectivity(x: int, y: int, frame: int) -> float: + """A precipitation field that drifts west to east and decays as it goes. + + Two overlapping cells rather than one, so the timeline shows something that + changes shape and not just a disc sliding across the screen. + """ + progress = frame / (FRAME_COUNT - 1) + + total = 0.0 + cells = ( + # (start x, start y, end x, end y, peak dBZ, radius in px) + (0.05, 0.35, 0.85, 0.30, 52.0, 95.0), + (-0.15, 0.62, 0.70, 0.72, 41.0, 70.0), + ) + for sx, sy, ex, ey, peak, radius in cells: + cx = (sx + (ex - sx) * progress) * WIDTH + cy = (sy + (ey - sy) * progress) * HEIGHT + # Cells grow, peak around the middle of the loop, then weaken. + intensity = peak * (0.45 + 0.55 * math.sin(math.pi * progress)) + distance = math.hypot(x - cx, y - cy) + if distance < radius: + falloff = math.cos(distance / radius * math.pi / 2) ** 2 + total = max(total, intensity * falloff) + + return total + + +def main() -> None: + frames_dir = OUT_DIR / "frames" + frames_dir.mkdir(parents=True, exist_ok=True) + + frames = [] + for index in range(FRAME_COUNT): + rows = [] + for y in range(HEIGHT): + row = bytearray() + for x in range(WIDTH): + row += bytes(colour_for(reflectivity(x, y, index))) + rows.append(bytes(row)) + + timestamp = BASE_TIMESTAMP_MS + index * FRAME_INTERVAL_MINUTES * 60_000 + name = f"{timestamp}.png" + write_png(frames_dir / name, rows) + frames.append({"ts": timestamp, "url": f"frames/{name}"}) + print(f" {name}") + + manifest = { + "region": "piemonte", + "product": "VMI", + "generatedAt": frames[-1]["ts"], + "bbox": list(BBOX), + "crs": "EPSG:3857", + "frames": frames, + "legend": { + "unit": "dBZ", + "stops": [ + {"value": value, "color": colour} for value, colour in LEGEND_STOPS + ], + }, + # Mock data is ours, so it carries no third-party obligation. The live + # adapter publishes the Radar-DPC credit here instead. + "attribution": "Dati dimostrativi generati da tool/generate_mock_frames.py", + } + (OUT_DIR / "manifest.json").write_text( + json.dumps(manifest, indent=2) + "\n", encoding="utf-8" + ) + + print(f"\n{FRAME_COUNT} frames + manifest.json written to {OUT_DIR}") + + +if __name__ == "__main__": + main()