From 71557a02d7d9b8a560adbb64c14cd9687406ee60 Mon Sep 17 00:00:00 2001 From: Alberto Balbo Date: Thu, 10 Sep 2026 12:04:22 +0200 Subject: [PATCH] Add the radar data layer with the offline mock adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the RadarSource seam and the manifest it speaks, plus the demo frames that make the app runnable with no network and no credentials. MockRadarSource is a real adapter rather than test scaffolding, and that is what makes the rest of milestone 3 testable: the timeline, prefetching, cache eviction and every degraded path can be exercised offline because the demo frames parse the same manifest document the Python worker will publish. Its assets come from tool/generate_mock_frames.py — committed so the app runs from a clone, generated by a script so they can be regenerated instead of hand-edited. Twenty-four 512x512 frames total 108 KB, and the generator is pure standard library so nobody needs Pillow to build the app. The manifest parser rejects three things that would otherwise fail silently and look plausible: - A CRS other than EPSG:3857. The map overlays each PNG on a lat/lng quad, which only lines up if the image is already in Web Mercator; anything else renders visibly skewed with no error to explain why. - A missing attribution. The frames are a derived product of CC BY-SA data, so the credit has to travel with them rather than be remembered at render time. - Legend stops that do not ascend, which would silently mislabel intensities. The legend travels in the manifest rather than living as a constant here, because the worker chose those colours when it rendered the PNGs and a local copy could drift. RadarLegend.colorFor returns null below the lowest stop: "no precipitation" has to be transparent, not the first colour of the ramp, or a dry region renders as drizzle everywhere. Every failure reaches the caller as a single RadarUnavailableException regardless of cause, because the app's response is the same in all of them — hold the last good frame and say how old it is — and branching on cause would only invite divergence. ArpaRadarSource is a stub whose every method throws. It is named by the region config as unavailable and must fail loudly: quietly serving something else would misreport where the data came from. Verified: analyze clean, 95 tests passing, including a check that every frame the manifest lists exists and is a real PNG, and that its bbox matches the region config. Co-Authored-By: Claude Opus 5 --- .../app/assets/mock/frames/1768478400000.png | Bin 0 -> 1799 bytes .../app/assets/mock/frames/1768478700000.png | Bin 0 -> 1949 bytes .../app/assets/mock/frames/1768479000000.png | Bin 0 -> 2160 bytes .../app/assets/mock/frames/1768479300000.png | Bin 0 -> 2481 bytes .../app/assets/mock/frames/1768479600000.png | Bin 0 -> 2757 bytes .../app/assets/mock/frames/1768479900000.png | Bin 0 -> 2983 bytes .../app/assets/mock/frames/1768480200000.png | Bin 0 -> 3170 bytes .../app/assets/mock/frames/1768480500000.png | Bin 0 -> 3328 bytes .../app/assets/mock/frames/1768480800000.png | Bin 0 -> 3330 bytes .../app/assets/mock/frames/1768481100000.png | Bin 0 -> 3411 bytes .../app/assets/mock/frames/1768481400000.png | Bin 0 -> 3471 bytes .../app/assets/mock/frames/1768481700000.png | Bin 0 -> 3566 bytes .../app/assets/mock/frames/1768482000000.png | Bin 0 -> 3505 bytes .../app/assets/mock/frames/1768482300000.png | Bin 0 -> 3493 bytes .../app/assets/mock/frames/1768482600000.png | Bin 0 -> 3429 bytes .../app/assets/mock/frames/1768482900000.png | Bin 0 -> 3340 bytes .../app/assets/mock/frames/1768483200000.png | Bin 0 -> 3299 bytes .../app/assets/mock/frames/1768483500000.png | Bin 0 -> 3147 bytes .../app/assets/mock/frames/1768483800000.png | Bin 0 -> 3074 bytes .../app/assets/mock/frames/1768484100000.png | Bin 0 -> 2920 bytes .../app/assets/mock/frames/1768484400000.png | Bin 0 -> 2682 bytes .../app/assets/mock/frames/1768484700000.png | Bin 0 -> 2598 bytes .../app/assets/mock/frames/1768485000000.png | Bin 0 -> 2441 bytes .../app/assets/mock/frames/1768485300000.png | Bin 0 -> 2329 bytes Nuvolari/app/assets/mock/manifest.json | 148 ++++++++++ .../app/lib/data/radar/arpa_radar_source.dart | 38 +++ .../app/lib/data/radar/mock_radar_source.dart | 86 ++++++ .../app/lib/data/radar/radar_manifest.dart | 252 ++++++++++++++++++ Nuvolari/app/lib/data/radar/radar_source.dart | 48 ++++ Nuvolari/app/pubspec.yaml | 4 + .../data/radar/mock_radar_source_test.dart | 187 +++++++++++++ .../test/data/radar/radar_manifest_test.dart | 233 ++++++++++++++++ Nuvolari/tool/generate_mock_frames.py | 175 ++++++++++++ 33 files changed, 1171 insertions(+) create mode 100644 Nuvolari/app/assets/mock/frames/1768478400000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768478700000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768479000000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768479300000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768479600000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768479900000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768480200000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768480500000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768480800000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768481100000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768481400000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768481700000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768482000000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768482300000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768482600000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768482900000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768483200000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768483500000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768483800000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768484100000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768484400000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768484700000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768485000000.png create mode 100644 Nuvolari/app/assets/mock/frames/1768485300000.png create mode 100644 Nuvolari/app/assets/mock/manifest.json create mode 100644 Nuvolari/app/lib/data/radar/arpa_radar_source.dart create mode 100644 Nuvolari/app/lib/data/radar/mock_radar_source.dart create mode 100644 Nuvolari/app/lib/data/radar/radar_manifest.dart create mode 100644 Nuvolari/app/lib/data/radar/radar_source.dart create mode 100644 Nuvolari/app/test/data/radar/mock_radar_source_test.dart create mode 100644 Nuvolari/app/test/data/radar/radar_manifest_test.dart create mode 100644 Nuvolari/tool/generate_mock_frames.py diff --git a/Nuvolari/app/assets/mock/frames/1768478400000.png b/Nuvolari/app/assets/mock/frames/1768478400000.png new file mode 100644 index 0000000000000000000000000000000000000000..f8c1bb749465bd42838c375d745afa8d50380d46 GIT binary patch literal 1799 zcmeHG{c96v9REC*YkIRbFA8mp6Vq<2sAG#dYTeLW?X@-&T>9e64`R=4A_GUWg3xNU zPn;{()z%+Y5NT-L!l?Tpt58^AuwGd$zKmg=;;2Yc`@`zG)s&TSY$3Pr6I$?Z7~U^; zd|y6q&vRX&y}Juncvk=@4AyG(fJC1X6g#~ZTkrn^*nbXcm5r@ae?A@y%5|>l$)20h zU}AIWR)4{m|EICOr-_z;ZdpzHqPD0JO3#nj4j0q%|NNOg9QYymtiAn9+-?7(n!(dv z3Dh*L1fMRpK{r1G6qHbussh7=OM<@w=YW+CxWVD^Vi&-C5>%WATyXAquem8;ou&~v z-&ps-z{_%60vm2yY&dHig})rDYOD{HgaZ?@`ups_1y(Gna~LYmyi;ZL;qDfE(`)s( z!W%Gh@1sn$AmV)`srL!Glpu|mE4&>FkGNAUOC_!>3t0Rd2V@QxgAZS8e2=uGNi3>! zO1O@!G&54#S|AL`q~8Ua)r3bzr9n+jyn&iLcZj)zM4D86v=2R{yYB?L*4| z%89pdTUMvcGK~X+ClKqgo+{~Mq>z6{_m%Htmv(0NC8b0;RRG?Pp)J|6S5%=>Cab~{ zS-6g&imW8IAlwtw$LJRwVToX_uuxkjZ`qehKuPkL>w)Q_BdMYw=OQ5YJ|O*!UmOK7 zD421oS20w?h@msj=vts3VWg06bkmf65RGd)NJ1BCdw9dln}ElSYwply9P(0xvnGX0 zXIn2ft`lRoP^(0K$&IZ=S*h=cc`<+)n|@4=b?4ZQE0NeV5h(md$)$?S#Cp0dK70M{ zCSlngu+|U?T{I8k1z@R10g{)@G8N|EPDqAnTROyUYVgZKsgO@X{uM%G-fR(!XyRM3 z<>{ahh(lJNq;vg+d=UtBM!aWcoE-iv&A;s&UC1Adjz+dp$UMSA+vWWZD(M;;Uy3~V zsyV|vYjCYZH<@0ASI3R`@Yq6 literal 0 HcmV?d00001 diff --git a/Nuvolari/app/assets/mock/frames/1768478700000.png b/Nuvolari/app/assets/mock/frames/1768478700000.png new file mode 100644 index 0000000000000000000000000000000000000000..65a6629be6bf78ef6204e3cb5239d91cff0f969c GIT binary patch literal 1949 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 G?E4o%5%q-t literal 0 HcmV?d00001 diff --git a/Nuvolari/app/assets/mock/frames/1768479900000.png b/Nuvolari/app/assets/mock/frames/1768479900000.png new file mode 100644 index 0000000000000000000000000000000000000000..b427ae7ec8d8ccc2d05d85dbbc3d9e5b68667be0 GIT binary patch literal 2983 zcmeHIYg8219sk{#VHw`L0&&F$BgFckQ4kG+D1$5#PZfMiT9p;;@zn^v5TjuR6{HO= z=6HxF3PS4fY>rql+EyBs9fJjmO~s7btHu0!@=RD18A0fsSABu&S=p36YH#lehX2 ztefn)X47s`veJC-T7*`e>I`NSb!de9u{l41#BNgSDvFi{Hk^Yw&a+~FN|NEuuQ4J) z8+)K=4d39xv|KGK(o<&4e@Lv_{!)3mQ7&+$>{QbMS94c)eF)j{CYrm}12@c)t9J?| zKlLxijtOYa;UM?dgfYF#v9QI_wyx2wZTe;mgIG^`i}b*`EQm2tfvi#P32_30aiQp# zE$!gTfaY@P=Dn_PhJk#%#c{RoDNjsLHk0=4>bW$L!(bpl?GI|rG;Ns483wtJTAix% z+BaxpgQOFD{zKO6eUHR$*Jz}xYdrP}X)(vsdO3I_!3`dJC+U&gyY(Wkeo{_#vuaezQM|jHle1C}F+P#PNr$lpA z3t$4@*?1NvO8;}r_Cf#*V0jBPi>aGF9$jf(|&YFP5q9^uS{{2 zXM`U)vqfE2B3AOWp^We*3T)E9`nnFl4ivu1N%p+kBqivMoR1tY)u-hkP`dH;49|rq z*qqX< zyZqw}&$Urrhev%P)z7ihX{54|a757FQ|^f27}nzb#^sp6X@UU8x(IiMc`umq+pZDy z5GUZ5Bfa%zZ;!4h7f5Vvle)Hwq4nm(>u87|o(WV*7>}rC#|EfJ0bbri=DWj0x&xs> zK&CYaEeif`LZgpBP3SCL%vnzu0a}{KfF5d?=UIexrm!2d6yi_IL0NgS&dN_fQWayV zaDXPkaZ0tZVPMxQBEl9V_jDN=hLD(|`f|HOXamuj?4!OLX3@T|kMITnQh5GGSZ0eY z{X=QT)I>Scl)n&aC{3}735$@ryuQ}OSZtHmuH5UAz$wIZFwsN3n%aM(#U#6_3DTZ~ z2^R6Wy6cs1?Q+chP#)=;1Z0oGl1!3hQO@P$RO-$J#+KyM>R()!v+Vrf8MkTFrAl@E zi8fu=i0CM5df*-RrDc~wXjvE7Q5WU>NXT{%Y#XWT!*)RK-uPhSi)SH9YaY8Tp!grU zqblZNnS80qu|b>RW)Bwm$Btp5Rvx!YdICYkt+zgQKciBdSjT^DVy@S>sBHd~@#1W5 z%|5YtWA@8qG=)FcFl*A49-&ox|3C_&gv7uCXwmZ#uJl*m(~7CEL+rO}^YL_MZ`@~1h;nyz8hNbM$$FCJ*h z)5yBvBs5LwV+F=gqMUs3w~@ZeW>bDHe`mWWAS_|j(j=(;pIkwevq;ot~SatXUOMR$>22lhfTN+7SU-w0vP6{-CKuLy3Sc*UQ zmHUgGbOQh9uV<(JGSnU35rW{zT?+t1P9WraLs@z=uIK|ZngWT)gN_funYVCvxBC6} z-D5b^u0nGB^Xb1=#_|YYgxtRPTxF~uhBCq~H9A+Z`yu3HtQM1|jP=Jn*13sS7n9n} zxZ_7^evT<2a@}ZWhmXwCx3wom1cLow0@B#moWh?myYC}E4tI-+UbIu> zr$&;K)bKqz`zwlt+9z?&hXl@9u-J;0@)Rx#qj0t*Win?0v?vq=dwcnOU&Jn~?62Sn zMZ)%RI2*03c%74g%rZLn`0B?H-g9#hTaB3GiX61Php|3T>uu;V zK$cNv_D-~;H;|%0u?~D72a|OuC^aZ2bx0M!0n3Brave(s4-~}oo>23VOxTY!b-zr- zRJ-h>_l)vA)2)Oi*viYn-taSkbvI6x%a!^x3DdB2pIF(yLkmHcL8;`kXK2*n+njPd z&Nx-Gd5Lu^9)A&4zUwE3YDF zay6kw*#Yc}X8n%@A`Egf{6s#kotglPhf>7a%K ztg}rgtcw60o!3HQ%ItFk-IUm1gtsv%*g+#cN)DRX7(b_`!8F%tJ6UobH*KJ}?{EyX)!db2lA7eSp~&+568Ur!wN^(nirCNYAvK Io@L$hAGYFRC;$Ke literal 0 HcmV?d00001 diff --git a/Nuvolari/app/assets/mock/frames/1768480200000.png b/Nuvolari/app/assets/mock/frames/1768480200000.png new file mode 100644 index 0000000000000000000000000000000000000000..750e585b09d103800258d94aac315a1f8383a227 GIT binary patch literal 3170 zcmd^>eNcO zH4x949=lZy)8m1ImT*ejLv=H^qXTEVR_ZutD-xR3v5Jsb&;9kwL;gv1q{J3rBtkGKL|RbcstL!LDha9bZNnoJZrwHy$v=R zgFT0>C0u(47{nZa%?|71fpRS~oC(+KrS}FLk))D`3!T<+Uw#eE(9%)wj-gzDv*Rmm z@t$E;2pj{b_?t0_z4H#ti~z-427Rt@^~UudTBQf@IxEEUp~khES`1sVb$_l==z$h{ zjoU^GYbeJ3X<$QaaB3>gz{aYf@@;|28VbYo&;B8J_8Qdy55yXpR3Eur^uN{Vz96`6+afHdD%={l@Y5Z~Jtp?t|=DuV1h$R;i$hnO}v<30z8%0z>rS581LBv7|=m-e4pq# z&2kuQsA;TG+S-WyN~8LvQl0elWIf{`x?U=DNNw7pG%y48S_`Exg{MH)Aw{#{UAuJR z6>u*D#ss*bpAkrl@sQ>c6@Wwjjv(}taH#gvI1RhReNs3t!UC+ zC6%lgtxzkxC>DJ?(jwZf}P(><=1vt?NK-1ASvr1&RELLawx zp;8~b)Z-lq#L+KEYX-CDHOE!wknBL{1wG##$Owj7?j)h|-g}GFgP{$%@KXn!?M=bz znJz=qbdu54?>BQJTI|BaxBkw1dxK-8w;7`H9}AC)Uka!E4&

Mt={Vkf@g!|)^zCB7R9sY`bR#K zgV&qb1<`o5kQoEf?(l0m=HgvvqZLjw&!}uCNB(@%2+tOf<@rL3?v_7xk3)`$ELB)a zS#y{0S?^YnY}^-)$;Q?lRJoEK&d1xTrOYXRsKpGm;8hDA?yZ?ja@JCOAvi;@Gmf^S zf%m&GtmAW;MTdpv&p!z$c*gGbhc;}7U17G9&N^J~W{Xv}9%omZF@b>THrJ$KGO`qC z?;3Y4N|Dju<`3o9v!^-R)YOWv7f-i>S-8iiKdH7G;l~WWgx&QauJnRj5;GlmE(p)m zL%c;K8ZtLO2pl=pSPh+Ks%d{t?2H4gVQZicGu2yO;>>RrV3d2bH`0_UBji|$ICFK> z@Epj?HNCGZ^WW`T26na0p4$3t)#Zbj%aH??t~Yl69e6e}@1x<~2L;5$sV?7%MkgI= z$Ps)BsxgSbEA3ghbzThzgh2)64FTZ8?Tg{Rd|GdRrFQn`ugY2mIz!-qJVUTm&i^n& z-dZffU#WI$fFA0QzU^z=AzeKLupnBJ>culSU`$^OIA)45*`7qiZi-<%IY)>ATrCj0 zQ$5>32uzz1Y|>Pef<>GXX*F~088}dK50~SRs)MGiU}cWb#|Z5(xIon7>u1<2x@zbZ zC4EE;t3@0rjAc;f-SAB88{uOa?Dk07qnH@?zP;iUcCQDnEgDZ;V`9OehuINQiw-&R zUL?KV@jabyEQHG{Hwr4q@%3WqA&RYgvZW)10n2Jw7bjigCb^U2#T|YYG}l>+C$Xk9 z^~laDQlZ&lHOpHpl|@G*X@S@J9E0FThs)J|d7eDpi61X@j3@uRd%hA+cvxtcP7ahu zGX(^DaEgLo6KKnoU9X3AG18!B(x(T?o)0!A2>l9ZSP8|^QWIAZ4iq?!o{_tn&xWB` z>DO8w2{;vc&IZrxO)~4k>!l+Q_&MAD3EEaDFm)c@i~xAT@RKc$Oq8PqpwnX95;iF> zQxPGEXpF>d)8v|hPffxm1?6Z!N*GfRR1!d7Bv8x}Oew%`y$DCWjKin<55dH|YwREtzZLqVq1rf;eg45q=(Q3x7W*{tqcrH5v!{P?kEk9uNbTPFabM0 zWUc43iHbz*`0!t0A-Bm?A!{!uD9^2Gbf&``&ek-m%)eheK`gJZ>)OS2#8SouHZekj z#&=Uev*SIDA+RJzC{#kt$JB{m$6<_f9xQM$&RF>WenuZA{_gpr5}$qhcDWEQ<`$S+ Iax4e_1q%4gUH||9 literal 0 HcmV?d00001 diff --git a/Nuvolari/app/assets/mock/frames/1768480500000.png b/Nuvolari/app/assets/mock/frames/1768480500000.png new file mode 100644 index 0000000000000000000000000000000000000000..0cf625e3f6f372433414463aef4bce7c71602bcb GIT binary patch literal 3328 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 ROf)Z-pPRMRaypaS{T~<=K&=1( literal 0 HcmV?d00001 diff --git a/Nuvolari/app/assets/mock/frames/1768481100000.png b/Nuvolari/app/assets/mock/frames/1768481100000.png new file mode 100644 index 0000000000000000000000000000000000000000..998b689eb0fff5b06cae13303f63cf42df50e408 GIT binary patch literal 3411 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 CdYE7U literal 0 HcmV?d00001 diff --git a/Nuvolari/app/assets/mock/frames/1768481700000.png b/Nuvolari/app/assets/mock/frames/1768481700000.png new file mode 100644 index 0000000000000000000000000000000000000000..fb9bd1c22185acc129374a72fbbe8ea7fcb9dcc8 GIT binary patch literal 3566 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 RUrm?lUAlOgu_eQ@>tFpQ#S8!d literal 0 HcmV?d00001 diff --git a/Nuvolari/app/assets/mock/frames/1768482000000.png b/Nuvolari/app/assets/mock/frames/1768482000000.png new file mode 100644 index 0000000000000000000000000000000000000000..6a906a8ae49e1a2e7a4cf997241f15c6fd2c091f GIT binary patch literal 3505 zcmds(`&ScZ8poe^GJz4fBorZFFd@{UvMpFpSubRuF|_t9(50xm)=j+A)*hkO2CZcV zFNie|IWCJ;3!Kt(Y*__YyVV72#-ahOJxEsytso{Q|2!zX@dVEozGO;!weW{~Jr_@gWrUTy zVi=7o{^_F?>$#HBQaF9;(h;f+g{yYY^zZxo%*{>B!zY{04ro6+d;Y^U*QIUWymvk# z0sH!2d#v>~rf7n&EgE0pd#-aQhUfcAEYZ`9@ zVfBsnZs7LBgWVYs%-O!0X@mjTXIq^IQ2UiN*0pV zPUbW`btxrYOPM+$`0% zIA11liOAc*w@)dy%NfuIpj0ExY0*W?UQ_N5I|upRpyD~l@%5qMH%?#s3@OVw@$TSN zJG)G$#HFI_C;g18Kf}f509^H>tD2?U7Go31=~V_{aoxMhQI zCbsr!D~P#>Vt=+toi1iyj%>&a7DzTKCrfSiNEOARaDObJaY%Nhjf8lYbk&=&m+k`Z z`v9p@?;NPMOL^4dGIpTX-))ItAx{l;9x0i4cS6HCp97?t0lBRfko$9x4`M47`h^4u zuu7!d10Jb=lnF&0*fb7-_bDK7t*01xRl^`X0Ev^8%2>H$6suI@85IyT;OcggzYg?$ zPDMEWEHv=50pSI~C=YNn=_%`Hz5aRtZ!-wbfpaQ=GwAvjMHs;5aLA2y4|m~LioJ{y zqfn&B=*a*juv9lL$jlv56+YS4bbA6DAyp_;W}=1?uD@o<+v6i~0Np=kp!jNHy(Q9v z;g#~E4>lenPT-V*8%3vX&gLpx2gevFpTb_tVV9||PYhcC_*8cJahPVXz9Ynrh|!WF z`x!W$g_fJ?Upkxs)|(F0Wac_sliwb<+~u(W#hyM6XVPm2c%PtSm+KVkO|+T1HSxr` zBXd|pDPovDo-m}luXG(-BI5h;mcsfpF?@;Z?^-!mESJ!%iMjM%s_neFwYSFz8P&vV zW$Xtr`40ym*Zlw}MC}ppxPd!f#FLv4MBfP0z95UV$k8&_Aff6ptf-w$(tLY3~UR(nRKOVB-ssJ~3R zwjP}~nfK>*wle16!<}{`ton#pZ|3ZpwxXdka@wWrNrQe)z{XV9r(NIS_G~Z)tei&V zCdefuisd3wp^{!+ajJkk7Dl*qfbz4+_2>WO(y?zDtY=kiJIb)rmt$$_TC~_KpMtzd z)RV3s+lKq8jX<62tgfxNS=wlnyVm;4)_HQ5m!_Ti@wYwi-z&pk!%UkruWrAE{ryFq z{Vcg~HIAXS@_1R69ASOG-@A>LFTQH>8W~Cd3bu)q( zE05?v>|RBfb#RI8@P{<(14OGdJ3*y%&4B;<^T)#RJ6(>7suf`{U-w=bfY}lVY)6Cw ztg_x|iedo#L!2sLmwF_hFTrR|z%!Tk8P|F@EiHByfSuXh(O!y}F)Xg;I$b1jTZzS? zMAlsW>%yWy#zYlv!g_Et7*ncnAGjNsx|e0u3yCU{)yG&UEX9|>eI0X%xSfV=FUmTZ zu-X7{q+II!nUVnLSBGhNFwmC4d@sM}uOgwirWY-zb~srK@~n`L9k_YQPpd z>F60H{R@xji+PWCX#a@8#%+yuS`rV3f?Ux&qsEEb2j)T>l& zzk_e43W}!J_WDsQq{M(YQt%kl^q#`{^8%v5&gR-A9N;({AoTZTN@LwTMwlGVEkM$c zT*_XR*V%7-{SOudB*qfUdnXo&{_bC za^WVruMcmh*iWdofa22D4FX2e6}4)bqQdd>-9dZ_RB$Ej;KOYg83trX-~DtY|9Hnb?qG(c&Pz-;r{`fX-VAVnu@4sZ{Zbs z;)8wkgLTgPJ21jc+{c=>!V{P`9zvf)71(6s}+?uLUf11QWWOC)Nlw+E>G|5 literal 0 HcmV?d00001 diff --git a/Nuvolari/app/assets/mock/frames/1768482300000.png b/Nuvolari/app/assets/mock/frames/1768482300000.png new file mode 100644 index 0000000000000000000000000000000000000000..c1669664eb3d92edbf83cd15a29a479e2082491e GIT binary patch literal 3493 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 literal 0 HcmV?d00001 diff --git a/Nuvolari/app/assets/mock/frames/1768482600000.png b/Nuvolari/app/assets/mock/frames/1768482600000.png new file mode 100644 index 0000000000000000000000000000000000000000..fbae7679748e1e958b6fa51e84b3cd2893eb881d GIT binary patch literal 3429 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 literal 0 HcmV?d00001 diff --git a/Nuvolari/app/assets/mock/frames/1768483200000.png b/Nuvolari/app/assets/mock/frames/1768483200000.png new file mode 100644 index 0000000000000000000000000000000000000000..7ded163774d3aebcda0e1ef6806c71b756ba0283 GIT binary patch literal 3299 zcmds(|65d59>+iD&IPVOaEBm3zRYE&RuYqMenGUjd{MMgX}7?7q?wg!T)#zOf*Uvo zQSb{4y0%ZT(SqVrK8^vUB^&D88e`NgkxiZ;Fa{<yVt}x8qbMe`~VV~Xm%iR09 zpU>yK&zW;x?|U$7UFJjHbG-rJp*5>Beh)yATM8IY@-tp^a|*!s^qP#+=Zk*0d+W=p zz%^|7a|`+}PzS^2t+4G~xdC40szODm_}cT)%C4{hVRP$zO`=BKcWn6D&dI&CDs7>kZ}G7ie6h)3?^{5TOcow*bmvq{<}vsE zkx!Cmi+92|k5D?%VX>Frh zGe#AK0p5jf+#fbf=8`ynwMUAvN_PhTX^wnRE%eCqsE48*{yh1A-66&L z;!t~v28#Uv#v~tE5VVBB**5dF2Yt^mctMUt@uVRjPS#HG036U*Rxy76acn}ikL36s z-z@osOU)USESSB!`Y4A9BZn2sxnyM5GM&LM1Wqe7dR)|t@RQjCMuBYfr53vy_(b(afL_B=x#edxjH5yxoweHTa7NEta`AImP zDKwf4=OvUhupL(-_Kk^HfX!hLp{XAheE{kdtApULYHo|m17MWe$lwp6@&}zSM3BKa zNK@3mNpCFK*ytu3>)4Op8-l2sHqyHJZUMZg;gadm;+bJP>DcShH5W9>xByC{z1J}` zIIO~-qO2$?JF?qK@);ahh>}+dhn$7eD!hc;XYl)-g%kc9{OguSO8}}#vt7$G7FBJW zQ5=~M>nicna@P#r#B?dC995mui-%_q;h^~ZA!o+LND4sN9Ot>}(j}*t?G#)IMAfTO z$G^Xt;3iw@Q)Qfnel6l?d$*{_N`Mm@u7Jr%Sx*+P6|QOoQE_8FBxLgCA@5Dpo*2Ev z0Q_(CJ@e;YlPvuJ-lfc+=ez5vCFJGEc`9Ka|GDPZw}Jpp zUi9Ipfr>s+!BLfFrx&+`2)^qihv4KP{BWACZR=|;6g`v8PqOb#ycBAH3?bF>x(jWd z%;qcD_h!CmnegYQnNxvO%*#Hc`QI3_yrFrz&U;JT5UxV4dAf;v7w%om_f*_Srn2cT zuMfTALM%FF;d<;O}*Z9fT-0#nq+)rOt)8LYgW4q<^U!-ZA2b z)474g1wSK>_DzACMr@2?C%#CsK$4n1ZRLL&v7Mo8EwY@B_MRFVv#UesvwyEk^aACP zXeN6_u-+Habbko{rKt8g3;TrBibM*cbRy!JkUhXE%2lurcX&a(ZXEG!NHMUqC)vS! zOTFN~dD#}IE)`F^diy&O{!n9>beyIHR2IvCU^`3*>DokG-b_sHYz6T(;Y&*_@jAQ1 zd|#Zfevr-pf81$aF2*zng<)W+D{|@Cew(;Xbevn+U{3ZVE|E#|pY>~%6xsFmx}BoFf;f@zu|(W9hQ1I4*d$Sw6wRz* znQw@P0chd4-JC$xz6IyZQ%#H9&pn{7b|xoWdO>OXMNvQ4X&7`?ACN63X>{0Z5t|Nx zJ_@!sx|df;o9PxG5R^I6Sx<3j@_T<(r&{P{$T06WnAM&Hk&W(oM#E*;hA!-mD~ z9F5?GQ2w+e5LlBeKx_yo?dE}nvK}w+!0j^9X!69vif}k;HxQeClzj2=1mi$sqLkd! zAxG$cp2R1C62^${fVdWujs_p0k0M#(R;-^{%##WDQ-oC}t)&_$4gyvQ4N>_+ec!$* zLzac$O}g>E0GWO(5vxQ>>lHhJL-=&6a7{j(EO`C+z?9aa<`^ow+Du$C7oI}c`v*-?0bMtAns{M?Nnid~6kV=fKFd4`?3@}|KEmK}R9sUT? zG?mS=@pb@8lsR@|)Pf__y77O65fG6L2~ZmZooa62{#+8^k24$M2|Q=1QEz}(d`nLa zjbGUsE$gUi>+4K`j}=@pwQD~=UJxg<=VW}`NPO#rKW0`xqByH+AcUqDB<(U@lu^B{ z68l!j5h5h#S@BM#WI7&i0o~?1l{_!vG$TM5B Ma$QDanr`pE0iVq`MF0Q* literal 0 HcmV?d00001 diff --git a/Nuvolari/app/assets/mock/frames/1768483500000.png b/Nuvolari/app/assets/mock/frames/1768483500000.png new file mode 100644 index 0000000000000000000000000000000000000000..63bb411dedcba640b5396ab941a5fca360c23525 GIT binary patch literal 3147 zcmds2|5p=N7Qb&Yz=%N-iy8qTf!(dZRuQ$LsEH6o+b+q9LeEy4Si63KTI08Zh|Hr( zi1HsUGpfHgu7YFhI=PX$;{BVqn?_E1R5_tDm3etmb0MuSo0klo0f$tlO;Jt;RY_c61 zPk~B2e!s`DS(`jv1Mr1aTQ<<5ugr>oHj~*~LRX$+@O6)AhrjR(oE3>@w7E9p_gwQ)1U^#IB3T+lnjASLjpJ+4iG!fFiQKJFV@7;4GpQvhC;@m>;t{z#m7O+&(L zm?Pv{x#jfP`>1qnXfZ&$D==pqGKGP09?01`#+ya(CTaz);{1q<;4z2d>|)wlv42My z9q0iyESELi=o^w#4j^62ZsRiOBNUu^0Y0#cCwom3s5}H4p8DB`-u2ZuU&=fwLFiYy zV*t-t-6&+$vIR_L7|G!}F9^er54S#-OmJdY8jVvu?63VFy(_^!CQOpEU46Ojx=-_g zdktva?|dBzfHH}bI*Al;g=+EyhHO9p{${RV znk{^7<<`;+fX9>sc`C6*=`IHNCW7DZ5XX7A45~Z|boV{tN`uIb_-_BPcrhJ_1^gFe z9JwdO5u<(15)MU4W=&vl7RQIPaNV^A3L5RBZwOO zey%7qq{poY6fnjhwm7xP6b;~HMfOsla9)@K;OAp;s#DC8HGP#ruFc%#69;`0Hu?hX$+u~Y$OD+k#(RL3$aa-hmfli40zNQEBOF==1=_8MWK;x zJTSN#8B?ft>>T;emcu}x1v`#X+be=4p=dQ6p`^HK_;JY?g9eezRSN$Pu-S0oT6KT-|z4ycD6UVr=^>9bCmB9|27@tpvTEPg|oHaoNko_C$PI*)V~cT@jV77XAW(J8i86C}0KixJ%fVZMZ+RhZZ zpnM`J;g?F|T)Nd^>ZsZ7_ldn>Lpg)VU?|-!%DIlXE7PB<}3y@#-A_+=O>lO36>nyK-q_b z<6$q~bY(Tdiu4eurD!(P77f{apA4%c6C+;;47Dd3F7cf*`0sd(H=Wa6TGNH{BCvQn z$I5;rP|efBf%g)nNSE|U&M|;DdBnjRpEj0E(*V?2#X)y#Ev4N8wQTR_9rZ}No2(A@ z+UrpX0xNgRn_GpmDDb!}fhqvn&N_PSM3}}vvy&T^L#=Y*Z)!;5~7#&!j|ydZ^zS)NTwNr3j^1!zDF|7dOX>CheVzf-emk;)MZ) zC6bKw(ggAOw#La4{W`^ENHJHqE-|9lpEzHb+-4%`FqbrgxBBe0(x(jmnL#`->M_DL zJH#GoX$f&#r1_-G?i;LL(gDI)mS>g?Fkt(EH31Q31gIutAkEbG6q`>yV{BUDR$?_F zLZaVq*eE0Xh=9S{ih@oIO}~Qe3ne*V@GsD*H0s#Jb!4ar$76}8SJ?3s&va78Y`&eb zm6BcnQmEjT(zsKC^_tAx#n{R=9TN!0G{nFGjbh||0*q?1(0Xnhq0vCBc@3=ES9#U+ zbXg3U(<9WZ3&bH0Sr_ZRllYD1*La5Rkr6XX0cTQ8yXewjgpQWifI(v6>a{mhsH8hB zK4P1-AKM;J1;|W=KO{93F=R;fx~;IH1|O0@suIz;9Ch#lF_%v;X3`n2a}LSly5h^S zYZ7M|r-q*~H4+MV6HXA~{-?RSI9&i6UekT3!`VYid$(+ZhZputAs&CySRP;aERFDaRF3=d(=Btzr|(zsHy*pE0fD-&($;^UTCW9Ux;N-=_w5-l-8QJ;#cIC)ieUnTSK-DakVhizY*3A-DzYJcYItZCn3u`} lUC69c1XdvUzk1d^iI`uoO|z=VutchB+2TC?$!znx{{f+D=uQ9t literal 0 HcmV?d00001 diff --git a/Nuvolari/app/assets/mock/frames/1768483800000.png b/Nuvolari/app/assets/mock/frames/1768483800000.png new file mode 100644 index 0000000000000000000000000000000000000000..1810c035353203b8ee1524b8d7feb9b097bd642b GIT binary patch literal 3074 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 literal 0 HcmV?d00001 diff --git a/Nuvolari/app/assets/mock/frames/1768484400000.png b/Nuvolari/app/assets/mock/frames/1768484400000.png new file mode 100644 index 0000000000000000000000000000000000000000..c4d7eebbf23866012430c931bf5bd73cd5c2a8d3 GIT binary patch literal 2682 zcmdUw{Zmxu701tW_p*C=-Mf~;hS%LCiXjcGRe}|hz2YKq%m9`~OcUKzr!|chm<|Ej zi1)$prr{+{CWD=-;FxA@G8S!`DTblD3A$8M6{`)7X<(U6S|pLT!Sn`~dwZU>cKR=P zf7o;GxzBUH=Q;PD&mJr-E{csxiUNSxjn6oL2*9DsfuKh-Uw2~xAZp!4=ei%&-MaJn z<@T}{#HP)2>vnEf@@#8s%$n#&KeiZ-h`Q(P>nrD=|7yu|cRqMK-(NHKsXDddKwc&0 zcxW!aqI~<=Cx<@CjD*)(wwr-|l*og7K7xR`-U`s{iUE2pkps8V1Yq881v*A=}o)$^5#G`=bLe*ExxcW(7{=1LXUT-7tK|>iI}S%Yj)wL z(E1|HDfE2?_RtAy;EJwiQ7K7Rw^;*myiaLqruiniCD^?-WCI9J|9A;E^(78`d+MdM-&OPrD>Cc@=Bfu@H_1MQIyDn zHAb%hKE=?fT@*!ty|EBp08@He;RIi>r)VBwU23k5Ktrd@r^WC2*7 z41RAhue5^*e)$=Gbp-U>xb8h$8wJ!Oca;IWV~~D(cJbTAJJXwZtSabz46q0a0G6lW-6F~|HbzKynK;*TcT9K6O!`MX9vK@j&fZHaT~A7va%cJ^8N1Wc|~X84m2~Jr?5)t z0#>QEJK(IT)T*x-*c~)VUzC%)U4d_2-S_11KmHDF`4KlC;olnVoHa;%<2EMlX0I6! zpDaUEAre%+QbNhCJy>6n3(RKyE21eWQL>&US327@^8Gw0^zMhFDu>AY# z^waf=vvQXJ)F0G5hvk1R;h?lyDeqJdnyUkG}xdqTC%l+A2>y3Am1Ox04^Sc?$LM#lo&5O%>G%3~w#N=4QI zL2v;4k_`VOROw`Y8E40|Xk>`A6{?f9w@iUO04d=J#ZkfyLmK|@6vA6k3QI>IVMscC zAJoJWjz~-|l=|Q)h5y?y<0YS(Wdv0ijxdvpxdCG0Xoo4FqyvK=dw3*`WHMmb)jc%( z{yBD#Gjjt;GVF^Tws6IrUKGiKcQ;^moW4Tez78W~y IU*JCQABXha@Bjb+ literal 0 HcmV?d00001 diff --git a/Nuvolari/app/assets/mock/frames/1768484700000.png b/Nuvolari/app/assets/mock/frames/1768484700000.png new file mode 100644 index 0000000000000000000000000000000000000000..ad5e44ae2be66d1003f19b3e3ad69bf79d929a54 GIT binary patch literal 2598 zcmdUx{ZCuh8OOip_yU(#Ul%GMP%uzrP}Sfh)u6!eg#u|*$&j*S(G~?Kdr8oyLSIrG zmBc5c2~KKor*vb@ng)$2tl1P;SFPT%#-1ENO;-^_Bf2RRQ`42M3AXcw1IBmfnq}R; zko(I$=ef`KJl{w5^SSqUvw3rB(z+x7sau||e-@y8Ng>Ab+imM7%Ru6dE%mkEvHtVM z%tyT$wNk^@NbR1U^q-E1+8}NFdse>XNUHU4f^q8fo4V{k?mKr^x7>biTTwVj_z4fn z#cbha|CdWkJ8L!|tMaCES4|tltgfv_*My0Y`2~fdEQfv$`8eg zHaPN>@AoM6V#V`tUGw%q3y(MI z*0=vs4~!|@u)Z8@Poz4?kr`!iGLWUAV4sGD*YUpe6eY=Qel8$U9<*Oj&ih-lX(8Aj zI+gMi+crT|J?s)5!Y|JJL)+BiL6XU_ig+^vd z1oecqb~F)mES0IM#b_J@B&1UP{z&I$c7||Wp$zCDF!ezLv4k7$=Xqw_q6CN_dY~dR z)~0W_&(Wgwi)j!>V?JNvl6s}@n;*-$_thvMP! zE8U^tG(uEmu1&qUO0d0mtxZ8b0@t@=$7bcDYRq(_l$kd0E@s?-tQz6@ViKFuaYGU) zue4@dMz~5HE5f%x^brtWKBW?(#d4=Y*Q9#YwUT;8i7x@Vi!62cy`7z#emvH;uKC`n zIMwMuCH*X&ULAgMd~bV%2=aUjd;e9rf7+m?9qu1j;7Gz+`SZt|N*tIN+{9)TBB9VT zV**ue@Y-qXHJzE=<2IhN1f6D9hSo>_Iv^KLuYC27aKe>`~{J0BsX1r}9RTQIV`>4lrf z$|fy!mhmq?zeMyxT_e{>*`)kcHkTj^xvsO$i=hNreB$`wlYtLoc6(~?eEsqZkm&CI zDau4+1bQLG1d({PsPhChOu8SklR+)^cosBFZwy19)b%bNz^nc4o$-mlV`XPbAf#N> z1A}FJqJqf;_WVN|V$`J2zob%A(7GkCpvokvB2ovW^(sT{!|w0yECfzDm2;D(hc#x7 z5%*UMfj(j&@Z0_t42Z-)`RcBV23gKeyVa#>e!=l1K3C{ zm({q#wh%U@6SMRU3Of$qHx6Y`=d0&z=139Q8)C=1T=q7F=QMe}MqO~42zKDwYQRWJYfPYZ8S_J#r(#GHpo zr_$t97FAlgR@y70RYjf&wWJ5Z48W=6IBh*cFzZ1r&Fc6G@~u!Nf7d8iAQ3fB-K}x% zK32x!J^zl;k0p<_e#4nH2Jxi(u%YtWz}ph!|Iae$m|aP2R-aYJ+4Ewg(=)F>7#{~A@R2=3gzo9?6UajokM(1=s=rry^Ft%VA4F{U}-8Uf*C%GaQ)CK4>plg zM_DL={fmrjq~;HHaW0A_eTS!pC-`81mZ^JnNZFr>o?ctkaQvtp{udtVaC-iVr%$lW zH8}>3GtLCmb3h?WLQ7F>bh`_ElS_el#HQBHX#%gt_b17E_h4ly?Chi18E3_XEc)1I zCr=V4x*ip_f1u@mpMopZ@!K!{nDa;6m#eATHQ1g9-N&> WbmMpD2J`u+53r@dTz|gK()}O9sk{^b literal 0 HcmV?d00001 diff --git a/Nuvolari/app/assets/mock/frames/1768485000000.png b/Nuvolari/app/assets/mock/frames/1768485000000.png new file mode 100644 index 0000000000000000000000000000000000000000..2db577bb680e499abf0498fc54eca405e4fe2927 GIT binary patch literal 2441 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 S`d5y#MhbH0Iu7NyYX1gHnKLs0 literal 0 HcmV?d00001 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()