From f8a35ce3223793b78c864f4ad6659124d6fba852 Mon Sep 17 00:00:00 2001 From: Simona Meiler Date: Mon, 23 Mar 2026 13:36:24 -0700 Subject: [PATCH 1/2] Fix TCTracks.from_FAST duplicate loading from year loop --- climada/hazard/tc_tracks.py | 151 +++++++++++++------------- climada/hazard/test/test_tc_tracks.py | 65 +++++++++++ 2 files changed, 138 insertions(+), 78 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index a8027cd714..9418dfdd8d 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -1944,86 +1944,81 @@ def from_FAST(cls, folder_name: str): if Path(file).suffix != ".nc": continue with xr.open_dataset(file) as dataset: - for year in dataset.year: - for i in dataset.n_trk: - - # Select track - track = dataset.sel(n_trk=i, year=year) - # chunk dataset at first NaN value - lon = track.lon_trks.data - last_valid_index = np.where(np.isfinite(lon))[0][-1] - track = track.isel(time=slice(0, last_valid_index + 1)) - # Select lat, lon - lat = track.lat_trks.data - lon = track.lon_trks.data - # Convert lon from 0-360 to -180 - 180 - lon = ((lon + 180) % 360) - 180 - # Convert time to pandas Datetime "yyyy.mm.dd" - reference_time = ( - f"{track.tc_years.item()}-{int(track.tc_month.item())}-01" - ) - time = pd.to_datetime( - track.time.data, unit="s", origin=reference_time - ).astype("datetime64[s]") - # Define variables - ms_to_kn = 1.943844 - max_wind_kn = track.vmax_trks.data * ms_to_kn - env_pressure = BASIN_ENV_PRESSURE[track.tc_basins.data.item()] - cen_pres = _estimate_pressure( - np.full(lat.shape, np.nan), - lat, - lon, - max_wind_kn, - ) + for i in dataset.n_trk: + + # Select track + track = dataset.sel(n_trk=i) + # chunk dataset at first NaN value + lon = track.lon_trks.data + last_valid_index = np.where(np.isfinite(lon))[0][-1] + track = track.isel(time=slice(0, last_valid_index + 1)) + # Select lat, lon + lat = track.lat_trks.data + lon = track.lon_trks.data + # Convert lon from 0-360 to -180 - 180 + lon = ((lon + 180) % 360) - 180 + # Convert time to pandas Datetime "yyyy.mm.dd" + reference_time = ( + f"{track.tc_years.item()}-{int(track.tc_month.item())}-01" + ) + time = pd.to_datetime( + track.time.data, unit="s", origin=reference_time + ).astype("datetime64[s]") + # Define variables + ms_to_kn = 1.943844 + max_wind_kn = track.vmax_trks.data * ms_to_kn + env_pressure = BASIN_ENV_PRESSURE[track.tc_basins.data.item()] + cen_pres = _estimate_pressure( + np.full(lat.shape, np.nan), + lat, + lon, + max_wind_kn, + ) - data.append( - xr.Dataset( - { - "time_step": ( - "time", - np.full(time.shape[0], track.time.data[1]), - ), - "max_sustained_wind": ( - "time", - track.vmax_trks.data, - ), - "central_pressure": ("time", cen_pres), - "radius_max_wind": ( - "time", - estimate_rmw( - np.full(lat.shape, np.nan), cen_pres - ), - ), - "environmental_pressure": ( - "time", - np.full(time.shape[0], env_pressure), - ), - "basin": ( - "time", - np.full( - time.shape[0], track.tc_basins.data.item() - ), - ), - }, - coords={ - "time": ("time", time), - "lat": ("time", lat), - "lon": ("time", lon), - }, - attrs={ - "max_sustained_wind_unit": "m/s", - "central_pressure_unit": "hPa", - "name": f"storm_{track.n_trk.item()}", - "sid": track.n_trk.item(), - "orig_event_flag": True, - "data_provider": "FAST", - "id_no": track.n_trk.item(), - "category": set_category( - max_wind_kn, wind_unit="kn", saffir_scale=None - ), - }, - ) + data.append( + xr.Dataset( + { + "time_step": ( + "time", + np.full(time.shape[0], track.time.data[1]), + ), + "max_sustained_wind": ( + "time", + track.vmax_trks.data, + ), + "central_pressure": ("time", cen_pres), + "radius_max_wind": ( + "time", + estimate_rmw(np.full(lat.shape, np.nan), cen_pres), + ), + "environmental_pressure": ( + "time", + np.full(time.shape[0], env_pressure), + ), + "basin": ( + "time", + np.full(time.shape[0], track.tc_basins.data.item()), + ), + }, + coords={ + "time": ("time", time), + "lat": ("time", lat), + "lon": ("time", lon), + }, + attrs={ + "max_sustained_wind_unit": "m/s", + "central_pressure_unit": "hPa", + "name": f"storm_{track.n_trk.item()}", + "sid": track.n_trk.item(), + "orig_event_flag": True, + "data_provider": "FAST", + "id_no": track.n_trk.item(), + "category": set_category( + max_wind_kn, wind_unit="kn", saffir_scale=None + ), + }, ) + ) return cls(data) diff --git a/climada/hazard/test/test_tc_tracks.py b/climada/hazard/test/test_tc_tracks.py index 6c16fda87c..a0c64aa7eb 100644 --- a/climada/hazard/test/test_tc_tracks.py +++ b/climada/hazard/test/test_tc_tracks.py @@ -19,6 +19,7 @@ Test tc_tracks module. """ +import tempfile import unittest from datetime import datetime as dt @@ -704,6 +705,70 @@ def test_from_FAST(self): self.assertEqual(tc_track.data[0].environmental_pressure.data[0], 1010) self.assertEqual(tc_track.data[0].basin[0], "NA") + def test_from_FAST_not_multiplied_by_year_dim(self): + """Regression test: FAST tracks must not be repeated across `year` dimension.""" + with tempfile.TemporaryDirectory() as tmpdir: + ds = xr.Dataset( + { + "lon_trks": ( + ("n_trk", "time"), + np.array( + [ + [290.0, 291.0, 292.0], + [300.0, 301.0, 302.0], + ], + dtype=float, + ), + ), + "lat_trks": ( + ("n_trk", "time"), + np.array( + [ + [10.0, 10.5, 11.0], + [15.0, 15.5, 16.0], + ], + dtype=float, + ), + ), + "vmax_trks": ( + ("n_trk", "time"), + np.array( + [ + [20.0, 21.0, 22.0], + [25.0, 26.0, 27.0], + ], + dtype=float, + ), + ), + "tc_month": ("n_trk", np.array([8, 9], dtype=np.int64)), + "tc_basins": ("n_trk", np.array(["NA", "NA"], dtype=" Date: Thu, 26 Mar 2026 12:07:24 -0700 Subject: [PATCH 2/2] Refactor test_from_FAST: extend fixture to 2 years, remove separate regression test The test fixture FAST_test_tracks.nc now has year=[2025,2026] (only seeds_per_month is extended; track variables retain their n_trk dim). The existing len(tc_track.data)==5 assertion now acts as the regression check: the buggy year-loop code would return 5x2=10 tracks. The separate test_from_FAST_not_multiplied_by_year_dim (with its temporary-file scaffolding) is removed. --- climada/hazard/test/data/FAST_test_tracks.nc | Bin 61704 -> 63336 bytes climada/hazard/test/test_tc_tracks.py | 74 ++----------------- 2 files changed, 7 insertions(+), 67 deletions(-) diff --git a/climada/hazard/test/data/FAST_test_tracks.nc b/climada/hazard/test/data/FAST_test_tracks.nc index dfe9d8b718e7a8c4d588620e44a8cf4fc5f577d4..45d7f0e6361ae963cc4afb3d74735f1ee2d94836 100644 GIT binary patch delta 4132 zcmZ`+3sjWH6`ud!e;1aAt_$q)7NH`h@>l^yqsU7hCP!5CG|)yKA|mptAjX6i(VBW{ zO@wRHv}uUO)Kfiaqi2zmL>{qCO(W!(q(*DigcuT8M3ky+(p1xV-Sy9&lkqtJe)rzF zb7$tecZS_p4Wko=ec_9%|3i375+8EvM0;dme$Fb@uUn1gGJ{R3u2{v7(*fe^W$P%e zbu36oo>N^}mAcrG;7CcBN2W1lN`i;Dde`KB?$*BmB8M}lS}cT2A$`|{r!2@--hnJF z9*zx~?B$97EfTiV6(x{asEVR&gNfp7b1@yq+6(ZD?t>7aMcJA+)~%<4(Dc0)F({u6 zf_AeV@(gp-!$;tv)&!%5yfy@USEJ2X9qB`SU!-TykRQ?vV#RS{vX(=<2z5f`uzKIkQFV%f-6`f6lg; zHl`z_;CDOTcWxWGWq<{r&M83KSyZFa|A=Ef@JtUJ`^C!o@`bDEL^ z{yvGcU&H$sdH*@@<9(TY3-9}QZ}dwv=de?wEezf>+oy#K;O--s)EtQz_WBiq=5K)! zziK#VwzN4=BfqI0c%BEI?}5`i@B$CK&;vjGZLE?-@Lr%jWU&;>wUD9}LXZyk0`1BY zxaVhAG9W(49+K%{xl~$a!Q(-8Xtr2F8w|qa5osxofsl^7&}Fe%EhZ_KpJTpG#dgv_ zbL>^ofyD;S&0(Wfr|NK7echGivwl_;R^e&0dapl5L6WYQy$YFQreO`agZKy*AfYCQ zdVIZ{pXDwOFu%j-L)#8$9DWF*HmrzgkXOnop&-PJKlrz+cGF*-7fMWOn_JA%DVXM^ zZ1MDKC+_v`p)icdX$U#YA?^-2MPc}ZDXwtnuDf4xC@LTQ8AR6;PGnK1^E)UGA+)H9 zwv59m2-k6ImWaHyQ5Z&KX&~gcT+44rrtaZdEF&rJI4HXT8H&&E4ZLIIO%e+x$ zXt)YvT|<3yHS$u?@W8xl8|q7&H*adff~08T@aTqn*OoVPott19o}_up+|(qlbCXR& z=9pVhx3&Vqz%w>)Nt5FyYEXv!VpaL0T zZmBDOg6nEOL>9Kef6EapW zChLU8Pgc?c&5!>dGm_1JddDVqQ*`$8dDGDkTabQUv4;CqCaa!}9XEuV3`sZWzn<%M zvCP8-t9E*qGuTj4U~$fU`e7WCS5*# zx|soMNFagi`!)_aVZ)g;^TFFfVu7#M)2xT%2O^_uT|$Pv>(-a)`Y_J#G2=LE zY(H7ca#LGi;%2nOZWc@YE1%r-PmORm zduDb#j$m&Ftbjk3NiAtQHsLYN>qcQ zsPN2~UG&g5mcSgEugGX>_`Ml>y%4gsB%hM44xorwtOSwi9 zgeP}0P8`Q49w<$<=+CHFmQ|r8#4lVSQ(^6ibV~NqHzV7SdwekMkb8)cdx(*Hh>?4U zk$Z@ddWeyF5|4BGc;0zDE$OE$%qTXLR#h9N=P8;kSnFfEmmEq%( zR2=i7PrEM^`3x9@?b^}^jL$&_An8B&7ava==^vb5f?Aa=3YmM^I8V~YFP!{8+_bv| delta 5586 zcmb7Idr*|u6~Eu^$GTfrw8ToirvU zr;k`Wj#OjRT3fTIlPEH#8Ev(RkLEE=)Tl{QAjHH`nQ1!{dtc{s_oMqq@66tB&+m8c zxsUJMbI<*H?;3x4$GFQe!+ekMk|ZAF)WFhX1-aSt4c0EhSdJNOk~(7-f8LA~&$6V~ zmeeJuCnm?&R8^gAbg&@8o7jM1c7t2c(!LTU-OeP8K+o3t@yD zD2z)i$jY5#4q>=a>HG>_=`K>taI1T?`{Xd8tcV9$n&>O{XZgyPmiWrM%Y9{1qC(GMC(QchiE$hIg`Pn?NdEh;K5`|-$dWvjocQa*!=QxK7@DJCd2qYIY=PUs z8RJ{mPTND9C~AOgXs&B)C~a+C)q)HDFm3P|XbjDVmm{1pIEzKB4gI0KnEpStW_=Sp zh)9QlNOQ0e&Bxi#dW}qNLz5U)WyeWhJqTEOSNEOR0C8+1t}95=$@oAyH>8w zWIuSk4nM65AVG(t9k~^>siOWAq3_m+NAfM>X4lq6&MaBi0nT`;A3Vhmp6Y|IA_NVk z`4Ersr~KeQ_JgPU!KbnHwm-4riTy z*k?JL>xa!_Y{={(ECfb}v)!i5WeUnXUgz_=fY*h*F5>lkUN7KvF|VKF^+H}RVzr%Z za8}S`CU*&W31^oIVFu}WHA@C^8 z_ip0pD;r!fdWxdY8{tOCc*_n9)^IL33}Qkv19z87HL|3>Y8gG7i_@lk_VZcAx!ELu z(3uXWbXDA^a#My7JPG3tdhb=GE|n?YL3Le==RsU=2rREK!LYCjgCUvc7)Ph@9Ao8$Gyh`NRqg1nD{tjF z1}1UaTxt23V>od{EP?YL5u4AQ5ycnzL``g>?C_@(mGR~AM6bX3)+R^@f695Ml4{Tr zDIVIhd(M*|3kb6usi38mD8Jj7)8)B7y)OhxD+1ub$}+<*T%KUtaAhJ6a~W0j9;O)0 z-kV>zL_W6=R<9_vd@94-3sADE2i_Zu{}Jn9Sof*pf#7K?$PYDKiiGnO?S}kF$g7N2 zPd{?*^{lKk8Qhv{{ERk;BDjX+AQxp;OvpJZIy8qOH@Juy6=Cb|AiPidkBp)Nc5pvo z5=o|_onVs1->;p}q?8r9gDSapppYp-#VEe^9Qie8gOEMRS$+v%&{Zmq)jW`lqEG}0 zMP-)I*a%JiMakwO)8U1!Y(y9dmm-fy5oF!Kj1IVG?G5+7Dr9vECF+Bp51Z@8cf(#k z9e|dmR?+|e%-C>1D2p0>5HY++po|v@l;=du>~!X4&mwpoV#KaHHNR0*+-U9qoX&k617rAi;9*6PdGaZbC1spya9*2+@8AO~*>^NL!;0s|`+%JD^PIeJkb zii)1f+Oc(SvE4!ny>?`a%T?7<+T>~$mN)iOhYN+fE2|BcPw?G87CnrGVs8&CfDp9Ok`F>*Kv9p{a76sR}9+a`~e5 zg!iWS(h2v@X4tP)bbaRy*Y^F3#u<*~y!3)vv6Y|F+8MWRvAIXA8 zrA~NSYvj{^)AEckA{Id_;z>KfOhJz;V|eYqxo}_0UNDxp*7e39v$H z;oGTU10#DD9?BCZfPsGT$caLh-5bRTY?T3`89u0(1Wn3ph#i(dcTnoD0v#8!uV#2c zKTZ$Tz64CW&CQZH{w3giZvi_+CNfM|JM^vK<*`p_E`ViX$DuK>w?9|2bzHOM)pMM% zW4P7jI_|TEb`Te@S@FO+`s% zh{32R3b^}hqK0FAi6QuP=Nbeb~{|Yt*WJIu;SjiUYv6GlBRz9JD@v{+g3xlq+HrBa> zsPI}u0=!cfX2-tfP`$60cMPD05jA2_V;