Sigmandndnns commited on
Commit
4fcd363
·
verified ·
1 Parent(s): ebeac1a

NatureV2-500: weights only; the code lives on PyPI

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. LICENSE +0 -203
  2. NOTICE +0 -11
  3. NatureV1_model_card.md +0 -0
  4. README.md +6 -35
  5. RESULTS_0.16.md +0 -208
  6. ROADMAP_0.16.md +0 -55
  7. b300_naturev1.py +0 -151
  8. colab_naturev1.py +0 -766
  9. docs/ARCHITECTURE.md +0 -282
  10. melissa_jamaica.png +0 -3
  11. melissa_landfall.png +0 -3
  12. molab_swarm_cell.py +0 -311
  13. naturev1/__init__.py +0 -253
  14. naturev1/_pipeline.py +0 -772
  15. naturev1/assimilation.py +0 -290
  16. naturev1/b300.py +0 -257
  17. naturev1/besttrack.py +0 -239
  18. naturev1/checkpoint.py +0 -408
  19. naturev1/corpora.py +0 -174
  20. naturev1/cyclone/__init__.py +0 -53
  21. naturev1/cyclone/aids.py +0 -213
  22. naturev1/cyclone/analogs.py +0 -136
  23. naturev1/cyclone/barotropic.py +0 -240
  24. naturev1/cyclone/baselines.py +0 -95
  25. naturev1/cyclone/benchmark.py +0 -105
  26. naturev1/cyclone/beta.py +0 -82
  27. naturev1/cyclone/bogus.py +0 -78
  28. naturev1/cyclone/climatology.py +0 -101
  29. naturev1/cyclone/cliper.py +0 -137
  30. naturev1/cyclone/coefficients.py +0 -770
  31. naturev1/cyclone/consensus.py +0 -234
  32. naturev1/cyclone/data.py +0 -361
  33. naturev1/cyclone/ensemble.py +0 -136
  34. naturev1/cyclone/field_tracker.py +0 -127
  35. naturev1/cyclone/fields.py +0 -231
  36. naturev1/cyclone/fit.py +0 -85
  37. naturev1/cyclone/forecast.py +0 -284
  38. naturev1/cyclone/forecastflow.py +0 -88
  39. naturev1/cyclone/genesis.py +0 -347
  40. naturev1/cyclone/geometry.py +0 -154
  41. naturev1/cyclone/intensity.py +0 -746
  42. naturev1/cyclone/kalman.py +0 -137
  43. naturev1/cyclone/land.py +0 -83
  44. naturev1/cyclone/members.py +0 -251
  45. naturev1/cyclone/multimodel.py +0 -134
  46. naturev1/cyclone/neural.py +0 -80
  47. naturev1/cyclone/ocean.py +0 -182
  48. naturev1/cyclone/ocean_climatology.npz +0 -3
  49. naturev1/cyclone/outflow.py +0 -75
  50. naturev1/cyclone/pouch.py +0 -66
LICENSE DELETED
@@ -1,203 +0,0 @@
1
- Copyright 2018- The Hugging Face team. All rights reserved.
2
-
3
- Apache License
4
- Version 2.0, January 2004
5
- http://www.apache.org/licenses/
6
-
7
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
8
-
9
- 1. Definitions.
10
-
11
- "License" shall mean the terms and conditions for use, reproduction,
12
- and distribution as defined by Sections 1 through 9 of this document.
13
-
14
- "Licensor" shall mean the copyright owner or entity authorized by
15
- the copyright owner that is granting the License.
16
-
17
- "Legal Entity" shall mean the union of the acting entity and all
18
- other entities that control, are controlled by, or are under common
19
- control with that entity. For the purposes of this definition,
20
- "control" means (i) the power, direct or indirect, to cause the
21
- direction or management of such entity, whether by contract or
22
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
23
- outstanding shares, or (iii) beneficial ownership of such entity.
24
-
25
- "You" (or "Your") shall mean an individual or Legal Entity
26
- exercising permissions granted by this License.
27
-
28
- "Source" form shall mean the preferred form for making modifications,
29
- including but not limited to software source code, documentation
30
- source, and configuration files.
31
-
32
- "Object" form shall mean any form resulting from mechanical
33
- transformation or translation of a Source form, including but
34
- not limited to compiled object code, generated documentation,
35
- and conversions to other media types.
36
-
37
- "Work" shall mean the work of authorship, whether in Source or
38
- Object form, made available under the License, as indicated by a
39
- copyright notice that is included in or attached to the work
40
- (an example is provided in the Appendix below).
41
-
42
- "Derivative Works" shall mean any work, whether in Source or Object
43
- form, that is based on (or derived from) the Work and for which the
44
- editorial revisions, annotations, elaborations, or other modifications
45
- represent, as a whole, an original work of authorship. For the purposes
46
- of this License, Derivative Works shall not include works that remain
47
- separable from, or merely link (or bind by name) to the interfaces of,
48
- the Work and Derivative Works thereof.
49
-
50
- "Contribution" shall mean any work of authorship, including
51
- the original version of the Work and any modifications or additions
52
- to that Work or Derivative Works thereof, that is intentionally
53
- submitted to Licensor for inclusion in the Work by the copyright owner
54
- or by an individual or Legal Entity authorized to submit on behalf of
55
- the copyright owner. For the purposes of this definition, "submitted"
56
- means any form of electronic, verbal, or written communication sent
57
- to the Licensor or its representatives, including but not limited to
58
- communication on electronic mailing lists, source code control systems,
59
- and issue tracking systems that are managed by, or on behalf of, the
60
- Licensor for the purpose of discussing and improving the Work, but
61
- excluding communication that is conspicuously marked or otherwise
62
- designated in writing by the copyright owner as "Not a Contribution."
63
-
64
- "Contributor" shall mean Licensor and any individual or Legal Entity
65
- on behalf of whom a Contribution has been received by Licensor and
66
- subsequently incorporated within the Work.
67
-
68
- 2. Grant of Copyright License. Subject to the terms and conditions of
69
- this License, each Contributor hereby grants to You a perpetual,
70
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
71
- copyright license to reproduce, prepare Derivative Works of,
72
- publicly display, publicly perform, sublicense, and distribute the
73
- Work and such Derivative Works in Source or Object form.
74
-
75
- 3. Grant of Patent License. Subject to the terms and conditions of
76
- this License, each Contributor hereby grants to You a perpetual,
77
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
78
- (except as stated in this section) patent license to make, have made,
79
- use, offer to sell, sell, import, and otherwise transfer the Work,
80
- where such license applies only to those patent claims licensable
81
- by such Contributor that are necessarily infringed by their
82
- Contribution(s) alone or by combination of their Contribution(s)
83
- with the Work to which such Contribution(s) was submitted. If You
84
- institute patent litigation against any entity (including a
85
- cross-claim or counterclaim in a lawsuit) alleging that the Work
86
- or a Contribution incorporated within the Work constitutes direct
87
- or contributory patent infringement, then any patent licenses
88
- granted to You under this License for that Work shall terminate
89
- as of the date such litigation is filed.
90
-
91
- 4. Redistribution. You may reproduce and distribute copies of the
92
- Work or Derivative Works thereof in any medium, with or without
93
- modifications, and in Source or Object form, provided that You
94
- meet the following conditions:
95
-
96
- (a) You must give any other recipients of the Work or
97
- Derivative Works a copy of this License; and
98
-
99
- (b) You must cause any modified files to carry prominent notices
100
- stating that You changed the files; and
101
-
102
- (c) You must retain, in the Source form of any Derivative Works
103
- that You distribute, all copyright, patent, trademark, and
104
- attribution notices from the Source form of the Work,
105
- excluding those notices that do not pertain to any part of
106
- the Derivative Works; and
107
-
108
- (d) If the Work includes a "NOTICE" text file as part of its
109
- distribution, then any Derivative Works that You distribute must
110
- include a readable copy of the attribution notices contained
111
- within such NOTICE file, excluding those notices that do not
112
- pertain to any part of the Derivative Works, in at least one
113
- of the following places: within a NOTICE text file distributed
114
- as part of the Derivative Works; within the Source form or
115
- documentation, if provided along with the Derivative Works; or,
116
- within a display generated by the Derivative Works, if and
117
- wherever such third-party notices normally appear. The contents
118
- of the NOTICE file are for informational purposes only and
119
- do not modify the License. You may add Your own attribution
120
- notices within Derivative Works that You distribute, alongside
121
- or as an addendum to the NOTICE text from the Work, provided
122
- that such additional attribution notices cannot be construed
123
- as modifying the License.
124
-
125
- You may add Your own copyright statement to Your modifications and
126
- may provide additional or different license terms and conditions
127
- for use, reproduction, or distribution of Your modifications, or
128
- for any such Derivative Works as a whole, provided Your use,
129
- reproduction, and distribution of the Work otherwise complies with
130
- the conditions stated in this License.
131
-
132
- 5. Submission of Contributions. Unless You explicitly state otherwise,
133
- any Contribution intentionally submitted for inclusion in the Work
134
- by You to the Licensor shall be under the terms and conditions of
135
- this License, without any additional terms or conditions.
136
- Notwithstanding the above, nothing herein shall supersede or modify
137
- the terms of any separate license agreement you may have executed
138
- with Licensor regarding such Contributions.
139
-
140
- 6. Trademarks. This License does not grant permission to use the trade
141
- names, trademarks, service marks, or product names of the Licensor,
142
- except as required for reasonable and customary use in describing the
143
- origin of the Work and reproducing the content of the NOTICE file.
144
-
145
- 7. Disclaimer of Warranty. Unless required by applicable law or
146
- agreed to in writing, Licensor provides the Work (and each
147
- Contributor provides its Contributions) on an "AS IS" BASIS,
148
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
149
- implied, including, without limitation, any warranties or conditions
150
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
151
- PARTICULAR PURPOSE. You are solely responsible for determining the
152
- appropriateness of using or redistributing the Work and assume any
153
- risks associated with Your exercise of permissions under this License.
154
-
155
- 8. Limitation of Liability. In no event and under no legal theory,
156
- whether in tort (including negligence), contract, or otherwise,
157
- unless required by applicable law (such as deliberate and grossly
158
- negligent acts) or agreed to in writing, shall any Contributor be
159
- liable to You for damages, including any direct, indirect, special,
160
- incidental, or consequential damages of any character arising as a
161
- result of this License or out of the use or inability to use the
162
- Work (including but not limited to damages for loss of goodwill,
163
- work stoppage, computer failure or malfunction, or any and all
164
- other commercial damages or losses), even if such Contributor
165
- has been advised of the possibility of such damages.
166
-
167
- 9. Accepting Warranty or Additional Liability. While redistributing
168
- the Work or Derivative Works thereof, You may choose to offer,
169
- and charge a fee for, acceptance of support, warranty, indemnity,
170
- or other liability obligations and/or rights consistent with this
171
- License. However, in accepting such obligations, You may act only
172
- on Your own behalf and on Your sole responsibility, not on behalf
173
- of any other Contributor, and only if You agree to indemnify,
174
- defend, and hold each Contributor harmless for any liability
175
- incurred by, or claims asserted against, such Contributor by reason
176
- of your accepting any such warranty or additional liability.
177
-
178
- END OF TERMS AND CONDITIONS
179
-
180
- APPENDIX: How to apply the Apache License to your work.
181
-
182
- To apply the Apache License to your work, attach the following
183
- boilerplate notice, with the fields enclosed by brackets "[]"
184
- replaced with your own identifying information. (Don't include
185
- the brackets!) The text should be enclosed in the appropriate
186
- comment syntax for the file format. We also recommend that a
187
- file or class name and description of purpose be included on the
188
- same "printed page" as the copyright notice for easier
189
- identification within third-party archives.
190
-
191
- Copyright [yyyy] [name of copyright owner]
192
-
193
- Licensed under the Apache License, Version 2.0 (the "License");
194
- you may not use this file except in compliance with the License.
195
- You may obtain a copy of the License at
196
-
197
- http://www.apache.org/licenses/LICENSE-2.0
198
-
199
- Unless required by applicable law or agreed to in writing, software
200
- distributed under the License is distributed on an "AS IS" BASIS,
201
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
202
- See the License for the specific language governing permissions and
203
- limitations under the License.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
NOTICE DELETED
@@ -1,11 +0,0 @@
1
- naturev1
2
- Copyright 2026 Nathan
3
-
4
- Licensed under the Apache License, Version 2.0. See LICENSE.
5
-
6
- Builds on `ihelix` (Apache-2.0), which in turn carries kernels from `helix-lm`
7
- and ultimately from HuggingFace Transformers (Apache-2.0).
8
-
9
- Data sources referenced by this package are public: NOAA GOES ABI imagery on
10
- AWS Open Data, and NOAA HURDAT2 best-track archives. This package is not
11
- affiliated with or endorsed by NOAA, NESDIS or the National Hurricane Center.
 
 
 
 
 
 
 
 
 
 
 
 
NatureV1_model_card.md DELETED
The diff for this file is too large to render. See raw diff
 
README.md CHANGED
@@ -5,44 +5,15 @@ tags:
5
  - weather
6
  - tropical-cyclone
7
  - hurricane
8
- - satellite
9
- - ihelix
10
  pipeline_tag: other
11
  ---
12
 
13
  # NatureV2-500
14
 
15
- The next model of the CorxLabs hurricane engine. It starts from the NatureV1-500 **stage-1 checkpoint**
16
- (`checkpoints/stage1/latest.pt`, with its `config.json`, `normalizer.json` and the storm banks, copied here from
17
- [CorxLabs/NatureV1-500](https://huggingface.co/CorxLabs/NatureV1-500)) and carries the complete `naturev1` 0.16 code base,
18
- so that the fine-tuning of the satellite encoder and the new features can start from one place.
 
19
 
20
- ## What is here
21
-
22
- * `checkpoints/stage1/latest.pt`, `config.json`, `normalizer.json`, `training_record.json`, `checkpoints/storm_bank_*.pt`
23
- -- the base to fine-tune from (the global atmosphere at 1.5 degrees after stage 1; the storm banks for the storm heads).
24
- * `naturev1/`, `pyproject.toml`, `tests/` -- the 0.16 code base: the model and its training stages (`model.py`,
25
- `train.py`, `sattrain.py`, `satstage.py`, the satellite readers `stormsat.py` / `satellite.py`), the hurricane physics
26
- engine (`cyclone/`), the swarm, impacts, landfall, the live path and the NHC guidance layer (`swarm/`).
27
- `pip install "naturev1[swarm,hub]"` installs the same code from PyPI (0.16.0).
28
- * `RESULTS_0.16.md` -- the complete record of the results and of what was built, with every held-out table.
29
- * `ROADMAP_0.16.md` -- the deficit matrix, what shipped and what was rejected. `NatureV1_model_card.md` -- the NatureV1
30
- card (sections 4.12-4.17 carry the verification of every release). `naturev1_README.md` -- the package README.
31
- * `molab_swarm_cell.py` -- the operational cell (one marimo cell: the live path, the parishes, the cycle record;
32
- `TRAIN_SATELLITE = True` runs the satellite stage on the GPU from `checkpoints/stage1`). `b300_naturev1.py`,
33
- `molab_naturev1.py`, `colab_naturev1.py` -- the training cells of the earlier stages.
34
-
35
- ## The plan
36
-
37
- 1. **The satellite encoder.** Fine-tune the band-13 path (SatNet-Storm 2.0 and the global satellite retrieval of 0.12)
38
- from the stage-1 weights on storm-centred GridSat / HURSAT / GOES imagery (`naturev1.satstage` stages it,
39
- `naturev1.sattrain` trains it; the cell wires both).
40
- 2. **New features.** The infrared structure diagnostics and their trend (`naturev1.swarm.infrared`), lightning, and the
41
- ocean heat feed, as inputs to the storm heads and to the rapid-intensification probability -- the explosive cases
42
- are still tail events to every mean forecast, and the satellite is where the next predictor is.
43
- 3. **Verification before use.** Every new number is scored on the same held-out seasons (2018, 2020-2022) and the same
44
- benchmark table (`benchmark_heldout_atlantic_2018_2020_2022.csv.gz` on the NatureV1-500 repository) before it
45
- changes anything in the operational path.
46
-
47
- Research model, not an operational forecast. For decisions, follow your national meteorological service and the
48
- National Hurricane Center.
 
5
  - weather
6
  - tropical-cyclone
7
  - hurricane
 
 
8
  pipeline_tag: other
9
  ---
10
 
11
  # NatureV2-500
12
 
13
+ Base weights for the next CorxLabs model: the NatureV1-500 stage-1 checkpoint (`checkpoints/stage1/latest.pt`) with
14
+ its `config.json`, `normalizer.json`, `training_record.json` and the storm banks, copied from
15
+ [CorxLabs/NatureV1-500](https://huggingface.co/CorxLabs/NatureV1-500). NatureV2 is a new architecture built on this
16
+ backbone: the satellite encoder enabled and new storm features added. The code is the `naturev1` package on PyPI
17
+ (`pip install "naturev1[swarm,hub]"`); nothing else is kept here.
18
 
19
+ Research model, not an operational forecast.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
RESULTS_0.16.md DELETED
@@ -1,208 +0,0 @@
1
- # NatureV1 0.16 -- the results, and the record of what was built
2
-
3
- CorxLabs, Westmoreland, Jamaica. October 2026. This is the complete account of the hurricane engine as it stands at
4
- 0.16.0: what the architecture is, how every number was earned, what was tried and rejected, what shipped, and where it
5
- is published. Every figure here comes from the held-out seasons unless it says otherwise; nothing was tuned on them.
6
-
7
- ## 1. What the system is
8
-
9
- NatureV1 is a weather model with a hurricane engine around it. Three layers:
10
-
11
- 1. **iHELIX**, the engine: attention over physical space on the sphere (conservative regrid, spherical convolution,
12
- binning), the kernels the model is built from (`ihelix`, on PyPI).
13
- 2. **NatureV1-500**, the model: the global atmosphere at 1.5 degrees with storm heads (track, intensity, rapid
14
- intensification, eyewall, landfall), every output a distribution, trained in stages (stage 1 the atmosphere,
15
- stage 2 the storm heads on the storm bank, stage 3 the satellite path: SatNet-Storm 2.0 and the global satellite
16
- retrieval on GridSat/HURSAT/GOES band 13), published at `CorxLabs/NatureV1-500` with its checkpoints
17
- (`checkpoints/stage1`, `stage2`, `rollout`, the storm banks), config and normaliser.
18
- 3. **The hurricane physics engine** (`naturev1.cyclone`, `naturev1.swarm`; versions 0.13 to 0.16), which is what
19
- this document is about. It is deterministic physics plus fitted tables, no network in the loop, and it is the part
20
- verified against every archived reference:
21
- * **Track.** A consensus of physics members (extrapolation, CLIPER rebuilt, beta-and-advection steering from the
22
- analysis at several depths, an analog member, the forecast-flow member that follows the dynamical model's own
23
- steering), simplex weights per lead; blended with the mean track of the live models (GFS, ECMWF IFS and AIFS,
24
- GraphCast-GFS, the GEFS mean, each followed at native resolution); the previous cycle's error fed back; and
25
- since 0.16 the NHC's operational aids blended in (section 4).
26
- * **Cone and swarm.** A flow-dependent cone (Goerss-style regression of the blend's error on member spread, model
27
- disagreement, speed, latitude, intensity) and a particle-filter swarm of up to a million worlds, each world
28
- steered by one model's flow, filtered on every satellite, recon and radar centre fix, sized to the cone,
29
- re-weighted by fixes that arrive after the analysis. It gives scenario probabilities (stall, loop, recurve,
30
- steady), strike probabilities and the parish impacts.
31
- * **Intensity.** SHIFOR, decay-SHIFOR, a SHIPS-style statistical-dynamical member, LGEM, a trend member and the
32
- models' own deepening, in a simplex consensus with a rapid-intensification regime; a 17-feature logistic P(RI);
33
- the RI path as a branch beside the mean; ocean heat content and 26 C isotherm depth from the World Ocean Atlas
34
- under the storm; peak probabilities P(peak >= 64/96/113/137 kt), calibrated peak quantiles and E[peak | RI];
35
- category probabilities per lead; since 0.16 the NHC intensity aids blended in.
36
- * **Impacts.** A parametric asymmetric wind field sized to the observed 34-kt radius, land decay, gusts, arrival
37
- times; rain from R-CLIPER reshaped against gauge-corrected radar with an orographic term, and since 0.16 the
38
- GFS's own precipitation travelling with every world; painted onto every parish, province and district of the
39
- Caribbean with terrain; the landfall report (when, where first, how strong, each parish's numbers, the
40
- arrival-time window).
41
- * **Operations.** One call from an NHC storm id (`naturev1.swarm.live.forecast_storm`), live or replayed as it was
42
- known; every cycle saved as JSON and a parish CSV; the previous cycle found again automatically; GOES band-13
43
- read quantitatively; the molab cell that runs all of it and keeps watching NHC.
44
-
45
- **The protocol that every number obeys.** Fitted on the tuning seasons 2016, 2017 and 2019. Scored once on the
46
- held-out seasons 2018, 2020, 2021 and 2022 (the Atlantic where the NHC guidance exists; all basins for the physics).
47
- Significance by a storm-block bootstrap (a star marks a whole 90% interval above zero). Shipped only if it helped
48
- held-out, reported either way. The coefficients module records the provenance of every table.
49
-
50
- ## 2. Where it stands against the references (held-out)
51
-
52
- **Track, km, Atlantic 2018 / 2020-2022, homogeneous (749 / 703 / 626 / 554 / 490 / 374 / 281 / 206 cases at
53
- 6 / 12 / 24 / 36 / 48 / 72 / 96 / 120 h, 79 storms).**
54
-
55
- | | 6 h | 12 h | 24 h | 36 h | 48 h | 72 h | 96 h | 120 h |
56
- |---|---|---|---|---|---|---|---|---|
57
- | NatureV1 0.14 blend (physics + live models) | 22.1 | 40.1 | 62.2 | 82.3 | 95.5 | 168.7 | 260.5 | 365.1 |
58
- | NatureV1 0.16, mean with HCCA | 21.5 | 35.7 | 54.1 | 72.5 | 89.4 | 149.6 | 227.7 | 318.7 |
59
- | **NatureV1 0.16 shipped (earned split + outlier gate)** | **22** | **36** | **53** | **72** | **89** | **149** | **225** | **317** |
60
- | HCCA (HFIP corrected consensus) | 29.9 | 40.7 | 60.3 | 80.7 | 104.6 | 170.5 | 253.8 | 363.9 |
61
- | TVCN (track consensus) | 29.8 | 41.8 | 62.7 | 84.5 | 109.5 | 170.0 | 248.8 | 346.5 |
62
- | NHC official forecast (OFCL) | 27.6 | 42.4 | 64.1 | 84.9 | 109.2 | 169.4 | 241.6 | 334.4 |
63
-
64
- The 0.16 blend is 11-13% under 0.14 at 12-120 h (every lead significant) and 12-22% under the official forecast at
65
- 6-72 h, 5-6% at 96-120 h. The aids' 6 h values are interpolations between their analysis and their 12 h point, so
66
- read the aids from 12 h on.
67
-
68
- **Track against the archived models, all basins (from 4.16 of the model card; each reference on its own cases).**
69
- NatureV1 beats HRES (+18% at 24 h, +24% at 72 h), Pangu (+9%, +8%), GraphCast (+4%, +11%), Aurora (+6%, +16%) at
70
- every lead to day 5, is level with FuXi past 24 h, and trails FGN (WeatherNext 2) by 5-7% from 24 h on its 787
71
- cases. Against the single dynamical models in the a-deck (GFS, HWRF, HMON, COAMPS-TC, the GEFS mean) it is better at
72
- every lead by 6-39%.
73
-
74
- **Intensity, kt mean absolute error, Atlantic held-out, homogeneous with the official forecast.**
75
-
76
- | | 6 h | 12 h | 24 h | 36 h | 48 h | 72 h | 96 h | 120 h |
77
- |---|---|---|---|---|---|---|---|---|
78
- | persistence / SHIFOR (0.15's own references) | -- | -- | 0.15 is 16-33% better than persistence and 5-16% better than SHIFOR at every lead | | | | | |
79
- | NatureV1 0.15 consensus | 3.4 | 6.0 | 9.6 | 12.2 | 13.9 | 16.2 | 18.4 | 18.8 |
80
- | **NatureV1 0.16 (0.15 + the aids)** | **3.3** | **5.4** | **7.1** | **8.5** | **9.5** | **10.5** | **12.5** | **12.8** |
81
- | IVCN (intensity consensus) | 4.3 | 5.5 | 7.2 | 8.5 | 9.4 | 10.7 | 12.7 | 13.8 |
82
- | HCCA | 4.3 | 5.5 | 7.2 | 8.7 | 9.6 | 11.1 | 12.5 | 13.0 |
83
- | HWRF (HWFI) | 4.6 | 6.2 | 8.0 | 9.4 | 10.6 | 13.3 | 15.3 | 16.3 |
84
- | SHIPS with decay (DSHP) | 4.5 | 6.1 | 8.4 | 10.2 | 11.6 | 13.4 | 16.3 | 18.3 |
85
- | LGEM | 4.5 | 6.4 | 9.0 | 11.1 | 12.2 | 13.3 | 15.8 | 17.5 |
86
- | NHC official forecast | 3.6 | 5.0 | 7.4 | 8.8 | 10.1 | 11.2 | 12.4 | 13.4 |
87
-
88
- 0.16 is 26-35% under 0.15 from 24 h on (every tuning season agreed in leave-one-season-out, 13-28% at 24-96 h) and
89
- 4-6% under the official forecast at 24-72 h, level at 96-120 h. The official forecast is itself written from this
90
- guidance three hours later: read it as a product comparison.
91
-
92
- **Rapid intensification, P(+30 kt in 24 h).** Our logistic (17 predictors: the SHIPS-style set, the models' deepening,
93
- ocean heat content and 26 C depth, the gale radius): held-out Brier skill 0.154, AUC 0.864 (0.14: 0.122, 0.840). On the
94
- 2020-2022 cases where the NHC e-deck carries RI rows (there are none in the archive before 2020): ours 0.125, the
95
- SHIPS-RII consensus 0.068, the deterministic SHIPS-RII 0.020, the logistic -0.070, the Bayesian 0.074, DTOPS 0.171;
96
- the equal-weight mean of ours and DTOPS 0.199. That mean is reported beside ours, not as the headline, because it
97
- could not be chosen on a tuning season.
98
-
99
- **Peak intensity.** P(peak within 5 days >= 64 / 96 / 113 / 137 kt): held-out Brier skill 0.32 / 0.31 / 0.28 / 0.28
100
- against 0.26 / 0.22 / 0.15 / 0.16 for the best per-lead category probability. Calibrated peak quantiles: coverage
101
- 0.53 / 0.77 / 0.91 / 0.95 at the 50 / 75 / 90 / 95% levels; the 90% quantile sits +4 kt above the true peak of the
102
- >= 137 kt storms where the consensus maximum sits -37 kt. E[peak | RI]: +0.6 kt bias, 13 kt MAE on the storms that went
103
- rapid. The bias that remains is conditional: -21 kt at 24 h on the storms that intensify rapidly (0.15: -23; the
104
- official forecast: -19), +14 kt on those that weaken fast.
105
-
106
- **Cone and swarm.** The flow-dependent cone replaces the climatological one; the swarm's raw worlds were 1.4-3.2 times
107
- too wide (the truth inside the nominal 67% radius 81-99% of the time); sized to the cone, 396 held-out forecasts put
108
- the truth inside the 50 / 67 / 90% radii 50-63 / 64-75 / 85-90% of the time.
109
-
110
- **Rain.** R-CLIPER along the observed track of 24 US landfalls against NOAA MRMS gauge-corrected radar delivered half
111
- the observed rain. The reshaped profile (multiplier 1.19, radial tail x1.5, front-right asymmetry 0.3) lifts the
112
- held-out threat score for 100 mm to 0.296 and for 250 mm from 0.12 to 0.348, and brings the strong storms' area
113
- >= 250 mm from 3.2x observed to 1.7x. The GFS's accumulated precipitation moved with each world, at half weight:
114
- Ida 23 vs 45 mm mean error, Nicholas 17 vs 55, Fred 21 vs 36, Ian level.
115
-
116
- **Timing.** No systematic along-track bias (+1 / +4 / +5 / +2 / -1 km at 6-48 h). Arrival-time error 3.6 h at 24 h,
117
- 7.3 h for storms under 3 m/s: reported as a window in the landfall report, nothing corrected. The previous cycle's
118
- error fed back into the track: +1.1-1.2% at 12-24 h, nothing past 48 h.
119
-
120
- ## 3. The 0.16 work, item by item
121
-
122
- The release started from six named deficits (the roadmap matrix) and ended with eight rows, each with a state.
123
-
124
- | deficit | what the archive said | what shipped / what was rejected |
125
- |---|---|---|
126
- | explosive peak gap (forecast peak 25-37 kt under category-5 peaks) | regression to the mean under RI uncertainty, not a loss artefact; any weight toward the RI path costs the 94% that do not explode | **shipped**: calibrated peak quantiles, E[peak \| RI]; the aids take 26-35% off the intensity error but move the RI-case bias only -23 -> -21 kt |
127
- | track 2-7% behind FGN past 12 h | an equal-weight mean diluted by weaker members; no FGN live | **shipped**: the NHC aids (HCCA) as a member, -11-13%; **rejected**: dynamic per-storm model weighting (~0), NNLS member weights (+0.3-1.1%, noise) |
128
- | light-rain bloat | a per-storm scale error from wetter tuning storms, not a radial-tail problem | **shipped**: the reshaped profile and the GFS rain member; **rejected**: every nonlinear transform, a 115-kt saturation, a speed factor |
129
- | terrain and moisture | no moisture flux in a parametric profile | **partly**: east of Melissa right since the reshape; the QPF member is the moisture term; IVT orography needs a calibration set |
130
- | stalls over water held too strong | the sign of the stall residual flips between seasons | **rejected**: the fixed rule is 45-122% worse on the stall cases; Melissa stalled over 120 m of 26 C water and intensified |
131
- | landfall timing 3-4 h early | no along-track bias; the error is a spread, largest for slow movers | **measured, reported as a window**, nothing nudged |
132
- | the operational guidance was not in the room | the a-deck carries every aid minutes after the cycle | **shipped**: `naturev1.swarm.adeck` + `naturev1.cyclone.aids`, two fitted tables, stand-ins by lineage (HAFS for HWRF/HMON, TVCN for HCCA) |
133
- | the NHC RI guidance was not in the room | the SHIPS text carries five schemes live | **shown beside ours** with the equal-weight mean and the no-tuning-sample caveat |
134
-
135
- **The averaging penalty**, raised on the Melissa 26 Oct 12 UTC cycle (ours 15 km at 24 h, HCCA 70, the mean 42), taken
136
- to the archive:
137
-
138
- | held-out cases | n | ours alone | HCCA | TVCN | 50/50 mean | ours closer than HCCA | ours the outlier against TVCN |
139
- |---|---|---|---|---|---|---|---|
140
- | all with HCCA | 641 | 62 km | 60 | 63 | 54 | -- | -- |
141
- | the 20% that disagree most (>= 92 km apart at 24 h) | 159 | 133 | 93 | 95 | 94 | 31% | 97% |
142
- | the 10% that disagree most (>= 128 km apart) | 80 | 220 | 101 | 106 | 138 | 16% | 100% |
143
-
144
- When the two disagree strongly, our standalone is the one that is wrong two times in three. Rejected on the tuning
145
- seasons: ours 2/3, a gate toward ours, the median and the trimmed mean of ours/HCCA/TVCN. Shipped: (1) **weights earned
146
- from the previous cycle** -- the previous forecasts of ours and of HCCA verified against the position just analysed,
147
- `sigmoid(0.5 log(e_HCCA / e_ours))` within 25-75% at 6-24 h; (2) **the outlier gate** -- beyond 1.5 cone radii of
148
- disagreement up to 10% of the pair's weight moves away from whichever member is further from TVCN, from 12 h on.
149
- Together on held-out: 1.4 / 2.5 / 1.1 / 0.7 / 1.0 / 1.9 / 2.3% lower error at 12 / 24 / 36 / 48 / 72 / 96 / 120 h, every
150
- lead significant. The disagreement is reported per lead and both tracks are in the record.
151
-
152
- **Also built in 0.16:** the GOES band-13 diagnostics with trend (`infrared.py`); the cycle record and history
153
- (`bulletin.py`); `forecast_storm(previous="auto", guidance=True)`; the molab cell rewritten with the watch loop; audit
154
- fixes (dateline-safe swarm rescale, caller's leads for the 24/48 h indices, silent fallbacks leave a note, NHC-only
155
- ids, the previous analysis position as the first feedback knot); the benchmark table.
156
-
157
- ## 4. Melissa (AL13 2025), replayed with what was known at the time
158
-
159
- | forecast from | before landfall | P(landfall, Jamaica) | centre ashore, median (10-90%) | consensus landfall | intensity then (10-90%) | RI branch (its probability) | P(peak >= cat 4 / cat 5) | track error 24 / 48 / 72 h: 0.16 (own; official) |
160
- |---|---|---|---|---|---|---|---|---|
161
- | 26 Oct 00 UTC | 65 h | 72% | 28 Oct 09 UTC (27 Oct 21 -> 28 Oct 21) | 28 Oct 06 UTC, Clarendon | 96 kt (64-132) | 124 kt, peak 130 (23%) | 62% / 28% | 44 / 121 / 195 km (42 / 158 / 277; 34 / 123 / 124) |
162
- | 26 Oct 12 UTC | 53 h | 83% | 28 Oct 12 UTC (06 -> 21) | 28 Oct 11 UTC, St Elizabeth | 101 kt (71-136) | 151 kt, peak 152 (19%) | 100% / 31% | 42 / 72 / 147 km (15 / 53 / 161; 40 / 54 / 73) |
163
- | 27 Oct 00 UTC | 41 h | 83% | 28 Oct 15 UTC (09 -> 21) | 28 Oct 14 UTC, St Elizabeth, 18.06N 77.72W | 98 kt (78-121) | 149 kt, peak 160 (8%) | 100% / 23% | 54 / 71 / 212 km (38 / 69 / 215; 79 / 79 / 164) |
164
-
165
- Actual: landfall near New Hope, St Elizabeth (18.09N 77.97W) about 17 UTC on 28 October at 160 kt; peak 165 kt. Right:
166
- the landfall point and probability, the parish ranking (St Elizabeth 77%, Manchester 69%, Westmoreland 60%, Clarendon
167
- 54%; the east 10-15%), the far-west rain (Westmoreland 413 mm if hit against the 309 mm gauge where 0.15 said 689;
168
- Manchester 584 / 865 against 817; St Catherine 468 / 666 against 668). Wrong, said plainly: the intensity -- the aids
169
- were all low on this storm and pulled the 48 h intensity to 85-100 kt against 105-165; the explosive case stayed a tail
170
- event (the 95% peak quantile 133-140 kt against 165), as it did for the official forecast (140 kt at 24 h against 155);
171
- parish timing about four hours early.
172
-
173
- ## 5. Published
174
-
175
- * **PyPI**: `naturev1` 0.16.0 (wheel and sdist; install verified from the index). `pip install "naturev1[swarm,hub]"`.
176
- * **Hugging Face** `CorxLabs/NatureV1-500`: the model card (section 4.17 carries the 0.16 tables), `melissa_landfall.png`,
177
- `melissa_jamaica.png`, `benchmark_heldout_atlantic_2018_2020_2022.csv.gz` (849 cases, 120,472 rows: the truth and
178
- every reference's forecast or error at every lead; `naturev1.cyclone.verify.compare` scores a new model on it).
179
- * **Git**, branch `claude/linear-context-architecture-a35c6h` of `Wisk-inc/transformers`, `natureweather/`: commits
180
- `ed86e0e` (rain shape, operations layer, infrared, audit), `5eae22b` (peak quantiles, arrival window, QPF member,
181
- cell, roadmap), `b3d5a09` (the guidance layer), `4a5917b` (Melissa), `4ac6e53` (earned weights), `45e92bd` (the
182
- outlier gate). Tests: 403 passed, 1 skipped, plus the 0.16 additions (`tests/test_adeck.py`, `test_peak.py`,
183
- `test_operational.py`).
184
- * **The cell**: `molab_swarm_cell.py`, one marimo cell, live or replay.
185
-
186
- ## 6. Limits that remain, and the next steps
187
-
188
- * The explosive cases are tail events to every mean forecast here, ours and the NHC's; the answer stays in the
189
- distribution (quantiles, the RI branch) until a predictor that separates them exists. The 0.12 satellite path is the
190
- candidate: GOES structure and lightning as RI predictors need an archive of storm-centred imagery to fit.
191
- * The guidance tables were fitted on Atlantic storms; an eastern Pacific storm gets the same weights, unverified there.
192
- HWRF and HMON appear in about half the 2026 cycles; the blend renormalises and the record says what it used.
193
- * The cone is sized for the track before the aids; with the aids the error is 11-13% smaller, so coverage runs a
194
- little above nominal. A refit on the blended track is straightforward.
195
- * The RI blend with the NHC schemes needs a tuning sample: build 2023-2025 as a new tuning set.
196
- * A split swarm when ours and HCCA disagree beyond the cone (half the worlds on each track) would show both scenarios
197
- in the threat map instead of a midpoint neither predicts; it needs a swarm re-verification on the 396 cases.
198
- * Real-time data still to bring in: NESDIS ocean heat content, ECMWF ENS tracks (IFS-ENS, AIFS-ENS), HAFS-A/B tracks,
199
- CIMSS ADT/SATCON/AiDT, NHC wind-speed probabilities, STOFS and SLOSH for surge.
200
-
201
- ## 7. NatureV2-500
202
-
203
- `CorxLabs/NatureV2-500` starts from the NatureV1-500 stage-1 checkpoint (`checkpoints/stage1/latest.pt`, with its
204
- `config.json` and `normalizer.json`) and carries this code base. The plan: fine-tune the satellite encoder (the
205
- GOES/GridSat band-13 path of 0.12, SatNet-Storm 2.0 and the global retrieval) on the storm-centred imagery, add the
206
- new features the 0.16 verification asked for (infrared structure and its trend, lightning, the ocean heat feed) as
207
- inputs to the storm heads and to the RI probability, and verify them on the same held-out seasons with the same
208
- benchmark table before any of it changes a number in the operational path.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ROADMAP_0.16.md DELETED
@@ -1,55 +0,0 @@
1
- # naturev1 0.16: the engineering roadmap, closed out
2
-
3
- The six deficits of 0.15, what the archive said causes each, the experiment that decided each fix, and where it ended.
4
- Every item was held to the same rule as everything before it: fitted on the tuning seasons (2016, 2017, 2019), scored
5
- once on the held-out seasons (2018, 2020-2022), shipped only if it helped there, and reported either way. "Held-out"
6
- below always means those seasons. The matrix the user supplied is kept, with the mechanics corrected where the archive
7
- said otherwise -- a roadmap built on the wrong cause fixes the wrong thing.
8
-
9
- | # | deficit | what the data say causes it | fix | state |
10
- |---|---|---|---|---|
11
- | 1 | explosive peak gap: forecast peak 25-37 kt under the true peak of category-5 storms | not a loss-function artefact (the intensity is a simplex-weighted consensus, no network); it is regression to the mean under rapid-intensification uncertainty -- any weight that moves the mean toward the RI path costs the 94% of cases that do not explode | close it in the **distribution**, not the mean: calibrated peak quantiles (50/75/90/95%) and E[peak \| RI]; then the operational guidance as members | **shipped** (`peak_quantiles`, `peak_if_ri`): held-out q90 sits +4 kt above the true peak of the >=137 kt storms where the consensus max sits -37 kt; coverage 0.53/0.77/0.91/0.95; E[peak \| RI] +0.6 kt bias on the storms that went rapid. The a-deck blend (row 7) takes 26-35% off the intensity error at 24-120 h but moves the RI-case bias at 24 h only from -23 to -21 kt (the official forecast: -19): the gap is a property of every mean forecast, which is why the distribution products stay the answer |
12
- | 2 | track: 2-7% behind FGN past 12 h | an equal-weight mean is diluted by weaker members; the live set has no FGN at all | dynamic per-storm model weighting from the last cycles' errors (tested: ~0); NNLS member weights (+0.3-1.1%, not shipped); the NHC a-deck's consensus aids as live members | **shipped** (row 7): the plain mean of our blend and HCCA, chosen on the tuning seasons, lowers the held-out Atlantic track error 11-13% at 12-120 h and beats the official forecast by 12-22% at 6-72 h |
13
- | 3 | light-rain bloat from the rain multiplier | the tuning storms are wetter than the held-out ones (obs/forecast 1.9 vs 1.5), so a pooled multiplier runs high in the periphery; it is a per-storm scale error, largest in the cores of slow movers, not a radial-tail problem | profile reshaped (tail x1.5, front-right asymmetry 0.3, multiplier 1.19); the model's own rain as a member | **shipped**: reshape (held-out CSI 250 mm 0.295 -> 0.348, strong storms' area >=250 mm 3.2x -> 1.7x) and the GFS accumulated precipitation travelling with every world as half of its rain (radar check on US landfalls: Ida 23 vs 45 mm MAE, Nicholas 17 vs 55, Fred 21 vs 36, Ian level). Rejected on the tuning storms: every nonlinear heavy-favouring transform, a 115-kt saturation, a translation-speed factor |
14
- | 4 | terrain and moisture: rain under the wind, not where two days of easterlies fed the Blue Mountains | a parametric profile has no moisture flux and no outer bands; the orographic term is a slope x wind product without moisture; the multiplier was calibrated on flat ground | reshaped profile; the model-QPF member, which carries the model's moisture and its own orography | **partly**: east of Melissa's track right since the reshape (Portland 470 vs 536 mm, St Catherine 624 vs 668, Kingston 447 vs 567); the QPF member is the moisture term; an integrated-vapour-transport orographic scheme needs a calibration set that does not exist yet |
15
- | 5 | stalls over water held too strong | the continuous depth-aware cooling lost on the tuning seasons (15.3 -> 15.4-15.6 kt); a stall rule (speed < 3 kt for > 12 h, D26 < 50 m, -5 to -15 kt) was tested rather than assumed | penalty fitted on stall cases, with the deep-water sign check | **rejected**: the sign of the stall residual flips between the tuning and the held-out seasons, and the fixed rule is 45-122% worse on the stall cases themselves; Melissa stalled over 120 m of 26 C water and intensified. Nothing shipped; the wake cooling already in the SHIPS-style members stands |
16
- | 6 | landfall timing 3-4 h early | hypothesis was a missing coastal slowdown; measured instead as along-track bias by speed, lead and distance to land | along/cross decomposition, speed-dependent correction, arrival-time check | **measured, no correction**: no systematic along-track bias (+1/+4/+5/+2/-1 km at 6-48 h); the arrival-time error is 3.6 h at 24 h overall and 7.3 h for storms moving under 3 m/s -- so the error is reported as a window (`ARRIVAL_TIME_ERROR`, in the landfall report) and nothing is nudged |
17
- | 6b | the averaging penalty: a fixed 50/50 mean with HCCA drags a forecast that caught the turn toward a member that did not (Melissa 26 Oct 12 UTC: ours 15 km, HCCA 70, the mean 42) | on held-out, when the two disagree most it is our standalone that is wrong two times in three (24 h: ours 133 km, HCCA 93, the mean 94; in the extreme tenth ours 220, HCCA 101), so trusting the standalone in a disagreement loses on average | weights earned from the previous cycle's verified errors; an outlier gate with TVCN as tie-breaker; rules that favour ours, the median and trimmed mean of three | **shipped** (`ADECK_TRACK["adaptive"]`, `["gate"]`): earned split at 6-24 h (b 0.5, within 25-75%) and up to 10% of the pair away from the member that stands alone beyond 1.5 cone radii from 12 h -- together 1.4-2.5% lower held-out error at 12-36 h, 1-2% at 48-120 h, every lead significant; the disagreement reported per lead with both tracks in the record. **Rejected**: ours 2/3, a gate toward ours, the median and trimmed mean of ours/HCCA/TVCN (all lost on the tuning seasons) |
18
- | 7 | the operational guidance was not in the room | the NHC a-deck carries every aid's track and intensity minutes after each cycle; nothing read it | `naturev1.swarm.adeck` reads the public a-deck (live and archive); `naturev1.cyclone.aids` blends the aids in with tables fitted on the tuning seasons | **shipped**: intensity -- simplex weights over our consensus, IVCN, HWRF, HMON, SHIPS, LGEM, GFS and HCCA (leave-one-season-out gains 13-28% at 24-96 h in every tuning season; held-out 26-35% at 24-120 h, 4-6% ahead of the official forecast at 24-72 h); track -- the mean with HCCA (row 2). HAFS-A/B stand in for HWRF/HMON and TVCN for HCCA when those are absent (lineage stand-ins, reported as such) |
19
- | 8 | the NHC's rapid-intensification guidance was not in the room | the SHIPS text carries the SHIPS-RII, logistic, Bayesian, DTOPS and consensus probabilities live; the e-deck carries them after the fact | read both; verify against ours on the archive | **shown beside ours, not the headline**: the archive's e-decks carry no RI rows before 2020, so nothing could be chosen on the tuning seasons; on 2020-2022 (453 cases, 24 events) ours has Brier skill 0.13, the SHIPS-RII consensus 0.07, DTOPS 0.17 and the equal-weight mean of ours and DTOPS 0.20 -- that mean is reported in the record and the cell as `ri_probability_combined`, with this caveat attached |
20
-
21
- ## Shipped in 0.16
22
-
23
- * **The NHC guidance as members** -- `naturev1.swarm.adeck` (a-deck, e-deck, SHIPS text), `naturev1.cyclone.aids`, `coefficients.ADECK_TRACK` / `ADECK_INTENSITY`; `forecast_storm(guidance=True)` by default; `track_ours` / `intensity_ours` keep our own forecast beside the blend; the official forecast and the RI guidance in `run["guidance"]` and the cycle record.
24
- * **The averaging penalty answered** -- the ours/HCCA split earned from the previous cycle and gated away from the outlier against TVCN; the disagreement per lead in `aids_track`.
25
- * **Rain** -- `RAIN_SCALE = {multiplier 1.19, tail 1.5, asymmetry 0.3 front-right}`; orographic cap 2; the GFS QPF member (`models.fetch_qpf`, `qpf_weight` 0.5).
26
- * **Peak quantiles and E[peak | RI]** -- `coefficients.PEAK_QUANTILES`, `PEAK_IF_RI`; in the landfall report ("plan for the 90% figure").
27
- * **Arrival-time window** -- `coefficients.ARRIVAL_TIME_ERROR`; in the landfall report.
28
- * **Operations layer** -- `naturev1.swarm.bulletin` (every cycle saved as JSON + parish CSV; the previous cycle found again and fed back automatically), `naturev1.swarm.infrared` (GOES band-13 structure diagnostics with trend), `forecast_storm(previous="auto")`, the molab cell with its watch loop.
29
- * **Audit fixes** -- missing physics context reported, swarm rescale keeps the longitude frame across the dateline, 24/48 h indices follow the caller's leads, silent fallbacks leave `intensity_note`, NHC-only ids, previous analysis position as the first feedback knot.
30
- * **The swarm sized to the flow-dependent cone** (0.15.0, verified on 396 held-out forecasts).
31
-
32
- ## Tried and rejected (kept here so nobody tries them again without new evidence)
33
-
34
- Stall penalty (row 5); translation-speed rain factor and 115-kt saturation; nonlinear rain transforms; dynamic per-storm
35
- model weighting; NNLS physics-member weights (+0.3-1.1%, inside the noise); the RI-path mixture into the mean; the
36
- continuous depth-aware wake cooling; CHIRPS as rain truth (too coarse at the parish scale); the fitted simplex over
37
- the track aids (within 0.2% of the plain mean on the tuning seasons, so the mean ships).
38
-
39
- ## Real-time data still to bring in (each needs a reader and a verification before it changes a number)
40
-
41
- * NESDIS ocean heat content (26 C depth, TCHP) on CoastWatch ERDDAP, daily -- replaces the climatology under the storm.
42
- * ECMWF open-data TC-track BUFR for IFS-ENS, AIFS-single and AIFS-ENS (51 + 51 member tracks and intensities).
43
- * HAFS-A/B tracks (3-hourly to 126 h, with radii) on NOMADS -- the a-deck already carries their interpolated runs.
44
- * CIMSS ADT / SATCON / AiDT / D-MINT text feeds (30-minute satellite intensity, radii) and STAR SAR winds -- fixes with intensity for the particle filter.
45
- * GLM lightning and GOES band-13 structure as RI predictors (an archive of storm-centred imagery is needed to fit them).
46
- * NHC wind-speed probabilities and arrival-time grids, STOFS-2D water levels and the Jamaica SLOSH MOM layer for a surge pillar.
47
-
48
- ## The benchmark we publish: the answer key
49
-
50
- `benchmark_heldout_atlantic_2018_2020_2022.csv.gz` on the model repository: the 849 held-out Atlantic cases (storm,
51
- time, position, intensity) with the best-track truth at every lead and every reference's forecast or error on it --
52
- NatureV1 0.14/0.16 tracks and 0.15/0.16 intensities, the NHC official forecast and its aids (HCCA, TVCN, IVCN, HWRF,
53
- HMON, SHIPS, LGEM, GFS, GEFS mean), the archived models' track errors (HRES, Pangu, GraphCast, FuXi, Aurora, FGN),
54
- CLIPER and extrapolation. `naturev1.cyclone.verify.compare` scores any new forecast against it on the same storms,
55
- with the storm-block bootstrap, so that a claim about a hurricane model can be checked rather than believed.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b300_naturev1.py DELETED
@@ -1,151 +0,0 @@
1
- # ═══════════════════════════════════════════════════════════════════════════════════════════════════
2
- # NatureV1 0.12 on 8x NVIDIA B300 -- the satellite path, then every basin.
3
- #
4
- # Cells for JupyterLab or VS Code ("# %%"), or run top to bottom as a script. Every long step runs as
5
- # its own detached process with a log under DATA/logs: closing the notebook or losing the connection
6
- # never stops it, and running the same cell again reattaches to it instead of starting it twice.
7
- #
8
- # What happens, and roughly how long it takes on 8x B300 with a fast NVMe array and a fast network:
9
- #
10
- # 1 install, preflight minutes checks GPUs, disks, network, HF token; measures a step
11
- # 2 base model + statistics from the Hub minutes the trained 0.10/0.11 release is the starting point
12
- # 3 labels minutes IBTrACS since 1980, every basin: ~230,000 storm fixes
13
- # 4 stage the record (two jobs at once) 1-4 h ERA5 1980-2021 (~0.33 TB) and GridSat-B1 1980-2021
14
- # (~4-6 TB read, ~0.8 TB of training shards written)
15
- # 5 stage A, 3 h: both satellite networks 3 h GPUs 0-3: the storm network; GPUs 4-7: the retrieval
16
- # 6 stage B: heads + backbone tail 2 h + ~1 every basin, satellites in the loop; then banks with
17
- # rollout members, consensus, calibration, test scores
18
- # 7 publish minutes only replaces the release if it beat the old one
19
- #
20
- # Every checkpoint of every run is pushed to HF_REPO as it is written (checkpoints/<run>/latest.pt, and
21
- # the weights of each one under checkpoints/<run>/history/), so a dead machine resumes on a new one.
22
- #
23
- # The training time is the GPU budget you set (STAGE_A_HOURS, STAGE_B_HOURS); staging is separate and
24
- # bound by network and disk, not by the GPUs. Nothing here has been run on a B300 by its author -- the
25
- # code is tested end to end on CPU (including real two-process data parallelism); the preflight is the
26
- # first thing that touches your hardware, and it says so if anything is wrong.
27
- # ═══════════════════════════════════════════════════════════════════════════════════════════════════
28
-
29
- # %% [0] settings ──────────────────────────────────────────────────────────────────────────────────────
30
- HF_REPO = "CorxLabs/NatureV1-500"
31
- HF_TOKEN = "" # paste a WRITE token here for this session, then clear it afterwards
32
- DATA = "/data/naturev1" # the NVMe array: >= 2 TB free (staged shards + ERA5 + working space)
33
- FIRST_YEAR = 1980 # GridSat-B1 begins 1980
34
- LAST_YEAR = 2021 # WeatherBench 2's 1.5-degree ERA5 ends 2021-12-31
35
- SAT_RESOLUTION = 0.25 # degrees of the staged global frames (0.1 writes ~4 TB of shards instead of ~0.8)
36
- STAGE_A_HOURS = 3.0 # both satellite networks, side by side, four GPUs each
37
- STAGE_B_HOURS = 2.0 # storm heads + backbone tail on all eight GPUs (banks and scoring come after)
38
- TAIL_BLOCKS = 4 # backbone blocks fine-tuned in stage B (of 17)
39
- ROTATIONS = 4 # test-time rotation ensemble kept in the banks (1: off); used only if it helps
40
- STAGING_WORKERS = None # None: all CPUs but 16
41
- KEEP_RAW = False # keep the raw GridSat files (4-6 TB) after staging
42
- BRANCH = "claude/linear-context-architecture-a35c6h"
43
-
44
- # %% [1] install and preflight ──────────────────────────────────────────────────────────────────────────
45
- import os, subprocess, sys
46
- from pathlib import Path
47
-
48
- REPO_URL = "git+https://github.com/Wisk-inc/transformers@" + BRANCH
49
- subprocess.run([sys.executable, "-m", "pip", "install", "-q", "--upgrade",
50
- f"ihelix @ {REPO_URL}#subdirectory=ihelix",
51
- f"naturev1[all] @ {REPO_URL}#subdirectory=natureweather",
52
- "safetensors", "netCDF4", "h5netcdf"], check=True)
53
- if HF_TOKEN.strip():
54
- os.environ["HF_TOKEN"] = HF_TOKEN.strip() # inherited by every job below; never written to disk
55
- os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "0")
56
- DATA = Path(DATA)
57
- LOGS, SAT, CKPT = DATA / "logs", DATA / "satellite", DATA / "runs"
58
- for folder in (LOGS, SAT, CKPT):
59
- folder.mkdir(parents=True, exist_ok=True)
60
-
61
- import naturev1
62
- from naturev1.hardware import hardware_preflight
63
-
64
- print(f"naturev1 {naturev1.__version__}")
65
- READY = hardware_preflight(DATA, HF_REPO, need_tb=1.6 if SAT_RESOLUTION >= 0.25 else 5.0, hours=STAGE_A_HOURS)
66
- assert READY, "fix the lines marked !! above before spending GPU hours"
67
-
68
- # %% [2] the starting model and its statistics, from the Hub ───────────────────────────────────────────
69
- import shutil
70
- import torch
71
- from huggingface_hub import hf_hub_download
72
-
73
- BASE = DATA / "base.pt"
74
- if not BASE.exists():
75
- for tag in ("stage3", "stage2"): # the newest released stage
76
- try:
77
- shutil.copy2(hf_hub_download(HF_REPO, f"checkpoints/{tag}/latest.pt"), BASE)
78
- print(f"starting from checkpoints/{tag}/latest.pt")
79
- break
80
- except Exception as error:
81
- print(f"no {tag} on the Hub ({type(error).__name__})")
82
- STATS = DATA / "stats.json"
83
- if not STATS.exists():
84
- shutil.copy2(hf_hub_download(HF_REPO, "staging/stats.json"), STATS)
85
- base = torch.load(BASE, map_location="cpu", weights_only=False)
86
- print("base config:", {k: base["config"][k] for k in ("hidden_size", "num_layers", "analysis_channels", "state_channels")})
87
- ANALYSIS_CHANNELS = base["config"]["analysis_channels"]
88
- TENDENCY = DATA / "tendency.pt"
89
- recorded = base["model"].get("state_tendency")
90
- if recorded is not None and float(recorded.abs().sum()) > 0:
91
- torch.save(recorded.float(), TENDENCY)
92
- print("tendency scale: recorded in the checkpoint")
93
- del base
94
-
95
- # %% [3] labels: every basin since 1980 ────────────────────────────────────────────────────────────────
96
- import numpy as np
97
- from naturev1.ibtracs import describe_table, fetch_ibtracs, parse_ibtracs, storm_label_table
98
-
99
- IBTRACS = fetch_ibtracs(DATA)
100
- LABELS = SAT / "labels.npz"
101
- if not LABELS.exists():
102
- np.savez(LABELS, **storm_label_table(parse_ibtracs(IBTRACS, since=FIRST_YEAR, until=LAST_YEAR)))
103
- print(describe_table(dict(np.load(LABELS))))
104
-
105
- # %% [4] stage the record: ERA5 and GridSat, as two detached jobs ──────────────────────────────────────
106
- from naturev1.b300 import follow, start_job
107
- from naturev1.satstage import staging_plan
108
-
109
- print(staging_plan(range(FIRST_YEAR, LAST_YEAR + 1), SAT_RESOLUTION, len(np.load(LABELS)["time"])))
110
- ERA5 = DATA / f"era5_{FIRST_YEAR}_{LAST_YEAR}.npy"
111
- start_job("era5", [sys.executable, "-c",
112
- f"from naturev1.b300 import stage_era5_record; "
113
- f"stage_era5_record({str(DATA)!r}, {FIRST_YEAR}, {LAST_YEAR}, {str(STATS)!r}, workers=32)"], LOGS)
114
- workers = STAGING_WORKERS or max((os.cpu_count() or 32) - 16, 4)
115
- start_job("gridsat", [sys.executable, "-m", "naturev1.satstage", "--root", str(SAT), "--ibtracs", str(IBTRACS),
116
- "--years", f"{FIRST_YEAR}-{LAST_YEAR}", "--workers", str(workers),
117
- "--resolution", str(SAT_RESOLUTION)] + (["--keep-raw", "--raw-dir", str(DATA / "gridsat_raw")]
118
- if KEEP_RAW else []), LOGS)
119
- follow(LOGS, ["era5", "gridsat"])
120
-
121
- # %% [5] stage A: the storm network on GPUs 0-3, the retrieval on GPUs 4-7, side by side ─────────────────
122
- if not TENDENCY.exists(): # a checkpoint from before 0.11 does not record it: measure it
123
- from naturev1.b300 import memmap_tendency
124
-
125
- torch.save(memmap_tendency(ERA5, ANALYSIS_CHANNELS), TENDENCY)
126
- common = ["--checkpoint", str(BASE), "--root", str(SAT), "--table", str(LABELS), "--output", str(CKPT),
127
- "--hours", str(STAGE_A_HOURS), "--hub-repo", HF_REPO]
128
- start_job("stage-a-satnet", ["-m", "naturev1.sattrain", "satnet", *common, "--workers", "12"], LOGS,
129
- gpus=[0, 1, 2, 3])
130
- start_job("stage-a-retrieval", ["-m", "naturev1.sattrain", "retrieval", *common, "--era5", str(ERA5),
131
- "--tendency", str(TENDENCY), "--workers", "10"], LOGS, gpus=[4, 5, 6, 7])
132
- follow(LOGS, ["stage-a-satnet", "stage-a-retrieval"])
133
-
134
- # %% [6] stage B: storm heads + the backbone's last blocks, every basin, satellites in the loop ───────────
135
- start_job("stage-b", ["-m", "naturev1.stormtrain", "--base", str(BASE),
136
- "--satnet", str(CKPT / "stage-a-satnet" / "latest.pt"),
137
- "--retrieval", str(CKPT / "stage-a-retrieval" / "latest.pt"),
138
- "--era5", str(ERA5), "--tendency", str(TENDENCY), "--ibtracs", str(IBTRACS),
139
- "--table", str(LABELS), "--root", str(SAT), "--output", str(CKPT),
140
- "--hours", str(STAGE_B_HOURS), "--tail", str(TAIL_BLOCKS), "--rotations", str(ROTATIONS),
141
- "--hub-repo", HF_REPO], LOGS,
142
- gpus=list(range(8)))
143
- follow(LOGS, ["stage-b"])
144
- print((CKPT / "stage-b" / "report.txt").read_text() if (CKPT / "stage-b" / "report.txt").exists() else "no report yet")
145
-
146
- # %% [7] publish: the release only if it beat the model it started from ──────────────────────────────────
147
- from naturev1.b300 import publish_release
148
-
149
- where = publish_release(CKPT / "stage-b" / "latest.pt", STATS, HF_REPO, DATA / "release",
150
- comparison=CKPT / "comparison.json", report=CKPT / "stage-b" / "report.txt")
151
- print("published to", where, "-- now clear HF_TOKEN in cell [0]")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
colab_naturev1.py DELETED
@@ -1,766 +0,0 @@
1
- # ═══════════════════════════════════════════════════════════════════════════════════════════════════
2
- # NatureV1 0.11.0 — the whole thing in one cell. Pure Python: marimo, Colab, Jupyter or a plain script.
3
- #
4
- # Paste and run. It installs what it needs, stages the data, builds the 89M-parameter model, trains it
5
- # in stages, scores itself against persistence and climatology, and only publishes if it earned it.
6
- #
7
- # With BACKGROUND = True (the default) the run happens in a supervised process on the machine itself:
8
- # it keeps going if the wifi drops or the notebook closes, and it restarts itself from its last
9
- # checkpoint if it crashes, runs out of GPU memory, or stalls. The cell then stays running as a live
10
- # window on it -- every log line as it happens, a proof-of-life line every minute when the run is
11
- # quiet, a status line every ten minutes -- until the run finishes (WATCH_SECONDS = None). Stopping the
12
- # cell stops only the window; the run never depends on it. Run the cell again any time to reopen it.
13
- #
14
- # If it stops for any reason -- a crash, a reboot, naturev1.stop(), the supervisor giving up -- just run
15
- # this cell again: every stage continues from its last checkpoint (saved every minute, a snapshot every
16
- # 30 minutes, and a second copy in BACKUP_DIR). naturev1.progress() shows where it will continue from.
17
- #
18
- # ON A HOSTED NOTEBOOK (molab, Colab): the run survives the notebook, but not the machine. molab ends a
19
- # session after 12 hours, or after 90 idle minutes, and wipes its disk. So, with HF_TOKEN set, everything
20
- # expensive is mirrored to HF_REPO as soon as it exists: the staged ERA5 (once, in the background), the
21
- # checkpoints (every 10 minutes, and once more when the session is told to stop), the storm feature
22
- # banks, the remembered batch size. A new session pulls them back in minutes instead of rebuilding them:
23
- # the download does not start over, training resumes where the last session stopped. On molab: set
24
- # HF_TOKEN, keep this tab open (the cell staying active is what keeps the notebook from being idle), and
25
- # when the 12 hours are up, open a new session and run this cell again.
26
- #
27
- # 0.9: the hurricane heads read the storm's physical environment -- deep-layer steering, 200-850 hPa
28
- # shear, mid-level moisture, ocean temperature -- computed from the model's own atmosphere, plus a
29
- # wider 1000 km pool. The track head predicts the residual from a physics-only steering track, and the
30
- # forecast is a multi-model consensus of learned track, steering track and extrapolation, weighted per
31
- # lead on held-out seasons. The scorecard shows every member, so you can see which one earns its place.
32
- #
33
- # 0.11: stage three -- what the satellite sees. Storm-centred infrared (HURSAT-B1 to 2016, GridSat-B1
34
- # after, GOES band 13 live) feeds a small branch into the hurricane heads, aimed at intensity, which a
35
- # 1.5-degree reanalysis cannot see. It is fine-tuned with the backbone frozen and can never end worse on
36
- # held-out storms than the model it started from. Then, from the red team: landfall and rapid-
37
- # intensification probabilities are calibrated on held-out seasons, an input monitor marks forecasts
38
- # from inputs unlike training as untrustworthy, the forecast can no longer state impossible structure,
39
- # and the rollout's tendency statistics ship with the weights. Stages already trained are loaded, not
40
- # redone: a finished 0.10 run goes straight to stage three.
41
- # ═══════════════════════════════════════════════════════════════════════════════════════════════════
42
-
43
- BACKGROUND = True # supervised background run: survives disconnects, restarts on crash or stall
44
- STALL_MINUTES = 30 # background: restart a run that shows no progress for this long (12 while downloading)
45
- WATCH_SECONDS = None # None: this cell stays running as a live window until the run finishes; or seconds
46
- MIRROR = True # mirror staged data, checkpoints and banks to HF_REPO (needs HF_TOKEN); see above
47
-
48
- RUN_PRETRAIN = True # stage one: every channel at every lead, on ERA5 reanalysis
49
- RUN_ROLLOUT = True # stage 1b: the whole atmosphere rolled forward 12 steps, CRPS ensemble
50
- RUN_STORMS = True # stage two: hurricane heads on HURDAT2, backbone frozen
51
- RETRAIN_STORMS = False # stage two: train the heads again even if a stage-two checkpoint exists
52
- RUN_SATELLITE = True # stage three: storm-centred infrared satellite, then calibration and the monitor
53
- RETRAIN_SATELLITE = False # stage three: train again even if a stage-three checkpoint exists
54
- RUN_SCORE = True # WeatherBench-style scorecards against persistence and climatology
55
- RUN_HINDCAST = True # forecast a held-out hurricane from its real analysis; compare with truth
56
- RUN_PUBLISH = False # push to the Hub -- only if it beats persistence somewhere
57
- RUN_FORECAST = False # live GOES forecast (refused while the satellite path is untrained)
58
- RUN_WATCHER = False # then re-forecast every hour
59
-
60
- STAGE_YEARS = 5 # training years staged to local disk, ~7.5 GB each at 89 channels
61
- VAL_YEARS = 1 # held-out years staged too, so validation and scoring are not network-bound
62
- EPOCHS = 8 # passes over the staged years in stage one
63
- BATCH = None # None: find the largest batch that fits once, then remember it; or a number
64
- MAX_BATCH = 16 # ceiling on the batch search: host RAM, not VRAM, is the limit above this
65
- WORKERS = 4 # data-loading processes
66
- ROLLOUT_STEPS = 12 # longest rollout trained: 12 x 6 h = 72 h, GraphCast's curriculum
67
- ROLLOUT_TRAIN = 4_000 # optimizer steps of rollout training (the log prints the time per step)
68
- MEMBERS = 2 # CRPS ensemble members; 1 = deterministic Gaussian training
69
- STORM_FIRST_SEASON = 1979 # the satellite era; earlier best-track intensities are much less reliable
70
- HINDCAST = ("DORIAN", 2019) # a held-out storm: 2019 is never trained on
71
- STORM = (24.6, -78.2) # live forecast: current storm centre (lat, lon)
72
- CITY = (25.77, -80.19) # live forecast: somewhere you want a local forecast
73
- HF_REPO = "CorxLabs/NatureV1-500"
74
- HF_TOKEN = "" # a write token, to publish and mirror checkpoints; or set the HF_TOKEN env var
75
- DATA_DIR = None # None: /content on Colab, ~/naturev1_data elsewhere; or any folder with room
76
- BACKUP_DIR = None # second copy of every checkpoint. None: ~/naturev1_backup; best on another disk;
77
- # "" turns it off
78
-
79
- # ═══ 0 ═══ install ═════════════════════════════════════════════════════════════════════════════════
80
- # Standard library only, before numpy or torch are imported: pip may upgrade numpy while satisfying
81
- # zarr or gcsfs, and a numpy that changes under an imported torch fails far from here.
82
- import importlib
83
- import importlib.metadata
84
- import importlib.util
85
- import os
86
- import shutil
87
- import subprocess
88
- import sys
89
-
90
- os.environ.setdefault("GRPC_VERBOSITY", "ERROR") # the cloud client logs every DataLoader fork otherwise
91
- # Let the GPU allocator grow segments instead of fragmenting them. Training at batch 8 fills a 96 GB card
92
- # to within ~130 MB, and without this the allocator has to flush and retry. Only takes effect if torch
93
- # has not touched the GPU yet in this kernel -- restart the kernel for it to apply.
94
- os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
95
-
96
-
97
- def _halt(message, kind="danger"):
98
- """
99
- End the cell here, on purpose. Only kind="danger" is an error. marimo shows the message as a
100
- coloured note instead of a traceback, Jupyter and Colab show it without one, and a plain script
101
- exits with status 0 for "success" and 1 for anything else -- which is what the supervisor of a
102
- background run reads, so a run stopped part-way is resumed and a finished one is not.
103
- """
104
- marimo = sys.modules.get("marimo")
105
- if marimo is not None and getattr(marimo, "running_in_notebook", lambda: False)():
106
- try:
107
- note = marimo.callout(marimo.plain_text(message), kind=kind)
108
- except Exception:
109
- note = None
110
- print(message)
111
- marimo.stop(True, note)
112
- ipython = sys.modules.get("IPython")
113
- if kind != "danger" and ipython is not None and getattr(ipython, "get_ipython", lambda: None)() is not None:
114
- class Halted(Exception):
115
- def _render_traceback_(self):
116
- return [] # the message printed above, and no traceback
117
- print(message)
118
- raise Halted("the cell stopped here on purpose -- the message is above")
119
- if kind == "success":
120
- print(message)
121
- raise SystemExit(0)
122
- raise SystemExit(message)
123
-
124
-
125
- # 0.11 is installed from the project's GitHub branch until it is on PyPI.
126
- _NEEDED = {"naturev1": ("naturev1[all] @ git+https://github.com/Wisk-inc/transformers"
127
- "@claude/linear-context-architecture-a35c6h#subdirectory=natureweather", (0, 11, 0)),
128
- "ihelix": ("ihelix>=0.5.1", (0, 5, 1))}
129
-
130
-
131
- def _version(module):
132
- try:
133
- return tuple(int(p) for p in importlib.metadata.version(module).split(".")[:3])
134
- except Exception:
135
- return None
136
-
137
-
138
- _missing = [req for mod, (req, low) in _NEEDED.items()
139
- if importlib.util.find_spec(mod) is None or (_version(mod) or (0,)) < low]
140
- if _missing:
141
- print(f"installing: {' '.join(_missing)}")
142
- _loaded = {name: getattr(sys.modules.get(name), "__version__", None) for name in ("numpy", "torch")}
143
- _tries = ([("uv", ["uv", "pip", "install", "--python", sys.executable, "--upgrade", *_missing])]
144
- if shutil.which("uv") else [])
145
- _pip = [sys.executable, "-m", "pip", "install", "--upgrade"]
146
- _tries += [("pip, fresh index", [*_pip, "--no-cache-dir", "--index-url", "https://pypi.org/simple", *_missing]),
147
- ("pip, no cache", [*_pip, "--no-cache-dir", *_missing]),
148
- ("pip", [*_pip, *_missing])]
149
- for _label, _command in _tries:
150
- try:
151
- subprocess.check_call(_command)
152
- print(f"installed via {_label}")
153
- break
154
- except (subprocess.CalledProcessError, FileNotFoundError):
155
- print(f" {_label} failed, trying the next way")
156
- else:
157
- _halt("could not install; add these in your package manager, then re-run: " + " ".join(_missing))
158
- importlib.invalidate_caches()
159
- # A restart is needed only if an old copy is already in memory, or pip moved numpy/torch under us.
160
- _stale = [m for m in _NEEDED if m in sys.modules]
161
- for _name, _was in _loaded.items():
162
- try:
163
- if _was and importlib.metadata.version(_name) != _was:
164
- _stale.append(f"{_name} {_was}")
165
- except importlib.metadata.PackageNotFoundError:
166
- pass
167
- if _stale:
168
- _halt(f"installed. {', '.join(_stale)} was already loaded in this kernel: RESTART THE KERNEL, then run "
169
- "this cell again. (A background run that is already training is not affected.)", kind="warn")
170
-
171
- if HF_TOKEN:
172
- os.environ["HF_TOKEN"] = HF_TOKEN
173
- # Every checkpoint is also copied here, so losing or damaging the data folder does not lose the run.
174
- os.environ["NATUREV1_BACKUP_DIR"] = (os.path.expanduser("~/naturev1_backup") if BACKUP_DIR is None
175
- else os.path.expanduser(BACKUP_DIR))
176
-
177
- if BACKGROUND and not os.environ.get("NATUREV1_BACKGROUND_CHILD"):
178
- # Hand this same pipeline, with these settings, to a supervised process on this machine, and stop
179
- # here: the notebook only starts and watches it. The GPU is never touched from the notebook.
180
- # Imported under a private name: marimo lets a name be defined by only one cell, and this way any
181
- # other cell can still `import naturev1` to call naturev1.follow() or naturev1.stop().
182
- import naturev1 as _naturev1
183
-
184
- _SETTINGS = {name: globals()[name] for name in (
185
- "RUN_PRETRAIN", "RUN_ROLLOUT", "RUN_STORMS", "RETRAIN_STORMS", "RUN_SATELLITE", "RETRAIN_SATELLITE",
186
- "RUN_SCORE", "RUN_HINDCAST", "RUN_PUBLISH",
187
- "RUN_FORECAST", "RUN_WATCHER", "STAGE_YEARS", "VAL_YEARS", "EPOCHS", "BATCH", "MAX_BATCH", "WORKERS",
188
- "ROLLOUT_STEPS", "ROLLOUT_TRAIN", "MEMBERS", "STORM_FIRST_SEASON", "HINDCAST", "STORM", "CITY",
189
- "HF_REPO", "HF_TOKEN", "DATA_DIR", "BACKUP_DIR", "MIRROR")}
190
- _was_running = _naturev1.status(DATA_DIR) is not None
191
- _naturev1.launch(directory=DATA_DIR, stall_minutes=STALL_MINUTES, **_SETTINGS)
192
- if _was_running:
193
- _naturev1.progress(DATA_DIR) # what the run that is already going has done so far
194
- # A fresh run: wait for its first lines. Then show the log live for a while, so the cell ends with
195
- # the run visibly moving rather than with a snapshot that looks frozen.
196
- _naturev1.follow(lines=25, directory=DATA_DIR, wait=90, watch=WATCH_SECONDS)
197
- if _naturev1.status(DATA_DIR) is None:
198
- _naturev1.progress(DATA_DIR)
199
- _halt("The run has ended -- the last lines above say whether it finished or stopped. If it finished, "
200
- "the scorecards and the hindcast are above. If it stopped, run this cell again: it continues "
201
- "from the last checkpoint, pulling data, checkpoints and banks back from the Hub mirror if this "
202
- "machine's disk was wiped.", kind="success")
203
- _halt("NatureV1 is running in the background; this cell was its window and has closed -- the run has "
204
- "NOT stopped. Nothing in this notebook can stop it except naturev1.stop().\n"
205
- "To reopen the window: run this cell again (it will not start a second run), or in any cell\n"
206
- " import naturev1; naturev1.follow(watch=None)\n"
207
- "If the run ever does stop, run this cell again: it continues from the last checkpoint, pulling "
208
- "data, checkpoints and banks back from the Hub mirror if this machine's disk was wiped.\n"
209
- "naturev1.progress() shows each stage's checkpoint, its age, and its backup.",
210
- kind="success")
211
-
212
- # ═══ 1 ═══ imports, paths, hardware ════════════════════════════════════════════════════════════════
213
- import datetime as dt
214
- import json
215
-
216
- import numpy as np
217
- import torch
218
- from ihelix import FieldGrid, Geometry, fibonacci_sphere
219
- from naturev1 import (HEADLINE_STATE_FIELDS, CachedERA5, CheckpointManager, ERA5Window, HubMirror, NatureConfig,
220
- NatureV1, RolloutSchedule, StateStepper, Trainer, TrainSettings, autotune_batch_size,
221
- benchmark_steps, build_climatology, build_forecast, calendar_features, catalogue,
222
- channel_loss_weights, check_forecast, check_grid_alignment, check_normalization,
223
- check_weights, corpus_scale, describe_environment, device_report, era5_loader,
224
- era5_source_grid, era5_splits, expand_variables, extrapolation_track, fetch_latest,
225
- format_pairing, format_plan, format_preflight, lead_offsets, masked_gaussian_nll,
226
- materialise, normalize_channels, open_weatherbench_levels, pair_tracks_with_reanalysis,
227
- parse_hurdat2, preflight, push_to_hub, rapid_intensification, resolve_all,
228
- responds_to_input, scene_from_netcdf, score_model, score_state, scoring_fields,
229
- split_by_storm, spread_skill_ratio, state_forecast, steering_track, storm_environment,
230
- storm_feature_bank, storm_scorecard, storm_state_vector, train_state_rollout,
231
- train_storm_heads, training_plan, upper_air_report, watch)
232
- from naturev1.besttrack import download
233
- from naturev1.checkpoint import TrainingState
234
- from naturev1.era5 import _target_index
235
- from naturev1.storms import calibration_report, fit_feature_monitor, fit_probability_calibration
236
- from naturev1.stormsat import (IBTRACS_NA_URL, GridSatArchive, HursatArchive, describe_patch, encode_bt,
237
- ibtracs_atcf_map, satellite_bank)
238
- from naturev1.safeio import atomic_write_text, read_json
239
-
240
- DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
241
- COLAB_DRIVE = "/content/drive/MyDrive"
242
- DATA_DIR = DATA_DIR or ("/content" if os.path.isdir("/content") else os.path.expanduser("~/naturev1_data"))
243
- CKPT_DIR = (f"{COLAB_DRIVE}/naturev1_ckpt" if os.path.isdir(COLAB_DRIVE) # Drive outlives the VM
244
- else os.path.join(DATA_DIR, "naturev1_ckpt"))
245
- os.makedirs(DATA_DIR, exist_ok=True)
246
- os.makedirs(CKPT_DIR, exist_ok=True)
247
- CACHE, VAL_CACHE, STATS = (os.path.join(DATA_DIR, name) for name in ("era5.npy", "era5_val.npy", "stats.json"))
248
- # Everything expensive goes to the Hub as soon as it exists and comes back at the start of a new session,
249
- # so a wiped disk costs minutes, not hours. Off without a repo or token; see the header.
250
- mirror = HubMirror(HF_REPO if MIRROR else None, token=HF_TOKEN or None)
251
-
252
-
253
- def banner(text):
254
- print(f"\n{'═' * 99}\n {text}\n{'═' * 99}")
255
-
256
-
257
- banner("HARDWARE")
258
- print(device_report())
259
- print(f"data {DATA_DIR} | checkpoints {CKPT_DIR}\n{mirror.describe()}")
260
-
261
- # ═══ 2 ═══ data: 13 pressure levels + surface + static, resolved against this machine ══════════════
262
- banner("DATA — ERA5 (WeatherBench 2), surface and 13 pressure levels")
263
- ERA5, SURFACE, UPPER = open_weatherbench_levels()
264
- LEVELS = [int(x) for x in ERA5.level.values]
265
- SRC, LAT, LON = era5_source_grid(ERA5)
266
- plan = resolve_all(ERA5, tuple(SURFACE) + tuple(UPPER), LEVELS, STAGE_YEARS + VAL_YEARS, SRC.num_points,
267
- layers=17, batch=MAX_BATCH, precision="bf16", path=DATA_DIR,
268
- staged=(CACHE, VAL_CACHE)) # data already on disk counts as room, not as used
269
- print(plan.summary())
270
- R = plan.resolved
271
- CHANNELS = expand_variables(ERA5, R["variables"], R["levels"])
272
- WIDTH = len(CHANNELS) + 7 # room for observation-mask channels, zero-padded
273
- print(f"\n{upper_air_report(SURFACE, UPPER, LEVELS)}")
274
- assert not check_weights(SRC), check_weights(SRC) # poles present, not deleted by cos(latitude)
275
-
276
- OFFSETS, SPLITS = lead_offsets((6, 12, 18, 24, 36, 48, 72, 96, 120)), era5_splits(ERA5)
277
- TRAIN_STEPS = max(R["years"] - VAL_YEARS, 1) * 1460
278
- VAL_STEPS = VAL_YEARS * 1460
279
- common = dict(history=6, lead_steps=OFFSETS, variables=tuple(R["variables"]), levels=R["levels"], channels=WIDTH)
280
- for name, index in SPLITS.items():
281
- print(f" {name:5} {str(ERA5.time.values[index[0]])[:10]} -> {str(ERA5.time.values[index[-1]])[:10]}")
282
- print("split by TIME: weather is autocorrelated for days, so a random split leaks the answer.")
283
-
284
- mirror.pull(STATS, "staging/stats.json") # the normalisation statistics, from an earlier session
285
- stream = ERA5Window(ERA5, indices=SPLITS["train"], stats_cache=STATS, **common)
286
- mirror.push(STATS, "staging/stats.json")
287
- print(f"\n{stream.normalizer.report()}")
288
- print(f"\n{corpus_scale(ERA5, tuple(R['variables']))}\n\n{catalogue()}")
289
-
290
- if R["streaming"]:
291
- print("\nnot enough disk to stage: streaming from the network (~100x slower per window)")
292
- train_ds, roll_ds = stream, ERA5Window(ERA5, indices=SPLITS["train"], stats_cache=STATS,
293
- state_steps=ROLLOUT_STEPS, **common)
294
- val_ds = ERA5Window(ERA5, indices=SPLITS["val"], augment=False, stats_cache=STATS,
295
- state_steps=ROLLOUT_STEPS, **common)
296
- else:
297
- # Always called, and never downloads twice: it returns at once when what is on disk already covers
298
- # the request, copies from disk what it can when the request grows, and resumes a download that died.
299
- TRAIN_SPAN, VAL_SPAN = SPLITS["train"][-TRAIN_STEPS:], SPLITS["val"][:VAL_STEPS]
300
- # A staging an earlier session already made comes back from the Hub in minutes; it is pushed there,
301
- # in the background, the first time it is complete. Then materialise() reuses, grows or resumes it.
302
- mirror.pull_staging(CACHE, "staging/era5", CHANNELS, TRAIN_SPAN)
303
- mirror.pull_staging(VAL_CACHE, "staging/era5_val", CHANNELS, VAL_SPAN)
304
- materialise(ERA5, TRAIN_SPAN, CACHE, variables=tuple(R["variables"]),
305
- levels=R["levels"], normalizer=stream.normalizer, workers=8)
306
- materialise(ERA5, VAL_SPAN, VAL_CACHE, variables=tuple(R["variables"]),
307
- levels=R["levels"], normalizer=stream.normalizer, workers=8)
308
- mirror.push_staging(CACHE, "staging/era5")
309
- mirror.push_staging(VAL_CACHE, "staging/era5_val")
310
- train_ds = CachedERA5(CACHE, history=6, lead_steps=OFFSETS, channels=WIDTH, augment=True,
311
- restrict_to=TRAIN_SPAN)
312
- roll_ds = CachedERA5(CACHE, history=6, lead_steps=OFFSETS, channels=WIDTH, augment=True,
313
- state_steps=ROLLOUT_STEPS, restrict_to=TRAIN_SPAN)
314
- val_ds = CachedERA5(VAL_CACHE, history=6, lead_steps=OFFSETS, channels=WIDTH, augment=False,
315
- state_steps=ROLLOUT_STEPS, restrict_to=VAL_SPAN)
316
- print(f"\ntrain {len(train_ds):,} windows | validate on {len(val_ds):,} held-out windows")
317
-
318
- sample = train_ds[0]["analysis"]
319
- for problem in (check_grid_alignment(SRC, sample[-1, :, train_ds.variables.index("2m_temperature")])
320
- + check_normalization(sample, "analysis")):
321
- _halt(f"DATA PROBLEM: {problem}")
322
- print("data ok: samples land where the grid says, no spike channels")
323
-
324
- # ═══ 3 ═══ what each channel IS — decides what a rollout does with it ══════════════════════════════
325
- stepper = StateStepper.build(roll_ds, input_channels=WIDTH, grid=SRC, samples=64)
326
- print(f"\nchannel roles:\n{stepper.roles.describe()}")
327
- WEIGHTS = channel_loss_weights(train_ds.variables, WIDTH)
328
-
329
- # ═══ 4 ═══ the model ═══════════════════════════════════════════════════════════════════════════════
330
- banner("MODEL")
331
- CFG = NatureConfig(
332
- analysis_channels=WIDTH, satellite_channels=6, environment_channels=8,
333
- hidden_size=512, num_layers=17, num_heads=8, num_kv_heads=4, head_dim=64, intermediate_size=2048,
334
- latent_points=4096, min_radius_km=120.0, max_radius_km=1600.0, history_frames=6,
335
- lead_times_hours=(6, 12, 18, 24, 36, 48, 72, 96, 120), track_modes=6,
336
- state_channels=stepper.roles.num_prognostic, # predict and evolve the whole atmosphere
337
- noise_dim=32 if MEMBERS > 1 else 0) # per-member noise for the CRPS ensemble
338
- model = NatureV1(CFG, fibonacci_sphere(CFG.latent_points, num_neighbours=CFG.latent_neighbours,
339
- cluster_size=CFG.latent_cluster)).to(DEVICE)
340
- model.gradient_checkpointing_enable(True)
341
- stepper.grid = SRC.to(DEVICE)
342
- print(f"NatureV1 {model.num_parameters()/1e6:.2f}M parameters on {DEVICE}; state head over "
343
- f"{CFG.state_channels} channels; {MEMBERS}-member ensemble")
344
- print(format_preflight(preflight(model=model, dataset=train_ds, grid=SRC, strict=True)))
345
-
346
-
347
- def restore(*stages):
348
- """Load the newest checkpoint of the first stage that has one -- locally, else from the Hub."""
349
- for stage in stages:
350
- manager = CheckpointManager(f"{CKPT_DIR}/{stage}", repo_id=HF_REPO)
351
- manager.fetch_from_hub()
352
- if manager.load(model, map_location=DEVICE) is not None:
353
- return stage
354
- return None
355
-
356
-
357
- # ═══ 5 ═══ the largest batch that fits, and what the run costs ═════════════════════════════════════
358
- banner("BENCHMARK — largest batch that fits, and what the run costs")
359
-
360
-
361
- def make_step(batch_size):
362
- items = [train_ds[i] for i in range(batch_size)]
363
- batch = {k: torch.stack([x[k] for x in items]).to(DEVICE) for k in ("analysis", "calendar", "field_target", "field_mask")}
364
-
365
- def step():
366
- with torch.autocast(DEVICE, dtype=torch.bfloat16, enabled=DEVICE == "cuda"):
367
- out = model(analysis=batch["analysis"], analysis_grid=SRC, calendar=batch["calendar"], output_grid=SRC)
368
- loss = masked_gaussian_nll(out["field_mean"], out["field_log_var"],
369
- torch.where(batch["field_mask"] > 0, batch["field_target"], torch.nan))
370
- loss.backward()
371
- model.zero_grad(set_to_none=True)
372
- return step
373
-
374
-
375
- # The search is done once and remembered: repeating it on every restart costs minutes, pushes the card
376
- # to the edge of its memory each time, and could pick a different batch -- a different run -- each time.
377
- BATCH_FILE = os.path.join(CKPT_DIR, "batch.json")
378
- mirror.pull(BATCH_FILE, "staging/batch.json")
379
- REMEMBERED = read_json(BATCH_FILE, default={}) if BATCH is None else {} # a damaged file is searched again
380
- if REMEMBERED.get("batch"):
381
- BATCH, SPEED = int(REMEMBERED["batch"]), float(REMEMBERED.get("samples_per_second", 0.0))
382
- print(f"batch {BATCH}, found on an earlier run (delete {BATCH_FILE} to search again)")
383
- else:
384
- if BATCH is None:
385
- BATCH = min(autotune_batch_size(make_step, start=1, target_fraction=0.85), MAX_BATCH)
386
- MARK = benchmark_steps(make_step(BATCH), BATCH, warmup=1, iterations=10, gradient_checkpointing=True)
387
- SPEED = MARK.samples_per_second
388
- atomic_write_text(BATCH_FILE, json.dumps({"batch": BATCH, "samples_per_second": SPEED}))
389
- mirror.push(BATCH_FILE, "staging/batch.json")
390
- print(f"batch {BATCH} | {MARK}")
391
- if SPEED:
392
- print(format_plan(training_plan(SPEED, corpus_samples=len(train_ds), epochs=EPOCHS,
393
- watts=600.0, electricity_per_kwh=0.15, cloud_per_hour=2.50)))
394
- if DEVICE == "cuda":
395
- torch.cuda.empty_cache() # hand back what the search held before training starts
396
- loader = era5_loader(train_ds, batch_size=BATCH, num_workers=WORKERS, analysis_grid=SRC, output_grid=SRC,
397
- prefetch_factor=2)
398
- val_loader = era5_loader(val_ds, batch_size=BATCH, num_workers=WORKERS, shuffle=False, analysis_grid=SRC,
399
- output_grid=SRC, prefetch_factor=2)
400
-
401
- # ═══ 6 ═══ stage one — every channel, every lead ═══════════════════════════════════════════════════
402
- if RUN_PRETRAIN:
403
- banner("STAGE ONE — pretraining on reanalysis")
404
- trainer = Trainer(model, TrainSettings(
405
- stage="pretrain", learning_rate=3e-4, warmup_steps=1000,
406
- precision=R["precision"], max_steps=int(len(train_ds) * EPOCHS / BATCH),
407
- checkpoint_dir=f"{CKPT_DIR}/stage1", checkpoint_seconds=60, hub_repo=HF_REPO, hub_push_seconds=600,
408
- ema_decay=0.0, log_every=5, val_every=500, val_batches=32), device=DEVICE)
409
- trainer.resume()
410
- trainer.fit(loader, epochs=EPOCHS, val_loader=val_loader)
411
- if trainer.interrupted:
412
- # Stopped part-way: checkpointed, and the next stage must not start from a half-trained model.
413
- _halt("stage one stopped and checkpointed -- run again to resume it where it left off", kind="warn")
414
- print("held-out falling = learning; held-out rising while training falls = memorising.")
415
-
416
- # ═══ 7 ═══ stage 1b — the whole atmosphere, rolled forward on its own forecasts ════════════════════
417
- if RUN_ROLLOUT:
418
- banner(f"ROLLOUT — {ROLLOUT_STEPS}-step full-state training, {MEMBERS}-member CRPS ensemble")
419
- if not RUN_PRETRAIN and restore("stage1") is None:
420
- print("!! no stage-one checkpoint: rolling out an untrained backbone. Set RUN_PRETRAIN = True.")
421
- steps = ROLLOUT_TRAIN
422
- roll_loader = era5_loader(roll_ds, batch_size=max(BATCH // 2, 1), num_workers=WORKERS,
423
- analysis_grid=SRC, output_grid=SRC, prefetch_factor=2)
424
- train_state_rollout(
425
- model, roll_loader, stepper, max_steps=steps,
426
- schedule=RolloutSchedule(start_step=max(steps // 10, 1), ramp_steps=max(steps // 2, 1),
427
- max_steps=ROLLOUT_STEPS),
428
- channel_weights=WEIGHTS, members=MEMBERS, precision=R["precision"],
429
- checkpoint_dir=f"{CKPT_DIR}/rollout", hub_repo=HF_REPO, log_every=25)
430
-
431
- # ═══ 8 ═══ stage two — hurricane heads, on the frozen backbone ═════════════════════════════════════
432
- banner("STAGE TWO — HURDAT2 best tracks")
433
- tracks = parse_hurdat2(download("https://www.nhc.noaa.gov/data/hurdat/hurdat2-1851-2024-040425.txt",
434
- os.path.join(DATA_DIR, "hurdat2.txt")))
435
- print(f"HURDAT2: {len(tracks):,} storms, {sum(len(t) for t in tracks):,} points, "
436
- f"{sum(int(t.landfall.sum()) for t in tracks):,} landfalls")
437
- walk = rapid_intensification(tracks, threshold_kt=30.0)
438
- print(f"rapid intensification: {walk['positives']:,} of {walk['eligible']:,} eligible = "
439
- f"{100 * walk['base_rate']:.2f}% (an always-'no' classifier scores {100 - 100 * walk['base_rate']:.0f}%)")
440
- GROUPS = split_by_storm([t for t in tracks if t.year >= STORM_FIRST_SEASON])
441
- STORE_TIMES = ERA5.time.values.astype("datetime64[s]").astype(np.int64)
442
-
443
-
444
- def paired(which):
445
- starts, targets, report = pair_tracks_with_reanalysis(GROUPS[which], STORE_TIMES, OFFSETS, history=6)
446
- return starts, targets, report
447
-
448
-
449
- def storm_banks():
450
- """The frozen backbone's features for every storm sample: built once, cached, mirrored."""
451
- (tr_starts, tr_targets, tr_report), (va_starts, va_targets, va_report) = paired("train"), paired("validation")
452
- print(f"\n{format_pairing(tr_report)}")
453
- print(f"validation: {va_report['paired']:,} samples from {va_report['storms']} unseen storms "
454
- f"(seasons 2017/2019/2021)")
455
- print("\nreading each storm's reanalysis once and running the frozen backbone over it "
456
- "(cached; resumes if interrupted):")
457
- for _name in ("storm_bank_train", "storm_bank_val"):
458
- mirror.pull(f"{CKPT_DIR}/{_name}.pt", f"checkpoints/{_name}.pt") # rebuilt anyway if the backbone changed
459
- train = storm_feature_bank(model, ERA5, stream, tr_starts, tr_targets, SRC,
460
- cache=f"{CKPT_DIR}/storm_bank_train.pt", precision=R["precision"])
461
- val = storm_feature_bank(model, ERA5, stream, va_starts, va_targets, SRC,
462
- cache=f"{CKPT_DIR}/storm_bank_val.pt", precision=R["precision"])
463
- for _name in ("storm_bank_train", "storm_bank_val"):
464
- mirror.push(f"{CKPT_DIR}/{_name}.pt", f"checkpoints/{_name}.pt")
465
- return train, val, tr_targets, va_targets
466
-
467
-
468
- bank_train = bank_val = None
469
- if RUN_STORMS:
470
- # A finished stage two is loaded *before* the banks are built: its backbone is the one they were
471
- # built with, so they are reused rather than rebuilt from whatever checkpoint the Hub has for 1b.
472
- resumed = None if RETRAIN_STORMS else restore("stage2")
473
- if resumed is None and not (RUN_PRETRAIN or RUN_ROLLOUT) and restore("rollout", "stage1") is None:
474
- _halt("no pretrained backbone found. Freezing an untrained one would fine-tune the storm "
475
- "heads on noise -- set RUN_PRETRAIN = True.")
476
- bank_train, bank_val, tr_targets, va_targets = storm_banks()
477
- if resumed is not None:
478
- print("\nstage two is already trained: its checkpoint is loaded (RETRAIN_STORMS = True trains it again)")
479
- else:
480
- history = train_storm_heads(model, bank_train, bank_val, epochs=300,
481
- patience=25, checkpoint_dir=f"{CKPT_DIR}/stage2", hub_repo=HF_REPO)
482
- best = min(history, key=lambda h: h["val"])
483
- banner("SCOREBOARD — is it generalising, or memorising?")
484
- print(f"best epoch {best['epoch']}: held-out {best['val']:.4f} training {best['train']:.4f} "
485
- f"gap {best['val'] - best['train']:+.4f} (a large positive gap = memorised the training storms)")
486
- print(f"\nheld-out storms, seasons never trained on:\n{storm_scorecard(model, bank_val, CFG.lead_times_hours)}")
487
-
488
- # ═══ 8b ═══ stage three — what the satellite sees ══════════════════════════════════════════════════
489
- if RUN_SATELLITE:
490
- banner("STAGE THREE — storm-centred infrared (HURSAT-B1 to 2016, GridSat-B1 after)")
491
- loaded = None if RETRAIN_SATELLITE else restore("stage3")
492
- if loaded is None and not RUN_STORMS and restore("stage2") is None:
493
- _halt("no trained hurricane heads to build on -- run with RUN_STORMS = True first")
494
- if bank_train is None:
495
- bank_train, bank_val, tr_targets, va_targets = storm_banks()
496
- print("\nstorm-centred infrared for every sample, now and six hours earlier (cached; mirrored):")
497
- hursat = HursatArchive(os.path.join(DATA_DIR, "hursat"),
498
- ibtracs_atcf_map(download(IBTRACS_NA_URL, os.path.join(DATA_DIR, "ibtracs.NA.csv"))))
499
- gridsat = GridSatArchive()
500
- for _name in ("satellite_bank_train", "satellite_bank_val"):
501
- mirror.pull(f"{CKPT_DIR}/{_name}.pt", f"checkpoints/{_name}.pt")
502
- sat_train = satellite_bank(tr_targets, f"{CKPT_DIR}/satellite_bank_train.pt", hursat, gridsat)
503
- sat_val = satellite_bank(va_targets, f"{CKPT_DIR}/satellite_bank_val.pt", hursat, gridsat)
504
- for _name in ("satellite_bank_train", "satellite_bank_val"):
505
- mirror.push(f"{CKPT_DIR}/{_name}.pt", f"checkpoints/{_name}.pt")
506
- shutil.rmtree(os.path.join(DATA_DIR, "hursat"), ignore_errors=True) # the patches are kept; the archives are not
507
- plain_val = dict(bank_val)
508
- bank_train["satellite"], bank_val["satellite"] = sat_train, sat_val
509
- coverage = float(sat_val["available"][:, 0].float().mean())
510
- # The model as it arrives -- stage two, no satellite -- scored first: that is what stage three must beat.
511
- before = None if loaded is not None else storm_scorecard(model, plain_val, CFG.lead_times_hours)
512
- if loaded is not None:
513
- print("\nstage three is already trained: its checkpoint is loaded (RETRAIN_SATELLITE = True trains it again)")
514
- elif coverage < 0.3:
515
- print(f"\nonly {coverage:.0%} of held-out samples have an image: too few to judge the satellite branch, "
516
- "so it is not trained. Calibration and the input monitor still are.")
517
- else:
518
- history = train_storm_heads(model, bank_train, bank_val, epochs=80, batch_size=128, learning_rate=2e-4,
519
- satellite_learning_rate=1e-3, augment=True, keep_if_no_better=True,
520
- patience=15, log_every=5)
521
- banner("CALIBRATION, INPUT MONITOR, ROLLOUT STATISTICS")
522
- print("probabilities on held-out seasons, each season calibrated without itself:\n"
523
- + calibration_report(model, bank_val))
524
- fit_probability_calibration(model, bank_val)
525
- print(f"\ninput monitor fitted on the training storms: limit {fit_feature_monitor(model, bank_train):.2f}")
526
- model.state_tendency.copy_(stepper.tendency.to(model.state_tendency.device))
527
- _stage3 = CheckpointManager(f"{CKPT_DIR}/stage3", repo_id=HF_REPO)
528
- _stage3.save(model, None, state=TrainingState(step=int(model.trained_heads()["storm_satellite"])),
529
- config=model.config.to_dict(), force=True)
530
- _stage3.push_now(TrainingState(step=int(model.trained_heads()["storm_satellite"])))
531
- banner("SCOREBOARD — what the satellite adds, on held-out storms")
532
- if before is not None:
533
- print(f"stage two, before the satellite (the score to beat):\n{before}")
534
- else:
535
- print(f"stage three with the images withheld:\n{storm_scorecard(model, plain_val, CFG.lead_times_hours)}")
536
- print(f"\nstage three, with the satellite ({coverage:.0%} of held-out samples have an image):\n"
537
- f"{storm_scorecard(model, bank_val, CFG.lead_times_hours)}")
538
-
539
- # ═══ 9 ═══ did it learn? scorecards against persistence and climatology ════════════════════════════
540
- if not (RUN_PRETRAIN or RUN_ROLLOUT or RUN_STORMS or RUN_SATELLITE):
541
- print(f"loaded: {restore('stage3', 'stage2', 'rollout', 'stage1') or 'nothing -- untrained'}")
542
- print(f"\ntraining record (steps per output; 0 = never trained, never shown as a forecast):\n"
543
- f" {model.trained_heads()}")
544
-
545
- _probe_stream = iter(val_loader)
546
-
547
-
548
- def _probe():
549
- batch = next(_probe_stream)
550
- return {"analysis": batch["analysis"].to(DEVICE), "calendar": batch["calendar"].to(DEVICE)}
551
-
552
-
553
- responds, spread = responds_to_input(model, _probe, SRC)
554
- print(f"\nresponds to the weather: {responds} (spread {spread:.2e})")
555
-
556
- card = state_card = None
557
- if RUN_SCORE:
558
- banner("SCORECARDS — area-weighted, physical units, identical batches for every baseline")
559
- climo = build_climatology(train_ds, np.arange(len(train_ds)), WIDTH, SRC.num_points,
560
- samples=200)
561
- batches = 32
562
- card = score_model(model, val_loader, SRC, LAT, len(LON), CFG.lead_times_hours,
563
- fields=scoring_fields(train_ds.variables, _target_index(train_ds.variables)),
564
- normalizer=train_ds.normalizer, climatology=climo, max_batches=batches, device=DEVICE)
565
- print("surface heads, direct multi-lead:\n" + card.table() + "\n" + card.verdict())
566
- state_card = score_state(model, val_loader, stepper, HEADLINE_STATE_FIELDS,
567
- steps=ROLLOUT_STEPS, climatology=climo,
568
- max_batches=batches, precision=R["precision"])
569
- print("\nfull-state rollout (Z500 in m2/s2, as WeatherBench 2 reports it):\n"
570
- + state_card.table() + "\n" + state_card.verdict())
571
- if MEMBERS > 1 and "geopotential@500" in train_ds.variables:
572
- batch = {k: (v.to(DEVICE) if torch.is_tensor(v) else v) for k, v in next(iter(val_loader)).items()}
573
- lead = min(4, batch["state_target"].shape[1])
574
- ensemble = state_forecast(model, batch, stepper, steps=lead, members=max(MEMBERS, 4),
575
- precision=R["precision"])
576
- z500 = train_ds.variables.index("geopotential@500")
577
- ratio = spread_skill_ratio(ensemble[:, -1, ..., z500], batch["state_target"][:, lead - 1, :, z500])
578
- print(f"\nZ500 +{6 * lead}h ensemble spread/skill {ratio:.2f} (1.0 calibrated, <1 overconfident)")
579
- if ratio < 0.05:
580
- print(" no spread yet: the noise path starts at exactly zero and CRPS training grows it.")
581
-
582
- # ═══ 10 ═══ hindcast — a held-out hurricane, from its real analysis ════════════════════════════════
583
- if RUN_HINDCAST:
584
- banner(f"HINDCAST — {HINDCAST[0].title()} {HINDCAST[1]}, a season the model never trained on")
585
- storm = next((t for t in tracks if t.name.strip().upper() == HINDCAST[0] and t.year == HINDCAST[1]), None)
586
- starts, targets, _ = (pair_tracks_with_reanalysis([storm], STORE_TIMES, OFFSETS, history=6)
587
- if storm is not None else ([], [], None))
588
- if not len(starts):
589
- print("that storm is not in the reanalysis window (1959-2021); pick another in HINDCAST")
590
- else:
591
- peak = int(np.nanargmax(storm.max_wind_kt))
592
- choice = min(range(len(targets)), key=lambda i: abs(targets[i].point - max(peak - 8, 0))) # ~48 h before peak
593
- target = targets[choice].build()
594
- window = ERA5Window(ERA5, indices=starts[choice:choice + 1], augment=False, stats_cache=STATS, **common)[0]
595
- issued = dt.datetime.fromtimestamp(float(window["valid_time"]), tz=dt.timezone.utc)
596
- centre = target["storm_center"].tolist()
597
- here = target["storm_center"][None].to(DEVICE)
598
- # The storm's environment and the physics-only steering track, from the analysis "now".
599
- now = window["analysis"][-1][None].to(DEVICE)
600
- environment = storm_environment(now, train_ds.variables, train_ds.normalizer, SRC, here)
601
- steer = steering_track(now, train_ds.variables, train_ds.normalizer, SRC, here, CFG.lead_times_hours)
602
- print(describe_environment(environment[0]))
603
- # The satellite view at issue time and six hours earlier, if stage three has trained that branch.
604
- seen = None
605
- if model.trained_heads()["storm_satellite"] > 0:
606
- from naturev1.stormsat import _earlier_position
607
- earlier = _earlier_position(targets[choice])
608
- when = np.datetime64(issued.replace(tzinfo=None), "m")
609
- wanted = [(when, tuple(centre))] + ([earlier] if earlier is not None else [])
610
- images = GridSatArchive().patches(wanted, progress=False)
611
- if images[0] is not None:
612
- codes = np.zeros((1, 2, *images[0].shape), np.uint8)
613
- codes[0, 0] = encode_bt(images[0])
614
- if len(images) > 1 and images[1] is not None:
615
- codes[0, 1] = encode_bt(images[1])
616
- seen = (torch.from_numpy(codes).to(DEVICE),
617
- torch.tensor([[True, len(images) > 1 and images[1] is not None]], device=DEVICE))
618
- print("satellite (GridSat-B1): " + describe_patch(images[0]))
619
- with torch.no_grad(), torch.autocast(DEVICE, dtype=torch.bfloat16, enabled=DEVICE == "cuda"):
620
- out = model.eval()(analysis=window["analysis"][None].to(DEVICE), analysis_grid=SRC,
621
- calendar=window["calendar"][None].to(DEVICE), output_grid=SRC,
622
- storm_center=here, storm_state=target["storm_state"][None].to(DEVICE),
623
- storm_environment=environment,
624
- track_prior=steer if model.uses_track_prior() else None,
625
- storm_satellite=seen)
626
- out = {k: v.float().cpu() for k, v in out.items()}
627
- out.setdefault("track_prior", steer.float().cpu())
628
- # A second physics member: the storm carried by the atmosphere the model *forecasts*, step by
629
- # step, rather than by today's flow held steady. Only once the state head has been trained.
630
- rolled = None
631
- if model.trained_heads()["state"] > 0:
632
- with torch.no_grad():
633
- future = state_forecast(model, {"analysis": window["analysis"][None].to(DEVICE),
634
- "calendar": window["calendar"][None].to(DEVICE),
635
- "valid_time": window["valid_time"].view(1)},
636
- stepper, steps=ROLLOUT_STEPS, precision=R["precision"])[0]
637
- rolled = steering_track(torch.cat([now[None], future.to(DEVICE)]), train_ds.variables,
638
- train_ds.normalizer, SRC, here, CFG.lead_times_hours).float().cpu()
639
- fc = build_forecast(out, CFG.lead_times_hours, issued, storm_center=centre,
640
- normalizer=train_ds.normalizer, trained=model.trained_heads(), inputs=("analysis",),
641
- consensus={"weights": model.track_consensus.cpu(), "storm_state": target["storm_state"]})
642
- print(f"issued {issued:%Y-%m-%d %H:%M}Z at {centre[0]:.1f}N {-centre[1]:.1f}W, "
643
- f"{float(target['current_wind_kt']):.0f} kt now")
644
-
645
- def km_off(d_lat, d_lon, lead):
646
- lat = centre[0] + float(target["track_target"][lead, 0])
647
- lon = centre[1] + float(target["track_target"][lead, 1])
648
- return 111.2 * np.hypot(centre[0] + float(d_lat) - lat,
649
- (centre[1] + float(d_lon) - lon) * np.cos(np.radians(lat)))
650
-
651
- members = {"steering": steer[0].cpu(), "extrap.": extrapolation_track(target["storm_state"][None],
652
- CFG.lead_times_hours)[0]}
653
- if rolled is not None:
654
- members["rollout-steer"] = rolled[0]
655
- if isinstance(fc["track_scenarios"], list):
656
- top = fc["track_scenarios"][0]
657
- members = {f"model ({top['probability']:.0%})": torch.tensor(
658
- [[p["latitude"] - centre[0], p["longitude"] - centre[1]] for p in top["track"]]), **members}
659
- if "track_consensus" in fc:
660
- members["consensus"] = torch.tensor([[p["latitude"] - centre[0], p["longitude"] - centre[1]]
661
- for p in fc["track_consensus"]["track"]])
662
- print("track error vs what happened, km, by member -- the physics members need no training:")
663
- print(" " + f"{'lead':>6}" + "".join(f"{name:>16}" for name in members) + f"{'wind fcst/actual':>20}")
664
- for lead, hours in enumerate(CFG.lead_times_hours):
665
- if target["track_valid"][lead] > 0:
666
- row = "".join(f"{km_off(*member[lead], lead):>13.0f} km" for member in members.values())
667
- wind = ""
668
- if isinstance(fc["intensity"], list):
669
- wind = f"{fc['intensity'][lead]['max_wind_ms'] * 1.94384:5.0f}"
670
- if target["intensity_mask"][lead, 0] > 0:
671
- wind += f" / {float(target['intensity_target'][lead, 0]) * 1.94384:3.0f} kt"
672
- print(f" +{hours:4d}h {row}{wind:>20}")
673
- if not isinstance(fc["track_scenarios"], list):
674
- print("track head untrained -- run RUN_STORMS first; the physics members above still stand")
675
- problems = check_forecast(fc)
676
- print("forecast checks: " + ("pass" if not problems else "; ".join(problems)))
677
-
678
- # ═══ 11 ═══ publish only if it earned it ═══════════════════════════════════════════════════════════
679
- if RUN_PUBLISH:
680
- cards = [c for c in (state_card, card) if c is not None]
681
- beats = [s for c in cards for s in c.scores if s.beats_persistence]
682
- if not cards:
683
- print("\nnot publishing: nothing was scored (set RUN_SCORE = True).")
684
- elif not responds:
685
- print("\nnot publishing: the model gives the same answer whatever weather it is shown.")
686
- elif not beats:
687
- print("\nnot publishing: it loses to persistence at every field and lead.")
688
- else:
689
- url = push_to_hub(model, HF_REPO, normalizer=train_ds.normalizer, scorecard=cards[0], name="NatureV1",
690
- author="Nathan", token=HF_TOKEN or None,
691
- training_data=f"ERA5 (WeatherBench 2), {len(CHANNELS)} channels, {R['years']} years; "
692
- f"full-state {ROLLOUT_STEPS}-step rollout, {MEMBERS}-member CRPS; "
693
- f"HURDAT2 {STORM_FIRST_SEASON}+ for the storm heads.")
694
- print(f"\npublished: {url} (beats persistence on {len(beats)} field-lead pairs)")
695
-
696
- # ═══ 12 ═══ live forecast from GOES — only once that input path is trained ═════════════════════════
697
- def load_scene(paths, center, half_width_deg=9.0, stride=4):
698
- """Geolocated samples plus true pixel footprints, cropped to a real box on the planet."""
699
- scene = scene_from_netcdf({c: str(p) for c, p in paths.items()}, stride=stride,
700
- bounds=(center[0] - half_width_deg, center[0] + half_width_deg,
701
- center[1] - half_width_deg, center[1] + half_width_deg))
702
- coords = torch.tensor(np.stack([np.radians(scene.latitude), np.radians(scene.longitude) % (2 * np.pi)], -1),
703
- dtype=torch.float64)
704
- grid = FieldGrid.from_points(coords, Geometry.globe(), num_neighbours=16, cluster_size=64,
705
- weights=torch.tensor(scene.area_km2, dtype=torch.float64))
706
- values = torch.tensor(normalize_channels(scene.values, scene.channels), dtype=torch.float32)
707
- if CFG.satellite_channels > values.shape[-1]:
708
- values = torch.cat([values, torch.zeros(values.shape[0], CFG.satellite_channels - values.shape[-1])], -1)
709
- return grid, values, scene
710
-
711
-
712
- @torch.no_grad()
713
- def forecast_now(storm=STORM, city=CITY):
714
- paths = fetch_latest(os.path.join(DATA_DIR, "goes"), ("C13", "C09"), satellite="east", product="conus")
715
- grid, values, scene = load_scene(paths, storm)
716
- print(scene)
717
- frames = CFG.history_frames
718
- out_grid = fibonacci_sphere(2000, num_neighbours=16, cluster_size=50)
719
- out = model.eval()(satellite=values[None, None].expand(1, frames, -1, -1).contiguous().to(DEVICE),
720
- satellite_grid=grid, output_grid=out_grid, neighbours=12,
721
- calendar=calendar_features(torch.full((1, frames), scene.timestamp.timestamp())).to(DEVICE),
722
- storm_center=torch.tensor([storm], dtype=torch.float32, device=DEVICE))
723
- return build_forecast({k: v.float().cpu() for k, v in out.items()}, CFG.lead_times_hours, scene.timestamp,
724
- storm_center=storm, output_grid=out_grid, point_of_interest=city,
725
- normalizer=train_ds.normalizer, trained=model.trained_heads(), inputs=("satellite",))
726
-
727
-
728
- def show(fc):
729
- print(f"\nissued {fc['issued']} trustworthy: {fc['trustworthy']}")
730
- for key in ("warning", "note"):
731
- if key in fc:
732
- print(f" !! {fc[key]}")
733
- for section in ("enso", "landfall", "track_scenarios", "eyewall", "rapid_intensification"):
734
- value = fc.get(section)
735
- if isinstance(value, dict) and value.get("available") is False:
736
- print(f" {section}: not available -- {value['reason']}")
737
- if isinstance(fc.get("track_scenarios"), list):
738
- for s in fc["track_scenarios"][:4]:
739
- p = s["track"][5]
740
- print(f" {s['probability']:>5.0%} -> +{p['lead_hours']}h {p['latitude']:6.2f},{p['longitude']:7.2f}"
741
- f" 95% cone {p['cone_radius_km_95']:.0f} km")
742
- print(" checks: " + ("pass" if not check_forecast(fc) else "; ".join(check_forecast(fc))))
743
-
744
-
745
- if RUN_FORECAST or RUN_WATCHER:
746
- if model.trained_heads()["satellite_encoder"] == 0:
747
- print("\nlive GOES forecast refused: no training stage has ever fed this model satellite imagery, so "
748
- "its satellite encoder is at initialisation and every number it produced would be noise shaped "
749
- "like a forecast. Use the HINDCAST section, which runs from the analysis the model was trained on.")
750
- elif RUN_FORECAST:
751
- banner("LIVE FORECAST — newest GOES scene")
752
- show(forecast_now())
753
-
754
-
755
- def on_new_scene(paths):
756
- result = forecast_now()
757
- os.makedirs(os.path.join(DATA_DIR, "forecasts"), exist_ok=True)
758
- stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M")
759
- with open(os.path.join(DATA_DIR, "forecasts", f"forecast_{stamp}.json"), "w") as handle:
760
- json.dump(result, handle, indent=2)
761
- show(result)
762
-
763
-
764
- if RUN_WATCHER and model.trained_heads()["satellite_encoder"] > 0:
765
- banner("WATCHING — a new forecast every hour")
766
- watch(on_new_scene, interval_seconds=3600, directory=os.path.join(DATA_DIR, "goes"), channels=("C13", "C09"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/ARCHITECTURE.md DELETED
@@ -1,282 +0,0 @@
1
- # How iHELIX and NatureV1 work
2
-
3
- This is the detailed version. The short version: **most models reach a grid by flattening it and hoping a
4
- positional encoding puts the geometry back.** iHELIX does the opposite — you declare what your axes are,
5
- and everything downstream works in a space where the geometry is already correct.
6
-
7
- ---
8
-
9
- ## 1. The problem with flattening
10
-
11
- Take a 240×121 latitude/longitude grid and flatten it row-major. Three things break:
12
-
13
- | | what the model sees | what is true |
14
- |---|---|---|
15
- | **Vertical neighbours** | 240 positions apart | adjacent |
16
- | **The date line** | 359.9° and 0.1° are 239 positions apart | adjacent |
17
- | **The poles** | ordinary interior points | all 240 entries are one place |
18
-
19
- A positional encoding gives the model an index. Nothing in an index says that cell 12,240 is directly
20
- above cell 12,000, or that the last column touches the first. So the model has to *learn spherical
21
- topology from data* — spending capacity rediscovering something you already knew.
22
-
23
- And it never quite succeeds, because the errors are worst exactly where the data is sparsest: near the
24
- poles, where meridians converge and grid cells shrink to nothing.
25
-
26
- ---
27
-
28
- ## 2. Geometry: what replaces "position in a sequence"
29
-
30
- You declare axes. Each one says what kind of thing it is:
31
-
32
- ```python
33
- Geometry([Axis("linear"), Axis("linear")]) # an image
34
- Geometry([Axis("linear")] * 3) # a volume or point cloud
35
- Geometry([Axis("periodic", period=L)] * 2 + [Axis("linear")]) # a channel-flow box
36
- Geometry.globe() # the Earth's surface
37
- Geometry.atmosphere() # Earth + pressure
38
- ```
39
-
40
- Each axis maps into a Euclidean space where **straight-line distance already is the distance you care
41
- about**:
42
-
43
- - **spherical** — `(latitude, longitude)` → a point on a sphere in 3-D. Chordal distance there is a
44
- monotone function of great-circle distance. The poles stop being special. The date line **stops
45
- existing**: 359.9° and 0.1° are near each other because they *are* near each other.
46
- - **periodic** — → a circle. Same trick, for a wind-tunnel box.
47
- - **log** — pressure. The model thinks in *factors* of pressure, not hectopascals, so equal ratios are
48
- equal distances.
49
- - **linear** — passes through, scaled.
50
-
51
- Every axis carries a `scale` in real units (km, say). That's how you declare that a kilometre upward is
52
- not meteorologically the same as a kilometre sideways.
53
-
54
- ### Local frames — the part that actually kills polar distortion
55
-
56
- At every point, the geometry provides an orthonormal frame: **east, north, up**. Neighbour offsets are
57
- expressed in *that point's own frame*.
58
-
59
- So "300 km east" produces the **same input vector** at the equator and at 80°N. The distortion isn't
60
- corrected by a factor — it is never introduced, because the representation never had it.
61
-
62
- > Measured: frames orthonormal to 1.11e-16 at the poles. Date-line distance 22 km, not 12,742 km.
63
-
64
- ---
65
-
66
- ## 3. The grid: data, not architecture
67
-
68
- A `FieldGrid` holds two structures, computed once from the sample positions:
69
-
70
- **Neighbour graph** — for each point, its nearest neighbours *in metric space*. A point beside the date
71
- line gets neighbours on both sides of it. A polar point gets the ones physically close, not the ones the
72
- array happens to list adjacently.
73
-
74
- **Hierarchy** — a tree of nested spatial clusters, built by recursive splitting. The split is along the
75
- **principal axis** (`torch.linalg.eigh` of the covariance), not the widest coordinate axis — coordinate
76
- axes are an arbitrary choice of frame, so splitting on them would make the tree depend on how the domain
77
- happens to be oriented.
78
-
79
- The model takes the grid **as an argument**. Swap in a finer grid and the same weights run unchanged.
80
- That is the entire answer to resolution: the model never sees a grid shape, a resolution, or a
81
- dimensionality. It sees points, distances and weights.
82
-
83
- ---
84
-
85
- ## 4. The three strands
86
-
87
- Every block braids three mixers. This is the core of the architecture.
88
-
89
- ### Strand I — Geodesic attention (short range)
90
-
91
- Each sample attends to its nearest neighbours in metric space. Three things are added to the logits:
92
-
93
- 1. **A learned geometric bias** from the displacement in the query's own frame, plus log-distance, plus
94
- how much the neighbour's frame is rotated relative to the query's (which on a curved manifold is what
95
- you need to parallel-transport a direction).
96
- 2. **A per-head physical window.** Each head has its own radius — a *length*, not a count. At 88M the
97
- eight heads sit on a geometric ladder: **120, 174, 252, 364, 527, 763, 1105, 1600 km**. One layer sees
98
- eight scales at once, and those scales mean the same thing when the sampling changes.
99
- 3. **Quadrature weights** — how much of the domain each sample stands for.
100
-
101
- That third term is what makes attention a discretization of a continuous operator:
102
-
103
- ```
104
- out(x) = ∫ a(x,y) v(y) dμ(y) / ∫ a(x,y) dμ(y)
105
- ```
106
-
107
- Refine the grid and the estimate **converges** instead of drifting.
108
-
109
- > Measured convergence: 0.455% → 0.174% → 0.090% → 0.034% as the grid refines.
110
-
111
- **The window has compact support** — exactly zero past 3σ, not merely small. This is not cosmetic. It's
112
- what makes the invariances hold *to the last bit* rather than to a few decimals. An early version
113
- truncated at K neighbours instead, and the permutation error was 3.0e-2; with compact support it is
114
- **exactly 0**.
115
-
116
- ### Strand II — Index attention (long range)
117
-
118
- Local attention reaches 1,600 km. A teleconnection reaches across an ocean.
119
-
120
- The domain is summarized bottom-up into a tree of nested regions. Each query region computes a routing
121
- vector, descends the tree keeping a beam, and ends up attending over the samples of the `topk` regions it
122
- selected — which may be **anywhere, at any distance**.
123
-
124
- This is the part a fixed mesh cannot do. Message passing on a multi-mesh (GraphCast) couples regions by
125
- distance along fixed edges, so reaching the far side costs hops and the route is decided in advance.
126
- Here the route is **content-addressed and learned**: a ridge over the Atlantic can read the Pacific in one
127
- step, and nothing in the wiring had to anticipate it.
128
-
129
- **Making a discrete top-k differentiable.** Selection is argmax — no gradient. So the routing score is
130
- accumulated down the descent path and fed back as an additive bias on the retrieved logits, via
131
- `logsigmoid(path_score)`. Gradient reaches every level of the tree. (An early version didn't accumulate,
132
- and the node pooler received no gradient at all.)
133
-
134
- 8 of the 17 layers carry the index strand, interleaved every other layer.
135
-
136
- ### Strand III — Temporal (the gated delta rule)
137
-
138
- **Space has no order, so nothing recurrent runs across it.** Time does, and this is the only place a
139
- recurrence runs.
140
-
141
- State is a **matrix per head per sample** whose size does not depend on how many frames have passed. A
142
- rollout costs the same at frame 1000 as at frame 1 — which is what makes long autoregressive integrations
143
- affordable. Training uses a chunkwise-parallel UT transform, so it parallelizes rather than stepping.
144
-
145
- A per-sample, per-head gate mixes strand I and strand II. Then a feed-forward, with RMSNorm and residuals
146
- throughout.
147
-
148
- ---
149
-
150
- ## 5. Encode → process → decode
151
-
152
- ```
153
- satellite (2 km) ─┐
154
- analysis (25 km) ─┼─► cross-attention ─► Fibonacci mesh (4,096 pts) ─► 17 blocks ─► cross-attention ─► any output grid
155
- environment ─┘
156
- ```
157
-
158
- Each source has **its own input projection and its own read radius** — a satellite scene at 2 km and an
159
- analysis field at 25 km are not the same kind of measurement and shouldn't pretend to be. What they share
160
- is the mesh they're read onto, which makes fusing them a **sum** rather than a resampling.
161
-
162
- Each encoder carries a learned `absent` vector, so a mesh point with no coverage (the far side of the
163
- planet from a geostationary satellite) produces a clean absence rather than attending to noise.
164
-
165
- The mesh is a **Fibonacci sphere** — near-equal-area, so there's no pole to distort and no seam to cross.
166
-
167
- ---
168
-
169
- ## 6. Why this beats a ViT/CNN on satellite imagery
170
-
171
- | | ViT / CNN | iHELIX |
172
- |---|---|---|
173
- | Pixel size | assumed constant | **measured**: a GOES limb pixel covers 64× the ground of a nadir pixel |
174
- | Location | `image[400, 1200]` says nothing | every sample carries lat/lon + true footprint area |
175
- | Values | normalized to 0–1, physics erased | brightness temperature stays in kelvin |
176
- | Two sources | resample one onto the other, lose information | read both onto one mesh, add |
177
-
178
- The projection is solved per-pixel from GOES-R PUG Vol 3 §5.1.2.8.1 — validated against the file's own
179
- metadata at 2.07 km at nadir where nominal C13 resolution is 2 km.
180
-
181
- ---
182
-
183
- ## 7. Parameter budget (88M)
184
-
185
- ```
186
- blocks 84.67M 95.1% ← the 17 braided blocks
187
- analysis_encoder 0.80M 0.9%
188
- environment_encoder 0.79M 0.9%
189
- satellite_encoder 0.79M 0.9%
190
- decoder 0.79M 0.9%
191
- ri_head 0.40M 0.4%
192
- summary_pool 0.26M 0.3%
193
- track_head 0.14M 0.2%
194
- eyewall_head 0.13M 0.1%
195
- field_head 0.07M 0.1%
196
- ...
197
- ```
198
-
199
- 95% of the model is the shared processor. The heads are almost free — which is exactly what makes freezing
200
- the backbone and fine-tuning on 24,585 storm points viable: **0.96M trainable, 1.1%**.
201
-
202
- ---
203
-
204
- ## 8. Every output is a distribution
205
-
206
- - **Track** — weighted scenarios, each a full trajectory with a *tilting* uncertainty ellipse (two sigmas
207
- and a correlation, so the ellipse can align with the direction of travel). Reads as "62% recurves
208
- offshore, 26% tracks the coast, 12% inland" rather than an average track through somewhere the storm was
209
- never going. *Measured: collapsing to one line scores 116 under the mixture likelihood where keeping both
210
- branches scores 8.2.*
211
- - **Fields** — per-point mean and variance, per lead time.
212
- - **Eyewall** — peak wind, RMW, and 34/50/64 kt radii per quadrant, each with intervals.
213
- - **RI** — probability per threshold, plus expected 24 h change and likeliest onset.
214
- - **Landfall** — probability per lead time.
215
-
216
- Heads start at **Atlantic climatology**, not zero: 65 ± 30 kt peak wind, 985 hPa, RI at the measured 4.11%
217
- base rate, track spread growing 0.6°→12° with lead. Without that, the intensity term alone was 119,191 of
218
- a 60,565 total loss.
219
-
220
- ---
221
-
222
- ## 9. The guarantees, and their one precondition
223
-
224
- | Property | Status |
225
- |---|---|
226
- | Sample order doesn't matter | **exact** (atol=0) |
227
- | Spinning the globe in longitude | **exact** for the local strand (atol=1e-6) |
228
- | Refining the grid converges | 0.455% → 0.034% |
229
- | Time is causal, space is not | enforced |
230
- | Temporal state doesn't grow | constant |
231
-
232
- **The one precondition:** each head's neighbour list must reach 3× its widest radius. `coverage()` reports
233
- whether it does, and the model warns at construction if not. At coverage 1.0 the samples beyond a head's
234
- window contribute *exactly* nothing and the invariances are exact.
235
-
236
- **One honest limit:** the index strand is *not* rotation-equivariant, because carving a domain into
237
- regions is a discretization and no finite partition of a sphere commutes with arbitrary rotation. A fixed
238
- icosahedral mesh has the same property. Sub-regions aren't isotropic, so every split below the first is
239
- well determined.
240
-
241
- ---
242
-
243
- ## 10. What 0.8 added, and why each one helps accuracy
244
-
245
- **Full-state residual prediction.** A `state_head` predicts the 6-hour *change* of every prognostic
246
- channel — all 86 of them, including all 13 pressure levels — rather than seven surface fields. Before
247
- this, a 12-step rollout froze 84 of 89 channels: Z500 never moved. The head is zero-initialised, so an
248
- untrained model is *exactly* persistence and training can only move it from the strongest trivial
249
- baseline. Changes are scaled by each channel's own 6-hour tendency spread (0.067σ for Z500, 1.08σ for
250
- ω@500), so no channel dominates the gradient by being volatile.
251
-
252
- **Channel roles.** Every input channel is prognostic (predicted), forcing (recomputed) or static
253
- (carried). Top-of-atmosphere solar radiation is computed from orbital geometry at each step's valid time,
254
- matching ERA5 at correlation 1.00000; it rotates with an augmented globe.
255
-
256
- **Physical constraints.** Mass conservation removes any change in the area-weighted mean surface pressure
257
- each step. Pressure levels are weighted in proportion to pressure (GraphCast), and every loss is
258
- area-weighted — the same weighting the WeatherBench score uses.
259
-
260
- **Fair-CRPS ensembles.** Per-member noise enters before the processor; a fair CRPS over members trains
261
- sharp members with calibrated spread. A model trained on squared error learns the conditional mean, and
262
- the mean of all the ways a hurricane could go is a weak, smeared vortex — the documented intensity low
263
- bias of deterministic AI models.
264
-
265
- **Storm-centred heads.** Storm heads used to read a mean over the whole planet, so two storms at the same
266
- hour got identical answers. They now pool the mesh around the storm (Gaussian, 500 km), and are told
267
- where it is and what it is doing: current intensity and 12-hour motion — the predictors every operational
268
- intensity scheme starts from, and exactly what a 1.5° reanalysis cannot show.
269
-
270
- **A training record.** Every output counts its optimizer steps, saved with the weights. Forecasts show a
271
- section only if its head was trained, and refuse outright when the input path (e.g. GOES imagery) never
272
- was.
273
-
274
- ## 11. What is not claimed
275
-
276
- **No weights here have been trained.** The architecture, losses, ingest and training loop are complete and
277
- exercised end to end on real data, but no accuracy has been measured, and nothing here should inform a
278
- decision about a real storm. The National Hurricane Center is the authoritative source.
279
-
280
- The next honest step is baselines: score against **persistence** ("tomorrow = today") and **climatology**
281
- on the same held-out batches. A model that ties climatology has learned nothing; likelihoods alone can't
282
- tell you which.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
melissa_jamaica.png DELETED

Git LFS Details

  • SHA256: ad22d73b9e5d56165a151290b2417895718b6dc6f3996adb34071e2737a1c5d0
  • Pointer size: 131 Bytes
  • Size of remote file: 307 kB
melissa_landfall.png DELETED

Git LFS Details

  • SHA256: fad18f5048584fd39ae86a41cefd113e15b047d92db1dba1a4d45a7564a4da7a
  • Pointer size: 131 Bytes
  • Size of remote file: 384 kB
molab_swarm_cell.py DELETED
@@ -1,311 +0,0 @@
1
- # NatureV1 0.16 -- the operational cell: the satellite path, the swarm, the parishes, the cycle record, in ONE marimo cell
2
- # (molab, 1 GPU; runs on a CPU box too, smaller).
3
- #
4
- # Paste this whole file into one marimo cell and run it. What it does:
5
- # 1. installs naturev1 0.16 with the swarm extras (GRIB decoder, terrain tiles, maps, the Hub client)
6
- # 2. builds the map of the whole Caribbean -- every parish / province / district of every country and territory,
7
- # with terrain -- and the full-consensus inputs (climatology + analog database), once, cached under DATA
8
- # 3. for every active NHC storm (or a replay, e.g. Melissa 2025): the best track + every satellite / recon / radar
9
- # centre fix from NHC, the GFS analysis AND the forecasts of five models -- GFS, ECMWF IFS, ECMWF AIFS,
10
- # GraphCast-GFS, the GEFS mean -- read from their public buckets; the NatureV1 consensus blended with the models'
11
- # mean track and fed the previous cycle's error; a cone sized for this forecast and a swarm sized to it; the
12
- # intensity consensus with its rapid-intensification branch, the chance the storm peaks at each category and the
13
- # calibrated peak quantiles (plan for the 90% figure); the swarm's worlds filtered minute by minute on the fixes
14
- # and painted onto the parishes: peak wind if hit, gusts, rain if hit and the wettest cell, when tropical-storm
15
- # and hurricane force arrive, the landfall report for LANDFALL_COUNTRIES
16
- # 4. the satellite, quantitatively: the newest GOES band-13 scene shown, and read -- coldest tops, the cold share of
17
- # the 50-200 km annulus, ring symmetry, an eye, and their trend since the previous cycle (diagnostics beside
18
- # the forecast; not yet inside the probabilities)
19
- # 5. the operations desk's record: every cycle saved under DATA/runs/<storm>/ as JSON plus the parish table as CSV;
20
- # the previous cycle found again automatically; what changed since it; optionally keeps watching NHC and
21
- # re-issues when a new 6-hourly cycle appears (WATCH_MINUTES)
22
- # 6. optionally (TRAIN_SATELLITE) trains the satellite networks of the 0.12 satellite path on this GPU
23
- #
24
- # Research model, not an operational forecast: for decisions, follow your national meteorological service and the
25
- # National Hurricane Center.
26
- import datetime as dt
27
- import os
28
- import subprocess
29
- import sys
30
- import time
31
-
32
- subprocess.run([sys.executable, "-m", "pip", "install", "-q", "--upgrade", "naturev1[swarm,hub]>=0.16.0", "ihelix>=0.6.0"],
33
- check=True)
34
-
35
- from pathlib import Path
36
-
37
- import marimo as mo
38
- import numpy as np
39
- import torch
40
-
41
- # ── settings ──────────────────────────────────────────────────────────────────────────────────────────────────────
42
- STORM = None # None: every active NHC storm. Or an NHC id, e.g. "al132025" (Melissa 2025)
43
- REPLAY_TIME = None # with STORM: replay what was known then, e.g. "2025-10-27T00" (fixes after it unused)
44
- LANDFALL_COUNTRIES = ("Jamaica",) # one landfall report per country: when, where first, how strong, every parish's numbers
45
- GPU = torch.cuda.is_available()
46
- PARTICLES = 1_000_000 if GPU else 50_000 # worlds in the swarm
47
- IMPACT_WORLDS = 40_000 if GPU else 2_000 # worlds painted onto the map (the map is the costly part)
48
- FULL_CONSENSUS = True # 13-member consensus: builds a 2.5 GB climatology + analog database once (~20-30 min)
49
- SHOW_GOES = True # the newest GOES-19 / GOES-18 infrared image around each live storm, shown and read
50
- FORECAST_FLOW = True # the models' forecast steering + their tracks as members (~150 MB per model, cached)
51
- MODELS = ("gfs", "ifs", "aifs", "graphcastgfs", "gefs") # any that is not yet published for the cycle is skipped
52
- PREVIOUS_CYCLE = "auto" # the previous cycle's error feeds the new track ("auto": from the saved records; None: off)
53
- WATCH_MINUTES = 0 # 0: issue once and stop. 20: keep watching NHC, re-issue when a new 6-hourly cycle appears
54
- TRAIN_SATELLITE = False # the 0.12 satellite networks on this GPU (needs ~1 TB disk for the staged GridSat)
55
- HF_REPO, HF_TOKEN = "CorxLabs/NatureV1-500", "" # only for TRAIN_SATELLITE pushes; paste a token, clear it after
56
- DATA = Path(os.environ.get("NATUREV1_DATA", "/data/naturev1" if Path("/data").exists() else Path.home() / "naturev1"))
57
- DATA.mkdir(parents=True, exist_ok=True)
58
- RUNS = DATA / "runs" # the cycle records: <storm>/<time>.json + <time>_parishes.csv
59
- DEVICE = "cuda" if GPU else "cpu"
60
-
61
- import naturev1
62
- from naturev1.satellite import SatelliteScene
63
- from naturev1.swarm.bulletin import history, load_previous, write_bulletin
64
- from naturev1.swarm.fixes import active_storms, fetch_deck, parse_bdeck
65
- from naturev1.swarm.geography import CARIBBEAN, RegionMap
66
- from naturev1.swarm.infrared import ir_metrics
67
- from naturev1.swarm.landfall import format_landfall, landfall_summary
68
- from naturev1.swarm.live import consensus_inputs, forecast_storm, goes_scene
69
- from naturev1.swarm.plot import impact_figure, landfall_figure
70
-
71
- print(f"naturev1 {naturev1.__version__} on {DEVICE}" + (f" ({torch.cuda.get_device_name(0)})" if GPU else ""))
72
-
73
- # ── the ground: the whole Caribbean, parish by parish, with terrain (downloaded once) ─────────────────────────────
74
- region_file = DATA / "caribbean_region.npz"
75
- if not region_file.exists():
76
- RegionMap.build(CARIBBEAN, cache=DATA / "region").save(region_file)
77
- region = RegionMap.load(region_file)
78
- print(f"map: {len(region.names)} administrative units, {int(region.land.sum()):,} land cells")
79
-
80
- climatology = analogs = None
81
- if FULL_CONSENSUS:
82
- climatology, analogs = consensus_inputs(DATA)
83
-
84
-
85
- # ── helpers ───────────────────────────────────────────────────────────────────────────────────────────────────────
86
- def newest_cycle(storm_id):
87
- """The newest 6-hourly best-track time NHC has posted for a storm (what a new issue would be based on)."""
88
- best = parse_bdeck(fetch_deck(storm_id, "b", False))
89
- synoptic = [f.time for f in best if int(str(f.time)[11:13]) % 6 == 0 and str(f.time)[14:16] == "00"]
90
- return max(synoptic) if synoptic else None
91
-
92
-
93
- def goes_and_infrared(case, storm_id):
94
- """The newest GOES scene around the storm and its infrared diagnostics, with the trend since the scene kept last time."""
95
- scene = goes_scene(case.lat, case.lon, DATA / "goes")
96
- keep = DATA / "goes" / f"{storm_id}_previous.npz"
97
- previous, hours = None, None
98
- if keep.exists():
99
- try:
100
- z = np.load(keep)
101
- when = dt.datetime.fromisoformat(str(z["time"]))
102
- hours = (scene.timestamp - when).total_seconds() / 3600.0
103
- previous = SatelliteScene(latitude=z["lat"], longitude=z["lon"], values=z["bt"][:, None], area_km2=z["area"],
104
- channels=("C13",), timestamp=when, satellite=str(z["satellite"]))
105
- except Exception:
106
- previous, hours = None, None
107
- ir = ir_metrics(scene, case.lat, case.lon, previous=previous if hours and 0.5 <= hours <= 12.0 else None, previous_hours=hours)
108
- np.savez(keep, lat=scene.latitude, lon=scene.longitude, bt=scene.values[:, 0], area=scene.area_km2,
109
- time=str(scene.timestamp), satellite=str(scene.satellite))
110
- return scene, ir
111
-
112
-
113
- def since_previous(storm_id, run, summary):
114
- """One line on what moved since the previous saved cycle: landfall probability and time, intensity, the ceiling."""
115
- prev = load_previous(RUNS, storm_id, run["analysis_time"])
116
- if prev is None:
117
- return ""
118
- rec = prev["record"]
119
- bits = [f"since the {str(prev['time'])[5:16]}Z cycle:"]
120
- lf = rec.get("landfall") or {}
121
- if summary and lf:
122
- bits.append(f"landfall probability {lf.get('p_landfall', float('nan')):.0%} -> {summary['p_landfall']:.0%}")
123
- if lf.get("consensus", {}).get("time") and summary.get("consensus", {}).get("time"):
124
- bits.append(f"consensus landfall {lf['consensus']['time'][5:16]} -> {summary['consensus']['time'][5:16]}")
125
- fc = rec.get("forecast", {})
126
- if fc.get("intensity") and len(fc["intensity"]) > 2 and fc["intensity"][2] is not None:
127
- bits.append(f"24 h intensity {fc['intensity'][2]:.0f} -> {run['result']['intensity'][2]:.0f} kt")
128
- pq_old, pq_new = fc.get("peak_quantiles"), run["result"].get("peak_quantiles")
129
- if pq_old and pq_new:
130
- bits.append(f"90% peak ceiling {pq_old['peak_kt'][2]:.0f} -> {pq_new['peak_kt'][2]:.0f} kt")
131
- return " \n**" + " ".join(bits[:1]) + "** " + "; ".join(bits[1:])
132
-
133
-
134
- def issue(storm_id, name, when):
135
- """One cycle for one storm: the forecast, the panels, the record. Returns (panel, analysis time)."""
136
- run = forecast_storm(storm_id, when=when, archive=when is not None, particles=PARTICLES, device=DEVICE, region=region,
137
- climatology=climatology, analogs=analogs, impact_particles=IMPACT_WORLDS, cache=DATA, name=name,
138
- forecast_flow=FORECAST_FLOW, models=MODELS, previous=PREVIOUS_CYCLE, save_runs=True)
139
- case, result, impact = run["case"], run["result"], run["impact"]
140
- scene, ir = None, None
141
- if SHOW_GOES and when is None:
142
- try:
143
- scene, ir = goes_and_infrared(case, storm_id)
144
- except Exception as error:
145
- print(f"GOES: {error}")
146
- fig = impact_figure(run, scene=scene)
147
- landfall_panels, main_summary = [], None
148
- if impact is not None:
149
- for country in LANDFALL_COUNTRIES:
150
- summary = landfall_summary(region, result, case, impact, country, analysis_time=run["analysis_time"])
151
- main_summary = main_summary or summary
152
- if summary["p_landfall"] >= 0.02:
153
- landfall_panels += [landfall_figure(run, summary, country), mo.plain_text(format_landfall(summary, top=14))]
154
- else:
155
- landfall_panels.append(mo.md(f"**{country}:** the centre reaches it in {summary['p_landfall']:.0%} of worlds within 5 days."))
156
- record = write_bulletin(run, main_summary, RUNS, storm_id) # the full record (replaces the bare one)
157
- sw = result["swarm"]
158
- scen = ", ".join(f"{k} {v['probability']:.0%}" for k, v in sw["scenarios"].items())
159
- filt = sw["filter"]
160
- sources = sorted({h.get("source", "best track") for h in filt})
161
- ess_after = f"; re-weighted by {len(run['fixes_after'])} newer fixes (ESS {run['ess_after']:.2f})" if run["ess_after"] else ""
162
- track = " ".join(f"{h} h {la:.1f}N {abs(lo):.1f}{'W' if lo < 0 else 'E'}"
163
- for h, (la, lo) in zip(result["leads"], result["track"]) if np.isfinite(la))
164
- intensity = " ".join(f"{h} h {v:.0f} kt" for h, v in zip(result["leads"], result["intensity"]) if np.isfinite(v))
165
- p_ri = result.get("ri_probability")
166
- ri = f" \n**Rapid intensification (+30 kt in 24 h):** {p_ri:.0%} ({result.get('intensity_regime')} weights)" if p_ri is not None else ""
167
- names = {64: "hurricane", 96: "major", 113: "Cat 4", 137: "Cat 5"}
168
- pk = result.get("peak_probability")
169
- if pk:
170
- ri += " \n**Chance the storm peaks at or above (next 5 days):** " + "; ".join(f"{names.get(int(t), f'{t:.0f} kt')} {p:.0%}" for t, p in zip(pk["thresholds_kt"], pk["probability"]))
171
- pq = result.get("peak_quantiles")
172
- if pq:
173
- ri += " \n**Peak within 5 days, calibrated quantiles:** " + "; ".join(f"{int(round(l * 100))}% {v:.0f} kt" for l, v in zip(pq["levels"], pq["peak_kt"])) + " (plan for the 90% figure)"
174
- if result.get("peak_if_ri_kt") is not None and p_ri is not None:
175
- ri += f" \n**If it intensifies rapidly ({p_ri:.0%}):** expected peak {result['peak_if_ri_kt']:.0f} kt"
176
- cats = result.get("category_probability")
177
- if cats:
178
- ri += " \n**P(at least ...):** " + "; ".join(
179
- f"{names.get(int(t), f'{t:.0f} kt')} " + " ".join(f"{h}h {p:.0%}" for h, p in zip(result["leads"], cats["probability"][:, j]) if np.isfinite(p))
180
- for j, t in enumerate(cats["thresholds_kt"]))
181
- if result.get("intensity_note"):
182
- ri += f" \n**Note:** {result['intensity_note']}"
183
- envt = result.get("environment") or {}
184
- if "tchp_kj_cm2" in envt and np.isfinite(envt["tchp_kj_cm2"][:49]).any():
185
- ri += (f" \n**Ocean under the first 48 h:** 26 C water {np.nanmean(envt['d26_m'][:49]):.0f} m deep, heat potential "
186
- f"{np.nanmean(envt['tchp_kj_cm2'][:49]):.0f} kJ/cm2, sea surface {np.nanmean(envt['sst_k'][:49]) - 273.15:.1f} C "
187
- f"(effective {np.nanmean(envt['sst_eff_k'][:49]) - 273.15:.1f} C under the core)")
188
- if run.get("r34_km") is not None and np.isfinite(run["r34_km"]):
189
- ri += f" \n**Observed 34-kt radius:** {run['r34_km']:.0f} km (the wind field is sized to it)"
190
- deep = result.get("model_deepening_hpa")
191
- if deep is not None and np.isfinite(deep).any():
192
- ri += " \n**Models' deepening (hPa, central-pressure deficit):** " + " ".join(f"{h}h {d:+.0f}" for h, d in zip(result["leads"], deep) if np.isfinite(d))
193
- if ir:
194
- ri += f" \n**{ir['text']}**"
195
- guide = run.get("guidance") or {}
196
- if guide.get("available"):
197
- used_t = (result.get("aids_track") or {}).get("members", [])
198
- used_i = (result.get("aids_intensity") or {}).get("members", [])
199
- subs = {**(result.get("aids_track") or {}).get("substituted", {}), **(result.get("aids_intensity") or {}).get("substituted", {})}
200
- ri += (f" \n**NHC guidance ({len(guide['available'])} aids in the a-deck):** track blended with {', '.join(used_t) or 'none'}; "
201
- f"intensity with {', '.join(used_i) or 'none'}" + (f"; stand-ins {', '.join(f'{v} for {k}' for k, v in subs.items())}" if subs else ""))
202
- if result.get("intensity_ours") is not None:
203
- ri += " \n**Our own consensus before the aids:** " + " ".join(f"{h} h {v:.0f} kt" for h, v in zip(result["leads"], result["intensity_ours"]) if np.isfinite(v))
204
- at = result.get("aids_track") or {}
205
- if at.get("disagreement_km") is not None:
206
- dis = np.asarray(at["disagreement_km"], float); cone = np.asarray(result.get("cone_km", np.full(len(dis), np.nan)), float)
207
- far = [f"{h} h {d:.0f} km (cone {c:.0f})" for h, d, c in zip(result["leads"], dis, cone) if np.isfinite(d) and np.isfinite(c) and d > c]
208
- ri += (" \n**Our track and HCCA disagree beyond the cone at " + "; ".join(far) + " -- both are in the record; the blend sits between them**") if far else \
209
- " \n**Our track and HCCA agree within the cone at every lead**"
210
- if at.get("adaptive_w_ours") is not None:
211
- pe = at.get("previous_errors_km", {})
212
- ri += (f" \n**Weights earned from the previous cycle:** ours {pe.get('ours', float('nan')):.0f} km off, HCCA {pe.get('HCCA', float('nan')):.0f} km off at the time now analysed; "
213
- f"our share {np.asarray(at['adaptive_w_ours'])[2]:.0%} at 24 h")
214
- of = guide.get("ofcl") or {}
215
- if of.get("intensity") is not None and of.get("track") is not None:
216
- ri += " \n**NHC official forecast:** " + " ".join(
217
- f"{h} h {la:.1f}N {abs(lo):.1f}{'W' if lo < 0 else 'E'} {v:.0f} kt"
218
- for h, (la, lo), v in zip(result["leads"], of["track"], of["intensity"]) if np.isfinite(la) and np.isfinite(v))
219
- if guide.get("nhc_ri") and guide["nhc_ri"].get("p30_24") is not None:
220
- schemes = "; ".join(f"{k} {v:.0%}" for k, v in (guide["nhc_ri"].get("schemes") or {}).items() if v is not None)
221
- ri += f" \n**NHC rapid-intensification guidance:** {guide['nhc_ri']['p30_24']:.0%} ({guide['nhc_ri']['source']}; {schemes})"
222
- if guide.get("ri_probability_combined") is not None:
223
- ri += f"; equal-weight with ours: **{guide['ri_probability_combined']:.0%}**"
224
- used = run.get("models", {})
225
- mm = run.get("model_track")
226
- model_text = ((f" \n**Models read:** {', '.join(m['title'] for m in used.values())}; their mean track: "
227
- + " ".join(f"{h} h {la:.1f}N {abs(lo):.1f}{'W' if lo < 0 else 'E'}" for h, (la, lo) in zip(result["leads"], mm) if np.isfinite(la)))
228
- if mm is not None else " \n**Models:** none read for this cycle (analysis-only forecast)")
229
- spread = result.get("model_spread_km")
230
- if spread is not None and np.isfinite(spread).any():
231
- model_text += " \n**Models' disagreement (km):** " + " ".join(f"{h} h {d:.0f}" for h, d in zip(result["leads"], spread) if np.isfinite(d))
232
- if run.get("previous_used") is not None:
233
- model_text += f" \n**Previous cycle:** {str(run['previous_used'])[:16]}Z, its error fed back into this track"
234
- cone = result.get("cone_km")
235
- cone_text = (" \n**Cone for this forecast (km, 67%):** " + " ".join(f"{h} h {r:.0f}" for h, r in zip(result["leads"], cone))
236
- + (" (climatological cone " + "/".join(f"{r:.0f}" for r in result["cone_fixed_km"]) + ")" if "cone_fixed_km" in result else "")) if cone is not None else ""
237
- drift = since_previous(storm_id, run, main_summary)
238
- record_text = f" \n**Record:** {record['json']}" + (f", parishes {record['csv'].name}" if record.get("csv") else "") + f"; {len(history(RUNS, storm_id))} cycle(s) on file"
239
- panel = mo.vstack([
240
- mo.md(f"## {name} ({storm_id}) -- analysis {str(run['analysis_time'])[:16]}Z, {case.wind_kt:.0f} kt at {case.lat:.1f}N {abs(case.lon):.1f}W\n"
241
- f"**Satellite path:** {len(filt)} fixes filtered the swarm ({', '.join(sources[:6])}){ess_after}. \n"
242
- f"**Swarm:** {PARTICLES:,} worlds sized to the cone; scenarios: {scen}. \n"
243
- f"**Track** ({result['consensus_table']}): {track}{model_text}{cone_text} \n**Intensity:** {intensity}{ri}{drift}{record_text}"),
244
- fig,
245
- *landfall_panels,
246
- mo.plain_text(impact.table(top=30)) if impact is not None else mo.md("no impact map for this storm"),
247
- mo.md(f"<sub>{region.attribution} Research model, not an operational forecast. Storm surge is not modelled.</sub>"),
248
- ])
249
- return panel, run["analysis_time"]
250
-
251
-
252
- # ── the storms: once, or watched ─────────────────────────────────────────────────────────────────────────────────
253
- panels, issued = [], {}
254
- while True:
255
- if STORM is None:
256
- storms = [(s["id"], s["name"]) for s in active_storms() if s["id"][:2] in ("al", "ep", "cp")]
257
- else:
258
- storms = [(STORM, STORM.upper())]
259
- if not storms and not panels:
260
- panels.append(mo.md("**No active storms in the Atlantic or eastern / central Pacific right now.** "
261
- "Set `STORM = \"al132025\"` and `REPLAY_TIME = \"2025-10-27T00\"` to replay Hurricane Melissa."))
262
- for storm_id, name in storms:
263
- try:
264
- if WATCH_MINUTES and REPLAY_TIME is None and issued.get(storm_id) is not None:
265
- newest = newest_cycle(storm_id)
266
- if newest is None or newest <= issued[storm_id]:
267
- continue # nothing new from NHC for this storm
268
- panel, when_issued = issue(storm_id, name, REPLAY_TIME)
269
- if issued.get(storm_id) == when_issued:
270
- continue # the GFS for the newer cycle is not out yet
271
- issued[storm_id] = when_issued
272
- panels.append(panel)
273
- mo.output.append(panel)
274
- except Exception as error: # one storm failing must not hide the others
275
- panels.append(mo.md(f"**{name} ({storm_id})**: no forecast -- {type(error).__name__}: {error}"))
276
- mo.output.append(panels[-1])
277
- if not WATCH_MINUTES or REPLAY_TIME is not None:
278
- break
279
- mo.output.append(mo.md(f"<sub>watching NHC; next check at {(dt.datetime.now(dt.timezone.utc) + dt.timedelta(minutes=WATCH_MINUTES)):%H:%M} UTC</sub>"))
280
- time.sleep(WATCH_MINUTES * 60)
281
-
282
- # ── optional: the satellite networks of the 0.12 satellite path, on this one GPU ─────────────────────────────────
283
- if TRAIN_SATELLITE:
284
- from naturev1.b300 import follow, start_job
285
- from naturev1.ibtracs import fetch_ibtracs, parse_ibtracs, storm_label_table
286
-
287
- if HF_TOKEN.strip():
288
- os.environ["HF_TOKEN"] = HF_TOKEN.strip()
289
- SAT, CKPT, LOGS = DATA / "satellite", DATA / "checkpoints", DATA / "logs"
290
- for folder in (SAT, CKPT, LOGS):
291
- folder.mkdir(parents=True, exist_ok=True)
292
- ibtracs = fetch_ibtracs(DATA)
293
- labels = SAT / "labels.npz"
294
- if not labels.exists():
295
- np.savez(labels, **storm_label_table(parse_ibtracs(ibtracs, since=1980, until=2021)))
296
- from huggingface_hub import hf_hub_download
297
- base = DATA / "base.pt"
298
- if not base.exists():
299
- import shutil
300
- shutil.copy2(hf_hub_download(HF_REPO, "checkpoints/stage3/latest.pt"), base)
301
- start_job("gridsat", [sys.executable, "-m", "naturev1.satstage", "--root", str(SAT), "--ibtracs", str(ibtracs),
302
- "--years", "1980-2021", "--workers", str(max((os.cpu_count() or 8) - 2, 2)),
303
- "--resolution", "0.25"], LOGS)
304
- follow(LOGS, ["gridsat"])
305
- common = ["--checkpoint", str(base), "--root", str(SAT), "--table", str(labels), "--output", str(CKPT),
306
- "--hours", "2.0", "--hub-repo", HF_REPO]
307
- start_job("stage-a-satnet", ["-m", "naturev1.sattrain", "satnet", *common, "--workers", "8"], LOGS, gpus=[0])
308
- follow(LOGS, ["stage-a-satnet"])
309
- panels.append(mo.md(f"satellite stage A finished: {CKPT / 'stage-a-satnet'}"))
310
-
311
- mo.vstack(panels)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/__init__.py DELETED
@@ -1,253 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """NatureV1 - a probabilistic weather model over geolocated fields."""
3
-
4
- from .besttrack import Track, parse_hurdat2, rapid_intensification, split_by_storm
5
- from .checkpoint import CheckpointManager, TrainingState
6
- from .corpora import CORPORA, augment_batch, catalogue, open_weatherbench, rotate_longitude
7
- from .era5 import (
8
- FIELD_DIMS,
9
- CachedERA5,
10
- ERA5Window,
11
- GridCollate,
12
- Normalizer,
13
- corpus_scale,
14
- equiangular_weights,
15
- era5_loader,
16
- era5_source_grid,
17
- era5_splits,
18
- expand_variables,
19
- lead_offsets,
20
- materialise,
21
- normalization,
22
- read_block,
23
- )
24
- from .fallback import (
25
- Choice,
26
- Plan,
27
- ensure_packages,
28
- install_packages,
29
- resolve_all,
30
- resolve_batch,
31
- resolve_precision,
32
- resolve_staging,
33
- resolve_variables,
34
- with_retry,
35
- )
36
- from .forecast import (
37
- build_forecast,
38
- decode_eyewall,
39
- decode_landfall,
40
- decode_point_forecast,
41
- decode_rapid_intensification,
42
- decode_track,
43
- nearest_point,
44
- )
45
- from .guards import (
46
- PHYSICAL,
47
- Bounds,
48
- PreflightError,
49
- TrainingWatchdog,
50
- check_device_agreement,
51
- check_environment,
52
- check_forecast,
53
- check_grid_alignment,
54
- check_normalization,
55
- check_weights,
56
- format_preflight,
57
- preflight,
58
- responds_to_input,
59
- )
60
- from .hub import from_pretrained, push_to_hub, save_for_hub
61
- from .launch import follow, launch, progress, status, stop
62
- from .live import fetch_latest, latest_scene_keys, watch
63
- from .losses import area_weights, focal_bce, masked_gaussian_nll, total_loss, track_mixture_nll
64
- from .mirror import HubMirror, session_started
65
- from .model import (
66
- RI_THRESHOLDS_KT,
67
- STORM_ENVIRONMENT_FEATURES,
68
- STORM_STATE_FEATURES,
69
- SURFACE_FIELDS,
70
- TRACK_MEMBERS,
71
- TRACKED_HEADS,
72
- WEATHER_TYPES,
73
- WIND_RADII_THRESHOLDS_KT,
74
- EyewallHead,
75
- NatureConfig,
76
- NatureV1,
77
- RapidIntensificationHead,
78
- calendar_features,
79
- )
80
- from .planning import autotune_batch_size, benchmark_steps, device_report, format_plan, training_plan
81
- from .rollout import (
82
- RolloutSchedule,
83
- advance_calendar,
84
- drift_report,
85
- field_to_channel,
86
- reinject,
87
- rollout_forecast,
88
- rollout_loss,
89
- )
90
- from .satellite import SatelliteScene, fixed_grid_to_latlon, footprint_area_km2, normalize_channels, scene_from_netcdf
91
- from .state import (
92
- FORCING_VARIABLES,
93
- HEADLINE_STATE_FIELDS,
94
- STATIC_VARIABLES,
95
- ChannelRoles,
96
- StateStepper,
97
- channel_loss_weights,
98
- channel_roles,
99
- conserve_mass,
100
- fair_crps,
101
- score_state,
102
- solar_forcing,
103
- spread_skill_ratio,
104
- state_forecast,
105
- state_rollout_loss,
106
- tendency_std,
107
- toa_accumulated,
108
- toa_flux,
109
- train_state_rollout,
110
- )
111
- from .steering import (
112
- BETA_DRIFT_MS,
113
- advect,
114
- describe_environment,
115
- extrapolation_track,
116
- steering_track,
117
- storm_environment,
118
- )
119
- from .storms import (
120
- StormTargets,
121
- calibration_report,
122
- fit_feature_monitor,
123
- fit_probability_calibration,
124
- StormWindow,
125
- consensus_track,
126
- describe_consensus,
127
- fit_track_consensus,
128
- format_pairing,
129
- lead_track_points,
130
- pair_tracks_with_reanalysis,
131
- storm_feature_bank,
132
- storm_scorecard,
133
- storm_state_vector,
134
- track_members,
135
- train_storm_heads,
136
- )
137
- from .train import Trainer, TrainSettings, apply_ema, load_for_inference, next_state_targets
138
- from .upper import (
139
- HEADLINE_LEVELS,
140
- PRESSURE_LEVELS,
141
- SURFACE_EXTRAS,
142
- UPPER_VARIABLES,
143
- channel_names,
144
- headline_channels,
145
- open_weatherbench_levels,
146
- read_levels,
147
- upper_air_report,
148
- volume_coords,
149
- volume_grid,
150
- )
151
- from .wb2 import (
152
- HEADLINE,
153
- Score,
154
- Scorecard,
155
- ScoredField,
156
- build_climatology,
157
- climatology_forecast,
158
- latitude_weights,
159
- persistence_forecast,
160
- score_model,
161
- scoring_fields,
162
- weighted_acc,
163
- weighted_rmse,
164
- )
165
-
166
-
167
- from . import stormsat # noqa: E402
168
-
169
- __version__ = "0.16.0"
170
-
171
- #: 0.12 names, imported from their modules on first use -- so ``import naturev1`` costs nothing extra and
172
- #: works with ihelix 0.5, and only the satellite networks themselves ask for ihelix 0.6.
173
- _LAZY = {
174
- "SatNetStorm": "satnet", "SatNetConfig": "satnet", "SatelliteRetrieval": "satnet", "apply_retrieval": "satnet",
175
- "cloud_motion": "satnet", "RI_WINDOWS": "satnet",
176
- "parse_ibtracs": "ibtracs", "storm_label_table": "ibtracs", "describe_table": "ibtracs", "fetch_ibtracs": "ibtracs",
177
- "stage_gridsat": "satstage", "stage_month": "satstage", "read_gridsat": "satstage", "decode_global": "satstage",
178
- "GlobalFrames": "satdata", "StormPatches": "satdata", "StormSequenceDataset": "satdata",
179
- "RetrievalDataset": "satdata",
180
- "Engine": "engine", "EngineSettings": "engine",
181
- "train_satnet": "sattrain", "train_retrieval": "sattrain", "merged_model": "sattrain",
182
- "train_storms": "stormtrain", "memmap_feature_bank": "stormtrain", "finalise_storms": "stormtrain",
183
- "compare_models": "stormtrain",
184
- "track_vortex": "tracker", "find_storms": "tracker", "rollout_member_tracks": "tracker",
185
- "vortex_centre": "tracker",
186
- "fit_intensity_correction": "storms", "ri2_report": "storms", "intensity_by_category": "storms",
187
- "hardware_preflight": "hardware", "publish_release": "b300",
188
- "select_rotations": "stormtrain", "class_weights": "stormtrain", "refine_centre": "satnet",
189
- "potential_intensity": "thermo", "storm_physics": "thermo", "cyclone_phase": "thermo", "genesis_map": "thermo",
190
- "STORM_PHYSICS_FEATURES": "thermo",
191
- "wind_field": "windfield", "wind_hazard": "windfield", "wind_probability_map": "windfield",
192
- "StatisticalModels": "statistical", "fit_intensity_consensus": "statistical",
193
- "multi_fix_centre": "tracker", "track_storm_in_rollout": "tracker", "AnalogForecaster": "tracker",
194
- "merge_rotations": "model", "rotation_angles": "model",
195
- "Assimilator": "assimilation", "Observations": "assimilation",
196
- "HighResHead": "highres", "decode_highres": "highres", "train_highres": "highres",
197
- }
198
-
199
- #: Subpackage entry points: ``naturev1.physics_forecast`` etc. resolve lazily to these modules.
200
- _LAZY_SUB = {"physics_forecast": "cyclone", "TEACHERS": "distill.catalog", "train_distillation": "distill.train",
201
- "ArchiveTeacher": "distill.sources"}
202
-
203
-
204
- def __getattr__(name):
205
- module = _LAZY.get(name) or _LAZY_SUB.get(name)
206
- if module is None:
207
- raise AttributeError(f"module 'naturev1' has no attribute {name!r}")
208
- import importlib
209
-
210
- value = getattr(importlib.import_module(f".{module}", __name__), name)
211
- globals()[name] = value
212
- return value
213
- __author__ = "Nathan"
214
- __all__ = [
215
- "CORPORA", "RI_THRESHOLDS_KT", "WIND_RADII_THRESHOLDS_KT", "EyewallHead", "RapidIntensificationHead",
216
- "Track", "apply_ema", "augment_batch", "autotune_batch_size", "benchmark_steps", "catalogue",
217
- "device_report", "focal_bce", "format_plan", "masked_gaussian_nll", "next_state_targets",
218
- "open_weatherbench", "parse_hurdat2", "rapid_intensification", "rotate_longitude", "split_by_storm",
219
- "training_plan", "CachedERA5", "ERA5Window", "corpus_scale", "era5_source_grid", "era5_splits",
220
- "materialise", "normalization", "Normalizer", "equiangular_weights", "era5_loader",
221
- "from_pretrained", "push_to_hub", "save_for_hub",
222
- "Plan", "Choice", "resolve_all", "resolve_variables", "resolve_staging",
223
- "resolve_precision", "resolve_batch", "with_retry", "ensure_packages", "install_packages",
224
- "ChannelRoles", "StateStepper", "channel_roles", "channel_loss_weights", "conserve_mass",
225
- "fair_crps", "solar_forcing", "spread_skill_ratio", "state_forecast", "state_rollout_loss",
226
- "tendency_std", "toa_accumulated", "toa_flux", "STATIC_VARIABLES", "FORCING_VARIABLES",
227
- "HEADLINE_STATE_FIELDS", "score_state", "train_state_rollout", "lead_track_points",
228
- "launch", "follow", "progress", "status", "stop", "HubMirror", "session_started",
229
- "area_weights", "TRACKED_HEADS", "STORM_STATE_FEATURES", "storm_feature_bank", "storm_scorecard",
230
- "storm_state_vector", "train_storm_heads", "calibration_report", "fit_feature_monitor",
231
- "fit_probability_calibration", "stormsat",
232
- "STORM_ENVIRONMENT_FEATURES", "TRACK_MEMBERS", "BETA_DRIFT_MS", "advect", "describe_environment",
233
- "extrapolation_track", "steering_track", "storm_environment", "consensus_track", "describe_consensus",
234
- "fit_track_consensus", "track_members",
235
- "preflight", "format_preflight", "PreflightError", "TrainingWatchdog", "check_forecast",
236
- "check_grid_alignment", "check_weights", "check_normalization", "check_device_agreement",
237
- "check_environment", "responds_to_input", "PHYSICAL", "Bounds",
238
- "RolloutSchedule", "rollout_loss", "rollout_forecast", "reinject", "advance_calendar",
239
- "field_to_channel", "drift_report",
240
- "PRESSURE_LEVELS", "UPPER_VARIABLES", "SURFACE_EXTRAS", "HEADLINE_LEVELS", "channel_names",
241
- "headline_channels", "open_weatherbench_levels", "read_levels", "upper_air_report",
242
- "volume_coords", "volume_grid",
243
- "HEADLINE", "Score", "Scorecard", "ScoredField", "scoring_fields", "score_model", "latitude_weights", "weighted_rmse",
244
- "weighted_acc", "persistence_forecast", "climatology_forecast", "build_climatology", "FIELD_DIMS", "read_block", "expand_variables", "lead_offsets", "GridCollate",
245
- "SURFACE_FIELDS", "WEATHER_TYPES", "CheckpointManager", "NatureConfig", "NatureV1", "SatelliteScene",
246
- "TrainSettings", "Trainer", "TrainingState", "build_forecast", "calendar_features", "decode_landfall",
247
- "decode_point_forecast", "decode_track", "decode_eyewall", "decode_rapid_intensification", "fetch_latest", "fixed_grid_to_latlon", "footprint_area_km2",
248
- "latest_scene_keys", "load_for_inference", "nearest_point", "normalize_channels", "scene_from_netcdf",
249
- "total_loss", "track_mixture_nll", "watch", "StormWindow", "StormTargets",
250
- "pair_tracks_with_reanalysis", "format_pairing",
251
- # 0.12, loaded on first use (the satellite networks need ihelix 0.6; nothing else does)
252
- *sorted(_LAZY), *sorted(_LAZY_SUB),
253
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/_pipeline.py DELETED
@@ -1,772 +0,0 @@
1
- # ruff: noqa
2
- # The whole training pipeline -- the same code as colab_naturev1.py, packaged so naturev1.launch() can run
3
- # it as a background process that survives a dropped connection. Importing it would start training.
4
- if __name__ not in ("__main__", "__naturev1_pipeline__"):
5
- raise ImportError("naturev1._pipeline is the training pipeline itself; start it with naturev1.launch()")
6
-
7
- # ═══════════════════════════════════════════════════════════════════════════════════════════════════
8
- # NatureV1 0.11.0 — the whole thing in one cell. Pure Python: marimo, Colab, Jupyter or a plain script.
9
- #
10
- # Paste and run. It installs what it needs, stages the data, builds the 89M-parameter model, trains it
11
- # in stages, scores itself against persistence and climatology, and only publishes if it earned it.
12
- #
13
- # With BACKGROUND = True (the default) the run happens in a supervised process on the machine itself:
14
- # it keeps going if the wifi drops or the notebook closes, and it restarts itself from its last
15
- # checkpoint if it crashes, runs out of GPU memory, or stalls. The cell then stays running as a live
16
- # window on it -- every log line as it happens, a proof-of-life line every minute when the run is
17
- # quiet, a status line every ten minutes -- until the run finishes (WATCH_SECONDS = None). Stopping the
18
- # cell stops only the window; the run never depends on it. Run the cell again any time to reopen it.
19
- #
20
- # If it stops for any reason -- a crash, a reboot, naturev1.stop(), the supervisor giving up -- just run
21
- # this cell again: every stage continues from its last checkpoint (saved every minute, a snapshot every
22
- # 30 minutes, and a second copy in BACKUP_DIR). naturev1.progress() shows where it will continue from.
23
- #
24
- # ON A HOSTED NOTEBOOK (molab, Colab): the run survives the notebook, but not the machine. molab ends a
25
- # session after 12 hours, or after 90 idle minutes, and wipes its disk. So, with HF_TOKEN set, everything
26
- # expensive is mirrored to HF_REPO as soon as it exists: the staged ERA5 (once, in the background), the
27
- # checkpoints (every 10 minutes, and once more when the session is told to stop), the storm feature
28
- # banks, the remembered batch size. A new session pulls them back in minutes instead of rebuilding them:
29
- # the download does not start over, training resumes where the last session stopped. On molab: set
30
- # HF_TOKEN, keep this tab open (the cell staying active is what keeps the notebook from being idle), and
31
- # when the 12 hours are up, open a new session and run this cell again.
32
- #
33
- # 0.9: the hurricane heads read the storm's physical environment -- deep-layer steering, 200-850 hPa
34
- # shear, mid-level moisture, ocean temperature -- computed from the model's own atmosphere, plus a
35
- # wider 1000 km pool. The track head predicts the residual from a physics-only steering track, and the
36
- # forecast is a multi-model consensus of learned track, steering track and extrapolation, weighted per
37
- # lead on held-out seasons. The scorecard shows every member, so you can see which one earns its place.
38
- #
39
- # 0.11: stage three -- what the satellite sees. Storm-centred infrared (HURSAT-B1 to 2016, GridSat-B1
40
- # after, GOES band 13 live) feeds a small branch into the hurricane heads, aimed at intensity, which a
41
- # 1.5-degree reanalysis cannot see. It is fine-tuned with the backbone frozen and can never end worse on
42
- # held-out storms than the model it started from. Then, from the red team: landfall and rapid-
43
- # intensification probabilities are calibrated on held-out seasons, an input monitor marks forecasts
44
- # from inputs unlike training as untrustworthy, the forecast can no longer state impossible structure,
45
- # and the rollout's tendency statistics ship with the weights. Stages already trained are loaded, not
46
- # redone: a finished 0.10 run goes straight to stage three.
47
- # ═══════════════════════════════════════════════════════════════════════════════════════════════════
48
-
49
- BACKGROUND = True # supervised background run: survives disconnects, restarts on crash or stall
50
- STALL_MINUTES = 30 # background: restart a run that shows no progress for this long (12 while downloading)
51
- WATCH_SECONDS = None # None: this cell stays running as a live window until the run finishes; or seconds
52
- MIRROR = True # mirror staged data, checkpoints and banks to HF_REPO (needs HF_TOKEN); see above
53
-
54
- RUN_PRETRAIN = True # stage one: every channel at every lead, on ERA5 reanalysis
55
- RUN_ROLLOUT = True # stage 1b: the whole atmosphere rolled forward 12 steps, CRPS ensemble
56
- RUN_STORMS = True # stage two: hurricane heads on HURDAT2, backbone frozen
57
- RETRAIN_STORMS = False # stage two: train the heads again even if a stage-two checkpoint exists
58
- RUN_SATELLITE = True # stage three: storm-centred infrared satellite, then calibration and the monitor
59
- RETRAIN_SATELLITE = False # stage three: train again even if a stage-three checkpoint exists
60
- RUN_SCORE = True # WeatherBench-style scorecards against persistence and climatology
61
- RUN_HINDCAST = True # forecast a held-out hurricane from its real analysis; compare with truth
62
- RUN_PUBLISH = False # push to the Hub -- only if it beats persistence somewhere
63
- RUN_FORECAST = False # live GOES forecast (refused while the satellite path is untrained)
64
- RUN_WATCHER = False # then re-forecast every hour
65
-
66
- STAGE_YEARS = 5 # training years staged to local disk, ~7.5 GB each at 89 channels
67
- VAL_YEARS = 1 # held-out years staged too, so validation and scoring are not network-bound
68
- EPOCHS = 8 # passes over the staged years in stage one
69
- BATCH = None # None: find the largest batch that fits once, then remember it; or a number
70
- MAX_BATCH = 16 # ceiling on the batch search: host RAM, not VRAM, is the limit above this
71
- WORKERS = 4 # data-loading processes
72
- ROLLOUT_STEPS = 12 # longest rollout trained: 12 x 6 h = 72 h, GraphCast's curriculum
73
- ROLLOUT_TRAIN = 4_000 # optimizer steps of rollout training (the log prints the time per step)
74
- MEMBERS = 2 # CRPS ensemble members; 1 = deterministic Gaussian training
75
- STORM_FIRST_SEASON = 1979 # the satellite era; earlier best-track intensities are much less reliable
76
- HINDCAST = ("DORIAN", 2019) # a held-out storm: 2019 is never trained on
77
- STORM = (24.6, -78.2) # live forecast: current storm centre (lat, lon)
78
- CITY = (25.77, -80.19) # live forecast: somewhere you want a local forecast
79
- HF_REPO = "CorxLabs/NatureV1-500"
80
- HF_TOKEN = "" # a write token, to publish and mirror checkpoints; or set the HF_TOKEN env var
81
- DATA_DIR = None # None: /content on Colab, ~/naturev1_data elsewhere; or any folder with room
82
- BACKUP_DIR = None # second copy of every checkpoint. None: ~/naturev1_backup; best on another disk;
83
- # "" turns it off
84
-
85
- # ═══ 0 ═══ install ═════════════════════════════════════════════════════════════════════════════════
86
- # Standard library only, before numpy or torch are imported: pip may upgrade numpy while satisfying
87
- # zarr or gcsfs, and a numpy that changes under an imported torch fails far from here.
88
- import importlib
89
- import importlib.metadata
90
- import importlib.util
91
- import os
92
- import shutil
93
- import subprocess
94
- import sys
95
-
96
- os.environ.setdefault("GRPC_VERBOSITY", "ERROR") # the cloud client logs every DataLoader fork otherwise
97
- # Let the GPU allocator grow segments instead of fragmenting them. Training at batch 8 fills a 96 GB card
98
- # to within ~130 MB, and without this the allocator has to flush and retry. Only takes effect if torch
99
- # has not touched the GPU yet in this kernel -- restart the kernel for it to apply.
100
- os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
101
-
102
-
103
- def _halt(message, kind="danger"):
104
- """
105
- End the cell here, on purpose. Only kind="danger" is an error. marimo shows the message as a
106
- coloured note instead of a traceback, Jupyter and Colab show it without one, and a plain script
107
- exits with status 0 for "success" and 1 for anything else -- which is what the supervisor of a
108
- background run reads, so a run stopped part-way is resumed and a finished one is not.
109
- """
110
- marimo = sys.modules.get("marimo")
111
- if marimo is not None and getattr(marimo, "running_in_notebook", lambda: False)():
112
- try:
113
- note = marimo.callout(marimo.plain_text(message), kind=kind)
114
- except Exception:
115
- note = None
116
- print(message)
117
- marimo.stop(True, note)
118
- ipython = sys.modules.get("IPython")
119
- if kind != "danger" and ipython is not None and getattr(ipython, "get_ipython", lambda: None)() is not None:
120
- class Halted(Exception):
121
- def _render_traceback_(self):
122
- return [] # the message printed above, and no traceback
123
- print(message)
124
- raise Halted("the cell stopped here on purpose -- the message is above")
125
- if kind == "success":
126
- print(message)
127
- raise SystemExit(0)
128
- raise SystemExit(message)
129
-
130
-
131
- # 0.11 is installed from the project's GitHub branch until it is on PyPI.
132
- _NEEDED = {"naturev1": ("naturev1[all] @ git+https://github.com/Wisk-inc/transformers"
133
- "@claude/linear-context-architecture-a35c6h#subdirectory=natureweather", (0, 11, 0)),
134
- "ihelix": ("ihelix>=0.5.1", (0, 5, 1))}
135
-
136
-
137
- def _version(module):
138
- try:
139
- return tuple(int(p) for p in importlib.metadata.version(module).split(".")[:3])
140
- except Exception:
141
- return None
142
-
143
-
144
- _missing = [req for mod, (req, low) in _NEEDED.items()
145
- if importlib.util.find_spec(mod) is None or (_version(mod) or (0,)) < low]
146
- if _missing:
147
- print(f"installing: {' '.join(_missing)}")
148
- _loaded = {name: getattr(sys.modules.get(name), "__version__", None) for name in ("numpy", "torch")}
149
- _tries = ([("uv", ["uv", "pip", "install", "--python", sys.executable, "--upgrade", *_missing])]
150
- if shutil.which("uv") else [])
151
- _pip = [sys.executable, "-m", "pip", "install", "--upgrade"]
152
- _tries += [("pip, fresh index", [*_pip, "--no-cache-dir", "--index-url", "https://pypi.org/simple", *_missing]),
153
- ("pip, no cache", [*_pip, "--no-cache-dir", *_missing]),
154
- ("pip", [*_pip, *_missing])]
155
- for _label, _command in _tries:
156
- try:
157
- subprocess.check_call(_command)
158
- print(f"installed via {_label}")
159
- break
160
- except (subprocess.CalledProcessError, FileNotFoundError):
161
- print(f" {_label} failed, trying the next way")
162
- else:
163
- _halt("could not install; add these in your package manager, then re-run: " + " ".join(_missing))
164
- importlib.invalidate_caches()
165
- # A restart is needed only if an old copy is already in memory, or pip moved numpy/torch under us.
166
- _stale = [m for m in _NEEDED if m in sys.modules]
167
- for _name, _was in _loaded.items():
168
- try:
169
- if _was and importlib.metadata.version(_name) != _was:
170
- _stale.append(f"{_name} {_was}")
171
- except importlib.metadata.PackageNotFoundError:
172
- pass
173
- if _stale:
174
- _halt(f"installed. {', '.join(_stale)} was already loaded in this kernel: RESTART THE KERNEL, then run "
175
- "this cell again. (A background run that is already training is not affected.)", kind="warn")
176
-
177
- if HF_TOKEN:
178
- os.environ["HF_TOKEN"] = HF_TOKEN
179
- # Every checkpoint is also copied here, so losing or damaging the data folder does not lose the run.
180
- os.environ["NATUREV1_BACKUP_DIR"] = (os.path.expanduser("~/naturev1_backup") if BACKUP_DIR is None
181
- else os.path.expanduser(BACKUP_DIR))
182
-
183
- if BACKGROUND and not os.environ.get("NATUREV1_BACKGROUND_CHILD"):
184
- # Hand this same pipeline, with these settings, to a supervised process on this machine, and stop
185
- # here: the notebook only starts and watches it. The GPU is never touched from the notebook.
186
- # Imported under a private name: marimo lets a name be defined by only one cell, and this way any
187
- # other cell can still `import naturev1` to call naturev1.follow() or naturev1.stop().
188
- import naturev1 as _naturev1
189
-
190
- _SETTINGS = {name: globals()[name] for name in (
191
- "RUN_PRETRAIN", "RUN_ROLLOUT", "RUN_STORMS", "RETRAIN_STORMS", "RUN_SATELLITE", "RETRAIN_SATELLITE",
192
- "RUN_SCORE", "RUN_HINDCAST", "RUN_PUBLISH",
193
- "RUN_FORECAST", "RUN_WATCHER", "STAGE_YEARS", "VAL_YEARS", "EPOCHS", "BATCH", "MAX_BATCH", "WORKERS",
194
- "ROLLOUT_STEPS", "ROLLOUT_TRAIN", "MEMBERS", "STORM_FIRST_SEASON", "HINDCAST", "STORM", "CITY",
195
- "HF_REPO", "HF_TOKEN", "DATA_DIR", "BACKUP_DIR", "MIRROR")}
196
- _was_running = _naturev1.status(DATA_DIR) is not None
197
- _naturev1.launch(directory=DATA_DIR, stall_minutes=STALL_MINUTES, **_SETTINGS)
198
- if _was_running:
199
- _naturev1.progress(DATA_DIR) # what the run that is already going has done so far
200
- # A fresh run: wait for its first lines. Then show the log live for a while, so the cell ends with
201
- # the run visibly moving rather than with a snapshot that looks frozen.
202
- _naturev1.follow(lines=25, directory=DATA_DIR, wait=90, watch=WATCH_SECONDS)
203
- if _naturev1.status(DATA_DIR) is None:
204
- _naturev1.progress(DATA_DIR)
205
- _halt("The run has ended -- the last lines above say whether it finished or stopped. If it finished, "
206
- "the scorecards and the hindcast are above. If it stopped, run this cell again: it continues "
207
- "from the last checkpoint, pulling data, checkpoints and banks back from the Hub mirror if this "
208
- "machine's disk was wiped.", kind="success")
209
- _halt("NatureV1 is running in the background; this cell was its window and has closed -- the run has "
210
- "NOT stopped. Nothing in this notebook can stop it except naturev1.stop().\n"
211
- "To reopen the window: run this cell again (it will not start a second run), or in any cell\n"
212
- " import naturev1; naturev1.follow(watch=None)\n"
213
- "If the run ever does stop, run this cell again: it continues from the last checkpoint, pulling "
214
- "data, checkpoints and banks back from the Hub mirror if this machine's disk was wiped.\n"
215
- "naturev1.progress() shows each stage's checkpoint, its age, and its backup.",
216
- kind="success")
217
-
218
- # ═══ 1 ═══ imports, paths, hardware ══════════════════════��═════════════════════════════════════════
219
- import datetime as dt
220
- import json
221
-
222
- import numpy as np
223
- import torch
224
- from ihelix import FieldGrid, Geometry, fibonacci_sphere
225
- from naturev1 import (HEADLINE_STATE_FIELDS, CachedERA5, CheckpointManager, ERA5Window, HubMirror, NatureConfig,
226
- NatureV1, RolloutSchedule, StateStepper, Trainer, TrainSettings, autotune_batch_size,
227
- benchmark_steps, build_climatology, build_forecast, calendar_features, catalogue,
228
- channel_loss_weights, check_forecast, check_grid_alignment, check_normalization,
229
- check_weights, corpus_scale, describe_environment, device_report, era5_loader,
230
- era5_source_grid, era5_splits, expand_variables, extrapolation_track, fetch_latest,
231
- format_pairing, format_plan, format_preflight, lead_offsets, masked_gaussian_nll,
232
- materialise, normalize_channels, open_weatherbench_levels, pair_tracks_with_reanalysis,
233
- parse_hurdat2, preflight, push_to_hub, rapid_intensification, resolve_all,
234
- responds_to_input, scene_from_netcdf, score_model, score_state, scoring_fields,
235
- split_by_storm, spread_skill_ratio, state_forecast, steering_track, storm_environment,
236
- storm_feature_bank, storm_scorecard, storm_state_vector, train_state_rollout,
237
- train_storm_heads, training_plan, upper_air_report, watch)
238
- from naturev1.besttrack import download
239
- from naturev1.checkpoint import TrainingState
240
- from naturev1.era5 import _target_index
241
- from naturev1.storms import calibration_report, fit_feature_monitor, fit_probability_calibration
242
- from naturev1.stormsat import (IBTRACS_NA_URL, GridSatArchive, HursatArchive, describe_patch, encode_bt,
243
- ibtracs_atcf_map, satellite_bank)
244
- from naturev1.safeio import atomic_write_text, read_json
245
-
246
- DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
247
- COLAB_DRIVE = "/content/drive/MyDrive"
248
- DATA_DIR = DATA_DIR or ("/content" if os.path.isdir("/content") else os.path.expanduser("~/naturev1_data"))
249
- CKPT_DIR = (f"{COLAB_DRIVE}/naturev1_ckpt" if os.path.isdir(COLAB_DRIVE) # Drive outlives the VM
250
- else os.path.join(DATA_DIR, "naturev1_ckpt"))
251
- os.makedirs(DATA_DIR, exist_ok=True)
252
- os.makedirs(CKPT_DIR, exist_ok=True)
253
- CACHE, VAL_CACHE, STATS = (os.path.join(DATA_DIR, name) for name in ("era5.npy", "era5_val.npy", "stats.json"))
254
- # Everything expensive goes to the Hub as soon as it exists and comes back at the start of a new session,
255
- # so a wiped disk costs minutes, not hours. Off without a repo or token; see the header.
256
- mirror = HubMirror(HF_REPO if MIRROR else None, token=HF_TOKEN or None)
257
-
258
-
259
- def banner(text):
260
- print(f"\n{'═' * 99}\n {text}\n{'═' * 99}")
261
-
262
-
263
- banner("HARDWARE")
264
- print(device_report())
265
- print(f"data {DATA_DIR} | checkpoints {CKPT_DIR}\n{mirror.describe()}")
266
-
267
- # ═══ 2 ═══ data: 13 pressure levels + surface + static, resolved against this machine ══════════════
268
- banner("DATA — ERA5 (WeatherBench 2), surface and 13 pressure levels")
269
- ERA5, SURFACE, UPPER = open_weatherbench_levels()
270
- LEVELS = [int(x) for x in ERA5.level.values]
271
- SRC, LAT, LON = era5_source_grid(ERA5)
272
- plan = resolve_all(ERA5, tuple(SURFACE) + tuple(UPPER), LEVELS, STAGE_YEARS + VAL_YEARS, SRC.num_points,
273
- layers=17, batch=MAX_BATCH, precision="bf16", path=DATA_DIR,
274
- staged=(CACHE, VAL_CACHE)) # data already on disk counts as room, not as used
275
- print(plan.summary())
276
- R = plan.resolved
277
- CHANNELS = expand_variables(ERA5, R["variables"], R["levels"])
278
- WIDTH = len(CHANNELS) + 7 # room for observation-mask channels, zero-padded
279
- print(f"\n{upper_air_report(SURFACE, UPPER, LEVELS)}")
280
- assert not check_weights(SRC), check_weights(SRC) # poles present, not deleted by cos(latitude)
281
-
282
- OFFSETS, SPLITS = lead_offsets((6, 12, 18, 24, 36, 48, 72, 96, 120)), era5_splits(ERA5)
283
- TRAIN_STEPS = max(R["years"] - VAL_YEARS, 1) * 1460
284
- VAL_STEPS = VAL_YEARS * 1460
285
- common = dict(history=6, lead_steps=OFFSETS, variables=tuple(R["variables"]), levels=R["levels"], channels=WIDTH)
286
- for name, index in SPLITS.items():
287
- print(f" {name:5} {str(ERA5.time.values[index[0]])[:10]} -> {str(ERA5.time.values[index[-1]])[:10]}")
288
- print("split by TIME: weather is autocorrelated for days, so a random split leaks the answer.")
289
-
290
- mirror.pull(STATS, "staging/stats.json") # the normalisation statistics, from an earlier session
291
- stream = ERA5Window(ERA5, indices=SPLITS["train"], stats_cache=STATS, **common)
292
- mirror.push(STATS, "staging/stats.json")
293
- print(f"\n{stream.normalizer.report()}")
294
- print(f"\n{corpus_scale(ERA5, tuple(R['variables']))}\n\n{catalogue()}")
295
-
296
- if R["streaming"]:
297
- print("\nnot enough disk to stage: streaming from the network (~100x slower per window)")
298
- train_ds, roll_ds = stream, ERA5Window(ERA5, indices=SPLITS["train"], stats_cache=STATS,
299
- state_steps=ROLLOUT_STEPS, **common)
300
- val_ds = ERA5Window(ERA5, indices=SPLITS["val"], augment=False, stats_cache=STATS,
301
- state_steps=ROLLOUT_STEPS, **common)
302
- else:
303
- # Always called, and never downloads twice: it returns at once when what is on disk already covers
304
- # the request, copies from disk what it can when the request grows, and resumes a download that died.
305
- TRAIN_SPAN, VAL_SPAN = SPLITS["train"][-TRAIN_STEPS:], SPLITS["val"][:VAL_STEPS]
306
- # A staging an earlier session already made comes back from the Hub in minutes; it is pushed there,
307
- # in the background, the first time it is complete. Then materialise() reuses, grows or resumes it.
308
- mirror.pull_staging(CACHE, "staging/era5", CHANNELS, TRAIN_SPAN)
309
- mirror.pull_staging(VAL_CACHE, "staging/era5_val", CHANNELS, VAL_SPAN)
310
- materialise(ERA5, TRAIN_SPAN, CACHE, variables=tuple(R["variables"]),
311
- levels=R["levels"], normalizer=stream.normalizer, workers=8)
312
- materialise(ERA5, VAL_SPAN, VAL_CACHE, variables=tuple(R["variables"]),
313
- levels=R["levels"], normalizer=stream.normalizer, workers=8)
314
- mirror.push_staging(CACHE, "staging/era5")
315
- mirror.push_staging(VAL_CACHE, "staging/era5_val")
316
- train_ds = CachedERA5(CACHE, history=6, lead_steps=OFFSETS, channels=WIDTH, augment=True,
317
- restrict_to=TRAIN_SPAN)
318
- roll_ds = CachedERA5(CACHE, history=6, lead_steps=OFFSETS, channels=WIDTH, augment=True,
319
- state_steps=ROLLOUT_STEPS, restrict_to=TRAIN_SPAN)
320
- val_ds = CachedERA5(VAL_CACHE, history=6, lead_steps=OFFSETS, channels=WIDTH, augment=False,
321
- state_steps=ROLLOUT_STEPS, restrict_to=VAL_SPAN)
322
- print(f"\ntrain {len(train_ds):,} windows | validate on {len(val_ds):,} held-out windows")
323
-
324
- sample = train_ds[0]["analysis"]
325
- for problem in (check_grid_alignment(SRC, sample[-1, :, train_ds.variables.index("2m_temperature")])
326
- + check_normalization(sample, "analysis")):
327
- _halt(f"DATA PROBLEM: {problem}")
328
- print("data ok: samples land where the grid says, no spike channels")
329
-
330
- # ═══ 3 ═══ what each channel IS — decides what a rollout does with it ══════════════════════════════
331
- stepper = StateStepper.build(roll_ds, input_channels=WIDTH, grid=SRC, samples=64)
332
- print(f"\nchannel roles:\n{stepper.roles.describe()}")
333
- WEIGHTS = channel_loss_weights(train_ds.variables, WIDTH)
334
-
335
- # ═══ 4 ═══ the model ═══════════════════════════════════════════════════════════════════════════════
336
- banner("MODEL")
337
- CFG = NatureConfig(
338
- analysis_channels=WIDTH, satellite_channels=6, environment_channels=8,
339
- hidden_size=512, num_layers=17, num_heads=8, num_kv_heads=4, head_dim=64, intermediate_size=2048,
340
- latent_points=4096, min_radius_km=120.0, max_radius_km=1600.0, history_frames=6,
341
- lead_times_hours=(6, 12, 18, 24, 36, 48, 72, 96, 120), track_modes=6,
342
- state_channels=stepper.roles.num_prognostic, # predict and evolve the whole atmosphere
343
- noise_dim=32 if MEMBERS > 1 else 0) # per-member noise for the CRPS ensemble
344
- model = NatureV1(CFG, fibonacci_sphere(CFG.latent_points, num_neighbours=CFG.latent_neighbours,
345
- cluster_size=CFG.latent_cluster)).to(DEVICE)
346
- model.gradient_checkpointing_enable(True)
347
- stepper.grid = SRC.to(DEVICE)
348
- print(f"NatureV1 {model.num_parameters()/1e6:.2f}M parameters on {DEVICE}; state head over "
349
- f"{CFG.state_channels} channels; {MEMBERS}-member ensemble")
350
- print(format_preflight(preflight(model=model, dataset=train_ds, grid=SRC, strict=True)))
351
-
352
-
353
- def restore(*stages):
354
- """Load the newest checkpoint of the first stage that has one -- locally, else from the Hub."""
355
- for stage in stages:
356
- manager = CheckpointManager(f"{CKPT_DIR}/{stage}", repo_id=HF_REPO)
357
- manager.fetch_from_hub()
358
- if manager.load(model, map_location=DEVICE) is not None:
359
- return stage
360
- return None
361
-
362
-
363
- # ═══ 5 ═══ the largest batch that fits, and what the run costs ═════════════════════════════════════
364
- banner("BENCHMARK — largest batch that fits, and what the run costs")
365
-
366
-
367
- def make_step(batch_size):
368
- items = [train_ds[i] for i in range(batch_size)]
369
- batch = {k: torch.stack([x[k] for x in items]).to(DEVICE) for k in ("analysis", "calendar", "field_target", "field_mask")}
370
-
371
- def step():
372
- with torch.autocast(DEVICE, dtype=torch.bfloat16, enabled=DEVICE == "cuda"):
373
- out = model(analysis=batch["analysis"], analysis_grid=SRC, calendar=batch["calendar"], output_grid=SRC)
374
- loss = masked_gaussian_nll(out["field_mean"], out["field_log_var"],
375
- torch.where(batch["field_mask"] > 0, batch["field_target"], torch.nan))
376
- loss.backward()
377
- model.zero_grad(set_to_none=True)
378
- return step
379
-
380
-
381
- # The search is done once and remembered: repeating it on every restart costs minutes, pushes the card
382
- # to the edge of its memory each time, and could pick a different batch -- a different run -- each time.
383
- BATCH_FILE = os.path.join(CKPT_DIR, "batch.json")
384
- mirror.pull(BATCH_FILE, "staging/batch.json")
385
- REMEMBERED = read_json(BATCH_FILE, default={}) if BATCH is None else {} # a damaged file is searched again
386
- if REMEMBERED.get("batch"):
387
- BATCH, SPEED = int(REMEMBERED["batch"]), float(REMEMBERED.get("samples_per_second", 0.0))
388
- print(f"batch {BATCH}, found on an earlier run (delete {BATCH_FILE} to search again)")
389
- else:
390
- if BATCH is None:
391
- BATCH = min(autotune_batch_size(make_step, start=1, target_fraction=0.85), MAX_BATCH)
392
- MARK = benchmark_steps(make_step(BATCH), BATCH, warmup=1, iterations=10, gradient_checkpointing=True)
393
- SPEED = MARK.samples_per_second
394
- atomic_write_text(BATCH_FILE, json.dumps({"batch": BATCH, "samples_per_second": SPEED}))
395
- mirror.push(BATCH_FILE, "staging/batch.json")
396
- print(f"batch {BATCH} | {MARK}")
397
- if SPEED:
398
- print(format_plan(training_plan(SPEED, corpus_samples=len(train_ds), epochs=EPOCHS,
399
- watts=600.0, electricity_per_kwh=0.15, cloud_per_hour=2.50)))
400
- if DEVICE == "cuda":
401
- torch.cuda.empty_cache() # hand back what the search held before training starts
402
- loader = era5_loader(train_ds, batch_size=BATCH, num_workers=WORKERS, analysis_grid=SRC, output_grid=SRC,
403
- prefetch_factor=2)
404
- val_loader = era5_loader(val_ds, batch_size=BATCH, num_workers=WORKERS, shuffle=False, analysis_grid=SRC,
405
- output_grid=SRC, prefetch_factor=2)
406
-
407
- # ═══ 6 ═══ stage one — every channel, every lead ═══════════════════════════════════════════════════
408
- if RUN_PRETRAIN:
409
- banner("STAGE ONE — pretraining on reanalysis")
410
- trainer = Trainer(model, TrainSettings(
411
- stage="pretrain", learning_rate=3e-4, warmup_steps=1000,
412
- precision=R["precision"], max_steps=int(len(train_ds) * EPOCHS / BATCH),
413
- checkpoint_dir=f"{CKPT_DIR}/stage1", checkpoint_seconds=60, hub_repo=HF_REPO, hub_push_seconds=600,
414
- ema_decay=0.0, log_every=5, val_every=500, val_batches=32), device=DEVICE)
415
- trainer.resume()
416
- trainer.fit(loader, epochs=EPOCHS, val_loader=val_loader)
417
- if trainer.interrupted:
418
- # Stopped part-way: checkpointed, and the next stage must not start from a half-trained model.
419
- _halt("stage one stopped and checkpointed -- run again to resume it where it left off", kind="warn")
420
- print("held-out falling = learning; held-out rising while training falls = memorising.")
421
-
422
- # ═══ 7 ═══ stage 1b — the whole atmosphere, rolled forward on its own forecasts ════════════════════
423
- if RUN_ROLLOUT:
424
- banner(f"ROLLOUT — {ROLLOUT_STEPS}-step full-state training, {MEMBERS}-member CRPS ensemble")
425
- if not RUN_PRETRAIN and restore("stage1") is None:
426
- print("!! no stage-one checkpoint: rolling out an untrained backbone. Set RUN_PRETRAIN = True.")
427
- steps = ROLLOUT_TRAIN
428
- roll_loader = era5_loader(roll_ds, batch_size=max(BATCH // 2, 1), num_workers=WORKERS,
429
- analysis_grid=SRC, output_grid=SRC, prefetch_factor=2)
430
- train_state_rollout(
431
- model, roll_loader, stepper, max_steps=steps,
432
- schedule=RolloutSchedule(start_step=max(steps // 10, 1), ramp_steps=max(steps // 2, 1),
433
- max_steps=ROLLOUT_STEPS),
434
- channel_weights=WEIGHTS, members=MEMBERS, precision=R["precision"],
435
- checkpoint_dir=f"{CKPT_DIR}/rollout", hub_repo=HF_REPO, log_every=25)
436
-
437
- # ═══ 8 ═══ stage two — hurricane heads, on the frozen backbone ═════════════════════════════════════
438
- banner("STAGE TWO — HURDAT2 best tracks")
439
- tracks = parse_hurdat2(download("https://www.nhc.noaa.gov/data/hurdat/hurdat2-1851-2024-040425.txt",
440
- os.path.join(DATA_DIR, "hurdat2.txt")))
441
- print(f"HURDAT2: {len(tracks):,} storms, {sum(len(t) for t in tracks):,} points, "
442
- f"{sum(int(t.landfall.sum()) for t in tracks):,} landfalls")
443
- walk = rapid_intensification(tracks, threshold_kt=30.0)
444
- print(f"rapid intensification: {walk['positives']:,} of {walk['eligible']:,} eligible = "
445
- f"{100 * walk['base_rate']:.2f}% (an always-'no' classifier scores {100 - 100 * walk['base_rate']:.0f}%)")
446
- GROUPS = split_by_storm([t for t in tracks if t.year >= STORM_FIRST_SEASON])
447
- STORE_TIMES = ERA5.time.values.astype("datetime64[s]").astype(np.int64)
448
-
449
-
450
- def paired(which):
451
- starts, targets, report = pair_tracks_with_reanalysis(GROUPS[which], STORE_TIMES, OFFSETS, history=6)
452
- return starts, targets, report
453
-
454
-
455
- def storm_banks():
456
- """The frozen backbone's features for every storm sample: built once, cached, mirrored."""
457
- (tr_starts, tr_targets, tr_report), (va_starts, va_targets, va_report) = paired("train"), paired("validation")
458
- print(f"\n{format_pairing(tr_report)}")
459
- print(f"validation: {va_report['paired']:,} samples from {va_report['storms']} unseen storms "
460
- f"(seasons 2017/2019/2021)")
461
- print("\nreading each storm's reanalysis once and running the frozen backbone over it "
462
- "(cached; resumes if interrupted):")
463
- for _name in ("storm_bank_train", "storm_bank_val"):
464
- mirror.pull(f"{CKPT_DIR}/{_name}.pt", f"checkpoints/{_name}.pt") # rebuilt anyway if the backbone changed
465
- train = storm_feature_bank(model, ERA5, stream, tr_starts, tr_targets, SRC,
466
- cache=f"{CKPT_DIR}/storm_bank_train.pt", precision=R["precision"])
467
- val = storm_feature_bank(model, ERA5, stream, va_starts, va_targets, SRC,
468
- cache=f"{CKPT_DIR}/storm_bank_val.pt", precision=R["precision"])
469
- for _name in ("storm_bank_train", "storm_bank_val"):
470
- mirror.push(f"{CKPT_DIR}/{_name}.pt", f"checkpoints/{_name}.pt")
471
- return train, val, tr_targets, va_targets
472
-
473
-
474
- bank_train = bank_val = None
475
- if RUN_STORMS:
476
- # A finished stage two is loaded *before* the banks are built: its backbone is the one they were
477
- # built with, so they are reused rather than rebuilt from whatever checkpoint the Hub has for 1b.
478
- resumed = None if RETRAIN_STORMS else restore("stage2")
479
- if resumed is None and not (RUN_PRETRAIN or RUN_ROLLOUT) and restore("rollout", "stage1") is None:
480
- _halt("no pretrained backbone found. Freezing an untrained one would fine-tune the storm "
481
- "heads on noise -- set RUN_PRETRAIN = True.")
482
- bank_train, bank_val, tr_targets, va_targets = storm_banks()
483
- if resumed is not None:
484
- print("\nstage two is already trained: its checkpoint is loaded (RETRAIN_STORMS = True trains it again)")
485
- else:
486
- history = train_storm_heads(model, bank_train, bank_val, epochs=300,
487
- patience=25, checkpoint_dir=f"{CKPT_DIR}/stage2", hub_repo=HF_REPO)
488
- best = min(history, key=lambda h: h["val"])
489
- banner("SCOREBOARD — is it generalising, or memorising?")
490
- print(f"best epoch {best['epoch']}: held-out {best['val']:.4f} training {best['train']:.4f} "
491
- f"gap {best['val'] - best['train']:+.4f} (a large positive gap = memorised the training storms)")
492
- print(f"\nheld-out storms, seasons never trained on:\n{storm_scorecard(model, bank_val, CFG.lead_times_hours)}")
493
-
494
- # ═══ 8b ═══ stage three — what the satellite sees ══════════════════════════════════════════════════
495
- if RUN_SATELLITE:
496
- banner("STAGE THREE — storm-centred infrared (HURSAT-B1 to 2016, GridSat-B1 after)")
497
- loaded = None if RETRAIN_SATELLITE else restore("stage3")
498
- if loaded is None and not RUN_STORMS and restore("stage2") is None:
499
- _halt("no trained hurricane heads to build on -- run with RUN_STORMS = True first")
500
- if bank_train is None:
501
- bank_train, bank_val, tr_targets, va_targets = storm_banks()
502
- print("\nstorm-centred infrared for every sample, now and six hours earlier (cached; mirrored):")
503
- hursat = HursatArchive(os.path.join(DATA_DIR, "hursat"),
504
- ibtracs_atcf_map(download(IBTRACS_NA_URL, os.path.join(DATA_DIR, "ibtracs.NA.csv"))))
505
- gridsat = GridSatArchive()
506
- for _name in ("satellite_bank_train", "satellite_bank_val"):
507
- mirror.pull(f"{CKPT_DIR}/{_name}.pt", f"checkpoints/{_name}.pt")
508
- sat_train = satellite_bank(tr_targets, f"{CKPT_DIR}/satellite_bank_train.pt", hursat, gridsat)
509
- sat_val = satellite_bank(va_targets, f"{CKPT_DIR}/satellite_bank_val.pt", hursat, gridsat)
510
- for _name in ("satellite_bank_train", "satellite_bank_val"):
511
- mirror.push(f"{CKPT_DIR}/{_name}.pt", f"checkpoints/{_name}.pt")
512
- shutil.rmtree(os.path.join(DATA_DIR, "hursat"), ignore_errors=True) # the patches are kept; the archives are not
513
- plain_val = dict(bank_val)
514
- bank_train["satellite"], bank_val["satellite"] = sat_train, sat_val
515
- coverage = float(sat_val["available"][:, 0].float().mean())
516
- # The model as it arrives -- stage two, no satellite -- scored first: that is what stage three must beat.
517
- before = None if loaded is not None else storm_scorecard(model, plain_val, CFG.lead_times_hours)
518
- if loaded is not None:
519
- print("\nstage three is already trained: its checkpoint is loaded (RETRAIN_SATELLITE = True trains it again)")
520
- elif coverage < 0.3:
521
- print(f"\nonly {coverage:.0%} of held-out samples have an image: too few to judge the satellite branch, "
522
- "so it is not trained. Calibration and the input monitor still are.")
523
- else:
524
- history = train_storm_heads(model, bank_train, bank_val, epochs=80, batch_size=128, learning_rate=2e-4,
525
- satellite_learning_rate=1e-3, augment=True, keep_if_no_better=True,
526
- patience=15, log_every=5)
527
- banner("CALIBRATION, INPUT MONITOR, ROLLOUT STATISTICS")
528
- print("probabilities on held-out seasons, each season calibrated without itself:\n"
529
- + calibration_report(model, bank_val))
530
- fit_probability_calibration(model, bank_val)
531
- print(f"\ninput monitor fitted on the training storms: limit {fit_feature_monitor(model, bank_train):.2f}")
532
- model.state_tendency.copy_(stepper.tendency.to(model.state_tendency.device))
533
- _stage3 = CheckpointManager(f"{CKPT_DIR}/stage3", repo_id=HF_REPO)
534
- _stage3.save(model, None, state=TrainingState(step=int(model.trained_heads()["storm_satellite"])),
535
- config=model.config.to_dict(), force=True)
536
- _stage3.push_now(TrainingState(step=int(model.trained_heads()["storm_satellite"])))
537
- banner("SCOREBOARD — what the satellite adds, on held-out storms")
538
- if before is not None:
539
- print(f"stage two, before the satellite (the score to beat):\n{before}")
540
- else:
541
- print(f"stage three with the images withheld:\n{storm_scorecard(model, plain_val, CFG.lead_times_hours)}")
542
- print(f"\nstage three, with the satellite ({coverage:.0%} of held-out samples have an image):\n"
543
- f"{storm_scorecard(model, bank_val, CFG.lead_times_hours)}")
544
-
545
- # ═══ 9 ═══ did it learn? scorecards against persistence and climatology ════════════════════════════
546
- if not (RUN_PRETRAIN or RUN_ROLLOUT or RUN_STORMS or RUN_SATELLITE):
547
- print(f"loaded: {restore('stage3', 'stage2', 'rollout', 'stage1') or 'nothing -- untrained'}")
548
- print(f"\ntraining record (steps per output; 0 = never trained, never shown as a forecast):\n"
549
- f" {model.trained_heads()}")
550
-
551
- _probe_stream = iter(val_loader)
552
-
553
-
554
- def _probe():
555
- batch = next(_probe_stream)
556
- return {"analysis": batch["analysis"].to(DEVICE), "calendar": batch["calendar"].to(DEVICE)}
557
-
558
-
559
- responds, spread = responds_to_input(model, _probe, SRC)
560
- print(f"\nresponds to the weather: {responds} (spread {spread:.2e})")
561
-
562
- card = state_card = None
563
- if RUN_SCORE:
564
- banner("SCORECARDS — area-weighted, physical units, identical batches for every baseline")
565
- climo = build_climatology(train_ds, np.arange(len(train_ds)), WIDTH, SRC.num_points,
566
- samples=200)
567
- batches = 32
568
- card = score_model(model, val_loader, SRC, LAT, len(LON), CFG.lead_times_hours,
569
- fields=scoring_fields(train_ds.variables, _target_index(train_ds.variables)),
570
- normalizer=train_ds.normalizer, climatology=climo, max_batches=batches, device=DEVICE)
571
- print("surface heads, direct multi-lead:\n" + card.table() + "\n" + card.verdict())
572
- state_card = score_state(model, val_loader, stepper, HEADLINE_STATE_FIELDS,
573
- steps=ROLLOUT_STEPS, climatology=climo,
574
- max_batches=batches, precision=R["precision"])
575
- print("\nfull-state rollout (Z500 in m2/s2, as WeatherBench 2 reports it):\n"
576
- + state_card.table() + "\n" + state_card.verdict())
577
- if MEMBERS > 1 and "geopotential@500" in train_ds.variables:
578
- batch = {k: (v.to(DEVICE) if torch.is_tensor(v) else v) for k, v in next(iter(val_loader)).items()}
579
- lead = min(4, batch["state_target"].shape[1])
580
- ensemble = state_forecast(model, batch, stepper, steps=lead, members=max(MEMBERS, 4),
581
- precision=R["precision"])
582
- z500 = train_ds.variables.index("geopotential@500")
583
- ratio = spread_skill_ratio(ensemble[:, -1, ..., z500], batch["state_target"][:, lead - 1, :, z500])
584
- print(f"\nZ500 +{6 * lead}h ensemble spread/skill {ratio:.2f} (1.0 calibrated, <1 overconfident)")
585
- if ratio < 0.05:
586
- print(" no spread yet: the noise path starts at exactly zero and CRPS training grows it.")
587
-
588
- # ═══ 10 ═══ hindcast — a held-out hurricane, from its real analysis ════════════════════════════════
589
- if RUN_HINDCAST:
590
- banner(f"HINDCAST — {HINDCAST[0].title()} {HINDCAST[1]}, a season the model never trained on")
591
- storm = next((t for t in tracks if t.name.strip().upper() == HINDCAST[0] and t.year == HINDCAST[1]), None)
592
- starts, targets, _ = (pair_tracks_with_reanalysis([storm], STORE_TIMES, OFFSETS, history=6)
593
- if storm is not None else ([], [], None))
594
- if not len(starts):
595
- print("that storm is not in the reanalysis window (1959-2021); pick another in HINDCAST")
596
- else:
597
- peak = int(np.nanargmax(storm.max_wind_kt))
598
- choice = min(range(len(targets)), key=lambda i: abs(targets[i].point - max(peak - 8, 0))) # ~48 h before peak
599
- target = targets[choice].build()
600
- window = ERA5Window(ERA5, indices=starts[choice:choice + 1], augment=False, stats_cache=STATS, **common)[0]
601
- issued = dt.datetime.fromtimestamp(float(window["valid_time"]), tz=dt.timezone.utc)
602
- centre = target["storm_center"].tolist()
603
- here = target["storm_center"][None].to(DEVICE)
604
- # The storm's environment and the physics-only steering track, from the analysis "now".
605
- now = window["analysis"][-1][None].to(DEVICE)
606
- environment = storm_environment(now, train_ds.variables, train_ds.normalizer, SRC, here)
607
- steer = steering_track(now, train_ds.variables, train_ds.normalizer, SRC, here, CFG.lead_times_hours)
608
- print(describe_environment(environment[0]))
609
- # The satellite view at issue time and six hours earlier, if stage three has trained that branch.
610
- seen = None
611
- if model.trained_heads()["storm_satellite"] > 0:
612
- from naturev1.stormsat import _earlier_position
613
- earlier = _earlier_position(targets[choice])
614
- when = np.datetime64(issued.replace(tzinfo=None), "m")
615
- wanted = [(when, tuple(centre))] + ([earlier] if earlier is not None else [])
616
- images = GridSatArchive().patches(wanted, progress=False)
617
- if images[0] is not None:
618
- codes = np.zeros((1, 2, *images[0].shape), np.uint8)
619
- codes[0, 0] = encode_bt(images[0])
620
- if len(images) > 1 and images[1] is not None:
621
- codes[0, 1] = encode_bt(images[1])
622
- seen = (torch.from_numpy(codes).to(DEVICE),
623
- torch.tensor([[True, len(images) > 1 and images[1] is not None]], device=DEVICE))
624
- print("satellite (GridSat-B1): " + describe_patch(images[0]))
625
- with torch.no_grad(), torch.autocast(DEVICE, dtype=torch.bfloat16, enabled=DEVICE == "cuda"):
626
- out = model.eval()(analysis=window["analysis"][None].to(DEVICE), analysis_grid=SRC,
627
- calendar=window["calendar"][None].to(DEVICE), output_grid=SRC,
628
- storm_center=here, storm_state=target["storm_state"][None].to(DEVICE),
629
- storm_environment=environment,
630
- track_prior=steer if model.uses_track_prior() else None,
631
- storm_satellite=seen)
632
- out = {k: v.float().cpu() for k, v in out.items()}
633
- out.setdefault("track_prior", steer.float().cpu())
634
- # A second physics member: the storm carried by the atmosphere the model *forecasts*, step by
635
- # step, rather than by today's flow held steady. Only once the state head has been trained.
636
- rolled = None
637
- if model.trained_heads()["state"] > 0:
638
- with torch.no_grad():
639
- future = state_forecast(model, {"analysis": window["analysis"][None].to(DEVICE),
640
- "calendar": window["calendar"][None].to(DEVICE),
641
- "valid_time": window["valid_time"].view(1)},
642
- stepper, steps=ROLLOUT_STEPS, precision=R["precision"])[0]
643
- rolled = steering_track(torch.cat([now[None], future.to(DEVICE)]), train_ds.variables,
644
- train_ds.normalizer, SRC, here, CFG.lead_times_hours).float().cpu()
645
- fc = build_forecast(out, CFG.lead_times_hours, issued, storm_center=centre,
646
- normalizer=train_ds.normalizer, trained=model.trained_heads(), inputs=("analysis",),
647
- consensus={"weights": model.track_consensus.cpu(), "storm_state": target["storm_state"]})
648
- print(f"issued {issued:%Y-%m-%d %H:%M}Z at {centre[0]:.1f}N {-centre[1]:.1f}W, "
649
- f"{float(target['current_wind_kt']):.0f} kt now")
650
-
651
- def km_off(d_lat, d_lon, lead):
652
- lat = centre[0] + float(target["track_target"][lead, 0])
653
- lon = centre[1] + float(target["track_target"][lead, 1])
654
- return 111.2 * np.hypot(centre[0] + float(d_lat) - lat,
655
- (centre[1] + float(d_lon) - lon) * np.cos(np.radians(lat)))
656
-
657
- members = {"steering": steer[0].cpu(), "extrap.": extrapolation_track(target["storm_state"][None],
658
- CFG.lead_times_hours)[0]}
659
- if rolled is not None:
660
- members["rollout-steer"] = rolled[0]
661
- if isinstance(fc["track_scenarios"], list):
662
- top = fc["track_scenarios"][0]
663
- members = {f"model ({top['probability']:.0%})": torch.tensor(
664
- [[p["latitude"] - centre[0], p["longitude"] - centre[1]] for p in top["track"]]), **members}
665
- if "track_consensus" in fc:
666
- members["consensus"] = torch.tensor([[p["latitude"] - centre[0], p["longitude"] - centre[1]]
667
- for p in fc["track_consensus"]["track"]])
668
- print("track error vs what happened, km, by member -- the physics members need no training:")
669
- print(" " + f"{'lead':>6}" + "".join(f"{name:>16}" for name in members) + f"{'wind fcst/actual':>20}")
670
- for lead, hours in enumerate(CFG.lead_times_hours):
671
- if target["track_valid"][lead] > 0:
672
- row = "".join(f"{km_off(*member[lead], lead):>13.0f} km" for member in members.values())
673
- wind = ""
674
- if isinstance(fc["intensity"], list):
675
- wind = f"{fc['intensity'][lead]['max_wind_ms'] * 1.94384:5.0f}"
676
- if target["intensity_mask"][lead, 0] > 0:
677
- wind += f" / {float(target['intensity_target'][lead, 0]) * 1.94384:3.0f} kt"
678
- print(f" +{hours:4d}h {row}{wind:>20}")
679
- if not isinstance(fc["track_scenarios"], list):
680
- print("track head untrained -- run RUN_STORMS first; the physics members above still stand")
681
- problems = check_forecast(fc)
682
- print("forecast checks: " + ("pass" if not problems else "; ".join(problems)))
683
-
684
- # ═══ 11 ═══ publish only if it earned it ═══════════════════════════════════════════════════════════
685
- if RUN_PUBLISH:
686
- cards = [c for c in (state_card, card) if c is not None]
687
- beats = [s for c in cards for s in c.scores if s.beats_persistence]
688
- if not cards:
689
- print("\nnot publishing: nothing was scored (set RUN_SCORE = True).")
690
- elif not responds:
691
- print("\nnot publishing: the model gives the same answer whatever weather it is shown.")
692
- elif not beats:
693
- print("\nnot publishing: it loses to persistence at every field and lead.")
694
- else:
695
- url = push_to_hub(model, HF_REPO, normalizer=train_ds.normalizer, scorecard=cards[0], name="NatureV1",
696
- author="Nathan", token=HF_TOKEN or None,
697
- training_data=f"ERA5 (WeatherBench 2), {len(CHANNELS)} channels, {R['years']} years; "
698
- f"full-state {ROLLOUT_STEPS}-step rollout, {MEMBERS}-member CRPS; "
699
- f"HURDAT2 {STORM_FIRST_SEASON}+ for the storm heads.")
700
- print(f"\npublished: {url} (beats persistence on {len(beats)} field-lead pairs)")
701
-
702
- # ═══ 12 ═══ live forecast from GOES — only once that input path is trained ═════════════════════════
703
- def load_scene(paths, center, half_width_deg=9.0, stride=4):
704
- """Geolocated samples plus true pixel footprints, cropped to a real box on the planet."""
705
- scene = scene_from_netcdf({c: str(p) for c, p in paths.items()}, stride=stride,
706
- bounds=(center[0] - half_width_deg, center[0] + half_width_deg,
707
- center[1] - half_width_deg, center[1] + half_width_deg))
708
- coords = torch.tensor(np.stack([np.radians(scene.latitude), np.radians(scene.longitude) % (2 * np.pi)], -1),
709
- dtype=torch.float64)
710
- grid = FieldGrid.from_points(coords, Geometry.globe(), num_neighbours=16, cluster_size=64,
711
- weights=torch.tensor(scene.area_km2, dtype=torch.float64))
712
- values = torch.tensor(normalize_channels(scene.values, scene.channels), dtype=torch.float32)
713
- if CFG.satellite_channels > values.shape[-1]:
714
- values = torch.cat([values, torch.zeros(values.shape[0], CFG.satellite_channels - values.shape[-1])], -1)
715
- return grid, values, scene
716
-
717
-
718
- @torch.no_grad()
719
- def forecast_now(storm=STORM, city=CITY):
720
- paths = fetch_latest(os.path.join(DATA_DIR, "goes"), ("C13", "C09"), satellite="east", product="conus")
721
- grid, values, scene = load_scene(paths, storm)
722
- print(scene)
723
- frames = CFG.history_frames
724
- out_grid = fibonacci_sphere(2000, num_neighbours=16, cluster_size=50)
725
- out = model.eval()(satellite=values[None, None].expand(1, frames, -1, -1).contiguous().to(DEVICE),
726
- satellite_grid=grid, output_grid=out_grid, neighbours=12,
727
- calendar=calendar_features(torch.full((1, frames), scene.timestamp.timestamp())).to(DEVICE),
728
- storm_center=torch.tensor([storm], dtype=torch.float32, device=DEVICE))
729
- return build_forecast({k: v.float().cpu() for k, v in out.items()}, CFG.lead_times_hours, scene.timestamp,
730
- storm_center=storm, output_grid=out_grid, point_of_interest=city,
731
- normalizer=train_ds.normalizer, trained=model.trained_heads(), inputs=("satellite",))
732
-
733
-
734
- def show(fc):
735
- print(f"\nissued {fc['issued']} trustworthy: {fc['trustworthy']}")
736
- for key in ("warning", "note"):
737
- if key in fc:
738
- print(f" !! {fc[key]}")
739
- for section in ("enso", "landfall", "track_scenarios", "eyewall", "rapid_intensification"):
740
- value = fc.get(section)
741
- if isinstance(value, dict) and value.get("available") is False:
742
- print(f" {section}: not available -- {value['reason']}")
743
- if isinstance(fc.get("track_scenarios"), list):
744
- for s in fc["track_scenarios"][:4]:
745
- p = s["track"][5]
746
- print(f" {s['probability']:>5.0%} -> +{p['lead_hours']}h {p['latitude']:6.2f},{p['longitude']:7.2f}"
747
- f" 95% cone {p['cone_radius_km_95']:.0f} km")
748
- print(" checks: " + ("pass" if not check_forecast(fc) else "; ".join(check_forecast(fc))))
749
-
750
-
751
- if RUN_FORECAST or RUN_WATCHER:
752
- if model.trained_heads()["satellite_encoder"] == 0:
753
- print("\nlive GOES forecast refused: no training stage has ever fed this model satellite imagery, so "
754
- "its satellite encoder is at initialisation and every number it produced would be noise shaped "
755
- "like a forecast. Use the HINDCAST section, which runs from the analysis the model was trained on.")
756
- elif RUN_FORECAST:
757
- banner("LIVE FORECAST — newest GOES scene")
758
- show(forecast_now())
759
-
760
-
761
- def on_new_scene(paths):
762
- result = forecast_now()
763
- os.makedirs(os.path.join(DATA_DIR, "forecasts"), exist_ok=True)
764
- stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M")
765
- with open(os.path.join(DATA_DIR, "forecasts", f"forecast_{stamp}.json"), "w") as handle:
766
- json.dump(result, handle, indent=2)
767
- show(result)
768
-
769
-
770
- if RUN_WATCHER and model.trained_heads()["satellite_encoder"] > 0:
771
- banner("WATCHING — a new forecast every hour")
772
- watch(on_new_scene, interval_seconds=3600, directory=os.path.join(DATA_DIR, "goes"), channels=("C13", "C09"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/assimilation.py DELETED
@@ -1,290 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Data assimilation for live running: scrub real-time inputs before they reach a model trained on ERA5.
4
-
5
- NatureV1 (like every machine-learning weather model) learned on ERA5, a reanalysis polished by 4D-Var with
6
- observations that arrived for years after the fact. In real time the inputs are messier: another centre's
7
- operational analysis with its own biases and resolution, late or missing observations, a radiosonde with a
8
- transmission error. Fed straight in, those differences become forecast error. ECMWF's AIFS sidesteps this by
9
- running inside an operational data-assimilation cycle; this module gives NatureV1 a compact version of one:
10
-
11
- 1. **Background** -- NatureV1's own 6-hour forecast from the previous cycle, with its own predicted variance
12
- (``state_delta_log_var``) as the background error variance: flow-dependent, large where the model is
13
- unsure (a developing storm, a front), small where it is confident.
14
- 2. **Bias correction** (:class:`BiasCorrector`) -- the running mean of innovations (observation minus background)
15
- per channel and latitude band, an exponentially-weighted version of variational bias correction, removed
16
- from each source before it is used; a real-time analysis that is systematically 0.5 K warmer than ERA5 at
17
- 850 hPa stops pulling NatureV1 off its climate.
18
- 3. **Quality control** (:func:`quality_control`) -- a gross check against physical limits, a background check
19
- (reject when ``|O - B| > k sqrt(sigma_o^2 + sigma_b^2)``), and a buddy check (reject an observation whose
20
- innovation disagrees with the median of its neighbours' by more than ``k`` of their spread).
21
- 4. **Analysis** (:func:`analyse`) -- optimal interpolation, solved locally in tiles (as in a local ensemble
22
- transform filter, without the ensemble): the observations within a Gaspari-Cohn localisation radius of a tile
23
- are combined with the background through ``x_a = x_b + B H^T (H B H^T + R)^-1 (y - H x_b)``, with Gaussian
24
- background-error correlations of a per-variable length scale.
25
- 5. **Super-observations** (:func:`superobs`) -- a gridded source (a 0.25-degree analysis) is first averaged into
26
- coarser boxes, so its spatially correlated errors are not counted as independent evidence.
27
-
28
- Everything works channel by channel on NatureV1's normalised ``(points, channels)`` state.
29
- """
30
-
31
- from __future__ import annotations
32
-
33
- from dataclasses import dataclass, field
34
-
35
- import numpy as np
36
-
37
- from .cyclone.geometry import EARTH_RADIUS_KM
38
-
39
- #: Physical limits for the gross check, in SI units, by variable base name.
40
- LIMITS = {"temperature": (150.0, 340.0), "2m_temperature": (180.0, 340.0), "geopotential": (-5000.0, 220000.0),
41
- "u_component_of_wind": (-120.0, 120.0), "v_component_of_wind": (-120.0, 120.0),
42
- "10m_u_component_of_wind": (-80.0, 80.0), "10m_v_component_of_wind": (-80.0, 80.0),
43
- "mean_sea_level_pressure": (85000.0, 110000.0), "specific_humidity": (0.0, 0.04),
44
- "surface_pressure": (45000.0, 110000.0), "sea_surface_temperature": (269.0, 310.0)}
45
- #: Background-error correlation length scale, km, by variable base name (default 400).
46
- LENGTH_KM = {"geopotential": 600.0, "mean_sea_level_pressure": 500.0, "temperature": 400.0, "2m_temperature": 250.0,
47
- "u_component_of_wind": 350.0, "v_component_of_wind": 350.0, "specific_humidity": 250.0}
48
-
49
-
50
- def unit_vectors(lat, lon) -> np.ndarray:
51
- la, lo = np.radians(lat), np.radians(lon)
52
- return np.stack([np.cos(la) * np.cos(lo), np.cos(la) * np.sin(lo), np.sin(la)], -1)
53
-
54
-
55
- def chord_km(a: np.ndarray, b: np.ndarray) -> np.ndarray:
56
- """Great-circle distance between unit vectors (rows of ``a`` against rows of ``b``), km."""
57
- dot = np.clip(a @ b.T, -1.0, 1.0)
58
- return EARTH_RADIUS_KM * np.arccos(dot)
59
-
60
-
61
- def gaspari_cohn(distance: np.ndarray, radius: float) -> np.ndarray:
62
- """Gaspari-Cohn fifth-order compactly supported correlation, zero beyond ``2 * radius`` -- here ``radius`` is half."""
63
- r = np.abs(distance) / radius
64
- out = np.zeros_like(r)
65
- a = r <= 1.0
66
- b = (r > 1.0) & (r < 2.0)
67
- ra, rb = r[a], r[b]
68
- out[a] = -0.25 * ra ** 5 + 0.5 * ra ** 4 + 0.625 * ra ** 3 - 5.0 / 3.0 * ra ** 2 + 1.0
69
- out[b] = rb ** 5 / 12.0 - 0.5 * rb ** 4 + 0.625 * rb ** 3 + 5.0 / 3.0 * rb ** 2 - 5.0 * rb + 4.0 - 2.0 / (3.0 * rb)
70
- return out
71
-
72
-
73
- @dataclass
74
- class Observations:
75
- """Observations of one channel: positions, values and error standard deviations (all in the same units as
76
- the state they are compared with)."""
77
-
78
- lat: np.ndarray
79
- lon: np.ndarray
80
- value: np.ndarray
81
- sigma: np.ndarray
82
- source: np.ndarray = field(default_factory=lambda: np.zeros(0, dtype="<U16"))
83
-
84
- def __post_init__(self):
85
- self.lat, self.lon = np.asarray(self.lat, np.float64), np.asarray(self.lon, np.float64)
86
- self.value, self.sigma = np.asarray(self.value, np.float64), np.broadcast_to(np.asarray(self.sigma, np.float64), self.lat.shape).copy()
87
- if self.source.size != self.lat.size:
88
- self.source = np.full(self.lat.size, "obs", dtype="<U16")
89
-
90
- def subset(self, keep: np.ndarray) -> "Observations":
91
- return Observations(self.lat[keep], self.lon[keep], self.value[keep], self.sigma[keep], self.source[keep])
92
-
93
- def __len__(self) -> int:
94
- return self.lat.size
95
-
96
-
97
- def interpolate_background(grid_lat: np.ndarray, grid_lon: np.ndarray, field: np.ndarray, lat, lon) -> np.ndarray:
98
- """Bilinear background ``H x_b`` at observation points on a regular grid (lat ascending, lon 0..360)."""
99
- step_lat = grid_lat[1] - grid_lat[0]
100
- step_lon = grid_lon[1] - grid_lon[0]
101
- grid = field.reshape(grid_lat.size, grid_lon.size)
102
- fi = np.clip((np.asarray(lat) - grid_lat[0]) / step_lat, 0, grid_lat.size - 1.000001)
103
- fj = (np.asarray(lon) % 360.0 - grid_lon[0]) / step_lon
104
- i0 = np.floor(fi).astype(int)
105
- j0 = np.floor(fj).astype(int) % grid_lon.size
106
- j1 = (j0 + 1) % grid_lon.size
107
- di, dj = fi - i0, fj - np.floor(fj)
108
- return ((1 - di) * (1 - dj) * grid[i0, j0] + (1 - di) * dj * grid[i0, j1] + di * (1 - dj) * grid[i0 + 1, j0]
109
- + di * dj * grid[i0 + 1, j1])
110
-
111
-
112
- class BiasCorrector:
113
- """Running mean of innovations per latitude band (an exponentially weighted variational bias correction)."""
114
-
115
- def __init__(self, bands_deg: float = 15.0, memory: float = 0.05) -> None:
116
- self.edges = np.arange(-90.0, 90.0 + bands_deg, bands_deg)
117
- self.memory = memory
118
- self.bias: dict[str, np.ndarray] = {}
119
- self.updates: dict[str, np.ndarray] = {}
120
-
121
- def correct(self, source: str, obs: Observations) -> Observations:
122
- b = self.bias.get(source)
123
- if b is None:
124
- return obs
125
- band = np.clip(np.searchsorted(self.edges, obs.lat) - 1, 0, len(self.edges) - 2)
126
- return Observations(obs.lat, obs.lon, obs.value - b[band], obs.sigma, obs.source)
127
-
128
- def update(self, source: str, obs: Observations, innovation: np.ndarray) -> None:
129
- band = np.clip(np.searchsorted(self.edges, obs.lat) - 1, 0, len(self.edges) - 2)
130
- current = self.bias.get(source, np.zeros(len(self.edges) - 1))
131
- seen = self.updates.get(source, np.zeros(len(self.edges) - 1))
132
- for k in range(len(current)):
133
- sel = band == k
134
- if sel.sum() >= 5:
135
- # Warm start: the first updates are a plain running mean, then the memory takes over.
136
- rate = max(self.memory, 1.0 / (seen[k] + 1.0))
137
- # Innovations measured against bias-corrected values: add the current estimate back.
138
- current[k] = (1 - rate) * current[k] + rate * (current[k] + float(np.median(innovation[sel])))
139
- seen[k] += 1
140
- self.bias[source], self.updates[source] = current, seen
141
-
142
-
143
- def quality_control(obs: Observations, background_at_obs: np.ndarray, background_sigma_at_obs: np.ndarray,
144
- limits: tuple[float, float] | None = None, k_background: float = 4.0, k_buddy: float = 3.0,
145
- buddy_km: float = 300.0) -> tuple[np.ndarray, dict]:
146
- """``(keep, counts)``: gross, background and buddy checks; ``limits`` in the observations' units."""
147
- keep = np.isfinite(obs.value)
148
- counts = {"total": int(len(obs))}
149
- if limits is not None:
150
- gross = (obs.value < limits[0]) | (obs.value > limits[1])
151
- keep &= ~gross
152
- counts["gross"] = int(gross.sum())
153
- innovation = obs.value - background_at_obs
154
- scale = np.sqrt(obs.sigma ** 2 + background_sigma_at_obs ** 2)
155
- background = np.abs(innovation) > k_background * scale
156
- keep &= ~background
157
- counts["background"] = int((background & np.isfinite(obs.value)).sum())
158
- buddy_reject = np.zeros(len(obs), bool)
159
- idx = np.nonzero(keep)[0]
160
- if idx.size > 3:
161
- v = unit_vectors(obs.lat[idx], obs.lon[idx])
162
- for start in range(0, idx.size, 2048):
163
- block = idx[start:start + 2048]
164
- d = chord_km(unit_vectors(obs.lat[block], obs.lon[block]), v)
165
- near = (d < buddy_km) & (d > 0)
166
- for row, i in enumerate(block):
167
- neighbours = idx[near[row]]
168
- if neighbours.size < 3:
169
- continue
170
- med = np.median(innovation[neighbours])
171
- spread = 1.4826 * np.median(np.abs(innovation[neighbours] - med)) + 0.5 * scale[i]
172
- if abs(innovation[i] - med) > k_buddy * spread:
173
- buddy_reject[i] = True
174
- keep &= ~buddy_reject
175
- counts["buddy"] = int(buddy_reject.sum())
176
- counts["kept"] = int(keep.sum())
177
- return keep, counts
178
-
179
-
180
- def superobs(obs: Observations, box_deg: float = 1.5) -> Observations:
181
- """Average observations into ``box_deg`` boxes (error shrinks only as the square root of a quarter of the
182
- count, a hedge against their correlated errors)."""
183
- i = np.floor((obs.lat + 90.0) / box_deg).astype(int)
184
- j = np.floor((obs.lon % 360.0) / box_deg).astype(int)
185
- key = i * 100000 + j
186
- unique, inverse, counts = np.unique(key, return_inverse=True, return_counts=True)
187
- lat = np.bincount(inverse, obs.lat) / counts
188
- lon = np.bincount(inverse, np.where(obs.lon % 360.0 > 359.0, obs.lon % 360.0 - 360.0, obs.lon % 360.0)) / counts
189
- value = np.bincount(inverse, obs.value) / counts
190
- sigma = np.sqrt(np.bincount(inverse, obs.sigma ** 2) / counts) / np.sqrt(np.maximum(counts / 4.0, 1.0))
191
- return Observations(lat, lon % 360.0, value, sigma)
192
-
193
-
194
- def analyse(grid_lat: np.ndarray, grid_lon: np.ndarray, background: np.ndarray, background_sigma: np.ndarray,
195
- obs: Observations, length_km: float = 400.0, localisation_km: float = 1200.0, tile: int = 4) -> tuple[np.ndarray, np.ndarray]:
196
- """
197
- Local optimal interpolation of one channel. ``background``/``background_sigma``: ``(P,)`` on the regular grid.
198
-
199
- Returns ``(analysis (P,), analysis_sigma (P,))``.
200
- """
201
- analysis = background.astype(np.float64).copy()
202
- a_sigma = background_sigma.astype(np.float64).copy()
203
- if len(obs) == 0:
204
- return analysis, a_sigma
205
- lat_g, lon_g = np.meshgrid(grid_lat, grid_lon, indexing="ij")
206
- hx = interpolate_background(grid_lat, grid_lon, background, obs.lat, obs.lon)
207
- sb_obs = interpolate_background(grid_lat, grid_lon, background_sigma, obs.lat, obs.lon)
208
- innovation = obs.value - hx
209
- ov = unit_vectors(obs.lat, obs.lon)
210
- h, w = lat_g.shape
211
- for i0 in range(0, h, tile):
212
- for j0 in range(0, w, tile):
213
- rows = slice(i0, min(i0 + tile, h))
214
- cols = slice(j0, min(j0 + tile, w))
215
- tlat, tlon = lat_g[rows, cols].ravel(), lon_g[rows, cols].ravel()
216
- tv = unit_vectors(tlat, tlon)
217
- centre = unit_vectors(tlat.mean(), np.degrees(np.arctan2(np.sin(np.radians(tlon)).mean(), np.cos(np.radians(tlon)).mean())))
218
- dc = chord_km(centre[None], ov)[0]
219
- near = dc < localisation_km + tile * 170.0
220
- if not near.any():
221
- continue
222
- o = np.nonzero(near)[0]
223
- d_oo = chord_km(ov[o], ov[o])
224
- d_to = chord_km(tv, ov[o])
225
- loc_oo = gaspari_cohn(d_oo, localisation_km / 2.0)
226
- loc_to = gaspari_cohn(d_to, localisation_km / 2.0)
227
- sb_o = sb_obs[o]
228
- point_index = (np.arange(h)[rows][:, None] * w + np.arange(w)[cols][None, :]).ravel()
229
- sb_t = background_sigma[point_index]
230
- b_oo = sb_o[:, None] * sb_o[None, :] * np.exp(-0.5 * (d_oo / length_km) ** 2) * loc_oo
231
- b_to = sb_t[:, None] * sb_o[None, :] * np.exp(-0.5 * (d_to / length_km) ** 2) * loc_to
232
- s = b_oo + np.diag(obs.sigma[o] ** 2)
233
- try:
234
- gain = np.linalg.solve(s, b_to.T).T # (T, O)
235
- except np.linalg.LinAlgError:
236
- continue
237
- analysis[point_index] = background[point_index] + gain @ innovation[o]
238
- reduction = np.einsum("to,to->t", gain, b_to)
239
- a_sigma[point_index] = np.sqrt(np.maximum(sb_t ** 2 - reduction, 0.05 * sb_t ** 2))
240
- return analysis, a_sigma
241
-
242
-
243
- class Assimilator:
244
- """
245
- The cycle: bias-correct each source, quality-control against the background, analyse every channel.
246
-
247
- ``state`` and ``sigma`` are ``(P, C)`` in physical units for the channels being analysed (decode NatureV1's
248
- normalised state first with its normaliser); ``observations`` maps a channel name to a list of
249
- ``(source name, Observations)``.
250
- """
251
-
252
- def __init__(self, channels: list[str], grid_lat=np.linspace(-90, 90, 121), grid_lon=np.arange(240) * 1.5,
253
- bias: BiasCorrector | None = None, anchors=("radiosonde", "buoy", "aircraft")) -> None:
254
- self.channels, self.grid_lat, self.grid_lon = list(channels), np.asarray(grid_lat), np.asarray(grid_lon)
255
- self.bias = bias or BiasCorrector()
256
- # Sources trusted to be unbiased: they anchor the system and are never bias-corrected, so a biased
257
- # background cannot drag the corrections along with it.
258
- self.anchors = set(anchors)
259
-
260
- def cycle(self, state: np.ndarray, sigma: np.ndarray, observations: dict) -> tuple[np.ndarray, np.ndarray, dict]:
261
- analysis, a_sigma = state.copy(), sigma.copy()
262
- report = {}
263
- for name, sources in observations.items():
264
- if name not in self.channels:
265
- continue
266
- c = self.channels.index(name)
267
- base = name.split("@")[0]
268
- merged = []
269
- for source, obs in sources:
270
- anchored = source in self.anchors
271
- if not anchored:
272
- obs = self.bias.correct(f"{source}|{name}", obs)
273
- hx = interpolate_background(self.grid_lat, self.grid_lon, state[:, c], obs.lat, obs.lon)
274
- sb = interpolate_background(self.grid_lat, self.grid_lon, sigma[:, c], obs.lat, obs.lon)
275
- keep, counts = quality_control(obs, hx, sb, LIMITS.get(base))
276
- if not anchored:
277
- self.bias.update(f"{source}|{name}", obs.subset(keep), (obs.value - hx)[keep])
278
- report[f"{source}|{name}"] = counts
279
- merged.append(obs.subset(keep))
280
- if not merged:
281
- continue
282
- combined = Observations(np.concatenate([m.lat for m in merged]), np.concatenate([m.lon for m in merged]),
283
- np.concatenate([m.value for m in merged]), np.concatenate([m.sigma for m in merged]))
284
- analysis[:, c], a_sigma[:, c] = analyse(self.grid_lat, self.grid_lon, state[:, c], sigma[:, c], combined,
285
- LENGTH_KM.get(base, 400.0))
286
- return analysis, a_sigma, report
287
-
288
-
289
- __all__ = ["Assimilator", "BiasCorrector", "LENGTH_KM", "LIMITS", "Observations", "analyse", "gaspari_cohn",
290
- "interpolate_background", "quality_control", "superobs", "unit_vectors"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/b300.py DELETED
@@ -1,257 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Orchestration for one 8-GPU machine: stage the record, launch the training runs, follow them.
4
-
5
- Every long job runs as its own process group, detached from the notebook or terminal that started it,
6
- writing to a log file and a PID file -- so closing the notebook, losing the connection or restarting the
7
- kernel never stops a run, and running the same cell again reattaches to it instead of starting a second
8
- copy. The runbook (``b300_naturev1.py``) is a thin layer of settings over these functions.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import json
14
- import os
15
- import signal
16
- import subprocess
17
- import sys
18
- import time
19
- from pathlib import Path
20
-
21
- import numpy as np
22
- import torch
23
-
24
-
25
- # ------------------------------------------------------------------------------------------- data --
26
-
27
- def stage_era5_record(directory: str | Path, first_year: int, last_year: int, stats_path: str | Path,
28
- workers: int = 32) -> Path:
29
- """
30
- Stage every six-hourly ERA5 state of ``first_year``-``last_year`` (WeatherBench 2, 1.5 degrees, the
31
- backbone's 89 channels) to one memmap, normalised with the *training* statistics -- so the retrieval
32
- and stage B see exactly what the backbone was trained on. About 7.7 GB a year. Resumable.
33
- """
34
- from .era5 import Normalizer, expand_variables, materialise
35
- from .upper import open_weatherbench_levels
36
-
37
- stats = json.loads(Path(stats_path).read_text())
38
- names = list(stats["variables"])
39
- levels = sorted({int(v.split("@")[1]) for v in names if "@" in v})
40
- base = tuple(dict.fromkeys(v.split("@")[0] for v in names))
41
- dataset, _, _ = open_weatherbench_levels()
42
- expanded = expand_variables(dataset, base, levels)
43
- if expanded != names:
44
- raise ValueError("the store's channels do not line up with the training statistics: "
45
- f"{len(expanded)} vs {len(names)}")
46
- years = dataset.time.values.astype("datetime64[Y]").astype(int) + 1970
47
- indices = np.where((years >= first_year) & (years <= last_year))[0]
48
- path = Path(directory) / f"era5_{first_year}_{last_year}.npy"
49
- print(f"[b300] ERA5 {first_year}-{last_year}: {len(indices):,} states, about "
50
- f"{len(indices) * 121 * 240 * len(names) * 2 / 1e9:.0f} GB", flush=True)
51
- return materialise(dataset, indices, path, variables=base, normalizer=Normalizer.from_dict(stats), levels=levels,
52
- workers=workers, chunk=16)
53
-
54
-
55
- def memmap_tendency(path: str | Path, channels: int, samples: int = 512, seed: int = 0) -> torch.Tensor:
56
- """
57
- Per-channel spread of the six-hour change, in the model's channel layout, from a staged memmap: the
58
- scale the full-state rollout and the retrieval predict in. Only for checkpoints that do not record it.
59
- """
60
- from .era5 import Normalizer
61
- from .satdata import prepared_to_state
62
-
63
- path = Path(path)
64
- meta = json.loads(path.with_suffix(".json").read_text())
65
- values = np.load(path, mmap_mode="r")
66
- normalizer = Normalizer.from_dict(meta["statistics"])
67
- times = np.asarray(meta["time"], dtype=np.int64)
68
- consecutive = np.where(np.diff(times) == 21600)[0]
69
- rng = np.random.default_rng(seed)
70
- chosen = rng.choice(consecutive, min(samples, len(consecutive)), replace=False)
71
- total = squares = None
72
- count = 0
73
- for row in chosen:
74
- delta = (prepared_to_state(values[row + 1], normalizer, channels)
75
- - prepared_to_state(values[row], normalizer, channels)).double()
76
- total = delta.sum(0) if total is None else total + delta.sum(0)
77
- squares = (delta ** 2).sum(0) if squares is None else squares + (delta ** 2).sum(0)
78
- count += delta.shape[0]
79
- mean = total / count
80
- return (squares / count - mean ** 2).clamp_min(0).sqrt().clamp_min(1e-3).float()
81
-
82
-
83
- # ------------------------------------------------------------------------------------------- jobs --
84
-
85
- def _pid_file(logs: Path, name: str) -> Path:
86
- return logs / f"{name}.pid"
87
-
88
-
89
- def running(logs: str | Path, name: str) -> int | None:
90
- """The PID of job ``name`` if it is still alive."""
91
- path = _pid_file(Path(logs), name)
92
- if not path.exists():
93
- return None
94
- try:
95
- pid = int(path.read_text().strip())
96
- os.kill(pid, 0)
97
- return pid
98
- except (ValueError, ProcessLookupError, PermissionError):
99
- return None
100
-
101
-
102
- def start_job(name: str, args: list[str], logs: str | Path, gpus: list[int] | None = None,
103
- env: dict | None = None) -> int:
104
- """
105
- Start ``args`` detached (its own session), logging to ``<logs>/<name>.log``. With ``gpus``, it runs
106
- under ``torchrun`` on exactly those GPUs. Returns the PID; if the job is already running, returns
107
- that PID and starts nothing.
108
- """
109
- logs = Path(logs)
110
- logs.mkdir(parents=True, exist_ok=True)
111
- alive = running(logs, name)
112
- if alive:
113
- print(f"[b300] {name} is already running (pid {alive}); following it", flush=True)
114
- return alive
115
- environment = {**os.environ, **(env or {})}
116
- command = list(args)
117
- if gpus is not None:
118
- environment["CUDA_VISIBLE_DEVICES"] = ",".join(str(g) for g in gpus)
119
- import zlib
120
-
121
- port = 29500 + zlib.crc32(name.encode()) % 400 # stable per job, distinct between jobs
122
- command = [sys.executable, "-m", "torch.distributed.run", f"--nproc_per_node={len(gpus)}",
123
- f"--master_port={port}", *command]
124
- environment.setdefault("OMP_NUM_THREADS", "4")
125
- environment.setdefault("PYTHONUNBUFFERED", "1")
126
- log = open(logs / f"{name}.log", "ab")
127
- log.write(f"\n===== {time.strftime('%Y-%m-%d %H:%M:%S')} start: {' '.join(command)}\n".encode())
128
- log.flush()
129
- process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, env=environment,
130
- start_new_session=True)
131
- _pid_file(logs, name).write_text(str(process.pid))
132
- print(f"[b300] started {name} (pid {process.pid}) -> {logs / (name + '.log')}", flush=True)
133
- return process.pid
134
-
135
-
136
- def stop_job(logs: str | Path, name: str) -> None:
137
- pid = running(logs, name)
138
- if pid:
139
- os.killpg(os.getpgid(pid), signal.SIGTERM)
140
- print(f"[b300] stopped {name}", flush=True)
141
-
142
-
143
- def follow(logs: str | Path, names: list[str], every: float = 2.0, quiet_minutes: float = 5.0,
144
- seconds: float | None = None) -> dict:
145
- """
146
- Print new log lines of every job as they come, a heartbeat when they are quiet, until all have ended
147
- (or ``seconds`` pass). Interrupting this stops only the watching. Returns each job's last line.
148
- """
149
- logs = Path(logs)
150
- offsets = {name: (logs / f"{name}.log").stat().st_size if (logs / f"{name}.log").exists() else 0
151
- for name in names}
152
- # Show the recent past once, so a re-attached cell has context.
153
- for name in names:
154
- path = logs / f"{name}.log"
155
- if path.exists():
156
- tail = path.read_bytes()[-4000:].decode(errors="replace").splitlines()[-15:]
157
- print("\n".join(f"[{name}] {line}" for line in tail), flush=True)
158
- began, last_output, last = time.time(), time.time(), {}
159
- try:
160
- while True:
161
- for name in names:
162
- path = logs / f"{name}.log"
163
- if not path.exists():
164
- continue
165
- with open(path, "rb") as handle:
166
- handle.seek(offsets[name])
167
- chunk = handle.read()
168
- offsets[name] = handle.tell()
169
- if chunk:
170
- text = chunk.decode(errors="replace").rstrip("\n")
171
- for line in text.splitlines():
172
- if line.strip():
173
- print(f"[{name}] {line}", flush=True)
174
- last[name] = line
175
- last_output = time.time()
176
- alive = [n for n in names if running(logs, n)]
177
- if not alive:
178
- print("[b300] all jobs have ended", flush=True)
179
- return last
180
- if time.time() - last_output > quiet_minutes * 60:
181
- print(f"[b300] {time.strftime('%H:%M')} still running: {', '.join(alive)}", flush=True)
182
- last_output = time.time()
183
- if seconds is not None and time.time() - began > seconds:
184
- return last
185
- time.sleep(every)
186
- except KeyboardInterrupt:
187
- print("[b300] stopped watching; the jobs keep running. Run the cell again to reattach.", flush=True)
188
- return last
189
-
190
-
191
- # ------------------------------------------------------------------------------------------ release --
192
-
193
- def publish_release(checkpoint: str | Path, stats_path: str | Path, hub_repo: str, directory: str | Path,
194
- comparison: str | Path | None = None, report: str | Path | None = None,
195
- minimum_better: float = 0.5, force: bool = False) -> str:
196
- """
197
- Publish the stage-B model as the release (``model.safetensors``, ``config.json``, ``normalizer.json``
198
- at the top of the repository, what :func:`naturev1.from_pretrained` reads) -- but only if it earned
199
- it: when ``comparison`` (from stage B) shows it better than the model it started from on fewer than
200
- ``minimum_better`` of the (lead, metric) pairs on the test storms, it goes to ``candidates/0.12/``
201
- instead and the current release is left alone. ``force`` publishes regardless. Returns where it went.
202
- """
203
- from dataclasses import asdict
204
- import hashlib
205
-
206
- from huggingface_hub import HfApi
207
- from safetensors.torch import save_file
208
-
209
- from .hub import from_pretrained
210
- from .sattrain import build_model
211
-
212
- payload = torch.load(checkpoint, map_location="cpu", weights_only=False)
213
- config = payload["config"]
214
- model = build_model(str(checkpoint), satnet_embed=config.get("satnet_embed", 0),
215
- retrieval_layers=config.get("retrieval_layers", 0), ri2=config.get("ri2", False),
216
- hemisphere_frame=config.get("hemisphere_frame", False))
217
- verdict, share = "release", None
218
- if comparison is not None and Path(comparison).exists():
219
- share = json.loads(Path(comparison).read_text()).get("better_share_global")
220
- if share is not None and share < minimum_better and not force:
221
- verdict = "candidate"
222
- out = Path(directory)
223
- out.mkdir(parents=True, exist_ok=True)
224
- state = {k: v.detach().cpu().contiguous() for k, v in model.state_dict().items()}
225
- digest = hashlib.sha256()
226
- for name in sorted(state):
227
- digest.update(name.encode())
228
- digest.update(state[name].numpy().tobytes() if state[name].dtype != torch.bfloat16
229
- else state[name].float().numpy().tobytes())
230
- save_file(state, out / "model.safetensors", metadata={"format": "pt", "tensor_sha256": digest.hexdigest()})
231
- torch.save(model.state_dict(), out / "model.safetensors.pt")
232
- (out / "config.json").write_text(json.dumps(asdict(model.config), indent=2, default=list))
233
- (out / "normalizer.json").write_text(Path(stats_path).read_text())
234
- (out / "training_record.json").write_text(json.dumps(
235
- {"trained_steps_per_output": model.trained_heads(), "released_from": str(checkpoint),
236
- "better_share_on_test_storms": share, "parameters": model.num_parameters()}, indent=2))
237
- for extra in (report, comparison):
238
- if extra is not None and Path(extra).exists():
239
- (out / Path(extra).name).write_text(Path(extra).read_text())
240
- prefix = "" if verdict == "release" else "candidates/0.12/"
241
- api = HfApi(token=os.environ.get("HF_TOKEN") or None)
242
- api.create_repo(hub_repo, repo_type="model", exist_ok=True)
243
- api.upload_folder(folder_path=str(out), repo_id=hub_repo, repo_type="model", path_in_repo=prefix or None,
244
- commit_message=f"NatureV1 0.12 {'release' if verdict == 'release' else 'candidate'}")
245
- if verdict == "release":
246
- loaded, _, _ = from_pretrained(hub_repo, device="cpu")
247
- same = all(torch.equal(loaded.state_dict()[k].cpu(), v) for k, v in state.items())
248
- print(f"[b300] released to {hub_repo}; reloaded with from_pretrained: "
249
- f"{'identical' if same else 'DIFFERENT -- check the upload'}", flush=True)
250
- else:
251
- print(f"[b300] better than the starting model on only {100 * (share or 0):.0f}% of test comparisons: "
252
- f"uploaded to {hub_repo}/candidates/0.12/ and left the current release in place "
253
- "(pass force=True to override)", flush=True)
254
- return prefix or "/"
255
-
256
-
257
- __all__ = ["follow", "publish_release", "memmap_tendency", "running", "stage_era5_record", "start_job", "stop_job"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/besttrack.py DELETED
@@ -1,239 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Best-track labels: HURDAT2 and IBTrACS, and the rapid-intensification events derived from them.
4
-
5
- This is the *label* source, not the training corpus, and the difference matters. HURDAT2 holds about
6
- eighty thousand six-hourly rows across 174 years of Atlantic storms; IBTrACS about seven times that
7
- globally. Against an 88M-parameter network those numbers are nothing, and a model fit to them directly
8
- will memorise storms rather than learn weather.
9
-
10
- What they are good for is supervising a small number of storm-specific heads on top of a backbone that
11
- learned the atmosphere somewhere else -- from reanalysis, where the label is simply the next state and
12
- the corpus is millions of times larger. See :mod:`naturev1.corpora` for that side.
13
-
14
- Rapid intensification is scarcer still. The National Hurricane Center's threshold is a 30-knot increase
15
- in maximum sustained wind within 24 hours; across the whole Atlantic record that fires on a low
16
- single-digit percentage of track points. Any honest RI model has to start from that base rate, so
17
- :func:`rapid_intensification` reports it rather than leaving you to assume.
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import datetime as dt
23
- import urllib.request
24
- from dataclasses import dataclass
25
- from pathlib import Path
26
-
27
- import numpy as np
28
-
29
-
30
- HURDAT2_ATLANTIC = "https://www.nhc.noaa.gov/data/hurdat/hurdat2-1851-2024-040425.txt"
31
- HURDAT2_PACIFIC = "https://www.nhc.noaa.gov/data/hurdat/hurdat2-nepac-1949-2024-040425.txt"
32
- IBTRACS_ALL = (
33
- "https://www.ncei.noaa.gov/data/international-best-track-archive-for-climate-stewardship-ibtracs/"
34
- "v04r01/access/csv/ibtracs.ALL.list.v04r01.csv"
35
- )
36
-
37
- #: HURDAT2 sentinel for "not reported".
38
- MISSING = -999
39
-
40
-
41
- @dataclass
42
- class Track:
43
- """One storm's best track. Arrays are parallel, one entry per synoptic time."""
44
-
45
- storm_id: str
46
- name: str
47
- time: np.ndarray # (T,) datetime64[s]
48
- latitude: np.ndarray # (T,) degrees
49
- longitude: np.ndarray # (T,) degrees, -180..180
50
- max_wind_kt: np.ndarray # (T,) knots, 1-minute sustained
51
- min_pressure_hpa: np.ndarray
52
- status: np.ndarray # (T,) two-letter classification
53
- rmw_nmi: np.ndarray # (T,) radius of maximum wind -- the eyewall's radius
54
- wind_radii_nmi: np.ndarray # (T, 3, 4) thresholds 34/50/64 kt by NE/SE/SW/NW quadrant
55
- record: np.ndarray # (T,) record identifier; "L" marks a landfall
56
- # Filled by the global IBTrACS reader (naturev1.ibtracs); HURDAT2 tracks leave them unset.
57
- basin: str = "" # NA, EP, WP, NI, SI, SP, SA
58
- interpolated: np.ndarray | None = None # (T,) True where IBTrACS interpolated the fix
59
- wind_source: np.ndarray | None = None # (T,) 0 US 1-min, 1 WMO 10-min converted, 2 WMO 1-min
60
- distance_to_land_km: np.ndarray | None = None # (T,)
61
-
62
- def __len__(self) -> int:
63
- return self.time.shape[0]
64
-
65
- @property
66
- def landfall(self) -> np.ndarray:
67
- """
68
- ``(T,)`` boolean: did the centre cross a coastline at this point.
69
-
70
- HURDAT2 marks landfalls explicitly in its record-identifier column, so this is an observation
71
- rather than something inferred from a land mask -- which would otherwise have to guess at
72
- resolution the archive already settled.
73
- """
74
- return self.record == "L"
75
-
76
- @property
77
- def year(self) -> int:
78
- return int(str(self.time[0])[:4])
79
-
80
- @property
81
- def peak_wind_kt(self) -> float:
82
- valid = self.max_wind_kt[self.max_wind_kt > 0]
83
- return float(valid.max()) if valid.size else float("nan")
84
-
85
- def __repr__(self) -> str:
86
- return f"Track({self.storm_id} {self.name} {self.year}, {len(self)} points, peak {self.peak_wind_kt:.0f} kt)"
87
-
88
-
89
- def download(url: str, destination: str | Path) -> Path:
90
- """Fetch a best-track file, skipping the download when it is already on disk."""
91
- destination = Path(destination)
92
- if destination.exists() and destination.stat().st_size > 0:
93
- return destination
94
- destination.parent.mkdir(parents=True, exist_ok=True)
95
- temporary = destination.with_suffix(destination.suffix + ".part")
96
- urllib.request.urlretrieve(url, temporary)
97
- temporary.replace(destination)
98
- return destination
99
-
100
-
101
- def _parse_latitude(token: str) -> float:
102
- value = float(token[:-1])
103
- return value if token[-1] == "N" else -value
104
-
105
-
106
- def _parse_longitude(token: str) -> float:
107
- value = float(token[:-1])
108
- value = value if token[-1] == "E" else -value
109
- return (value + 180.0) % 360.0 - 180.0
110
-
111
-
112
- def parse_hurdat2(path: str | Path) -> list[Track]:
113
- """
114
- Parse a HURDAT2 file into :class:`Track` objects.
115
-
116
- The format is a header line naming the storm and how many rows follow, then that many data rows. The
117
- trailing column is the radius of maximum wind, which is the one direct observation of eyewall size in
118
- the archive -- and is ``-999`` for most of the record, so treat its absence as the norm.
119
-
120
- The third column is the record identifier, which carries an ``L`` at every landfall. It is the only
121
- place the archive states landfall directly, so it is kept: inferring landfall from a land mask
122
- instead means guessing at a resolution the archive has already settled.
123
- """
124
- tracks: list[Track] = []
125
- with open(path) as handle:
126
- lines = [line.rstrip("\n") for line in handle if line.strip()]
127
-
128
- index = 0
129
- while index < len(lines):
130
- header = [part.strip() for part in lines[index].split(",")]
131
- storm_id, name, count = header[0], header[1], int(header[2])
132
- rows = lines[index + 1 : index + 1 + count]
133
- index += 1 + count
134
-
135
- times, lats, lons, winds, pressures, statuses, rmws, radii, records = [], [], [], [], [], [], [], [], []
136
- for row in rows:
137
- field = [part.strip() for part in row.split(",")]
138
- stamp = dt.datetime.strptime(field[0] + field[1], "%Y%m%d%H%M")
139
- times.append(np.datetime64(stamp, "s"))
140
- records.append(field[2])
141
- statuses.append(field[3])
142
- lats.append(_parse_latitude(field[4]))
143
- lons.append(_parse_longitude(field[5]))
144
- winds.append(float(field[6]))
145
- pressures.append(float(field[7]))
146
- radii.append([[float(field[8 + group * 4 + quadrant]) for quadrant in range(4)] for group in range(3)])
147
- rmws.append(float(field[20]) if len(field) > 20 else MISSING)
148
-
149
- def as_nan(values):
150
- return np.where(np.asarray(values, dtype=np.float32) == MISSING, np.nan, values)
151
-
152
- tracks.append(
153
- Track(
154
- storm_id=storm_id, name=name, time=np.array(times),
155
- latitude=np.asarray(lats, dtype=np.float32), longitude=np.asarray(lons, dtype=np.float32),
156
- max_wind_kt=as_nan(winds), min_pressure_hpa=as_nan(pressures),
157
- status=np.asarray(statuses), rmw_nmi=as_nan(rmws),
158
- wind_radii_nmi=as_nan(np.asarray(radii, dtype=np.float32)),
159
- record=np.asarray(records),
160
- )
161
- )
162
- return tracks
163
-
164
-
165
- def rapid_intensification(
166
- tracks: list[Track], threshold_kt: float = 30.0, window_hours: int = 24, tropical_only: bool = True
167
- ) -> dict:
168
- """
169
- Label rapid intensification and report how rare it actually is.
170
-
171
- The National Hurricane Center defines RI as an increase in maximum sustained wind of at least 30 kt
172
- in 24 hours. This walks every track, marks the points from which that happens, and returns both the
173
- labels and the base rate -- because the base rate is the number that decides how the head must be
174
- trained. A classifier that always says "no" will score in the high nineties on this task and be
175
- worthless, so accuracy is not the metric and class weighting is not optional.
176
-
177
- Args:
178
- tracks: parsed best tracks.
179
- threshold_kt: intensification that counts as rapid.
180
- window_hours: the window it must happen within.
181
- tropical_only: restrict to tropical classifications (TD/TS/HU), which is the usual convention --
182
- extratropical transition can show a wind rise that is not intensification in this sense.
183
-
184
- Returns:
185
- ``{"labels": {storm_id: bool array}, "base_rate": float, "positives": int, "eligible": int,
186
- "delta": {storm_id: float array}}``
187
- """
188
- tropical = {"TD", "TS", "HU"}
189
- labels, deltas, positives, eligible = {}, {}, 0, 0
190
-
191
- for track in tracks:
192
- wind = track.max_wind_kt
193
- seconds = track.time.astype("datetime64[s]").astype(np.int64)
194
- change = np.full(len(track), np.nan, dtype=np.float32)
195
- for i in range(len(track)):
196
- if tropical_only and track.status[i] not in tropical:
197
- continue
198
- if not np.isfinite(wind[i]):
199
- continue
200
- horizon = seconds[i] + window_hours * 3600
201
- reachable = np.where((seconds > seconds[i]) & (seconds <= horizon))[0]
202
- if reachable.size == 0:
203
- continue
204
- future = wind[reachable]
205
- if not np.isfinite(future).any():
206
- continue
207
- change[i] = float(np.nanmax(future) - wind[i])
208
- flag = np.isfinite(change) & (change >= threshold_kt)
209
- labels[track.storm_id] = flag
210
- deltas[track.storm_id] = change
211
- positives += int(flag.sum())
212
- eligible += int(np.isfinite(change).sum())
213
-
214
- return {
215
- "labels": labels, "delta": deltas, "positives": positives, "eligible": eligible,
216
- "base_rate": positives / max(eligible, 1),
217
- "threshold_kt": threshold_kt, "window_hours": window_hours,
218
- }
219
-
220
-
221
- def split_by_storm(
222
- tracks: list[Track], validation_years: tuple[int, ...] = (2017, 2019, 2021, 2023),
223
- test_years: tuple[int, ...] = (2018, 2020, 2022, 2024),
224
- ) -> dict[str, list[Track]]:
225
- """
226
- Split by *season*, never by track point.
227
-
228
- This is the one that quietly ruins best-track models. Six-hourly points from the same storm are
229
- almost the same picture: split them at random and near-duplicates land on both sides, validation
230
- looks superb, and the model has memorised storms rather than learned anything. Holding out whole
231
- seasons also keeps a storm's environment -- that year's ENSO state, that year's sea-surface
232
- temperatures -- out of training entirely, which is the honest test.
233
- """
234
- groups = {"train": [], "validation": [], "test": []}
235
- for track in tracks:
236
- year = track.year
237
- key = "validation" if year in validation_years else "test" if year in test_years else "train"
238
- groups[key].append(track)
239
- return groups
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/checkpoint.py DELETED
@@ -1,408 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Checkpointing that assumes the process will be killed without warning.
4
-
5
- On a hosted notebook that is not a worst case, it is the normal case: the cell is reclaimed, the runtime
6
- recycles, the tab closes. So every save here is written to a temporary file and then renamed into place,
7
- because rename is atomic on every filesystem that matters -- there is no instant at which the live
8
- checkpoint is half-written. A process killed mid-save leaves a stray temporary file and an intact
9
- previous checkpoint, which is the outcome you want.
10
-
11
- Resuming restores everything that makes a run reproducible, not just the weights: optimizer moments,
12
- the learning-rate schedule, the gradient scaler, the epoch and step counters, and the random states of
13
- Python, NumPy and Torch. Restoring weights alone gives you a run that silently reshuffles its data and
14
- restarts its schedule, which looks like it resumed and did not.
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- import json
20
- import os
21
- import random
22
- import shutil
23
- import time
24
- from dataclasses import dataclass
25
- from pathlib import Path
26
-
27
- import torch
28
-
29
- from .safeio import atomic_copy, atomic_torch_save, atomic_write_text, remove_stale_temporaries
30
-
31
-
32
- @dataclass
33
- class TrainingState:
34
- """Everything needed to continue a run exactly where it stopped."""
35
-
36
- step: int = 0
37
- epoch: int = 0
38
- samples_seen: int = 0
39
- best_loss: float = float("inf")
40
- wall_seconds: float = 0.0
41
- history: list = None
42
-
43
- def __post_init__(self) -> None:
44
- if self.history is None:
45
- self.history = []
46
-
47
- def to_dict(self) -> dict:
48
- return {
49
- "step": self.step, "epoch": self.epoch, "samples_seen": self.samples_seen,
50
- "best_loss": self.best_loss, "wall_seconds": self.wall_seconds,
51
- "history": self.history[-500:],
52
- }
53
-
54
-
55
- def _rng_state() -> dict:
56
- state = {"python": random.getstate(), "torch": torch.get_rng_state()}
57
- try:
58
- import numpy as np
59
-
60
- state["numpy"] = np.random.get_state()
61
- except ImportError:
62
- pass
63
- if torch.cuda.is_available():
64
- state["cuda"] = torch.cuda.get_rng_state_all()
65
- return state
66
-
67
-
68
- def _restore_rng(state: dict) -> None:
69
- if not state:
70
- return
71
- random.setstate(state["python"])
72
- torch.set_rng_state(state["torch"].cpu() if torch.is_tensor(state["torch"]) else state["torch"])
73
- if "numpy" in state:
74
- import numpy as np
75
-
76
- np.random.set_state(state["numpy"])
77
- if "cuda" in state and torch.cuda.is_available():
78
- try:
79
- torch.cuda.set_rng_state_all([s.cpu() for s in state["cuda"]])
80
- except (RuntimeError, ValueError):
81
- # A different GPU count than the run that saved it: keep going with fresh CUDA seeds.
82
- pass
83
-
84
-
85
- class CheckpointManager:
86
- """
87
- Periodic, crash-safe checkpoints with optional mirroring to the Hugging Face Hub.
88
-
89
- Args:
90
- directory: where checkpoints live. On Colab, point this at Google Drive so it outlives the VM.
91
- every_seconds: minimum wall time between saves. Saving is cheap next to a training step, so a
92
- minute is a reasonable default; the cost is bounded by the size of the model, not the data.
93
- keep: how many recent checkpoints to retain, oldest pruned first.
94
- repo_id: Hub repository to mirror to, e.g. ``"you/NatureV1"``. Needs ``HF_TOKEN`` in the
95
- environment or a prior ``huggingface-cli login``.
96
- push_every_seconds: uploads are slower than local writes, so they get their own, longer interval.
97
- snapshot_every_seconds: how often a numbered copy is kept beside ``latest.pt``. With the
98
- optimizer state a checkpoint of the full model is about a gigabyte; copying it on every
99
- one-minute save wrote two gigabytes a minute to Google Drive and left the GPU waiting on
100
- the disk. ``latest.pt`` is still rewritten every ``every_seconds``.
101
- backup_dir: a second folder that gets its own copy of the checkpoint, as often as the numbered
102
- snapshots, in ``<backup_dir>/<tag>/latest.pt`` -- so deleting or damaging the run's folder
103
- does not lose the run. Defaults to the ``NATUREV1_BACKUP_DIR`` environment variable, which
104
- the cell sets; empty turns it off. Loading falls back to it when nothing local is usable.
105
- """
106
-
107
- LATEST = "latest.pt"
108
-
109
- def __init__(
110
- self,
111
- directory: str | Path,
112
- every_seconds: float = 60.0,
113
- keep: int = 3,
114
- repo_id: str | None = None,
115
- push_every_seconds: float = 900.0,
116
- private: bool = False,
117
- snapshot_every_seconds: float = 1800.0,
118
- backup_dir: str | Path | None = None,
119
- ) -> None:
120
- self.directory = Path(directory)
121
- self.directory.mkdir(parents=True, exist_ok=True)
122
- self.every_seconds = every_seconds
123
- self.keep = keep
124
- self.repo_id = repo_id
125
- self.push_every_seconds = push_every_seconds
126
- self.snapshot_every_seconds = snapshot_every_seconds
127
- self.private = private
128
- self._last_save = 0.0
129
- self._last_push = 0.0
130
- self._last_snapshot = 0.0
131
- self._upload = None
132
- self._warned_token = False
133
- self._warned_disk = 0.0
134
- backup_dir = os.environ.get("NATUREV1_BACKUP_DIR") if backup_dir is None else backup_dir
135
- self.backup = Path(backup_dir) / self.tag / self.LATEST if backup_dir else None
136
- # A save killed part-way leaves a gigabyte-sized temporary behind; restarted runs piled them up.
137
- freed = remove_stale_temporaries(self.directory)
138
- if self.backup is not None and self.backup.parent.exists():
139
- freed += remove_stale_temporaries(self.backup.parent)
140
- if freed:
141
- print(f"[checkpoint] removed {freed / 1e9:.2f} GB of half-written saves from killed runs", flush=True)
142
-
143
- @property
144
- def tag(self) -> str:
145
- """Which run this directory holds -- ``stage1``, ``stage2``, ``rollout`` -- for the Hub path."""
146
- return self.directory.name or "run"
147
-
148
- @property
149
- def hub_path(self) -> str:
150
- """
151
- Where this run's checkpoint lives in the Hub repository.
152
-
153
- One path per run. Every stage used to push to the same ``latest.pt``, so on a fresh machine a
154
- stage pulled whichever stage had pushed last: stage two resumed from stage one's step count,
155
- found itself past its own ``max_steps`` and stopped without training anything.
156
- """
157
- return f"checkpoints/{self.tag}/{self.LATEST}"
158
-
159
- # ------------------------------------------------------------------ saving --
160
-
161
- def due(self) -> bool:
162
- """Whether enough time has passed to warrant another save."""
163
- return (time.time() - self._last_save) >= self.every_seconds
164
-
165
- def save(
166
- self,
167
- model: torch.nn.Module,
168
- optimizer: torch.optim.Optimizer | None,
169
- scheduler=None,
170
- scaler=None,
171
- state: TrainingState | None = None,
172
- config: dict | None = None,
173
- force: bool = False,
174
- ) -> Path | None:
175
- """
176
- Write a checkpoint if one is due. Returns the path written, or None if it was skipped.
177
-
178
- The write goes to a temporary file first and is renamed into place, so an interrupted save can
179
- never leave a corrupt ``latest.pt`` behind.
180
- """
181
- if not force and not self.due():
182
- return None
183
- state = state or TrainingState()
184
- payload = {
185
- "model": model.state_dict(),
186
- "optimizer": optimizer.state_dict() if optimizer is not None else None,
187
- "scheduler": scheduler.state_dict() if scheduler is not None else None,
188
- "scaler": scaler.state_dict() if scaler is not None else None,
189
- "state": state.to_dict(),
190
- "config": config or {},
191
- "rng": _rng_state(),
192
- "saved_at": time.time(),
193
- "format": 2,
194
- "tag": self.tag,
195
- }
196
-
197
- target = self.directory / self.LATEST
198
- record = json.dumps(state.to_dict(), indent=2, default=str) + "\n"
199
- try:
200
- atomic_torch_save(payload, target) # atomic; readers see either the old file or the new one
201
- if force or (time.time() - self._last_snapshot) >= self.snapshot_every_seconds:
202
- self._prune(make_room=True)
203
- atomic_copy(target, self.directory / f"step_{state.step:09d}.pt")
204
- self._prune()
205
- self._last_snapshot = time.time()
206
- self._back_up(target, record)
207
- atomic_write_text(self.directory / "state.json", record)
208
- except OSError as error:
209
- # A full disk used to crash the run here, and every restart crashed again at its first
210
- # save. The previous checkpoint is intact (the write is atomic), so keep training and say so.
211
- self._last_save = time.time()
212
- if time.time() - self._warned_disk > 600:
213
- print(f"[checkpoint] !! could not save ({type(error).__name__}: {error}). Training goes on "
214
- f"and the last good checkpoint is kept; free some disk space in {self.directory}.",
215
- flush=True)
216
- self._warned_disk = time.time()
217
- return None
218
- self._last_save = time.time()
219
- self._maybe_push(target, state)
220
- return target
221
-
222
- def _back_up(self, target: Path, record: str) -> None:
223
- if self.backup is None:
224
- return
225
- try:
226
- atomic_copy(target, self.backup)
227
- atomic_write_text(self.backup.parent / "state.json", record)
228
- except OSError as error:
229
- print(f"[checkpoint] backup to {self.backup.parent} skipped ({type(error).__name__}: {error})",
230
- flush=True)
231
-
232
- def _prune(self, make_room: bool = False) -> None:
233
- """Keep the newest ``keep`` snapshots -- one fewer just before writing another, so a full disk
234
- is not needed to hold ``keep + 1`` of them at once."""
235
- numbered = sorted(self.directory.glob("step_*.pt"))
236
- keep = max(self.keep - 1, 1) if make_room else self.keep
237
- for stale in numbered[: max(0, len(numbered) - keep)]:
238
- stale.unlink(missing_ok=True)
239
-
240
- def push_now(self, state: TrainingState, timeout: float = 120.0) -> bool:
241
- """
242
- Push the latest checkpoint to the Hub right now and wait for it -- for the end of a session.
243
-
244
- A hosted notebook that is being shut down sends SIGTERM and gives a process a little time. The
245
- regular push is on a timer and runs on a thread that would die with the process; this one
246
- ignores the timer and blocks, so the last checkpoint reaches the Hub. Returns whether it did.
247
- """
248
- if not self.repo_id or not (self.directory / self.LATEST).exists():
249
- return False
250
- if self._upload is not None and self._upload.is_alive():
251
- self._upload.join(timeout)
252
- self._last_push = 0.0
253
- self._maybe_push(self.directory / self.LATEST, state)
254
- if self._upload is None:
255
- return False
256
- self._upload.join(timeout)
257
- return not self._upload.is_alive()
258
-
259
- def _maybe_push(self, path: Path, state: TrainingState) -> None:
260
- """
261
- Mirror the checkpoint to the Hub, in the background, at most every ``push_every_seconds``.
262
-
263
- Two things this used to get wrong. A failed push -- no token, no network -- did not reset the
264
- timer, so it was retried and reported on every one-minute save for the rest of the run. And a
265
- successful one ran on the training thread: a gigabyte upload stopped training for minutes
266
- every quarter hour. Now a failure waits a full interval like a success, and the upload runs
267
- on its own thread from a copy, so ``latest.pt`` can keep being replaced underneath it.
268
- """
269
- if not self.repo_id or (time.time() - self._last_push) < self.push_every_seconds:
270
- return
271
- self._last_push = time.time()
272
- if self._upload is not None and self._upload.is_alive():
273
- return # the previous upload is still going; skip this one
274
- try:
275
- from huggingface_hub import get_token
276
- except ImportError:
277
- get_token = None
278
- if get_token is not None and not (os.environ.get("HF_TOKEN") or get_token()):
279
- if not self._warned_token:
280
- print("[checkpoint] no Hugging Face token: checkpoints stay local (set HF_TOKEN to mirror "
281
- "them to the Hub)", flush=True)
282
- self._warned_token = True
283
- return
284
-
285
- snapshot = self.directory / ".push.pt"
286
- try:
287
- shutil.copy2(path, snapshot)
288
- except OSError as error:
289
- print(f"[checkpoint] Hub push skipped: {type(error).__name__}: {error}", flush=True)
290
- return
291
- step, tag, repo, hub_path, private = state.step, self.tag, self.repo_id, self.hub_path, self.private
292
-
293
- def upload() -> None:
294
- began = time.time()
295
- try:
296
- from huggingface_hub import HfApi
297
-
298
- try:
299
- # The upload library draws two progress bars straight into the training log, a few
300
- # hundred lines per push. One line saying it happened is what the log needs.
301
- from huggingface_hub.utils import disable_progress_bars
302
-
303
- disable_progress_bars()
304
- except ImportError:
305
- pass
306
- api = HfApi()
307
- api.create_repo(repo, repo_type="model", exist_ok=True, private=private)
308
- api.upload_file(path_or_fileobj=str(snapshot), path_in_repo=hub_path, repo_id=repo,
309
- repo_type="model", commit_message=f"{tag} step {step}")
310
- print(f"[checkpoint] pushed {tag} step {step:,} to {repo} "
311
- f"({snapshot.stat().st_size / 1e9:.2f} GB in {time.time() - began:.0f} s)", flush=True)
312
- except Exception as error: # a failed upload must never kill a training run
313
- print(f"[checkpoint] Hub push skipped: {type(error).__name__}: {error}", flush=True)
314
-
315
- import threading
316
-
317
- self._upload = threading.Thread(target=upload, name="hub-push", daemon=True)
318
- self._upload.start()
319
-
320
- # ----------------------------------------------------------------- loading --
321
-
322
- def load(
323
- self,
324
- model: torch.nn.Module,
325
- optimizer: torch.optim.Optimizer | None = None,
326
- scheduler=None,
327
- scaler=None,
328
- map_location: str = "cpu",
329
- strict: bool = True,
330
- ) -> TrainingState | None:
331
- """
332
- Restore the newest usable checkpoint, falling back through older ones if the newest is damaged.
333
-
334
- Returns the :class:`TrainingState` to continue from, or None when there is nothing to resume.
335
- """
336
- for path in self._candidates():
337
- try:
338
- payload = torch.load(path, map_location=map_location, weights_only=False)
339
- except Exception as error:
340
- print(f"[checkpoint] {path.name} unreadable ({type(error).__name__}), trying an older one", flush=True)
341
- continue
342
- try:
343
- model.load_state_dict(payload["model"], strict=strict)
344
- except RuntimeError as error:
345
- # A checkpoint from a different configuration -- 24 input channels where the model now
346
- # has 96, say -- cannot be resumed. Crashing here strands the user; resuming into it is
347
- # impossible. Set it aside where it can still be recovered, say so, and move on.
348
- self._quarantine(path, error)
349
- continue
350
- if optimizer is not None and payload.get("optimizer"):
351
- try:
352
- optimizer.load_state_dict(payload["optimizer"])
353
- except (ValueError, KeyError, RuntimeError) as error:
354
- print(f"[checkpoint] optimizer state not restored ({type(error).__name__}: the set of "
355
- "trainable parameters changed); weights are restored, moments restart", flush=True)
356
- if scheduler is not None and payload.get("scheduler"):
357
- scheduler.load_state_dict(payload["scheduler"])
358
- if scaler is not None and payload.get("scaler"):
359
- scaler.load_state_dict(payload["scaler"])
360
- _restore_rng(payload.get("rng", {}))
361
- state = TrainingState(**payload["state"])
362
- self._last_save = time.time()
363
- where = f"the backup in {path.parent}" if path == self.backup else path.name
364
- print(f"[checkpoint] resumed {where} at step {state.step} (epoch {state.epoch})", flush=True)
365
- return state
366
- return None
367
-
368
- def _quarantine(self, path: Path, error: Exception) -> None:
369
- """Move an incompatible checkpoint out of the way, keeping it recoverable."""
370
- aside = self.directory / "incompatible"
371
- aside.mkdir(exist_ok=True)
372
- destination = aside / f"{int(time.time())}_{path.name}"
373
- shutil.move(str(path), destination)
374
- reason = str(error).splitlines()[0][:200]
375
- print(f"[checkpoint] {path.name} was trained with a different configuration and cannot be "
376
- f"resumed ({reason}). Moved to {destination}; starting this run fresh.", flush=True)
377
-
378
- def _candidates(self) -> list[Path]:
379
- latest = self.directory / self.LATEST
380
- numbered = sorted(self.directory.glob("step_*.pt"), reverse=True)
381
- backup = [self.backup] if self.backup is not None and self.backup.exists() else []
382
- return ([latest] if latest.exists() else []) + numbered + backup
383
-
384
- def fetch_from_hub(self) -> bool:
385
- """
386
- Pull this run's checkpoint from the Hub when the local directory is empty -- a fresh VM, same run.
387
-
388
- Reads :attr:`hub_path`. Releases before 0.8 pushed every stage to a bare ``latest.pt``; that
389
- file is accepted only for ``stage1``, the one stage it can safely be assumed to belong to.
390
- """
391
- if not self.repo_id or (self.directory / self.LATEST).exists():
392
- return False
393
- if self.backup is not None and self.backup.exists():
394
- return False # a backup on this machine comes before a download
395
- from huggingface_hub import hf_hub_download
396
-
397
- sources = [self.hub_path] + ([self.LATEST] if self.tag == "stage1" else [])
398
- for filename in sources:
399
- try:
400
- downloaded = hf_hub_download(repo_id=self.repo_id, filename=filename, repo_type="model")
401
- except Exception as error:
402
- last = error
403
- continue
404
- shutil.copy2(downloaded, self.directory / self.LATEST)
405
- print(f"[checkpoint] pulled {filename} from {self.repo_id}", flush=True)
406
- return True
407
- print(f"[checkpoint] nothing to pull from the Hub for {self.tag} ({type(last).__name__})", flush=True)
408
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/corpora.py DELETED
@@ -1,174 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- The corpora, and why there are two of them.
4
-
5
- Best-track archives are labels, not a training set. HURDAT2 holds 55,230 Atlantic track points across
6
- 174 years; rapid intensification at the 30-knot threshold fires on 1,839 of them; radius of maximum wind
7
- is reported on 2,587. Fitting 89M parameters to that would memorise storms and call it learning, and the
8
- validation score would look wonderful right up until a storm it had not seen.
9
-
10
- So the model is trained twice.
11
-
12
- **Stage one is self-supervised on reanalysis**, where the label is free: given the atmosphere now,
13
- predict it six hours from now. ERA5 covers 1959 to 2022 at hourly resolution, which is 561,024 timesteps
14
- and, at a modest 240x121 grid with 84 variables, about 2.3e11 values. That is roughly three million times
15
- the size of the best-track record, it needs no annotation, and it is where the backbone actually learns
16
- what the atmosphere does.
17
-
18
- **Stage two fine-tunes the storm heads** on best tracks with the backbone frozen, so the parameters
19
- actually fitted to those scarce labels number under a million rather than eighty-nine.
20
-
21
- There is also a free multiplier available here that most architectures do not get. This model is exactly
22
- equivariant to rotating the globe about its axis -- verified to float64 round-off -- so shifting a storm
23
- in longitude produces a genuinely different sample that is guaranteed physically consistent, rather than
24
- the approximate augmentation a pixel-grid model would be forced to settle for.
25
- """
26
-
27
- from __future__ import annotations
28
-
29
- from dataclasses import dataclass
30
-
31
- import numpy as np
32
- import torch
33
-
34
-
35
- @dataclass(frozen=True)
36
- class Corpus:
37
- """One public dataset, with the numbers that decide whether it is worth the bandwidth."""
38
-
39
- name: str
40
- uri: str
41
- span: str
42
- cadence: str
43
- approx_samples: int
44
- role: str
45
- notes: str
46
-
47
-
48
- #: Everything below is public and needs no account except ERA5 via Copernicus (free registration).
49
- CORPORA = (
50
- Corpus(
51
- "WeatherBench 2 (ERA5)", "gs://weatherbench2/datasets/era5/", "1959-2022", "1h and 6h",
52
- 561_024, "pretraining",
53
- "Analysis-ready Zarr from 64x32 up to 1440x721. The 6-hourly 240x121 store holds 92,040 verified "
54
- "timesteps; the hourly stores hold six times that. Start here.",
55
- ),
56
- Corpus(
57
- "ARCO-ERA5", "gs://gcp-public-data-arco-era5/ar/", "1940-present", "1h",
58
- 745_000, "pretraining",
59
- "Same data, longer span, chunked for random access. Use when you outgrow WeatherBench 2.",
60
- ),
61
- Corpus(
62
- "GOES-19 / GOES-18 ABI", "s3://noaa-goes19/, s3://noaa-goes18/", "2017-present", "5-10 min",
63
- 1_800_000, "pretraining + live",
64
- "16 bands, both hemispheres of the Americas. GOES-16 is retired and its bucket returns nothing.",
65
- ),
66
- Corpus(
67
- "TC-PRIMED", "s3://noaa-nesdis-tcprimed-pds/v01r01/", "1998-present", "per overpass",
68
- 180_000, "storm fine-tuning",
69
- "Satellite overpasses already paired with best-track position and intensity. Purpose-built for "
70
- "exactly this task and the single highest-value dataset for the storm heads.",
71
- ),
72
- Corpus(
73
- "IBTrACS v04r01",
74
- "https://www.ncei.noaa.gov/data/international-best-track-archive-for-climate-stewardship-ibtracs/"
75
- "v04r01/access/csv/ibtracs.ALL.list.v04r01.csv",
76
- "1842-present", "3-6h", 585_000, "labels",
77
- "Global, every basin. About seven times the Atlantic-only HURDAT2 record -- use this, not HURDAT2, "
78
- "unless you specifically want Atlantic.",
79
- ),
80
- Corpus(
81
- "HURDAT2 Atlantic", "https://www.nhc.noaa.gov/data/hurdat/", "1851-2024", "6h",
82
- 55_230, "labels",
83
- "Highest-quality reanalysed Atlantic tracks. Carries radius of maximum wind and wind radii, which "
84
- "IBTrACS does less consistently -- but only on ~5% of points.",
85
- ),
86
- Corpus(
87
- "SHIPS developmental data", "https://rammb2.cira.colostate.edu/research/tropical-cyclones/ships/",
88
- "1982-present", "6h", 120_000, "storm fine-tuning",
89
- "The environmental predictors operational RI forecasting is built on: shear, ocean heat content, "
90
- "mid-level humidity, persistence. Precomputed per storm. Feed these as the environment channels.",
91
- ),
92
- Corpus(
93
- "OISST v2.1 sea-surface temperature", "s3://noaa-cdr-sea-surface-temp-optimum-interpolation-pds/",
94
- "1981-present", "daily", 16_000, "environment",
95
- "Sea-surface temperature drives intensification. Daily quarter-degree, the standard choice.",
96
- ),
97
- )
98
-
99
-
100
- def catalogue() -> str:
101
- """A printable summary of the corpora, their sizes and what each is for."""
102
- lines = [f"{'dataset':28} {'span':16} {'cadence':10} {'samples':>10} role"]
103
- lines.append("-" * 88)
104
- for corpus in CORPORA:
105
- lines.append(
106
- f"{corpus.name:28} {corpus.span:16} {corpus.cadence:10} {corpus.approx_samples:>10,} {corpus.role}"
107
- )
108
- return "\n".join(lines)
109
-
110
-
111
- def open_weatherbench(
112
- resolution: str = "1959-2022-6h-240x121_equiangular_with_poles_conservative.zarr",
113
- variables: tuple[str, ...] = (
114
- "2m_temperature", "mean_sea_level_pressure", "10m_u_component_of_wind",
115
- "10m_v_component_of_wind", "total_precipitation_6hr", "sea_surface_temperature",
116
- ),
117
- ):
118
- """
119
- Open the WeatherBench 2 ERA5 archive straight from cloud storage, lazily.
120
-
121
- Nothing downloads until a chunk is actually read, so a training run streams what it needs instead of
122
- staging terabytes first. ``pip install zarr gcsfs xarray`` and no credentials are required.
123
-
124
- Args:
125
- resolution: one of the WeatherBench 2 store names. 240x121 six-hourly is the sensible default;
126
- drop to 64x32 to debug a pipeline, rise to 1440x721 when you have the bandwidth.
127
- variables: which fields to keep.
128
- """
129
- import xarray as xr
130
-
131
- store = f"gs://weatherbench2/datasets/era5/{resolution}"
132
- dataset = xr.open_zarr(store, storage_options={"token": "anon"}, consolidated=True)
133
- keep = [name for name in variables if name in dataset]
134
- missing = set(variables) - set(keep)
135
- if missing:
136
- print(f"[corpora] not in this store, skipping: {sorted(missing)}")
137
- return dataset[keep]
138
-
139
-
140
- def latlon_to_coords(latitudes: np.ndarray, longitudes: np.ndarray) -> torch.Tensor:
141
- """Turn a latitude/longitude mesh in degrees into the radian coordinate pairs a grid expects."""
142
- lat_mesh, lon_mesh = np.meshgrid(latitudes, longitudes, indexing="ij")
143
- return torch.tensor(
144
- np.stack([np.radians(lat_mesh.ravel()), np.radians(lon_mesh.ravel()) % (2 * np.pi)], -1),
145
- dtype=torch.float64,
146
- )
147
-
148
-
149
- def rotate_longitude(coords: torch.Tensor, radians: float) -> torch.Tensor:
150
- """
151
- Shift every sample in longitude -- an augmentation that is exact for this architecture.
152
-
153
- The local strand reads displacements in each sample's own east/north/up frame, which turns with the
154
- sample, so a longitude shift produces identical arithmetic on genuinely different data. A pixel-grid
155
- model has to roll an array and accept the seam; here the operation is a symmetry of the model, which
156
- makes it free and physically honest. Because it preserves latitude it also preserves the Coriolis
157
- parameter and the climate zone, so the augmented sample is still a possible atmosphere.
158
- """
159
- shifted = coords.clone()
160
- shifted[:, 1] = torch.remainder(shifted[:, 1] + radians, 2 * torch.pi)
161
- return shifted
162
-
163
-
164
- def augment_batch(coords: torch.Tensor, generator: torch.Generator | None = None) -> torch.Tensor:
165
- """Apply a random longitude rotation, the safe augmentation for a global field."""
166
- angle = float(torch.rand((), generator=generator) * 2 * torch.pi)
167
- return rotate_longitude(coords, angle)
168
-
169
-
170
- def estimate_epoch(num_samples: int, samples_per_second: float) -> dict:
171
- """Wall time for one pass over a corpus at a measured throughput."""
172
- seconds = num_samples / max(samples_per_second, 1e-9)
173
- return {"samples": num_samples, "samples_per_second": samples_per_second,
174
- "hours": seconds / 3600, "days": seconds / 86400}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/__init__.py DELETED
@@ -1,53 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Tropical-cyclone physics that needs no neural network -- tracks, intensity, genesis -- built to beat
4
- extrapolation at every lead, and measured doing so on seasons it never saw.
5
-
6
- Every module is one idea:
7
-
8
- ========================== =================================================================================
9
- geometry great-circle moves and storm-centred frames; NHC along/cross-track errors
10
- fields analysis boxes with spherical calculus and pressure-weighted layer means
11
- data hindcast cases from IBTrACS + WeatherBench 2 ERA5 boxes, chunk-aware and resumable
12
- verify homogeneous verification with storm-block bootstrap confidence intervals
13
- baselines every extrapolation (6 h, 12 h, great-circle) and the strongest of them per lead
14
- kalman robust (Huber) Kalman motion estimate with damped acceleration
15
- cliper CLIPER rebuilt: poleward frame, three motion windows, exact local displacements
16
- analogs track-shape analogs rotated to the storm's heading, as departures from persistence
17
- vortex the storm removed from the analysis by local Biot-Savart inversion
18
- steering eight steering layers, intensity-dependent layer, case-adaptive optimal layer
19
- beta beta drift from the vortex's outer strength and the absolute-vorticity gradient
20
- trajectory RK4 on the sphere through flows sampled where the storm will be
21
- bogus vortex bogus specified by outer circulation, for the barotropic model
22
- barotropic limited-area barotropic vorticity model (Arakawa Jacobian, exact sine-transform
23
- Poisson solver, sponge): beta gyres emerge, the environment evolves
24
- vorticity_advection motion from the vorticity budget, independent of the best track
25
- climatology ERA5 1981-2010 flow climatology; steering anomalies damped with lead time
26
- stall cols and stagnation points of the steering flow: Dorian's stall, detected
27
- ridge subtropical ridge axis, strength, tendency, weakness and the upstream trough
28
- shear vertical shear in seven layer pairs, generalised shear, vortex tilt
29
- reformation competing centres and downshear re-formation toward the convection
30
- ensemble physics ensemble with data-void-aware flow perturbations (the Bermuda High problem)
31
- regimes stall / recurve / high / weak / straight classification
32
- consensus along- and cross-track weights per lead, fitted on past seasons
33
- members the member suite computed from one analysis
34
- ocean cold wake under slow storms (the ocean fighting back)
35
- land landfall timing, inland and terrain decay along the forecast track
36
- intensity SHIFOR, SHIPS-style and LGEM-style intensity along the forecast track
37
- structure outermost closed isobar, gale radius, size evolution
38
- outflow 200 hPa divergence, eddy flux convergence, outflow channels, trough interaction
39
- field_tracker GFDL-style multi-field tracker for any gridded forecast
40
- teachers GraphCast, Pangu, HRES, Aurora, FGN, FuXi, GenCast tracks on the same storms
41
- genesis disturbance detection, tracking and a 48-hour genesis probability
42
- pouch the wave-relative marsupial pouch
43
- synthetic Dorian-type and other hard scenarios with known truth, and balanced vortex implants
44
- fit / coefficients the constants, fitted once on disjoint data and shipped as code
45
- benchmark the hindcast benchmark and its report
46
- forecast :func:`physics_forecast`, everything in one call (``swarm=N`` adds
47
- :mod:`naturev1.swarm`, which works alongside it)
48
- ========================== =================================================================================
49
- """
50
-
51
- from .forecast import physics_forecast
52
-
53
- __all__ = ["physics_forecast"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/aids.py DELETED
@@ -1,213 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- The operational guidance blended in.
4
-
5
- The NHC's a-deck carries every aid's track and intensity for a cycle minutes after it. Two tables fitted on the
6
- tuning seasons (``coefficients.ADECK_TRACK``, ``coefficients.ADECK_INTENSITY``) say how much of each aid to take
7
- beside our own forecast, lead by lead. The weights are renormalised over the members present, so a missing aid never
8
- breaks the blend, and a retired model's slot can be taken by its successor (``substitutes``: HAFS-A for HWRF, HAFS-B
9
- for HMON, TVCN for HCCA) -- a stand-in by lineage, reported as such, not a verified member.
10
-
11
- Reading the decks is :mod:`naturev1.swarm.adeck`; this module is the arithmetic, with no network.
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import numpy as np
17
-
18
- from . import geometry as geo
19
-
20
-
21
- def resolve(available: dict, members, substitutes: dict | None = None) -> tuple[dict, dict]:
22
- """The members present in ``available`` (``{tech: array}``), a substitute standing in for an absent one.
23
- Returns ``(chosen {member: array}, substituted {member: stand-in})``; ``"ours"`` is never looked up."""
24
- chosen, subs = {}, {}
25
- for m in members:
26
- if m == "ours":
27
- continue
28
- v = available.get(m)
29
- if v is not None and np.isfinite(np.asarray(v, np.float64)).any():
30
- chosen[m] = np.asarray(v, np.float64)
31
- continue
32
- for s in (substitutes or {}).get(m, ()):
33
- v = available.get(s)
34
- if v is not None and np.isfinite(np.asarray(v, np.float64)).any():
35
- chosen[m] = np.asarray(v, np.float64)
36
- subs[m] = s
37
- break
38
- return chosen, subs
39
-
40
-
41
- def _weights(table: dict, leads) -> np.ndarray:
42
- """(L, M) weights at the caller's leads: the nearest lead of the table."""
43
- tl = np.asarray(table["leads"], np.float64)
44
- W = np.asarray(table["weights"], np.float64)
45
- return W[[int(np.argmin(np.abs(tl - float(h)))) for h in leads]]
46
-
47
-
48
- def _share(members, w: np.ndarray, s: np.ndarray) -> dict:
49
- share = w / np.maximum(s, 1e-9)[:, None]
50
- return {m: float(np.mean(share[:, j])) for j, m in enumerate(members) if share[:, j].any()}
51
-
52
-
53
- def previous_errors(case, previous_time, previous_tracks: dict, leads) -> dict:
54
- """
55
- How far each member's previous-cycle forecast was from the position now analysed: ``{member: km}`` for the
56
- members in ``previous_tracks`` (``{member: (L, 2) track issued at previous_time}``), the previous track
57
- interpolated to the gap between the cycles (6-24 h; nothing outside that). This is what the adaptive weights of
58
- :func:`blend_track` are earned from -- the member that caught the last turn keeps more of its vote.
59
- """
60
- out = {}
61
- try:
62
- gap = float((np.datetime64(case.time, "m") - np.datetime64(previous_time, "m")) / np.timedelta64(60, "m"))
63
- except Exception:
64
- return out
65
- if not (5.5 <= gap <= 24.5):
66
- return out
67
- hours = np.asarray(leads, np.float64)
68
- for member, t in previous_tracks.items():
69
- t = np.asarray(t, np.float64) if t is not None else None
70
- if t is None or t.ndim != 2 or t.shape[0] != len(hours):
71
- continue
72
- ok = np.isfinite(t[:, 0]) & np.isfinite(t[:, 1])
73
- if ok.sum() < 1 or hours[ok].max() < gap:
74
- continue
75
- lat = np.interp(gap, hours[ok], t[ok, 0])
76
- lon = np.interp(gap, hours[ok], t[ok, 1])
77
- out[member] = float(geo.great_circle_km(lat, lon, float(case.lat), float(case.lon)))
78
- return out
79
-
80
-
81
- def adaptive_weights(table: dict, leads, previous: dict | None) -> np.ndarray | None:
82
- """
83
- The weight of ours against the table's first aid, per lead, from the previous cycle's verified errors:
84
- ``w_ours = clip(sigmoid(b * log((e_aid + eps) / (e_ours + eps))), 1 - cap, cap)``, with ``b`` per lead from
85
- ``table["adaptive"]`` (zero where the tuning seasons found nothing to earn). None without the table or the errors.
86
- """
87
- adaptive = table.get("adaptive")
88
- if not adaptive or not previous:
89
- return None
90
- members = list(table["members"])
91
- aid = members[1] if len(members) > 1 else None
92
- e_ours, e_aid = previous.get("ours"), previous.get(aid)
93
- if e_ours is None or e_aid is None:
94
- return None
95
- eps = float(adaptive.get("eps_km", 5.0))
96
- cap = float(adaptive.get("cap", 0.75))
97
- tl = np.asarray(table["leads"], np.float64)
98
- b = np.asarray(adaptive["b"], np.float64)[[int(np.argmin(np.abs(tl - float(h)))) for h in leads]]
99
- r = np.log((float(e_aid) + eps) / (float(e_ours) + eps))
100
- return np.clip(1.0 / (1.0 + np.exp(-b * r)), 1.0 - cap, cap)
101
-
102
-
103
- def outlier_gate(table: dict, leads, X: np.ndarray, cone_km) -> np.ndarray | None:
104
- """
105
- The disagreement gate: when ours and the first aid sit further apart than ``start`` cone radii, up to ``gamma`` of
106
- the pair's weight moves away from whichever of the two is further from the tie-breaker aid (TVCN) -- on the
107
- tuning seasons a tie, on held-out 1-2% at 12-120 h, a loss at 6 h, so it starts at ``min_lead_h``. Returns the
108
- signed shift of ours' share per lead (negative: away from ours), or None without the table entry, the
109
- tie-breaker or the cone.
110
- """
111
- gate = table.get("gate")
112
- if not gate or cone_km is None:
113
- return None
114
- members = list(table["members"])
115
- tb = gate.get("tiebreaker", "TVCN")
116
- if len(members) < 2 or tb not in members:
117
- return None
118
- k = members.index(tb)
119
- cone = np.asarray(cone_km, np.float64)
120
- if cone.shape != (len(leads),):
121
- return None
122
- d_oh = np.hypot(X[:, 0, 0] - X[:, 1, 0], X[:, 0, 1] - X[:, 1, 1])
123
- d_ot = np.hypot(X[:, 0, 0] - X[:, k, 0], X[:, 0, 1] - X[:, k, 1])
124
- d_ht = np.hypot(X[:, 1, 0] - X[:, k, 0], X[:, 1, 1] - X[:, k, 1])
125
- ok = np.isfinite(d_oh) & np.isfinite(d_ot) & np.isfinite(d_ht) & np.isfinite(cone) & (cone > 0) & (np.asarray(leads, np.float64) >= float(gate.get("min_lead_h", 12)))
126
- shift = float(gate.get("gamma", 0.1)) * np.clip(np.where(ok, d_oh / np.maximum(cone, 1e-6), 0.0) - float(gate.get("start_cone_radii", 1.5)), 0.0, 1.0)
127
- return np.where(ok, np.where(d_ot > d_ht, -shift, shift), 0.0)
128
-
129
-
130
- def blend_track(case, track, aids: dict, leads, table: dict, previous_errors: dict | None = None, cone_km=None) -> tuple[np.ndarray, dict | None]:
131
- """
132
- Our track blended with the aids' tracks in local displacement space (km east and north of the analysis
133
- position), weights per lead renormalised over the members present at that lead.
134
-
135
- Args:
136
- track: our (L, 2) latitude/longitude at ``leads``.
137
- aids: ``{tech: (L, 2)}`` at the same leads (:func:`naturev1.swarm.adeck.aids_at`).
138
- table: ``{"members": ["ours", ...], "leads", "weights" (L, M), "substitutes": {member: [stand-ins]},
139
- "adaptive": {"b": per lead, "cap", "eps_km"}}``.
140
- previous_errors: ``{member: km}`` from :func:`previous_errors` -- with the table's ``adaptive`` entry the
141
- split between ours and the first aid is earned from them (the other aids keep their table weights).
142
- cone_km: this forecast's cone radius per lead -- with the table's ``gate`` entry the split also moves away
143
- from whichever of ours and the first aid stands alone against the tie-breaker (:func:`outlier_gate`).
144
-
145
- Returns:
146
- ``(track, used)``: the blended track and ``{"members", "substituted", "weight_share", "disagreement_km"
147
- (ours against the first aid, per lead), "adaptive_w_ours" (when earned), "gate_shift" (when applied)}``, or
148
- ``(track unchanged, None)`` when none of the table's aids is present.
149
- """
150
- members = list(table["members"])
151
- chosen, subs = resolve(aids or {}, members, table.get("substitutes"))
152
- track = np.asarray(track, np.float64)
153
- if not chosen:
154
- return track, None
155
- L, M = len(leads), len(members)
156
- la0, lo0 = float(case.lat), float(case.lon)
157
- X = np.full((L, M, 2), np.nan)
158
- for j, m in enumerate(members):
159
- t = track if m == "ours" else chosen.get(m)
160
- if t is None or np.asarray(t).shape != (L, 2):
161
- continue
162
- e, n = geo.local_displacement_km(la0, lo0, np.asarray(t)[:, 0], np.asarray(t)[:, 1])
163
- X[:, j, 0], X[:, j, 1] = e, n
164
- W = _weights(table, leads).copy()
165
- used: dict = {"members": [m for m in members if m in chosen], "substituted": subs}
166
- if M > 1:
167
- pair = W[:, 0] + W[:, 1] # the ours / first-aid share, split by what was earned ...
168
- split = np.where(pair > 0, W[:, 0] / np.maximum(pair, 1e-9), 0.5)
169
- w_ours = adaptive_weights(table, leads, previous_errors)
170
- if w_ours is not None:
171
- split = w_ours
172
- used["adaptive_w_ours"] = w_ours
173
- used["previous_errors_km"] = dict(previous_errors)
174
- shift = outlier_gate(table, leads, X, cone_km) # ... and moved away from the member that stands alone
175
- if shift is not None and np.any(shift != 0):
176
- split = np.clip(split + shift, 0.0, 1.0)
177
- used["gate_shift"] = shift
178
- W[:, 0], W[:, 1] = pair * split, pair * (1.0 - split)
179
- ok = np.isfinite(X).all(-1)
180
- w = np.where(ok, W, 0.0)
181
- s = w.sum(-1)
182
- D = (np.nan_to_num(X) * w[..., None]).sum(1) / np.maximum(s, 1e-9)[:, None]
183
- lat, lon = geo.move(la0, lo0, D[:, 0], D[:, 1])
184
- out = np.where((s > 0)[:, None], np.stack([np.asarray(lat, np.float64), np.asarray(lon, np.float64)], -1), track)
185
- used["weight_share"] = _share(members, w, s)
186
- if M > 1 and ok[:, 1].any():
187
- used["disagreement_km"] = np.where(ok[:, 0] & ok[:, 1], np.hypot(X[:, 0, 0] - X[:, 1, 0], X[:, 0, 1] - X[:, 1, 1]), np.nan)
188
- return out, used
189
-
190
-
191
- def blend_intensity(intensity, aids: dict, leads, table: dict) -> tuple[np.ndarray, dict | None]:
192
- """Our intensity (kt at ``leads``) blended with the aids' intensities, as :func:`blend_track`; clipped to 15-200 kt."""
193
- members = list(table["members"])
194
- chosen, subs = resolve(aids or {}, members, table.get("substitutes"))
195
- intensity = np.asarray(intensity, np.float64)
196
- if not chosen:
197
- return intensity, None
198
- L, M = len(leads), len(members)
199
- X = np.full((L, M), np.nan)
200
- for j, m in enumerate(members):
201
- v = intensity if m == "ours" else chosen.get(m)
202
- if v is not None and np.asarray(v).shape == (L,):
203
- X[:, j] = v
204
- W = _weights(table, leads)
205
- ok = np.isfinite(X)
206
- w = np.where(ok, W, 0.0)
207
- s = w.sum(-1)
208
- out = np.where(s > 0, (np.nan_to_num(X) * w).sum(-1) / np.maximum(s, 1e-9), intensity)
209
- out = np.where(np.isfinite(out), np.clip(out, 15.0, 200.0), out)
210
- return out, {"members": [m for m in members if m in chosen], "substituted": subs, "weight_share": _share(members, w, s)}
211
-
212
-
213
- __all__ = ["adaptive_weights", "blend_intensity", "blend_track", "outlier_gate", "previous_errors", "resolve"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/analogs.py DELETED
@@ -1,136 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Track-shape analogs: storms that were moving *like this one*, turned to face the same way.
4
-
5
- The idea: CLIPER is a regression -- it averages every storm into one linear rule. Analogs are the
6
- non-parametric opposite: find the historical storms whose recent track looked most like this one's, and see
7
- what they did next. Two refinements make it work far from the classic version:
8
-
9
- * **rotation to the motion frame** -- each historical case is stored with its future displacements rotated
10
- so that its last 12 hours of motion point along the same axis. A storm heading north-west at 5 m/s is then
11
- compared with every storm that was turning and accelerating the same way, wherever it was heading, and its
12
- future is rotated back onto this storm's heading. Curvature and acceleration -- the shape of the track --
13
- are what is matched, not the compass direction;
14
- * **context still counts** -- latitude, longitude within the basin, season and intensity enter the distance,
15
- so a turning storm at 28 N is matched with turning storms near the ridge, not in the deep tropics.
16
-
17
- What is stored and averaged is each analog's *departure from persistence* (its future minus carrying on at its
18
- own current speed and heading), added back onto this storm's own current motion -- so the first hours inherit
19
- the storm's exact motion and the analogs contribute the turns and speed changes that follow.
20
-
21
- Futures are combined with Gaussian kernel weights over the nearest analogs; the poleward frame mirrors the
22
- southern hemisphere so both share one database. The database is built from best tracks
23
- (:meth:`AnalogDatabase.from_cases`) -- for the benchmark, 1980-2014 only.
24
- """
25
-
26
- from __future__ import annotations
27
-
28
- import numpy as np
29
-
30
- from .geometry import from_local, local_displacement_km, rotate, wrap_longitude
31
-
32
- #: Feature scales (one unit of distance per scale).
33
- SCALES = {"lat": 4.0, "lon": 15.0, "season": 0.5, "wind": 30.0, "speed": 6.0, "turn": 15.0, "accel": 4.0, "turn24": 20.0}
34
-
35
-
36
- def _shape_features(case):
37
- """``(features, heading_deg)`` in the poleward frame, or None without a 12-hour history."""
38
- from .cliper import GROUPS
39
- from .data import HISTORY_HOURS
40
-
41
- h = list(HISTORY_HOURS)
42
- lat, lon = case.history["lat"], case.history["lon"]
43
- sign = -1.0 if case.lat < 0 else 1.0
44
-
45
- def leg(a, b):
46
- i, j = h.index(a), h.index(b)
47
- if not (np.isfinite(lat[i]) and np.isfinite(lat[j])):
48
- return None
49
- e, n = local_displacement_km(lat[i], lon[i], lat[j], lon[j])
50
- return np.array([e, sign * n]) / ((b - a))
51
- m0 = leg(-6, 0)
52
- m1 = leg(-12, -6)
53
- if m0 is None or m1 is None:
54
- return None
55
- m2 = leg(-24, -12)
56
- heading = np.degrees(np.arctan2(m0[0], m0[1]))
57
- def angle(a, b):
58
- return float((np.degrees(np.arctan2(a[0], a[1]) - np.arctan2(b[0], b[1])) + 180.0) % 360.0 - 180.0)
59
- turn = angle(m0, m1)
60
- turn24 = angle(m0, m2) if m2 is not None else turn
61
- speed = float(np.hypot(*m0)) / 3.6
62
- accel = speed - float(np.hypot(*m1)) / 3.6
63
- _, reference = GROUPS.get(case.basin, ("NA", 0.0))
64
- day = (np.datetime64(case.time, "D") - np.datetime64(str(case.time)[:4] + "-01-01", "D")).astype(int)
65
- phase = 2 * np.pi * ((day + (182 if sign < 0 else 0)) % 365) / 365.0
66
- wind = case.wind_kt if np.isfinite(case.wind_kt) else 50.0
67
- f = np.array([abs(case.lat) / SCALES["lat"], wrap_longitude(case.lon - reference) / SCALES["lon"],
68
- np.cos(phase) / SCALES["season"], np.sin(phase) / SCALES["season"], wind / SCALES["wind"],
69
- speed / SCALES["speed"], turn / SCALES["turn"], accel / SCALES["accel"], turn24 / SCALES["turn24"]])
70
- return f, float(heading)
71
-
72
-
73
- class AnalogDatabase:
74
- """Historical cases' shape features and their futures in the motion-aligned poleward frame."""
75
-
76
- def __init__(self, features: np.ndarray, futures: np.ndarray, groups: np.ndarray, leads) -> None:
77
- self.features, self.futures, self.groups, self.leads = features, futures, groups, list(leads)
78
-
79
- @classmethod
80
- def from_cases(cls, cases, leads) -> "AnalogDatabase":
81
- from .cliper import group_of
82
-
83
- feats, futs, groups = [], [], []
84
- for c in cases:
85
- got = _shape_features(c)
86
- if got is None:
87
- continue
88
- f, heading = got
89
- sign = -1.0 if c.lat < 0 else 1.0
90
- speed_kmh = f[5] * SCALES["speed"] * 3.6
91
- fut = np.full((len(leads), 2), np.nan)
92
- for k in range(len(leads)):
93
- if c.truth["valid"][k]:
94
- e, n = local_displacement_km(c.lat, c.lon, c.truth["lat"][k], c.truth["lon"][k])
95
- # Rotate so the storm's current heading points north, then keep only what departs from
96
- # carrying on straight at the current speed: the analog's *deviation* from persistence.
97
- re, rn = rotate(e, sign * n, heading)
98
- fut[k] = re, rn - speed_kmh * leads[k]
99
- feats.append(f)
100
- futs.append(fut)
101
- groups.append(group_of(c))
102
- return cls(np.asarray(feats), np.asarray(futs), np.asarray(groups), leads)
103
-
104
- def forecast(self, case, neighbours: int = 60, bandwidth: float = 1.0, same_group: bool = True) -> np.ndarray:
105
- """``(L, 2)`` lat/lon: the kernel-weighted mean future of the nearest analogs, rotated to this heading."""
106
- from .cliper import group_of
107
-
108
- out = np.full((len(self.leads), 2), np.nan)
109
- got = _shape_features(case)
110
- if got is None or len(self.features) == 0:
111
- return out
112
- f, heading = got
113
- speed_kmh = f[5] * SCALES["speed"] * 3.6
114
- pool = self.groups == group_of(case) if same_group else np.ones(len(self.groups), bool)
115
- if pool.sum() < neighbours:
116
- pool = np.ones(len(self.groups), bool)
117
- idx = np.nonzero(pool)[0]
118
- d2 = ((self.features[idx] - f) ** 2).sum(1)
119
- order = idx[np.argsort(d2)[:neighbours]]
120
- weights = np.exp(-0.5 * np.sort(d2)[:neighbours] / bandwidth ** 2)
121
- sign = -1.0 if case.lat < 0 else 1.0
122
- for k in range(len(self.leads)):
123
- fut = self.futures[order, k]
124
- ok = np.isfinite(fut[:, 0])
125
- if ok.sum() < 5:
126
- continue
127
- w = weights[ok] / weights[ok].sum()
128
- east, north = (fut[ok] * w[:, None]).sum(0)
129
- north = north + speed_kmh * self.leads[k]
130
- east, north = rotate(east, north, -heading)
131
- lat, lon = from_local(case.lat, case.lon, east, sign * north)
132
- out[k] = lat, lon
133
- return out
134
-
135
-
136
- __all__ = ["AnalogDatabase", "SCALES"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/barotropic.py DELETED
@@ -1,240 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- A limited-area barotropic vorticity model: the storm and its environment evolving together.
4
-
5
- The idea (NHC's LBAR, Vic Ooyama's barotropic legacy): a frozen steering flow is wrong by day three, because
6
- ridges and troughs move. The deep-layer flow is, to a good first approximation, barotropic, so it can be
7
- *forecast* by conserving absolute vorticity -- cheaply, without any of the physics a full model needs. And a
8
- vortex embedded in that flow develops its own beta gyres and drifts with them; nothing about beta drift has
9
- to be prescribed. This model:
10
-
11
- * lives on a square grid in the storm-centred azimuthal-equidistant plane (exact distances from the storm,
12
- no pole problem), with the true Coriolis parameter at every point (not a beta-plane approximation);
13
- * starts from the analysis's deep-layer environmental wind (vortex removed, winds rotated into the grid's
14
- frame), plus a bogus vortex whose outer circulation matches the analysis's (:mod:`naturev1.cyclone.bogus`);
15
- * steps ``dzeta/dt = -J(psi, zeta + f) - nu4 del^4 zeta`` with Arakawa's energy- and enstrophy-conserving
16
- Jacobian and a third-order strong-stability-preserving Runge-Kutta scheme;
17
- * inverts vorticity for the streamfunction exactly, every stage, with a fast sine transform (Dirichlet
18
- boundary values from the analysis flow integrated around the domain's edge, held fixed: the large scale
19
- outside the box does not change -- the limitation LBAR also had);
20
- * relaxes a sponge at the edge back to the analysis, so waves leave instead of reflecting;
21
- * tracks the vortex as the maximum of cyclonic vorticity near its previous position, refined to sub-grid
22
- precision by a quadratic fit.
23
- """
24
-
25
- from __future__ import annotations
26
-
27
- import numpy as np
28
-
29
- from .bogus import alpha_for_size, bogus_vorticity
30
- from .fields import Environment
31
- from .geometry import OMEGA, bearing_deg, from_local, hemisphere
32
-
33
- GRID_KM = 80.0
34
- CELLS = 73 # 5760 km across
35
- STEP_S = 900.0
36
- SPONGE_CELLS = 6
37
- SPONGE_TAU_S = 3 * 3600.0
38
- #: Grid-scale damping time of the hyperdiffusion, s.
39
- DAMPING_S = 6 * 3600.0
40
- #: Deep-layer mean used for the environment (top, bottom) hPa, and the bogus's share of the 850 hPa outer wind.
41
- LAYER = (300.0, 850.0)
42
- DEEP_FRACTION = 0.8
43
-
44
-
45
- def dst1(a: np.ndarray, axis: int = -1) -> np.ndarray:
46
- """Type-I discrete sine transform along ``axis`` via an odd extension and the FFT (no scipy needed)."""
47
- a = np.moveaxis(a, axis, -1)
48
- n = a.shape[-1]
49
- zero = np.zeros(a.shape[:-1] + (1,))
50
- extended = np.concatenate([zero, a, zero, -a[..., ::-1]], -1)
51
- spectrum = np.fft.rfft(extended, axis=-1)
52
- out = -spectrum[..., 1:n + 1].imag / 2.0
53
- return np.moveaxis(out, -1, axis)
54
-
55
-
56
- class PoissonSolver:
57
- """Exact solution of the 5-point ``del^2 psi = zeta`` with Dirichlet boundary values on a square grid."""
58
-
59
- def __init__(self, cells: int, spacing_m: float) -> None:
60
- self.n, self.h = cells, spacing_m
61
- m = cells - 2
62
- k = np.arange(1, m + 1)
63
- lam = (2.0 * np.cos(np.pi * k / (m + 1)) - 2.0) / spacing_m ** 2
64
- self.eigen = lam[:, None] + lam[None, :]
65
- self.norm = (2.0 / (m + 1)) ** 2
66
-
67
- def solve(self, zeta: np.ndarray, boundary: np.ndarray) -> np.ndarray:
68
- """``zeta`` and ``boundary`` are full ``(n, n)``; boundary values are read from the edge of ``boundary``."""
69
- h2 = self.h ** 2
70
- rhs = zeta[1:-1, 1:-1].copy()
71
- rhs[0, :] -= boundary[0, 1:-1] / h2
72
- rhs[-1, :] -= boundary[-1, 1:-1] / h2
73
- rhs[:, 0] -= boundary[1:-1, 0] / h2
74
- rhs[:, -1] -= boundary[1:-1, -1] / h2
75
- spec = dst1(dst1(rhs, 0), 1) / self.eigen
76
- inner = dst1(dst1(spec, 0), 1) * self.norm
77
- psi = boundary.copy()
78
- psi[1:-1, 1:-1] = inner
79
- return psi
80
-
81
-
82
- def arakawa_jacobian(psi: np.ndarray, q: np.ndarray, h: float) -> np.ndarray:
83
- """J(psi, q) = psi_x q_y - psi_y q_x on interior points (Arakawa 1966), rows = y, columns = x."""
84
- p, z = psi, q
85
- pe, pw, pn, ps = p[1:-1, 2:], p[1:-1, :-2], p[2:, 1:-1], p[:-2, 1:-1]
86
- qe, qw, qn, qs = z[1:-1, 2:], z[1:-1, :-2], z[2:, 1:-1], z[:-2, 1:-1]
87
- pne, pnw, pse, psw = p[2:, 2:], p[2:, :-2], p[:-2, 2:], p[:-2, :-2]
88
- qne, qnw, qse, qsw = z[2:, 2:], z[2:, :-2], z[:-2, 2:], z[:-2, :-2]
89
- j1 = (pe - pw) * (qn - qs) - (pn - ps) * (qe - qw)
90
- j2 = pe * (qne - qse) - pw * (qnw - qsw) - pn * (qne - qnw) + ps * (qse - qsw)
91
- j3 = qn * (pne - pnw) - qs * (pse - psw) - qe * (pne - pse) + qw * (pnw - psw)
92
- out = np.zeros_like(psi)
93
- out[1:-1, 1:-1] = (j1 + j2 + j3) / (12.0 * h * h)
94
- return out
95
-
96
-
97
- def laplacian(f: np.ndarray, h: float) -> np.ndarray:
98
- out = np.zeros_like(f)
99
- out[1:-1, 1:-1] = (f[1:-1, 2:] + f[1:-1, :-2] + f[2:, 1:-1] + f[:-2, 1:-1] - 4 * f[1:-1, 1:-1]) / (h * h)
100
- return out
101
-
102
-
103
- class BarotropicModel:
104
- """
105
- One storm's barotropic forecast. Build from an analysis (vortex already removed), then :meth:`run`.
106
-
107
- Args:
108
- env: the environmental analysis (``remove_vortex(...).environment``).
109
- lat0, lon0: the storm's position.
110
- outer_ms: the outer strength of its vortex at 850 hPa (:func:`naturev1.cyclone.beta.outer_strength`).
111
- r34_nmi: best-track gale radius, for the profile's outer decay (NaN for the default).
112
- """
113
-
114
- def __init__(self, env: Environment, lat0: float, lon0: float, outer_ms: float, r34_nmi: float = float("nan"),
115
- cells: int = CELLS, grid_km: float = GRID_KM, rmax_km: float | None = None) -> None:
116
- self.lat0, self.lon0 = float(lat0), float(lon0)
117
- self.n, self.h = cells, grid_km * 1000.0
118
- half = (cells - 1) / 2.0
119
- axis = (np.arange(cells) - half) * grid_km
120
- self.x, self.y = np.meshgrid(axis, axis) # km; rows y (north), columns x (east)
121
- lat, lon = from_local(self.lat0, self.lon0, self.x, self.y)
122
- self.lat, self.lon = lat, lon
123
- self.f = 2.0 * OMEGA * np.sin(np.radians(lat))
124
- # The grid's own x-axis direction at each point, as a geographic bearing.
125
- lat_e, lon_e = from_local(self.lat0, self.lon0, self.x + 1.0, self.y)
126
- theta = np.radians(bearing_deg(lat, lon, lat_e, lon_e))
127
- ex_e, ex_n = np.sin(theta), np.cos(theta)
128
- u = env.sample(env.layer_mean("u", *LAYER), lat, lon)
129
- v = env.sample(env.layer_mean("v", *LAYER), lat, lon)
130
- ux = u * ex_e + v * ex_n
131
- uy = -u * ex_n + v * ex_e
132
- self.sign = float(hemisphere(self.lat0))
133
- # Environmental vorticity on the grid, and the boundary streamfunction from the edge flow.
134
- zeta_env = np.zeros_like(ux)
135
- zeta_env[1:-1, 1:-1] = ((uy[1:-1, 2:] - uy[1:-1, :-2]) - (ux[2:, 1:-1] - ux[:-2, 1:-1])) / (2 * self.h)
136
- zeta_env[0, :], zeta_env[-1, :], zeta_env[:, 0], zeta_env[:, -1] = (zeta_env[1, :], zeta_env[-2, :],
137
- zeta_env[:, 1], zeta_env[:, -2])
138
- self.boundary = self._edge_streamfunction(ux, uy)
139
- r = np.hypot(self.x, self.y)
140
- rmax = max(rmax_km or 0.0, 2.5 * grid_km)
141
- self.zeta_env = zeta_env
142
- self.zeta0 = zeta_env + bogus_vorticity(r, DEEP_FRACTION * outer_ms, rmax, alpha_for_size(r34_nmi), self.sign)
143
- self.solver = PoissonSolver(cells, self.h)
144
- k = np.pi / self.h
145
- self.nu4 = 1.0 / (DAMPING_S * k ** 4)
146
- distance = np.minimum.reduce([np.arange(cells)[None, :] + 0 * self.x, (cells - 1 - np.arange(cells))[None, :] + 0 * self.x,
147
- np.arange(cells)[:, None] + 0 * self.x, (cells - 1 - np.arange(cells))[:, None] + 0 * self.x])
148
- self.sponge = np.where(distance < SPONGE_CELLS, (1.0 - distance / SPONGE_CELLS) ** 2 / SPONGE_TAU_S, 0.0)
149
-
150
- def _edge_streamfunction(self, ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
151
- """psi on the edge from d psi = v dx - u dy integrated counter-clockwise; net divergence spread evenly."""
152
- n, h = self.n, self.h
153
- loop = ([(0, j) for j in range(n)] + [(i, n - 1) for i in range(1, n)]
154
- + [(n - 1, j) for j in range(n - 2, -1, -1)] + [(i, 0) for i in range(n - 2, 0, -1)])
155
- values = [0.0]
156
- for (i0, j0), (i1, j1) in zip(loop[:-1], loop[1:]):
157
- um = 0.5 * (ux[i0, j0] + ux[i1, j1])
158
- vm = 0.5 * (uy[i0, j0] + uy[i1, j1])
159
- dx, dy = (j1 - j0) * h, (i1 - i0) * h
160
- values.append(values[-1] + vm * dx - um * dy)
161
- i0, j0 = loop[-1]
162
- i1, j1 = loop[0]
163
- closing = values[-1] + 0.5 * (uy[i0, j0] + uy[i1, j1]) * (j1 - j0) * h - 0.5 * (ux[i0, j0] + ux[i1, j1]) * (i1 - i0) * h
164
- boundary = np.zeros((n, n))
165
- total = len(loop)
166
- for k, (i, j) in enumerate(loop):
167
- boundary[i, j] = values[k] - closing * k / total
168
- return boundary
169
-
170
- def _tendency(self, zeta: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
171
- psi = self.solver.solve(zeta, self.boundary)
172
- tend = -arakawa_jacobian(psi, zeta + self.f, self.h)
173
- tend -= self.nu4 * laplacian(laplacian(zeta, self.h), self.h)
174
- tend -= self.sponge * (zeta - self.zeta_env)
175
- tend[0, :] = tend[-1, :] = tend[:, 0] = tend[:, -1] = 0.0
176
- return tend, psi
177
-
178
- def _locate(self, zeta: np.ndarray, near: tuple[float, float], search_km: float = 450.0) -> tuple[float, float]:
179
- field = self.sign * zeta
180
- smooth = field.copy()
181
- smooth[1:-1, 1:-1] = (field[1:-1, 1:-1] * 4 + field[1:-1, 2:] + field[1:-1, :-2] + field[2:, 1:-1]
182
- + field[:-2, 1:-1]) / 8.0
183
- mask = np.hypot(self.x - near[0], self.y - near[1]) < search_km
184
- mask[:2, :] = mask[-2:, :] = mask[:, :2] = mask[:, -2:] = False
185
- if not mask.any():
186
- return near
187
- candidate = np.where(mask, smooth, -np.inf)
188
- i, j = np.unravel_index(int(np.argmax(candidate)), candidate.shape)
189
- c, e, w, nn, s = smooth[i, j], smooth[i, j + 1], smooth[i, j - 1], smooth[i + 1, j], smooth[i - 1, j]
190
- dx = 0.5 * (w - e) / (w - 2 * c + e) if (w - 2 * c + e) < 0 else 0.0
191
- dy = 0.5 * (s - nn) / (s - 2 * c + nn) if (s - 2 * c + nn) < 0 else 0.0
192
- step = self.h / 1000.0
193
- return float(self.x[i, j] + np.clip(dx, -0.5, 0.5) * step), float(self.y[i, j] + np.clip(dy, -0.5, 0.5) * step)
194
-
195
- def run(self, leads, return_fields: bool = False) -> dict:
196
- """Integrate to the last lead; returns ``{"track": (L, 2) lat/lon, "xy": (L, 2) km, "speed_ms": (L,)}``."""
197
- leads = [float(h) for h in leads]
198
- zeta = self.zeta0.copy()
199
- position = (0.0, 0.0)
200
- out_xy, fields = [], []
201
- t, k = 0.0, 0
202
- end = max(leads)
203
- dt = STEP_S
204
- while k < len(leads) and leads[k] <= 0:
205
- out_xy.append(position)
206
- k += 1
207
- steps_per_hour = 3600.0 / dt
208
- step = 0
209
- while t < end - 1e-9:
210
- k1, _ = self._tendency(zeta)
211
- z1 = zeta + dt * k1
212
- k2, _ = self._tendency(z1)
213
- z2 = 0.75 * zeta + 0.25 * (z1 + dt * k2)
214
- k3, _ = self._tendency(z2)
215
- zeta = zeta / 3.0 + 2.0 / 3.0 * (z2 + dt * k3)
216
- step += 1
217
- t = step / steps_per_hour
218
- if step % int(steps_per_hour) == 0:
219
- position = self._locate(zeta, position)
220
- while k < len(leads) and leads[k] <= t + 1e-9:
221
- position = self._locate(zeta, position)
222
- out_xy.append(position)
223
- if return_fields:
224
- fields.append(zeta.copy())
225
- k += 1
226
- xy = np.asarray(out_xy)
227
- lat, lon = from_local(self.lat0, self.lon0, xy[:, 0], xy[:, 1])
228
- result = {"track": np.stack([lat, lon], -1), "xy": xy}
229
- if return_fields:
230
- result["zeta"] = fields
231
- return result
232
-
233
-
234
- def barotropic_track(env_clean: Environment, case, outer_ms: float, leads) -> np.ndarray:
235
- """``(L, 2)`` lat/lon from one barotropic forecast of ``case``."""
236
- model = BarotropicModel(env_clean, case.lat, case.lon, outer_ms, getattr(case, "r34_nmi", float("nan")))
237
- return model.run(leads)["track"]
238
-
239
-
240
- __all__ = ["BarotropicModel", "PoissonSolver", "arakawa_jacobian", "barotropic_track", "dst1", "laplacian"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/baselines.py DELETED
@@ -1,95 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- The baselines a track model has to beat, defined exactly -- and all of them, so "beats extrapolation" is not
4
- won against a strawman.
5
-
6
- * ``xtrp12``: the classic: the last 12 hours of motion continued in a straight line in latitude and
7
- longitude (NHC's XTRP; also what :func:`naturev1.steering.extrapolation_track` does).
8
- * ``xtrp6``: the same with the last 6 hours -- noisier, but closer to now.
9
- * ``xtrp_gc``: the 12-hour motion continued along the great circle (constant speed, the initial bearing
10
- turning as the sphere requires) -- what extrapolation should mean at high latitude.
11
- * ``persistence``: intensity stays what it is now.
12
-
13
- :func:`strongest_extrapolation` returns, per lead, the best of the extrapolations on a given sample, the
14
- baseline every claim in the benchmark is made against.
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- import numpy as np
20
-
21
- from .geometry import bearing_deg, destination, great_circle_km, wrap_longitude
22
-
23
-
24
- def _past(case, hours: int):
25
- from .data import HISTORY_HOURS
26
-
27
- k = HISTORY_HOURS.index(-hours)
28
- lat, lon = case.history["lat"][k], case.history["lon"][k]
29
- return (lat, lon) if np.isfinite(lat) and np.isfinite(lon) else None
30
-
31
-
32
- def motion_back(case, hours: int):
33
- """``(dlat, dlon)`` degrees per hour over the last ``hours``, falling back to the other window."""
34
- for h in (hours, 12 if hours == 6 else 6):
35
- past = _past(case, h)
36
- if past is not None:
37
- dlon = wrap_longitude(case.lon - past[1])
38
- return (case.lat - past[0]) / h, dlon / h
39
- return None
40
-
41
-
42
- def xtrp_latlon(case, leads, hours: int = 12) -> np.ndarray:
43
- """Linear extrapolation in latitude and longitude; ``(L, 2)``."""
44
- rate = motion_back(case, hours)
45
- out = np.full((len(leads), 2), np.nan)
46
- if rate is None:
47
- return out
48
- for k, lead in enumerate(leads):
49
- out[k] = case.lat + rate[0] * lead, wrap_longitude(case.lon + rate[1] * lead)
50
- return out
51
-
52
-
53
- def xtrp_great_circle(case, leads, hours: int = 12) -> np.ndarray:
54
- past = _past(case, hours) or _past(case, 6 if hours == 12 else 12)
55
- out = np.full((len(leads), 2), np.nan)
56
- if past is None:
57
- return out
58
- span = hours if _past(case, hours) is not None else (6 if hours == 12 else 12)
59
- speed = great_circle_km(past[0], past[1], case.lat, case.lon) / span
60
- # Bearing of motion *at* the current position: the back-bearing to the past fix, reversed.
61
- heading = (bearing_deg(case.lat, case.lon, past[0], past[1]) + 180.0) % 360.0
62
- for k, lead in enumerate(leads):
63
- out[k] = destination(case.lat, case.lon, heading, speed * lead)
64
- return out
65
-
66
-
67
- def persistence_intensity(case, leads) -> np.ndarray:
68
- return np.full(len(leads), case.wind_kt)
69
-
70
-
71
- def baseline_tracks(cases, leads) -> dict[str, np.ndarray]:
72
- return {"xtrp12": np.stack([xtrp_latlon(c, leads, 12) for c in cases]),
73
- "xtrp6": np.stack([xtrp_latlon(c, leads, 6) for c in cases]),
74
- "xtrp_gc": np.stack([xtrp_great_circle(c, leads, 12) for c in cases])}
75
-
76
-
77
- def strongest_extrapolation(errors: dict[str, np.ndarray], names=("xtrp12", "xtrp6", "xtrp_gc")) -> np.ndarray:
78
- """
79
- Per case and lead, the error of whichever extrapolation has the lowest *mean* error at that lead on
80
- this sample -- chosen per lead with hindsight, which only makes the baseline harder to beat.
81
- """
82
- mask = None
83
- for n in names:
84
- ok = np.isfinite(errors[n])
85
- mask = ok if mask is None else mask & ok
86
- out = np.full_like(errors[names[0]], np.nan)
87
- for k in range(out.shape[1]):
88
- means = [np.nanmean(np.where(mask[:, k], errors[n][:, k], np.nan)) for n in names]
89
- best = names[int(np.nanargmin(means))]
90
- out[:, k] = errors[best][:, k]
91
- return out
92
-
93
-
94
- __all__ = ["baseline_tracks", "motion_back", "persistence_intensity", "strongest_extrapolation", "xtrp_great_circle",
95
- "xtrp_latlon"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/benchmark.py DELETED
@@ -1,105 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- The benchmark: every member, the consensus and the baselines on whole seasons -- and the teachers on the same
4
- storms -- verified homogeneously, with storm-block bootstrap intervals.
5
-
6
- :func:`run_season` computes the member suite (:func:`naturev1.cyclone.members.run_members`) for every case of
7
- a built season in parallel processes and caches the forecasts; :func:`benchmark` scores them against the
8
- strongest extrapolation, CLIPER and any teacher tracks given, and :func:`report` writes the table a reader
9
- needs: mean track error per lead, sample sizes, skill and whether its whole 90% interval clears zero.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import json
15
- import multiprocessing as mp
16
- import pickle
17
- from pathlib import Path
18
-
19
- import numpy as np
20
-
21
- from .data import LEADS, Season
22
-
23
- _WORKER: dict = {}
24
-
25
-
26
- def _init(season_dir: str, climatology_path: str | None, analogs_path: str | None, members) -> None:
27
- from .climatology import Climatology
28
-
29
- _WORKER["season"] = Season(season_dir)
30
- _WORKER["climatology"] = Climatology(climatology_path) if climatology_path else None
31
- _WORKER["analogs"] = pickle.load(open(analogs_path, "rb")) if analogs_path else None
32
- _WORKER["members"] = tuple(members)
33
-
34
-
35
- def _one(i: int):
36
- from .members import run_members
37
-
38
- season = _WORKER["season"]
39
- case = season.cases[i]
40
- try:
41
- out = run_members(case, season.environment(i), _WORKER["climatology"], _WORKER["analogs"], names=_WORKER["members"])
42
- except Exception as error: # one bad case must not end a season
43
- return case.key, {"_error": repr(error)}
44
- keep = {k: v for k, v in out.items() if not k.startswith("_")}
45
- if "_ensemble" in out:
46
- keep["ensemble_spread_km"] = out["_ensemble"]["spread_km"]
47
- return case.key, keep
48
-
49
-
50
- def run_season(season_dir: str | Path, out_path: str | Path, climatology_path=None, analogs_path=None, members=None,
51
- processes: int = 4, log=print) -> dict:
52
- """Member forecasts for every case of a season, cached in ``out_path`` (a pickle)."""
53
- from .members import DEFAULT_MEMBERS
54
-
55
- out_path = Path(out_path)
56
- if out_path.exists():
57
- return pickle.load(open(out_path, "rb"))
58
- members = members or DEFAULT_MEMBERS
59
- season = Season(season_dir)
60
- results = {}
61
- with mp.Pool(processes, initializer=_init, initargs=(str(season_dir), climatology_path and str(climatology_path),
62
- analogs_path and str(analogs_path), members)) as pool:
63
- for n, (key, value) in enumerate(pool.imap(_one, range(len(season)), chunksize=8), start=1):
64
- results[key] = value
65
- if log and n % 250 == 0:
66
- log(f"[benchmark] {Path(season_dir).name}: {n}/{len(season)}")
67
- pickle.dump(results, open(out_path, "wb"))
68
- return results
69
-
70
-
71
- def consensus_tracks(cases, results: dict, table: dict | None = None) -> np.ndarray:
72
- """``(N, L, 2)`` the shipped consensus (:data:`naturev1.cyclone.coefficients.CONSENSUS`) of cached members."""
73
- from .consensus import apply_consensus
74
-
75
- if table is None:
76
- from .coefficients import CONSENSUS as table
77
- nan = np.full((len(LEADS), 2), np.nan)
78
- stack = np.stack([np.stack([results.get(c.key, {}).get(m, nan) for c in cases]) for m in table["members"]], 2)
79
- return apply_consensus(cases, stack, table)
80
-
81
-
82
- def benchmark(cases, forecasts: dict, baseline: str = "best_xtrp", samples: int = 1000) -> dict:
83
- """Homogeneous comparison of ``forecasts`` (``{name: (N, L, 2)}``) against the strongest extrapolation."""
84
- from .baselines import baseline_tracks, strongest_extrapolation
85
- from .verify import compare, track_errors
86
-
87
- base = baseline_tracks(cases, LEADS)
88
- errors = {k: track_errors(cases, v) for k, v in base.items()}
89
- errors = {"best_xtrp": strongest_extrapolation(errors)}
90
- for name, f in forecasts.items():
91
- errors[name] = track_errors(cases, f)
92
- return compare(cases, errors, baseline, LEADS, samples=samples)
93
-
94
-
95
- def report(result: dict, title: str) -> str:
96
- from .verify import format_comparison
97
-
98
- return format_comparison(result, title)
99
-
100
-
101
- def save_result(result: dict, path: str | Path) -> None:
102
- Path(path).write_text(json.dumps(result, indent=1, default=float))
103
-
104
-
105
- __all__ = ["benchmark", "consensus_tracks", "report", "run_season", "save_result"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/beta.py DELETED
@@ -1,82 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Beta drift that depends on the storm and on the air around it, not a fixed 1 m/s north-west.
4
-
5
- The idea: a vortex on a sphere drifts because its own winds carry planetary vorticity around it, spinning up
6
- a pair of "beta gyres" whose flow pushes the vortex poleward and westward (Holland 1983; Chan and Williams
7
- 1987; Fiorino and Elsberry 1989). Two facts from that theory are used here, where a fixed vector ignores both:
8
-
9
- * **Speed scales with the vortex's outer strength** -- the tangential wind 300-800 km out, not the eyewall --
10
- and with the planetary vorticity gradient. A large, strong-outer-wind typhoon drifts at 3-4 m/s; a small,
11
- compact hurricane at well under 1. The outer strength is measured from the vortex that
12
- :func:`naturev1.cyclone.vortex.remove_vortex` took out of the analysis; the gradient is beta at the storm's
13
- latitude.
14
- * **Direction follows the gradient of absolute vorticity, not just of f** -- the drift is up the gradient of
15
- ``sign(f) * (f + zeta_environment)``, turned towards the west by the gyre angle. Where the environment's own
16
- vorticity gradient is strong (the flank of a monsoon trough, the edge of a ridge), the drift turns with it.
17
-
18
- The scale constants are physical defaults checked on the 2016/2017/2019 seasons only.
19
- """
20
-
21
- from __future__ import annotations
22
-
23
- import numpy as np
24
-
25
- from .fields import Environment
26
- from .geometry import beta as beta_at, hemisphere, rotate
27
- from .steering import disc_smooth
28
-
29
- #: Drift speed for a storm at 20 degrees with an outer tangential wind of 6 m/s, m/s.
30
- DRIFT_MS = 1.6
31
- #: Angle from the up-gradient direction towards the west, degrees.
32
- GYRE_ANGLE_DEG = 40.0
33
- #: Outer-strength radii, km, and the clip on the measured strength, m/s.
34
- OUTER_RADII_KM = (300.0, 400.0, 500.0, 600.0, 700.0, 800.0)
35
- OUTER_CLIP_MS = (1.0, 15.0)
36
-
37
-
38
- def outer_strength(split, level_hpa: int = 850) -> float:
39
- """Mean cyclonic tangential wind of the removed vortex between 300 and 800 km at ``level_hpa``, m/s."""
40
- levels = list(split.environment.levels)
41
- k = levels.index(level_hpa) if level_hpa in levels else int(np.argmin([abs(p - level_hpa) for p in levels]))
42
- profile = split.tangential_profile(k, OUTER_RADII_KM)
43
- value = float(np.nanmean(profile)) if np.isfinite(profile).any() else 4.0
44
- return float(np.clip(value, *OUTER_CLIP_MS))
45
-
46
-
47
- class BetaDrift:
48
- """
49
- Drift velocity anywhere in the box for a vortex of a given outer strength.
50
-
51
- ``eta_gradient`` is the smoothed gradient of the environment's deep-layer absolute vorticity, so the
52
- drift direction responds to the environment as well as to the planet.
53
- """
54
-
55
- def __init__(self, env: Environment, outer_ms: float, top: float = 300.0, bottom: float = 850.0,
56
- smoothing_km: float = 800.0, drift_ms: float | None = None, angle_deg: float | None = None) -> None:
57
- self.env, self.outer_ms = env, outer_ms
58
- self.drift_ms = DRIFT_MS if drift_ms is None else drift_ms
59
- self.angle_deg = GYRE_ANGLE_DEG if angle_deg is None else angle_deg
60
- u = env.layer_mean("u", top, bottom)
61
- v = env.layer_mean("v", top, bottom)
62
- zeta = env.vorticity(u, v)
63
- self.smoothing_km = smoothing_km
64
- self.gx = disc_smooth(env, env.ddx(zeta), smoothing_km)
65
- self.gy = disc_smooth(env, env.ddy(zeta), smoothing_km)
66
-
67
- def velocity(self, lat, lon) -> tuple[np.ndarray, np.ndarray]:
68
- lat = np.atleast_1d(np.asarray(lat, np.float64))
69
- lon = np.atleast_1d(np.asarray(lon, np.float64))
70
- b = beta_at(lat)
71
- gx = self.env.sample(self.gx, lat, lon)
72
- gy = self.env.sample(self.gy, lat, lon) + b
73
- sign = hemisphere(lat)
74
- gx, gy = sign * gx, sign * gy
75
- size = np.hypot(gx, gy)
76
- speed = self.drift_ms * np.sqrt(np.clip(size / beta_at(20.0), 0.05, 4.0)) * np.sqrt(self.outer_ms / 6.0)
77
- ex, ey = gx / np.maximum(size, 1e-15), gy / np.maximum(size, 1e-15)
78
- dx, dy = rotate(ex, ey, sign * self.angle_deg)
79
- return speed * dx, speed * dy
80
-
81
-
82
- __all__ = ["BetaDrift", "DRIFT_MS", "GYRE_ANGLE_DEG", "outer_strength"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/bogus.py DELETED
@@ -1,78 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- The storm a 1.5-degree analysis cannot hold, put back in the shape that matters for its motion.
4
-
5
- The idea: at 1.5 degrees a hurricane's core is a smudge, but its *motion* does not depend on the core -- it
6
- depends on the vortex's outer circulation (300-1000 km), which drives the beta gyres (Fiorino and Elsberry
7
- 1989), and on how deep it is. So the bogus vortex is specified by its outer strength, not its maximum wind:
8
-
9
- * a modified-Rankine tangential profile ``V(r) = Vm (r / Rm)`` inside ``Rm`` and ``Vm (Rm / r)^alpha`` outside,
10
- tapered to zero beyond ``R_taper`` (a vortex with unbounded circulation would interact with the whole
11
- domain);
12
- * ``Vm`` chosen so the profile's mean tangential wind between 300 and 800 km equals the outer strength
13
- measured from the analysis's own vortex (:func:`naturev1.cyclone.beta.outer_strength`), so the bogus has
14
- exactly the outer circulation the reanalysis saw, with a core the model grid can resolve;
15
- * ``Rm`` widened from the best-track radius of maximum wind to at least two grid lengths;
16
- * the size taken from the best-track gale radius when it exists (larger storms decay more slowly outside).
17
-
18
- Returns winds and vorticity on any (x, y) km grid around the centre.
19
- """
20
-
21
- from __future__ import annotations
22
-
23
- import numpy as np
24
-
25
- #: Profile shape outside the radius of maximum wind, and the taper radius, km.
26
- ALPHA = 0.55
27
- TAPER_KM = 1400.0
28
-
29
-
30
- def rankine_profile(r_km: np.ndarray, vmax: float, rmax_km: float, alpha: float = ALPHA,
31
- taper_km: float = TAPER_KM) -> np.ndarray:
32
- r = np.asarray(r_km, np.float64)
33
- inner = vmax * r / rmax_km
34
- outer = vmax * (rmax_km / np.maximum(r, 1e-6)) ** alpha
35
- profile = np.where(r < rmax_km, inner, outer)
36
- return profile * np.exp(-(r / taper_km) ** 4)
37
-
38
-
39
- def vmax_for_outer_strength(outer_ms: float, rmax_km: float, alpha: float = ALPHA,
40
- radii=(300.0, 400.0, 500.0, 600.0, 700.0, 800.0)) -> float:
41
- """The peak wind that gives the profile a mean tangential wind of ``outer_ms`` over ``radii``."""
42
- unit = rankine_profile(np.asarray(radii), 1.0, rmax_km, alpha).mean()
43
- return float(outer_ms / max(unit, 1e-9))
44
-
45
-
46
- def alpha_for_size(r34_nmi: float) -> float:
47
- """Outer decay exponent from the gale radius: big storms decay slowly (0.4), small ones fast (0.7)."""
48
- if not np.isfinite(r34_nmi) or r34_nmi <= 0:
49
- return ALPHA
50
- return float(np.clip(0.75 - 0.0025 * r34_nmi, 0.4, 0.7))
51
-
52
-
53
- def bogus_winds(x_km: np.ndarray, y_km: np.ndarray, outer_ms: float, rmax_km: float = 150.0, alpha: float = ALPHA,
54
- cyclonic_sign: float = 1.0) -> tuple[np.ndarray, np.ndarray]:
55
- """(u, v) m/s of the bogus vortex centred at the origin; ``cyclonic_sign`` -1 in the southern hemisphere."""
56
- r = np.hypot(x_km, y_km)
57
- vm = vmax_for_outer_strength(outer_ms, rmax_km, alpha)
58
- vt = cyclonic_sign * rankine_profile(r, vm, rmax_km, alpha)
59
- with np.errstate(invalid="ignore", divide="ignore"):
60
- u = np.where(r > 0, -vt * y_km / r, 0.0)
61
- v = np.where(r > 0, vt * x_km / r, 0.0)
62
- return u, v
63
-
64
-
65
- def bogus_vorticity(r_km: np.ndarray, outer_ms: float, rmax_km: float = 150.0, alpha: float = ALPHA,
66
- cyclonic_sign: float = 1.0) -> np.ndarray:
67
- """Relative vorticity (s^-1) of the bogus vortex, ``(1/r) d(r V)/dr``, evaluated by central differences."""
68
- r = np.asarray(r_km, np.float64)
69
- vm = vmax_for_outer_strength(outer_ms, rmax_km, alpha)
70
- h = 1.0
71
- rv = lambda rr: rr * rankine_profile(rr, vm, rmax_km, alpha)
72
- rr = np.maximum(r, 2 * h)
73
- zeta = (rv(rr + h) - rv(rr - h)) / (2 * h) / rr / 1000.0
74
- return cyclonic_sign * zeta
75
-
76
-
77
- __all__ = ["ALPHA", "TAPER_KM", "alpha_for_size", "bogus_vorticity", "bogus_winds", "rankine_profile",
78
- "vmax_for_outer_strength"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/climatology.py DELETED
@@ -1,101 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- The flow a storm will meet in three days is mostly the flow it would meet on an average year -- plus what is
4
- left of today's anomaly.
5
-
6
- The idea: a steering flow frozen at the initial time is the right answer at hour zero and an increasingly
7
- wrong one after: troughs move on, ridges rebuild, and by day five the analysis's particular pattern has lost
8
- most of its memory. What is *not* lost is the seasonal circulation -- the subtropical ridge in September is
9
- where it always is in September. So the flow is split into climatology plus anomaly, and the anomaly is
10
- **damped with lead time** (the persistence of steering anomalies is a few days; Neumann's CLIPER is the
11
- fully-damped limit, the frozen flow the undamped one):
12
-
13
- ``V(x, t) = V_clim(x, doy) + exp(-t / tau) * (V_analysis(x) - V_clim(x, doy))``
14
-
15
- The climatology is ERA5 1981-2010 (WeatherBench 2's daily climatology, a 7-day window) -- years that do not
16
- overlap the 2016-2022 hindcast seasons, so the benchmark is not flattered by its own future. It is cached as a
17
- day-of-year stack of the winds the steering layers use, plus the 500 hPa height (for the ridge's anomaly) and
18
- the day-to-day standard deviation of the 500 hPa wind (the natural size of an analysis's flow uncertainty,
19
- used by the ensemble).
20
- """
21
-
22
- from __future__ import annotations
23
-
24
- from pathlib import Path
25
-
26
- import numpy as np
27
-
28
- from .fields import Environment
29
-
30
- STORE = ("gs://weatherbench2/datasets/era5-daily-climatology/"
31
- "daily_clim_daily_mean_1981_to_2010_7_dw-240x121_equiangular_with_poles_conservative.zarr")
32
- LEVELS = (200, 250, 300, 400, 500, 600, 700, 850)
33
- CHANNELS = [f"u@{p}" for p in LEVELS] + [f"v@{p}" for p in LEVELS] + ["z@500", "ustd@500", "vstd@500"]
34
-
35
-
36
- def build_climatology(path: str | Path, log=print) -> Path:
37
- """Read the day-of-year climatology into ``path`` (``(366, C, 121, 240)`` float16 + ``.json``); idempotent."""
38
- import json
39
-
40
- import xarray as xr
41
-
42
- path = Path(path)
43
- if path.exists() and path.with_suffix(".json").exists():
44
- return path
45
- ds = xr.open_zarr(STORE, storage_options={"token": "anon"}, chunks=None)
46
- lat = ds.latitude.values
47
- out = np.lib.format.open_memmap(path, mode="w+", dtype=np.float16, shape=(366, len(CHANNELS), 121, 240))
48
- levels = list(ds.level.values.astype(int))
49
- order = [levels.index(p) for p in LEVELS]
50
-
51
- def oriented(array):
52
- array = np.swapaxes(np.asarray(array, np.float32), -1, -2)
53
- return array[..., ::-1, :] if lat[0] > lat[-1] else array
54
-
55
- for name, slot, idx in (("u_component_of_wind_mean", 0, order), ("v_component_of_wind_mean", len(LEVELS), order)):
56
- data = oriented(ds[name].values) # (366, L, lat, lon)
57
- out[:, slot:slot + len(LEVELS)] = data[:, idx]
58
- log(f"[climatology] {name}")
59
- out[:, 2 * len(LEVELS)] = oriented(ds["geopotential_mean"].sel(level=500).values) / 100.0 # dam-scaled for fp16
60
- out[:, 2 * len(LEVELS) + 1] = oriented(ds["u_component_of_wind_std"].sel(level=500).values)
61
- out[:, 2 * len(LEVELS) + 2] = oriented(ds["v_component_of_wind_std"].sel(level=500).values)
62
- out.flush()
63
- path.with_suffix(".json").write_text(json.dumps({"channels": CHANNELS, "levels": list(LEVELS), "z_scale": 100.0}))
64
- return path
65
-
66
-
67
- class Climatology:
68
- """Day-of-year climatology, cut into the same boxes as the hindcast analyses."""
69
-
70
- def __init__(self, path: str | Path) -> None:
71
- self.data = np.load(path, mmap_mode="r")
72
- self.lat = np.linspace(-90.0, 90.0, 121)
73
- self.slot = {n: k for k, n in enumerate(CHANNELS)}
74
-
75
- def environment(self, like: Environment, when: np.datetime64, shift_days: float = 0.0) -> Environment:
76
- """An :class:`Environment` on ``like``'s box holding the climatological winds (and z500) for ``when``."""
77
- day = int((np.datetime64(when, "D") - np.datetime64(str(when)[:4] + "-01-01", "D")).astype(int) + shift_days) % 366
78
- rows = np.rint((like.lat - self.lat[0]) / 1.5).astype(int)
79
- cols = np.rint((like.lon % 360.0) / 1.5).astype(int) % 240
80
- block = self.data[day][:, rows][:, :, cols].astype(np.float64)
81
- fields = {"u": np.stack([block[self.slot[f"u@{p}"]] for p in LEVELS]),
82
- "v": np.stack([block[self.slot[f"v@{p}"]] for p in LEVELS]),
83
- "z": np.repeat(block[self.slot["z@500"]][None] * 100.0, len(LEVELS), 0),
84
- "ustd500": block[self.slot["ustd@500"]], "vstd500": block[self.slot["vstd@500"]]}
85
- return Environment(lat=like.lat, lon=like.lon, levels=LEVELS, fields=fields, time=like.time)
86
-
87
-
88
- class DampedAnomalyFlow:
89
- """``V_clim + exp(-t / tau) (V_analysis - V_clim)`` from two steering flows (``velocity(lat, lon)`` objects)."""
90
-
91
- def __init__(self, analysis_flow, climate_flow, tau_h: float = 72.0) -> None:
92
- self.analysis, self.climate, self.tau_h = analysis_flow, climate_flow, tau_h
93
-
94
- def __call__(self, lat, lon, hours):
95
- ua, va = self.analysis.velocity(lat, lon)
96
- uc, vc = self.climate.velocity(lat, lon)
97
- memory = np.exp(-np.asarray(hours, np.float64) / max(self.tau_h, 1e-6))
98
- return uc + memory * (ua - uc), vc + memory * (va - vc)
99
-
100
-
101
- __all__ = ["CHANNELS", "Climatology", "DampedAnomalyFlow", "LEVELS", "STORE", "build_climatology"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/cliper.py DELETED
@@ -1,137 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- CLIPER, rebuilt: climatology and persistence as a regression, per basin, in the poleward frame.
4
-
5
- The idea (Neumann 1972, still NHC's skill baseline as CLIPER5): where storms in this place, at this time of
6
- year, moving like this, went next -- from 35 years of best tracks, with no model fields at all. It beats pure
7
- extrapolation from about a day on because climatology knows storms recurve at the subtropical ridge and
8
- slow down near it; extrapolation knows only the last six hours.
9
-
10
- This version improves on the classic in three ways:
11
-
12
- * **Poleward frame**: southern-hemisphere storms are mirrored north-south, so the Southern Indian and South
13
- Pacific basins share one model with twice the data, and every "poleward" coefficient means the same thing
14
- in both hemispheres.
15
- * **Three motion windows** (the last 6 h, the 6 h before, the 12 h before that): curvature and acceleration
16
- enter as differences of windows, which the regression weighs against climatology lead by lead.
17
- * **Displacements in an exact local frame** (azimuthal equidistant), not degree differences, so the
18
- regression's target means the same at 10 and 40 degrees latitude.
19
-
20
- Coefficients are fitted once on 1980-2014 (no overlap with the 2016-2022 seasons the benchmark uses) by
21
- :mod:`naturev1.cyclone.fit` and stored as constants in :mod:`naturev1.cyclone.coefficients`.
22
- """
23
-
24
- from __future__ import annotations
25
-
26
- import numpy as np
27
-
28
- from .geometry import from_local, local_displacement_km, wrap_longitude
29
-
30
- #: Basin groups sharing one regression, and each group's reference longitude.
31
- GROUPS = {"NA": ("NA", -60.0), "EP": ("EP", -120.0), "WP": ("WP", 140.0), "NI": ("NI", 85.0),
32
- "SI": ("SH", 80.0), "SP": ("SH", 170.0), "SA": ("SH", -30.0)}
33
-
34
- PREDICTORS = ("one", "lat", "lat2", "lon", "u6", "v6", "u12", "v12", "u24", "v24", "wind", "dwind", "season_s",
35
- "season_c", "lat_u6", "lat_v6", "u6v6", "u6sq", "v6sq", "wind_v6", "lon_v6")
36
-
37
-
38
- def _motion(case, start: int, stop: int):
39
- """(east, north) km/h between history offsets ``start`` and ``stop`` hours (negative), or None."""
40
- from .data import HISTORY_HOURS
41
-
42
- a, b = HISTORY_HOURS.index(start), HISTORY_HOURS.index(stop)
43
- la, loa = case.history["lat"][a], case.history["lon"][a]
44
- lb, lob = case.history["lat"][b], case.history["lon"][b]
45
- if not (np.isfinite(la) and np.isfinite(lb)):
46
- return None
47
- east, north = local_displacement_km(la, loa, lb, lob)
48
- hours = stop - start
49
- return east / hours, north / hours
50
-
51
-
52
- def predictors(case) -> np.ndarray | None:
53
- """The CLIPER predictor vector in the poleward frame, or None without a 6- or 12-hour history."""
54
- sign = -1.0 if case.lat < 0 else 1.0
55
- m6 = _motion(case, -6, 0) or (_motion(case, -12, 0))
56
- if m6 is None:
57
- return None
58
- m12 = _motion(case, -12, -6) or m6
59
- m24 = _motion(case, -24, -12) or m12
60
- u6, v6 = m6[0], sign * m6[1]
61
- u12, v12 = m12[0], sign * m12[1]
62
- u24, v24 = m24[0], sign * m24[1]
63
- _, reference = GROUPS.get(case.basin, ("NA", -60.0))
64
- lat = abs(case.lat) / 30.0
65
- lon = wrap_longitude(case.lon - reference) / 60.0
66
- wind = (case.wind_kt if np.isfinite(case.wind_kt) else 50.0) / 100.0
67
- before = case.history["wind"][-3] # 12 hours ago
68
- dwind = ((case.wind_kt - before) / 20.0) if np.isfinite(before) and np.isfinite(case.wind_kt) else 0.0
69
- day = (np.datetime64(case.time, "D") - np.datetime64(str(case.time)[:4] + "-01-01", "D")).astype(int)
70
- phase = 2 * np.pi * ((day + (182 if sign < 0 else 0)) % 365) / 365.0
71
- s = 1.0 / 20.0 # km/h -> order one
72
- u6s, v6s = u6 * s, v6 * s
73
- return np.array([1.0, lat, lat * lat, lon, u6s, v6s, u12 * s, v12 * s, u24 * s, v24 * s, wind, dwind,
74
- np.sin(phase), np.cos(phase), lat * u6s, lat * v6s, u6s * v6s, u6s * u6s, v6s * v6s,
75
- wind * v6s, lon * v6s])
76
-
77
-
78
- def group_of(case) -> str:
79
- return GROUPS.get(case.basin, ("NA", 0.0))[0]
80
-
81
-
82
- def cliper_track(case, leads, table: dict | None = None) -> np.ndarray:
83
- """``(L, 2)`` lat/lon from the stored coefficients (NaN where the leads or the group are not covered)."""
84
- if table is None:
85
- from .coefficients import CLIPER as table
86
- out = np.full((len(leads), 2), np.nan)
87
- x = predictors(case)
88
- group = table.get(group_of(case))
89
- if x is None or group is None:
90
- return out
91
- sign = -1.0 if case.lat < 0 else 1.0
92
- stored_leads = list(table["leads"])
93
- weights = np.asarray(group, np.float64) # (L, 2, P)
94
- for k, lead in enumerate(leads):
95
- if lead not in stored_leads:
96
- continue
97
- east, north = weights[stored_leads.index(lead)] @ x
98
- lat, lon = from_local(case.lat, case.lon, east, sign * north)
99
- out[k] = lat, lon
100
- return out
101
-
102
-
103
- def fit_cliper(cases, leads, ridge: float = 1e-2) -> dict:
104
- """
105
- Least squares (with a small ridge) of the poleward-frame displacement at each lead on :func:`predictors`,
106
- one regression per basin group. Returns ``{"leads": [...], group: (L, 2, P) nested lists}``.
107
- """
108
- rows: dict[str, list] = {}
109
- for case in cases:
110
- x = predictors(case)
111
- if x is None:
112
- continue
113
- sign = -1.0 if case.lat < 0 else 1.0
114
- target = np.full((len(leads), 2), np.nan)
115
- for k in range(len(leads)):
116
- if case.truth["valid"][k]:
117
- east, north = local_displacement_km(case.lat, case.lon, case.truth["lat"][k], case.truth["lon"][k])
118
- target[k] = east, sign * north
119
- rows.setdefault(group_of(case), []).append((x, target))
120
- table = {"leads": [int(h) for h in leads]}
121
- for group, items in rows.items():
122
- x = np.stack([i[0] for i in items])
123
- y = np.stack([i[1] for i in items])
124
- weights = np.zeros((len(leads), 2, x.shape[1]))
125
- for k in range(len(leads)):
126
- ok = np.isfinite(y[:, k, 0])
127
- if ok.sum() < 5 * x.shape[1]:
128
- weights[k] = np.nan
129
- continue
130
- a = x[ok]
131
- gram = a.T @ a + ridge * ok.sum() * np.diag([0.0] + [1.0] * (x.shape[1] - 1))
132
- weights[k] = np.linalg.solve(gram, a.T @ y[ok, k]).T
133
- table[group] = weights.round(5).tolist()
134
- return table
135
-
136
-
137
- __all__ = ["GROUPS", "PREDICTORS", "cliper_track", "fit_cliper", "group_of", "predictors"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/coefficients.py DELETED
@@ -1,770 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Hard-coded constants of the tropical-cyclone physics, generated by :mod:`naturev1.cyclone.fit`.
4
-
5
- Do not edit by hand: regenerate with ``naturev1.cyclone.fit.write_coefficients``. Provenance of every table is in
6
- :data:`PROVENANCE`; nothing here was fitted on the 2018, 2020, 2021 or 2022 seasons the benchmark reports.
7
- """
8
-
9
- nan = float("nan")
10
-
11
- LEADS = [6, 12, 24, 36, 48, 72, 96, 120]
12
-
13
- CLIPER = {'leads': [6, 12, 24, 36, 48, 72, 96, 120],
14
- 'SH': [[[-6.89849, 4.55311, 5.27073, -0.6131, 94.40557, -4.42295, 5.76681, 0.295, -4.2088, 4.68075, 2.09058, 0.46463,
15
- -0.25732, 0.29081, 24.15884, 3.00957, 2.13546, 0.0907, 0.6306, 1.90969, 1.06164],
16
- [9.86812, -4.73926, -5.52023, 1.11141, 11.44614, 67.94021, -3.64462, 3.85791, -3.30421, -0.58698, -0.86277,
17
- 0.91485, -1.66603, -1.45924, -3.81025, 31.49136, 1.69392, 0.08787, 2.88735, 21.22811, 0.23959]],
18
- [[-18.41425, 12.81992, 15.52967, -1.4921, 187.19514, -14.70217, 9.04517, 3.66699, -11.08835, 9.79055, 5.20326,
19
- 1.52524, -0.11925, 1.92083, 44.74067, 10.55012, 4.61026, 0.72396, 3.46313, 8.45619, 2.70911],
20
- [22.28041, -10.80969, -9.28192, 2.52104, 27.51898, 119.72323, -9.23941, 7.20763, -8.02007, -2.56349, 1.22476,
21
- 2.41026, -4.46188, -4.1664, -8.82557, 63.81331, 7.02853, 2.13998, 6.82641, 48.68957, 3.76544]],
22
- [[-52.46231, 40.13144, 51.15546, -4.60957, 358.21768, -36.8936, 22.53576, 13.34013, -32.60534, 14.82684,
23
- 13.62215, 5.82275, 0.7088, 9.45188, 71.93162, 30.98404, 1.12105, 0.62557, 15.2583, 33.9271, 13.58604],
24
- [53.42745, -24.2113, -15.56955, 7.35784, 66.32803, 193.75807, -17.32424, 17.5433, -19.69158, -17.2441,
25
- 14.30136, 5.64206, -14.15874, -11.95333, -26.34444, 120.17492, 17.01594, 10.47998, 16.16886, 115.50358,
26
- 13.83153]],
27
- [[-94.35612, 73.38788, 90.83379, -10.24711, 500.88914, -61.71059, 31.29922, 14.84663, -51.72601, 22.80821,
28
- 26.80545, 12.14121, -0.36291, 19.78906, 106.14399, 55.52652, -17.08894, -3.14437, 34.58213, 70.99715,
29
- 20.24861],
30
- [91.93743, -39.65755, -25.20118, 12.90685, 101.23193, 239.4925, -23.84764, 14.31463, -28.40863, -25.54232,
31
- 40.70569, 10.89734, -27.74962, -23.23559, -34.85518, 162.19301, 15.2109, 21.40256, 22.90112, 187.33009,
32
- 24.3049]],
33
- [[-139.80538, 109.25377, 131.05002, -20.52933, 615.5265, -89.80457, 40.19393, 21.64613, -62.00295, 36.19187,
34
- 45.44497, 17.18632, -4.42606, 29.85471, 121.08593, 65.97433, -40.64935, -15.89213, 56.61788, 98.93821,
35
- 19.55115],
36
- [141.33508, -65.07264, -46.0827, 18.5825, 129.44505, 253.8835, -27.39761, 23.58675, -36.63969, -30.4526,
37
- 78.74436, 17.08254, -46.20355, -39.26373, -38.95082, 177.20303, 0.6972, 26.41611, 25.01441, 245.48092,
38
- 29.40196]],
39
- [[-228.75069, 166.68038, 190.23038, -53.49356, 748.18585, -111.63548, 51.69735, 24.31313, -37.98832, 40.61027,
40
- 85.72059, 37.67029, -13.2301, 44.13079, 129.046, 94.20008, -67.12184, -38.44348, 82.07731, 90.69575,
41
- -9.86827],
42
- [271.87026, -158.54519, -140.65643, 26.95161, 135.4888, 268.79865, -27.57028, 28.76617, -47.75765, -41.24527,
43
- 158.18148, 48.38695, -98.02939, -81.48959, -7.2034, 170.93914, -12.54187, 24.45127, 36.33418, 277.34997,
44
- 36.88958]],
45
- [[-311.91764, 217.3, 242.4542, -99.0233, 804.36005, -160.92961, 54.02222, 8.24152, -18.95993, 44.45592,
46
- 94.54263, 69.5194, -14.26466, 65.18202, 187.74659, 101.84084, -77.68017, -51.41698, 124.25634, 60.72763,
47
- -63.30426],
48
- [432.47081, -280.35791, -260.64893, 31.11299, 124.67457, 267.04998, -37.55164, 21.34623, -66.13774,
49
- -61.25982, 192.55883, 85.81275, -147.05231, -125.48425, 49.87659, 124.45063, -44.3798, 26.85232, 33.17025,
50
- 246.04304, 2.22019]],
51
- [[-395.8374, 222.13794, 276.2057, -171.77674, 801.86338, -176.19246, 59.64088, 16.02142, 26.4853, 38.237,
52
- 89.26081, 72.6628, -11.63199, 81.12329, 245.69749, 85.97211, -102.31933, -51.39082, 128.7768, -3.17239,
53
- -120.85532],
54
- [609.71396, -427.99459, -403.71133, 20.82984, 77.72191, 253.14287, -37.77152, 23.97872, -55.89085, -81.56886,
55
- 181.08041, 122.80727, -188.94111, -173.77959, 88.6667, 26.22527, -109.42297, 36.82808, -7.22977, 185.34796,
56
- -37.09241]]],
57
- 'WP': [[[-16.20677, 15.63998, 10.06914, -3.74299, 93.75863, -5.40601, 8.19762, 5.1524, -8.56842, 6.9766, -2.31176,
58
- -1.71704, 3.18447, 3.66333, 21.32686, -0.95038, 5.77288, -1.55543, -3.04609, 9.97277, -5.07394],
59
- [13.50039, -1.81948, -6.73653, 5.45262, 12.99839, 69.60777, -8.73442, 7.36191, -5.79528, -4.65655, -3.29464,
60
- 0.42635, -1.17755, -3.87253, 5.35639, 28.44964, -0.02443, -0.6877, -0.56882, 21.28817, -2.37855]],
61
- [[-43.33451, 39.26894, 30.74485, -12.23165, 184.65042, -18.97883, 14.70594, 15.94676, -22.16473, 17.49647,
62
- -7.21557, -4.54139, 9.90573, 11.14076, 43.7145, 2.17248, 13.02577, -1.36712, -5.6229, 30.98623, -9.81739],
63
- [30.54725, -4.91513, -10.12713, 13.96885, 28.0555, 124.03229, -20.27628, 14.36824, -13.64905, -11.69131,
64
- -5.1331, 1.01649, -2.77651, -9.94192, 14.08919, 61.94014, 4.76309, -0.21385, 0.21013, 49.42618, -4.14531]],
65
- [[-128.40854, 105.06989, 98.52555, -42.83307, 347.43636, -57.58312, 22.01075, 34.27455, -55.26274, 46.96565,
66
- -17.68337, -13.07826, 31.88054, 34.41883, 92.24259, 28.43562, 34.34689, 1.15779, 4.09304, 95.40397,
67
- -22.14084],
68
- [72.46964, -10.07829, -9.80993, 36.48727, 57.5315, 192.58188, -43.93762, 22.39047, -27.52741, -21.33891,
69
- -0.97934, 2.30881, -5.72227, -26.32042, 37.01298, 131.14381, 20.2822, 5.6267, 12.1905, 120.17773,
70
- -7.2647]],
71
- [[-246.22865, 192.82272, 190.36649, -94.7955, 481.98355, -94.53456, 24.68945, 52.83694, -84.01519, 68.41848,
72
- -24.3452, -23.35148, 63.58966, 64.59874, 123.74873, 71.06015, 52.32938, 1.78221, 22.653, 188.57723,
73
- -43.03743],
74
- [125.15148, -12.39789, -6.66224, 67.34554, 77.14063, 225.02866, -57.74292, 29.03936, -40.45363, -35.74492,
75
- 17.71042, 3.3196, -7.31209, -48.42763, 59.27303, 193.01925, 29.30954, 10.76265, 30.22705, 193.2349,
76
- -23.00858]],
77
- [[-378.0917, 296.36841, 287.33006, -170.44207, 594.94495, -104.02902, 13.86768, 64.71427, -87.95867, 74.47373,
78
- -21.74208, -32.72182, 101.69244, 91.20896, 112.25741, 109.15678, 52.52173, -5.88387, 22.23961, 286.06762,
79
- -65.45707],
80
- [198.08326, -16.94066, -20.40089, 105.97841, 98.78439, 250.97908, -69.98445, 22.31221, -41.24996, -46.28173,
81
- 49.59685, 5.57246, -9.78638, -80.81538, 61.09144, 221.52819, 11.05386, 12.50686, 28.91994, 253.61661,
82
- -41.74733]],
83
- [[-653.59538, 506.70533, 419.20707, -370.4655, 730.69148, -78.22394, 17.6356, 56.58439, -68.66478, 84.54675,
84
- 25.0756, -24.89897, 171.35518, 107.47726, 28.82199, 138.79654, 19.88845, -30.49115, 15.25375, 405.77192,
85
- -70.72863],
86
- [379.12461, -61.82457, -125.34344, 185.66916, 96.34344, 268.93865, -76.29996, 7.4685, -28.81106, -39.44926,
87
- 133.74491, 31.26702, -26.61039, -179.85099, 62.614, 182.42887, -43.63986, 0.94889, 33.52414, 304.65999,
88
- -39.47807]],
89
- [[-897.67447, 670.04154, 480.55428, -601.62708, 801.01675, -11.31436, 30.84273, 50.90279, -25.13451, 78.57773,
90
- 69.23942, -14.80544, 200.28509, 74.18916, -85.18752, 128.03856, 26.93606, -26.69193, -18.08332, 432.09076,
91
- -84.24039],
92
- [604.07426, -179.57362, -306.29838, 275.8111, 71.16481, 273.13995, -63.82291, 15.87954, -27.59178, -49.02211,
93
- 195.00731, 79.65092, -62.25121, -309.14757, 79.73423, 84.02348, -86.25105, -6.95591, 36.97797, 276.84592,
94
- -7.45035]],
95
- [[-1117.40609, 770.74186, 506.26311, -839.01551, 862.67992, 2.35721, 30.69398, 61.28793, -36.92812, 50.84527,
96
- 118.99847, -34.31243, 180.78113, -0.10516, -143.39981, 95.68428, -17.60192, -30.87194, -26.08792, 433.79439,
97
- -147.2518],
98
- [851.06251, -366.06152, -511.15683, 374.00238, 66.7686, 273.08502, -40.82897, 28.79323, -64.79473, -59.64446,
99
- 220.17852, 92.48093, -117.03989, -451.59675, 120.11612, -29.31688, -77.35583, 8.34809, 45.64576, 220.87016,
100
- 42.26594]]],
101
- 'EP': [[[-14.10155, 10.7506, 10.93923, 4.04594, 78.00045, -2.38971, 14.87339, 0.08937, -5.30831, 6.78377, -0.43682,
102
- -0.3926, 1.61448, 5.22697, 28.24363, 1.17719, 6.41496, -3.87028, 1.42438, 2.76606, -6.42886],
103
- [8.8687, -3.0245, -1.53857, 1.08307, 4.30622, 57.6545, 0.22957, 11.5313, -5.49554, -6.03769, -1.49113,
104
- 2.31404, -1.04613, -0.51434, 6.25738, 30.01525, -6.10255, -0.23447, 8.80973, 19.96839, -1.91677]],
105
- [[-34.85265, 25.45339, 27.88811, 11.00401, 155.88499, -8.79767, 24.69022, 3.9085, -14.0247, 16.85116, -2.0146,
106
- -0.25681, 4.11728, 14.51727, 59.23789, 6.299, 13.29241, -6.41778, 2.42611, 7.0105, -20.35959],
107
- [19.11693, -6.31076, -0.1908, 2.74093, 11.93734, 109.40368, -3.46368, 15.65958, -12.59234, -14.66074,
108
- -1.95992, 6.48166, -3.16552, -1.53529, 16.91493, 61.58156, -7.69779, 0.83476, 17.65171, 44.72503,
109
- -5.11928]],
110
- [[-94.07023, 66.06869, 73.65271, 31.10041, 300.51449, -20.21636, 46.21104, 12.87457, -42.92967, 35.59433,
111
- -10.36306, 0.41671, 11.24113, 44.12991, 116.71825, 27.24361, 27.27627, -8.16348, 14.73383, 19.87556,
112
- -65.7655],
113
- [45.34318, -14.86517, 4.49271, 5.88653, 26.30837, 185.53952, -13.92399, 21.48984, -25.4368, -33.46387,
114
- -0.73858, 17.88958, -10.91001, -4.84443, 42.16668, 117.78172, -0.55086, 6.5768, 41.99272, 92.51631,
115
- -18.77148]],
116
- [[-172.78613, 113.91341, 122.06318, 51.65896, 440.8733, -34.47618, 60.24896, 19.85061, -80.00821, 53.35082,
117
- -23.25532, -0.10875, 16.93984, 84.41628, 149.62937, 46.68605, 8.47969, -11.06512, 29.00573, 36.81206,
118
- -115.24421],
119
- [84.42589, -30.80777, -2.64203, 4.62837, 28.4251, 233.43171, -24.01027, 23.43787, -33.42158, -52.08475,
120
- 2.88012, 28.76511, -21.99061, -8.60319, 53.01886, 149.51026, -7.61571, 3.99682, 60.80214, 128.1721,
121
- -38.61486]],
122
- [[-267.69028, 165.66531, 169.99404, 75.41722, 546.79914, -45.53888, 70.58117, 16.35894, -96.30306, 66.90357,
123
- -36.24983, 2.29243, 19.93098, 130.2162, 158.66875, 65.0486, -54.24025, -18.70754, 15.90572, 53.32363,
124
- -168.36153],
125
- [139.40898, -57.62791, -27.561, 0.24818, 17.72865, 253.69821, -24.28178, 13.21468, -33.02873, -60.72511,
126
- 8.39666, 38.89736, -37.06063, -14.5925, 42.94765, 161.36429, -43.33423, -2.58853, 60.56868, 146.59029,
127
- -62.70576]],
128
- [[-511.1468, 251.50406, 243.42129, 109.49582, 632.4447, -59.47003, 77.36327, 16.60987, -60.91765, 49.01528,
129
- -61.92422, 10.56417, 2.36614, 201.39689, 126.61161, 74.4434, -141.53288, -54.37215, 43.72297, 82.33669,
130
- -217.59884],
131
- [254.75358, -129.58295, -108.71395, -24.3251, 0.21155, 231.55942, -18.52024, 8.75631, -35.37236, -60.83596,
132
- 25.7126, 60.84015, -72.79191, -47.39666, 25.2762, 144.87154, -117.12103, -3.83355, 88.41045, 127.32908,
133
- -98.96257]],
134
- [[-766.90219, 307.83642, 303.60288, 140.14602, 679.08811, -97.8231, 102.63709, -30.21819, 3.64232, 47.42414,
135
- -89.66976, -3.45734, -61.30598, 238.50877, 103.50796, 41.9625, -238.54794, -65.4156, 35.87017, 114.32341,
136
- -310.17189],
137
- [362.67025, -215.41649, -207.36841, -56.91425, -31.81393, 217.62739, -18.65506, 3.30264, -63.08998,
138
- -41.12408, 25.73649, 72.91571, -113.83786, -92.33626, 67.69776, 101.02156, -111.44527, -0.27672, 127.04858,
139
- 126.36957, -149.8012]],
140
- [[-1057.72533, 371.27592, 410.23032, 147.57668, 743.81289, -185.15576, 136.97215, -59.87554, 33.41684,
141
- 45.71323, -115.78355, -15.10783, -174.7672, 237.93246, 210.94902, 2.78143, -374.73489, -55.6333, -52.73704,
142
- 148.00718, -406.68044],
143
- [446.57062, -320.73884, -318.99586, -108.73124, -84.30502, 185.2249, -44.19411, 10.44054, -85.61826,
144
- -62.82437, 33.34707, 40.12021, -165.32924, -156.53856, 155.54921, 60.78069, -140.97183, 1.80045, 186.71327,
145
- 126.64964, -162.31242]]],
146
- 'NA': [[[-19.7397, 14.92792, 8.28093, -6.12276, 90.9361, -1.7382, 11.56228, 3.23465, -10.1254, 7.94461, -0.66331,
147
- 0.0269, 0.31266, 6.53544, 20.3387, -0.52592, 0.89, -1.70758, 1.14218, -1.98799, -7.52874],
148
- [14.64359, 0.50116, -8.20719, 0.74821, 17.7035, 61.28995, -5.42721, 5.10377, -9.03081, -5.79328, -4.96155,
149
- 0.5542, -2.72867, -2.45076, -1.21414, 31.93361, -2.3194, -0.02424, 3.60817, 21.16033, 2.50119]],
150
- [[-50.6297, 34.55303, 27.92063, -15.4313, 181.45277, -7.74589, 19.8419, 10.42758, -28.4207, 19.14275,
151
- -4.60102, -0.53944, 1.24463, 18.28998, 40.80277, 7.10197, 3.99405, -3.59454, 3.19958, -4.19619, -17.55586],
152
- [31.05824, 2.10531, -14.1071, 1.16249, 43.34005, 108.99681, -13.93417, 10.98735, -22.16962, -18.00755,
153
- -9.79642, 1.28715, -7.9456, -6.02374, -3.05803, 65.41266, -3.77653, 1.70732, 9.23992, 50.73953, 7.28982]],
154
- [[-141.54223, 82.34601, 99.11153, -43.18294, 355.73482, -30.64933, 20.3621, 27.3561, -66.58324, 45.54927,
155
- -20.02348, -3.79037, 1.71088, 52.15413, 74.26414, 55.20514, -4.93865, -10.70825, 20.52591, -8.93112,
156
- -42.67116],
157
- [70.50493, 10.87541, -26.0053, 6.11513, 109.93085, 188.15395, -44.24787, 14.44529, -44.81098, -51.60302,
158
- -14.11999, 1.83004, -26.62172, -20.05412, -12.042, 118.01404, -6.71696, 6.76635, 28.44459, 120.52326,
159
- 4.80817]],
160
- [[-261.22824, 144.74105, 195.13376, -89.60911, 506.87602, -49.60913, 20.79732, 57.23271, -101.71518, 58.35085,
161
- -41.8696, -11.59963, -2.01073, 91.61068, 81.03763, 117.59619, -19.69545, -19.93342, 38.91485, 3.57093,
162
- -57.02817],
163
- [127.68728, 25.59671, -54.75308, 11.42159, 181.857, 235.77496, -58.82489, 16.5011, -63.00386, -78.39415,
164
- -5.31764, -2.17082, -58.70125, -47.88312, -42.95628, 138.78454, -36.24036, 10.73129, 50.47223, 181.90725,
165
- -13.22026]],
166
- [[-405.4043, 220.17132, 301.43824, -160.0718, 646.66092, -53.72306, -8.07799, 57.50692, -112.66158, 78.84926,
167
- -68.15473, -13.57639, -14.1496, 128.01225, 54.61582, 165.59779, -46.14126, -32.95471, 69.67783, 39.97092,
168
- -57.21088],
169
- [201.10028, 41.99469, -109.22198, 5.21129, 241.27225, 263.3353, -77.89816, 1.69393, -56.37484, -80.2929,
170
- 14.18586, -11.04127, -96.07297, -88.91099, -96.2007, 127.82502, -73.57562, 11.32393, 71.57404, 233.97845,
171
- -17.9474]],
172
- [[-691.64733, 388.95659, 469.78782, -357.82076, 805.17452, -66.59878, -7.87234, 68.01242, -89.21878, 70.82546,
173
- -150.75366, 8.37021, -79.94989, 184.81859, -67.61334, 226.54426, -160.80337, -65.33925, 74.40014, 209.37993,
174
- -13.01405],
175
- [390.47994, 58.83516, -267.46859, -30.9205, 278.28722, 269.38136, -70.93147, -4.13945, -30.27543, -64.69202,
176
- 63.47772, -11.6303, -167.51813, -186.7325, -202.32758, -4.02372, -158.63774, -0.66526, 75.93318, 372.17822,
177
- 56.56196]],
178
- [[-953.05098, 537.87041, 551.8665, -607.87374, 897.83015, -46.18598, 0.78403, 75.73439, -65.2684, 42.84475,
179
- -198.20381, -1.75547, -185.01742, 195.66753, -154.03784, 155.2259, -260.22738, -98.46607, 127.25818,
180
- 365.5767, 19.55962],
181
- [613.95029, 20.63313, -444.47182, -41.88751, 266.62887, 270.36036, -36.25045, 25.08128, -71.77993, -64.45922,
182
- 122.61241, -10.47869, -214.9661, -286.61729, -197.92851, -178.52325, -198.56748, -15.7277, 94.45912,
183
- 454.05165, 105.29334]],
184
- [[-1095.29946, 647.61768, 558.5217, -793.06032, 1048.80267, 76.3199, 40.63491, 93.74903, -64.07158, 15.05188,
185
- -159.80678, -13.94131, -189.713, 185.20489, -390.37736, 14.58833, -323.5125, -127.31392, 126.68583,
186
- 452.64929, -15.98189],
187
- [810.0692, -51.11922, -580.16376, 8.15322, 271.43572, 371.27445, -33.00497, 54.81563, -133.06933, -71.30383,
188
- 200.96844, -13.13643, -217.75833, -387.55524, -241.95566, -303.27907, -161.43052, -26.82786, 69.937,
189
- 388.13546, 25.6288]]]}
190
-
191
- SHIFOR = {'leads': [6, 12, 24, 36, 48, 72, 96, 120],
192
- 'SH': [[1.76007, -0.90077, 2.32674, 0.42115, -1.30162, -0.02532, -0.53499, -0.54332, -0.26904, -0.08504, -1.66244,
193
- -3.06006],
194
- [4.09508, -1.48605, 4.02743, 0.75198, -3.1553, -0.05986, -1.22806, -1.22248, -0.66239, -0.35764, -4.76723,
195
- -6.49058],
196
- [9.88996, -2.86549, 6.0916, 1.12335, -8.15216, -0.10467, -2.89685, -2.9604, -1.79756, -1.03137, -12.88379,
197
- -13.37389],
198
- [16.45586, -5.73433, 6.36939, 1.50715, -13.59456, 0.12029, -4.60118, -4.73173, -2.92609, -1.78443, -20.86682,
199
- -18.98281],
200
- [23.24285, -10.60723, 5.67503, 1.81966, -18.49634, 0.5858, -6.13469, -6.21274, -3.91867, -2.7779, -26.74283,
201
- -23.32109],
202
- [34.91492, -22.64959, 4.5837, 1.32516, -26.19189, 1.84623, -8.40239, -8.46128, -5.10437, -4.86784, -32.69072,
203
- -28.44295],
204
- [42.14955, -33.17887, 2.43926, 0.92393, -30.23285, 2.72906, -9.69778, -9.66652, -4.84368, -6.44235, -34.56102,
205
- -29.74156],
206
- [44.99802, -40.83523, 1.28041, 0.34374, -30.45204, 2.68992, -9.96993, -9.58373, -3.93063, -6.77259, -34.69276,
207
- -28.11705]],
208
- 'WP': [[2.21411, 0.40398, 2.56884, 0.26312, -1.54928, 2.22279, -0.70958, -0.57175, -0.14949, -0.18877, -1.68926,
209
- -2.94357],
210
- [5.28234, 1.29639, 4.27834, 0.55566, -3.88038, 5.15965, -1.6828, -1.30171, -0.33372, -0.56369, -4.63631,
211
- -6.31083],
212
- [13.16827, 2.92199, 6.04146, 0.92332, -9.89103, 11.35858, -4.24041, -3.11133, -0.81991, -1.53392, -11.8276,
213
- -13.84501],
214
- [22.07979, 3.05388, 5.70915, 1.51251, -16.08309, 16.82707, -6.96511, -4.81219, -1.23466, -2.32386, -19.00725,
215
- -20.91063],
216
- [31.07652, 0.31897, 4.6837, 2.09066, -21.55483, 21.47013, -9.36483, -6.22239, -1.49956, -3.18045, -24.33408,
217
- -26.13634],
218
- [47.22438, -11.00067, 3.1879, 2.545, -29.61875, 29.83476, -12.3836, -7.85842, -1.37074, -4.7943, -29.85482,
219
- -31.94801],
220
- [58.71765, -23.92176, 2.14552, 1.99125, -34.21927, 37.13748, -13.7553, -8.50005, -0.44011, -5.64727,
221
- -31.58945, -33.21763],
222
- [63.93141, -34.14055, 1.06048, 1.64987, -34.66421, 41.22994, -13.47819, -8.15688, 1.13342, -4.77211,
223
- -32.27454, -32.12786]],
224
- 'EP': [[2.19012, -0.84295, 2.94455, 0.17367, -2.54225, 0.00364, -0.64069, -0.1806, -0.45782, -0.10985, -1.49099,
225
- -2.57433],
226
- [5.29755, -1.97529, 5.05806, 0.33555, -6.02778, -0.04648, -1.54176, -0.36721, -1.0904, -0.39132, -3.86724,
227
- -5.79627],
228
- [13.93435, -5.80117, 7.53045, 0.51995, -14.68916, -0.18744, -3.49323, -0.54622, -2.30369, -1.36716, -9.21523,
229
- -12.93402],
230
- [23.93544, -11.61159, 8.06456, 0.76308, -23.57601, -0.69842, -5.42339, -0.76196, -3.03569, -2.8418, -14.03622,
231
- -19.64002],
232
- [33.28129, -18.11303, 8.0925, 0.54536, -31.07787, -1.61092, -7.08132, -1.22438, -3.32052, -4.04584, -17.83046,
233
- -25.19716],
234
- [46.54852, -29.01323, 5.57758, 0.45074, -39.30633, -4.34583, -9.2534, -1.5847, -1.76018, -5.13272, -22.60284,
235
- -31.59354],
236
- [51.18926, -36.09798, 4.20598, -0.00089, -40.42125, -7.74327, -10.69798, -1.68099, 0.193, -6.00398, -24.50991,
237
- -32.24391],
238
- [50.93793, -40.61493, 2.35584, 0.0867, -36.75436, -10.96966, -11.95292, -1.7968, 1.3075, -8.54996, -25.6835,
239
- -31.53106]],
240
- 'NA': [[1.51128, 0.0948, 3.22709, -0.5008, -0.44459, 0.7917, -0.55248, -0.14997, -0.07728, -0.12045, -1.78307,
241
- -2.21302],
242
- [3.67149, 0.20315, 4.57082, -0.66572, -0.99601, 1.82278, -1.37483, -0.3758, -0.23001, -0.34222, -4.58774,
243
- -5.10333],
244
- [8.38714, -0.42911, 4.36906, -0.16208, -1.87212, 3.27129, -3.58989, -0.67827, -0.72355, -0.80377, -10.13454,
245
- -11.36066],
246
- [12.72618, -3.2875, 3.72623, 0.52978, -2.02156, 4.4073, -5.8223, -0.88602, -1.38303, -1.27749, -13.64035,
247
- -17.11837],
248
- [16.80352, -6.61127, 3.28779, 1.0925, -2.24077, 5.2732, -7.6675, -1.11268, -2.20605, -2.14458, -16.77665,
249
- -21.44993],
250
- [22.51545, -11.81332, 4.02639, 0.36408, -1.90706, 7.71557, -9.40935, -1.46537, -4.28497, -3.27487, -21.93892,
251
- -26.98143],
252
- [26.73661, -15.35052, 3.15588, -0.38738, -2.23523, 11.21103, -9.78587, -2.16465, -5.16989, -2.50045,
253
- -26.24888, -29.62563],
254
- [31.90237, -21.86714, 1.05514, 0.23431, -4.87391, 15.09835, -9.48598, -4.24274, -5.24929, -2.8807, -26.30648,
255
- -30.69178]]}
256
-
257
- CONSENSUS = {'members': ['xtrp6', 'cliper', 'analog', 'budget_mix', 'evo12', 'med_anom48', 'int_anom48', 'budget_anom96',
258
- 'evo_anom48', 'evo_anom96', 'ens48_mean', 'baro_res12', 'baro'],
259
- 'reference': 1,
260
- 'along': [[0.00021, 0.01934, 0.15259, 0.11654, 0.0057, 0.00019, 0.00028, 0.08891, 0.21448, 0.2435, 0.07794, 0.07163,
261
- 0.0227],
262
- [0.0226, 0.0749, 0.0, 0.13872, 0.16053, 0.0, 0.1026, 0.0, 0.0, 0.34861, 0.0, 0.05783, 0.11964],
263
- [0.04657, 0.06086, 0.0, 0.04709, 0.13328, 0.0, 0.12294, 0.0, 0.0, 0.2309, 0.0, 0.16366, 0.24898],
264
- [0.0, 0.16694, 0.0, 0.00963, 0.1108, 0.0, 0.05164, 0.0, 0.0, 0.25396, 0.0, 0.18252, 0.26465],
265
- [0.0, 0.22451, 0.0, 0.0, 0.03734, 0.0, 0.0, 0.0, 0.0, 0.34537, 0.00701, 0.10732, 0.29571],
266
- [0.0, 0.28684, 0.0, 0.0, 0.12786, 0.0, 0.0, 0.0, 0.22918, 0.0, 0.042, 0.0, 0.2981],
267
- [0.0, 0.37101, 0.0, 0.0, 0.09724, 0.0, 0.08262, 0.0, 0.16738, 0.0, 0.03124, 0.0, 0.21771],
268
- [0.0, 0.52248, 0.0, 0.0, 0.01603, 0.0, 0.15546, 0.0, 0.13808, 0.02426, 0.0182, 0.0, 0.10473]],
269
- 'cross': [[0.0, 0.0, 0.33787, 0.00179, 0.1248, 0.0, 0.0, 0.1246, 0.13298, 0.26711, 0.0, 0.0, 0.01085],
270
- [0.0, 0.0, 0.26022, 0.0744, 0.0, 0.0, 0.0, 0.0, 0.09196, 0.52375, 0.0, 0.0, 0.04968],
271
- [0.0, 0.0, 0.16694, 0.0327, 0.0, 0.0, 0.0, 0.0, 0.14503, 0.43359, 0.0, 0.06027, 0.16148],
272
- [0.0, 0.0119, 0.13084, 0.0, 0.0, 0.0, 0.0, 0.0, 0.10335, 0.43103, 0.01842, 0.11755, 0.1869],
273
- [0.0, 0.08731, 0.12105, 0.0, 0.0, 0.0, 0.0, 0.0, 0.07608, 0.39105, 0.0, 0.04151, 0.28301],
274
- [0.0, 0.2554, 0.07573, 0.0, 0.0063, 0.0, 0.0, 0.0, 0.0, 0.42361, 0.0, 0.0, 0.23895],
275
- [0.0, 0.4164, 0.09732, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.3536, 0.0, 0.0, 0.13268],
276
- [0.0, 0.63458, 0.03286, 0.0, 0.0, 0.0, 0.0125, 0.0, 0.00596, 0.21784, 0.0, 0.0, 0.09626]],
277
- 'leads': [6, 12, 24, 36, 48, 72, 96, 120]}
278
-
279
- CONSENSUS_BASIC = {'members': ['xtrp6', 'cliper', 'budget_mix', 'evo12', 'baro_res12', 'baro'],
280
- 'reference': 1,
281
- 'along': [[0.0, 0.0, 0.35055, 0.59572, 0.0, 0.05108], [0.0, 0.09401, 0.08682, 0.56344, 0.1178, 0.16503],
282
- [0.00737, 0.14174, 0.17161, 0.29753, 0.17979, 0.24077], [0.0, 0.21182, 0.0681, 0.27717, 0.18486, 0.28244],
283
- [0.0, 0.30195, 0.00589, 0.26211, 0.08425, 0.34754], [0.0, 0.43549, 0.0, 0.20768, 0.0, 0.3227],
284
- [0.0, 0.58243, 0.0, 0.14713, 0.0, 0.23043], [0.0, 0.78737, 0.05386, 0.03008, 0.0, 0.10951]],
285
- 'cross': [[0.0, 0.07939, 0.40468, 0.49114, 0.0, 0.02479], [0.0, 0.09729, 0.43163, 0.36452, 0.03493, 0.07163],
286
- [0.0, 0.13878, 0.20812, 0.35892, 0.14325, 0.15094], [0.02741, 0.20422, 0.07016, 0.32484, 0.19683, 0.17653],
287
- [0.01206, 0.30444, 0.01223, 0.28652, 0.14923, 0.23552], [0.0, 0.48332, 0.0, 0.23687, 0.0, 0.27981],
288
- [0.0, 0.69491, 0.00613, 0.14618, 0.0, 0.15278], [0.0, 0.82058, 0.03974, 0.03784, 0.0, 0.10184]],
289
- 'leads': [6, 12, 24, 36, 48, 72, 96, 120]}
290
-
291
- ANALOGS = {'neighbours': 100, 'bandwidth': 1.5, 'years': [1980, 2014]}
292
-
293
- GENESIS = {'features': ['one', 'vort850', 'vort600', 'dvort24', 'okubo_weiss', 'rh600', 'shear', 'sst', 'depth', 'warm_core',
294
- 'gpi', 'age', 'lat', 'pouch'],
295
- 'pairs': [['vort850', 'vort600'], ['vort850', 'dvort24'], ['vort850', 'okubo_weiss'], ['vort850', 'rh600'],
296
- ['vort850', 'shear'], ['vort850', 'sst'], ['vort850', 'depth'], ['vort850', 'warm_core'],
297
- ['vort850', 'gpi'], ['vort850', 'age'], ['vort850', 'lat'], ['vort850', 'pouch'], ['vort600', 'dvort24'],
298
- ['vort600', 'okubo_weiss'], ['vort600', 'rh600'], ['vort600', 'shear'], ['vort600', 'sst'],
299
- ['vort600', 'depth'], ['vort600', 'warm_core'], ['vort600', 'gpi'], ['vort600', 'age'], ['vort600', 'lat'],
300
- ['vort600', 'pouch'], ['dvort24', 'okubo_weiss'], ['dvort24', 'rh600'], ['dvort24', 'shear'],
301
- ['dvort24', 'sst'], ['dvort24', 'depth'], ['dvort24', 'warm_core'], ['dvort24', 'gpi'], ['dvort24', 'age'],
302
- ['dvort24', 'lat'], ['dvort24', 'pouch'], ['okubo_weiss', 'rh600'], ['okubo_weiss', 'shear'],
303
- ['okubo_weiss', 'sst'], ['okubo_weiss', 'depth'], ['okubo_weiss', 'warm_core'], ['okubo_weiss', 'gpi'],
304
- ['okubo_weiss', 'age'], ['okubo_weiss', 'lat'], ['okubo_weiss', 'pouch'], ['rh600', 'shear'],
305
- ['rh600', 'sst'], ['rh600', 'depth'], ['rh600', 'warm_core'], ['rh600', 'gpi'], ['rh600', 'age'],
306
- ['rh600', 'lat'], ['rh600', 'pouch'], ['shear', 'sst'], ['shear', 'depth'], ['shear', 'warm_core'],
307
- ['shear', 'gpi'], ['shear', 'age'], ['shear', 'lat'], ['shear', 'pouch'], ['sst', 'depth'],
308
- ['sst', 'warm_core'], ['sst', 'gpi'], ['sst', 'age'], ['sst', 'lat'], ['sst', 'pouch'],
309
- ['depth', 'warm_core'], ['depth', 'gpi'], ['depth', 'age'], ['depth', 'lat'], ['depth', 'pouch'],
310
- ['warm_core', 'gpi'], ['warm_core', 'age'], ['warm_core', 'lat'], ['warm_core', 'pouch'], ['gpi', 'age'],
311
- ['gpi', 'lat'], ['gpi', 'pouch'], ['age', 'lat'], ['age', 'pouch'], ['lat', 'pouch'],
312
- ['vort850', 'vort850'], ['vort600', 'vort600'], ['dvort24', 'dvort24'], ['okubo_weiss', 'okubo_weiss'],
313
- ['rh600', 'rh600'], ['shear', 'shear'], ['sst', 'sst'], ['depth', 'depth'], ['warm_core', 'warm_core'],
314
- ['gpi', 'gpi'], ['age', 'age'], ['lat', 'lat'], ['pouch', 'pouch']],
315
- 'weights': [-7.58249, 0.19861, 0.32566, 0.04204, 0.04884, 0.4964, -0.10314, 0.24073, 0.26262, 0.17275, 0.41731,
316
- 0.11061, 1.08455, 0.32539, -0.00679, 0.02776, -0.00082, 0.06403, -0.09248, -0.0746, 0.00054, -0.02602,
317
- -0.02172, -0.11481, 0.14349, 0.13787, -0.00896, -0.00037, 0.24426, -0.01709, 0.07373, -0.00423, -0.00845,
318
- 0.0115, -0.06458, 0.13616, -0.08795, 0.00119, -0.3756, 0.0412, -0.0191, 0.01328, 0.12894, 0.06904,
319
- 0.28895, 0.07189, -0.14992, 0.01504, -0.01308, -0.01191, -0.00238, -0.02039, 0.0068, -0.01226, -0.00065,
320
- 0.03225, -0.49127, -0.18598, -0.19156, 0.92325, 0.25688, 1.17138, 0.33377, -0.15139, 0.01379, 0.0093,
321
- -0.12922, 0.02151, 0.05668, 0.04417, -0.10183, -0.02042, 0.01805, -0.06257, -0.00841, 0.96345, -0.05078,
322
- 0.0141, -0.2137, -0.04163, -0.02136, 0.1558, 0.48613, -0.31486, -0.1906, 0.49737, 0.17046, 0.13345,
323
- 0.02198, 0.17818, 0.57605, -0.55852, -0.01018, -0.02851, -0.00499, -0.00012, 1.12441, -0.14708, -0.6432,
324
- -0.01274, -0.11505, -0.04481, 0.25813, -0.86029, -0.49477],
325
- 'l2': 0.001,
326
- 'window_h': 48,
327
- 'radius_km': 300}
328
-
329
- CONE_KM = {'leads': [6, 12, 24, 36, 48, 72, 96, 120],
330
- 'radius_km': [30.1, 62.7, 139.7, 228.5, 331.4, 553.2, 761.9, 967.9],
331
- 'coverage_target': 0.67,
332
- 'held_out_coverage': [0.621, 0.638, 0.647, 0.65, 0.644, 0.633, 0.602, 0.618]}
333
-
334
- SHIPS = {'leads': [6, 12, 24, 36, 48, 72, 96, 120],
335
- 'weights': [[-1.31313, -1.66457, 2.7483, 2.08196, -0.45915, 0.03452, -0.64158, 1.14775, 0.04853, -1.89071, -1.80528,
336
- 0.84571, -0.35999, 0.4748, 0.44672, -0.24416],
337
- [-3.57171, -3.6942, 3.90705, 4.99396, -1.05255, -0.27807, -0.91958, 2.48292, 0.31477, -5.00068, -4.367,
338
- 1.79782, -0.253, 1.37527, 1.09845, -0.2809],
339
- [-8.89026, -7.68792, 4.22342, 10.60653, -1.52681, -1.74306, -2.8958, 3.14103, 0.4392, -7.34503, -7.72056,
340
- 3.96753, -0.0593, 3.3196, 2.29822, 1.20836],
341
- [-14.05136, -11.16993, 4.0088, 13.65478, -1.06303, -2.8919, -3.44144, 3.70186, 0.15576, -6.73174,
342
- -9.85759, 5.67507, 0.68192, 4.96721, 2.81145, 2.9585],
343
- [-16.48706, -14.24566, 3.54134, 13.50534, 0.11819, -3.28402, -3.1441, 4.16155, 0.03002, -7.52477,
344
- -11.74133, 7.32196, 1.33752, 6.38696, 2.74023, 3.95641],
345
- [-17.28843, -19.61155, 1.60223, 11.71886, 1.27779, -1.63354, -4.01854, 5.5052, 1.31705, -6.44989,
346
- -13.41993, 9.88744, 4.8081, 8.95286, -0.74586, 5.4121],
347
- [-14.33444, -23.3762, 0.86087, 8.84625, 1.50228, 0.13558, -1.24792, 5.50986, 3.09639, -4.66178,
348
- -14.09579, 14.12939, 7.93271, 9.89397, -5.1649, 4.70609],
349
- [-11.73284, -30.43969, 1.4328, 6.2217, 1.31383, 2.34188, 2.52635, 7.39947, 3.40345, -1.80484, -13.4355,
350
- 19.48895, 10.86058, 7.99959, -8.20696, 3.65797]]}
351
-
352
- LGEM = {'kappa': [0.01998, -0.0056, 0.00753, 0.00599, -0.00472], 'n': 2.5, 'beta_per_h': 0.04167}
353
-
354
- INTENSITY_CONSENSUS = {'members': ['shifor', 'decay_shifor', 'ships', 'lgem', 'trend', 'mdl'],
355
- 'leads': [6, 12, 24, 36, 48, 72, 96, 120],
356
- 'weights': [[0.286, 0.2617, 0.3221, 0.0373, 0.0, 0.0929], [0.1238, 0.2622, 0.3851, 0.0776, 0.0, 0.1513],
357
- [0.1174, 0.2059, 0.2855, 0.1719, 0.0, 0.2193], [0.2105, 0.0762, 0.2885, 0.1968, 0.0, 0.228],
358
- [0.2731, 0.0, 0.2803, 0.1949, 0.0, 0.2517], [0.3726, 0.0, 0.2007, 0.2186, 0.0, 0.2082],
359
- [0.5754, 0.0, 0.0, 0.2122, 0.0, 0.2124], [0.6852, 0.0, 0.0, 0.1216, 0.0, 0.1932]],
360
- 'bias': [0.075, 0.171, 0.177, 0.333, 0.893, 2.41, 3.794, 3.711],
361
- 'ri_regime': {'cut': 0.15,
362
- 'weights': [[0.142, 0.1683, 0.213, 0.1966, 0.1388, 0.1413],
363
- [0.0, 0.0853, 0.1761, 0.3104, 0.1725, 0.2557],
364
- [0.0, 0.0406, 0.0931, 0.5375, 0.0216, 0.3072], [0.0, 0.0466, 0.2146, 0.6263, 0.0, 0.1125],
365
- [0.0, 0.2072, 0.1804, 0.5336, 0.0, 0.0788], [0.2607, 0.0301, 0.1053, 0.4325, 0.0, 0.1715],
366
- [0.3568, 0.0, 0.0, 0.3855, 0.0, 0.2577], [0.5113, 0.0, 0.0, 0.1494, 0.0, 0.3393]],
367
- 'bias': [2.511, 5.505, 9.977, 11.069, 10.605, 7.831, 4.412, 4.25]},
368
- 'basin_bias': {'EP': [-0.0, -0.27, -0.0, -0.0, -0.0, -0.0, 0.0, 0.0],
369
- 'NA': [0.57, 1.29, 2.68, 3.56, 3.67, 0.0, 0.0, 0.0],
370
- 'SI': [0.0, 0.0, 0.0, 0.0, -0.0, -0.0, -0.0, -0.0],
371
- 'SP': [0.0, 0.0, 0.0, -0.0, -0.0, 0.0, 0.0, 0.0],
372
- 'WP': [-0.52, -1.11, -2.08, -2.42, -1.61, -0.0, -0.0, -0.0]},
373
- 'basin_bias_full': {'EP': [-0.23, -0.51, -0.94, -1.45, -1.57, -0.99, -0.11, -0.0],
374
- 'NA': [0.59, 1.34, 2.85, 3.82, 4.1, 3.24, 2.44, 3.78],
375
- 'SI': [0.23, 0.51, 0.59, 0.46, -0.26, -2.64, -3.96, -4.68],
376
- 'SP': [0.14, 0.04, 0.02, -0.09, -0.44, 0.01, 0.0, 0.09],
377
- 'WP': [-0.52, -1.12, -2.11, -2.63, -2.19, -0.83, -0.63, -1.05]},
378
- 'basin_bias_note': "per-basin residual of the dev seasons, shrunk by the seasons' disagreement (zero where the "
379
- 'three seasons do not share a sign)'}
380
-
381
- SWARM = {'priors': {'fix_sigma_km': 20.0},
382
- 'leads': [6, 12, 24, 36, 48, 72, 96, 120],
383
- 'cone_scale': [0.8158, 0.9239, 0.8595, 0.8029, 0.7465, 0.7748, 0.7233, 0.7292],
384
- 'coverage_target': 0.67}
385
-
386
- TREND = {'tau_h': 24.0}
387
-
388
- RI = {'features': ['one', 'dv12', 'dv24', 'wind', 'headroom', 'shear', 'rh', 'sst', 'lat', 'land24', 'model_dp24',
389
- 'model_dp48', 'has_model', 'heat_potential', 'warm_depth', 'size', 'has_size'],
390
- 'threshold_kt': 30.0,
391
- 'weights': [-3.7741, 0.39038, 0.13388, 0.02863, 0.41741, -0.63661, 0.01293, 0.40512, -0.10709, -0.10695, 0.51014,
392
- 0.3428, 0.08111, -0.04398, 0.16124, 0.15615, 0.58521],
393
- 'base_rate': 0.08814,
394
- 'cases': 2462}
395
-
396
- CONSENSUS_FF = {'members': ['xtrp6', 'cliper', 'analog', 'budget_mix', 'evo12', 'med_anom48', 'int_anom48', 'budget_anom96',
397
- 'evo_anom48', 'evo_anom96', 'ens48_mean', 'baro_res12', 'baro', 'fflow'],
398
- 'leads': [6, 12, 24, 36, 48, 72, 96, 120],
399
- 'along': [[0.0, 0.0087, 0.1577, 0.0, 0.0718, 0.0, 0.0, 0.0, 0.0882, 0.5273, 0.0, 0.0, 0.0126, 0.1422],
400
- [0.0, 0.0, 0.0, 0.0212, 0.0, 0.0, 0.0, 0.0, 0.0, 0.4357, 0.0, 0.0111, 0.0985, 0.4501],
401
- [0.0, 0.0, 0.0, 0.0056, 0.0, 0.0, 0.0, 0.0, 0.0, 0.2311, 0.0, 0.0549, 0.1862, 0.5525],
402
- [0.0, 0.0, 0.0, 0.0117, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1623, 0.0, 0.0438, 0.1871, 0.6127],
403
- [0.0, 0.0, 0.0, 0.0213, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1453, 0.0, 0.0372, 0.1601, 0.6349],
404
- [0.0, 0.0285, 0.0108, 0.0, 0.031, 0.0, 0.0, 0.0, 0.0048, 0.1155, 0.0, 0.0, 0.1243, 0.6602],
405
- [0.0, 0.1508, 0.0, 0.0, 0.0587, 0.0, 0.0, 0.0, 0.0793, 0.0, 0.0, 0.0, 0.095, 0.5997],
406
- [0.0, 0.3733, 0.0, 0.0, 0.0034, 0.0, 0.0, 0.0, 0.07, 0.0333, 0.0, 0.0, 0.024, 0.5025]],
407
- 'cross': [[0.0, 0.0, 0.1638, 0.0603, 0.0, 0.0, 0.0, 0.0, 0.0, 0.3952, 0.0, 0.0, 0.0238, 0.357],
408
- [0.0, 0.0, 0.0, 0.0956, 0.0, 0.0, 0.0, 0.0, 0.0, 0.3636, 0.0, 0.0, 0.0474, 0.4935],
409
- [0.0, 0.0, 0.0, 0.0239, 0.0, 0.0, 0.0, 0.0, 0.0, 0.2741, 0.0, 0.0, 0.1138, 0.5882],
410
- [0.0, 0.0, 0.0, 0.0134, 0.0, 0.0, 0.0, 0.0, 0.0, 0.2249, 0.0, 0.0431, 0.1019, 0.6167],
411
- [0.0, 0.0, 0.0052, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.2066, 0.0, 0.0612, 0.1102, 0.6167],
412
- [0.0, 0.0, 0.0607, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1837, 0.0, 0.0, 0.1287, 0.6269],
413
- [0.0, 0.0, 0.1465, 0.0, 0.0, 0.0, 0.0, 0.0, 0.018, 0.1575, 0.0, 0.0, 0.0809, 0.5971],
414
- [0.0, 0.2311, 0.0819, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0334, 0.0913, 0.0, 0.0, 0.0545, 0.5079]],
415
- 'reference': 1,
416
- 'model_blend': {'leads': [6, 12, 24, 36, 48, 72, 96, 120],
417
- 'alpha': [0.2, 0.45, 0.7, 0.8, 0.85, 0.9, 0.9, 0.9],
418
- 'offset_tau_h': None,
419
- 'alpha_multi': [0.25, 0.5, 0.775, 0.875, 0.95, 0.95, 0.95, 0.975]}}
420
-
421
- CONSENSUS_BASIC_FF = {'members': ['xtrp6', 'cliper', 'budget_mix', 'evo12', 'baro_res12', 'baro', 'fflow'],
422
- 'leads': [6, 12, 24, 36, 48, 72, 96, 120],
423
- 'along': [[0.0, 0.0, 0.3882, 0.4795, 0.0, 0.05, 0.0814], [0.0, 0.0, 0.2915, 0.1357, 0.0271, 0.1281, 0.4319],
424
- [0.0, 0.0, 0.1056, 0.0835, 0.0599, 0.2064, 0.5701], [0.0, 0.0, 0.0667, 0.0698, 0.0457, 0.1972, 0.6306],
425
- [0.0, 0.0, 0.0667, 0.0637, 0.048, 0.159, 0.6526], [0.0, 0.0699, 0.0125, 0.0906, 0.0, 0.1297, 0.6682],
426
- [0.0, 0.2002, 0.0, 0.0748, 0.0, 0.0968, 0.6103], [0.0, 0.4415, 0.0, 0.0274, 0.0, 0.0235, 0.5166]],
427
- 'cross': [[0.0, 0.0, 0.3901, 0.1183, 0.0, 0.0259, 0.4656], [0.0, 0.0, 0.3888, 0.0, 0.0, 0.0599, 0.5514],
428
- [0.0, 0.0, 0.195, 0.0297, 0.0056, 0.1175, 0.6523], [0.0, 0.0, 0.1129, 0.0558, 0.0836, 0.0832, 0.6645],
429
- [0.0, 0.0, 0.0664, 0.0702, 0.1365, 0.0669, 0.66], [0.0198, 0.0612, 0.015, 0.0825, 0.0, 0.1543, 0.6671],
430
- [0.0178, 0.2063, 0.0, 0.0545, 0.0, 0.0868, 0.6346], [0.0, 0.3901, 0.0, 0.0224, 0.0, 0.055, 0.5324]],
431
- 'reference': 1,
432
- 'model_blend': {'leads': [6, 12, 24, 36, 48, 72, 96, 120],
433
- 'alpha': [0.2, 0.45, 0.7, 0.8, 0.85, 0.9, 0.9, 0.9],
434
- 'offset_tau_h': None,
435
- 'alpha_multi': [0.25, 0.5, 0.775, 0.875, 0.95, 0.95, 0.95, 0.975]}}
436
-
437
- CONE_KM_FF = {'leads': [6, 12, 24, 36, 48, 72, 96, 120], 'radius_km': [27.0, 48.4, 80.9, 109.3, 137.7, 212.0, 304.9, 412.7]}
438
-
439
- CONE_KM_BASIC_FF = {'leads': [6, 12, 24, 36, 48, 72, 96, 120], 'radius_km': [27.1, 47.9, 80.1, 109.1, 137.4, 213.6, 303.1, 410.4]}
440
-
441
- SWARM_FF = {'priors': {'fix_sigma_km': 20.0},
442
- 'leads': [6, 12, 24, 36, 48, 72, 96, 120],
443
- 'cone_scale': [0.7006, 0.7004, 0.5164, 0.4108, 0.3379, 0.2734, 0.2885, 0.3051],
444
- 'coverage_target': 0.67}
445
-
446
- CONE_FLOW = {'leads': [6, 12, 24, 36, 48, 72, 96, 120],
447
- 'predictors': ['one', 'member_spread_km', 'model_consensus_km', 'fflow_model_km', 'speed_kmh', 'abs_lat', 'wind_kt'],
448
- 'coef': [[2.96823, 0.59342, 0.00565, 0.12792, 0.23186, 0.10118, -0.0204],
449
- [14.95615, 0.22804, 0.21996, 0.06738, 0.00608, 0.07014, -0.0616],
450
- [39.00611, -0.00096, 0.39293, 0.03688, -0.25629, -0.59159, -0.0671],
451
- [66.14719, 0.06061, 0.3074, -0.03535, -0.20376, -0.62383, -0.15522],
452
- [101.59234, 0.11243, 0.14908, -0.03998, -0.38773, -0.38886, -0.27248],
453
- [106.22439, 0.1211, 0.12004, 0.00269, -0.20855, 1.44964, -0.494],
454
- [89.42837, 0.20677, 0.16346, -0.07412, -0.34735, 4.60663, -0.84788],
455
- [110.41666, 0.33869, 0.03837, -0.0704, -2.76008, 6.45517, -0.70091]],
456
- 'scale': [1.1696, 1.1781, 1.2718, 1.1672, 1.1014, 1.0922, 1.083, 1.1142],
457
- 'medians': [1.0, 204.81, 157.665, 161.497, 16.417, 18.1, 45.0],
458
- 'coverage_target': 0.67}
459
-
460
- MODEL_INTENSITY = {'leads': [6, 12, 24, 36, 48, 72, 96, 120],
461
- 'kt_per_hpa': [0.73942, 1.46518, 2.17201, 2.30907, 2.35915, 2.22927, 1.9977, 1.52248]}
462
-
463
- INTENSITY_SPREAD = {'leads': [6, 12, 24, 36, 48, 72, 96, 120],
464
- 'thresholds_kt': [64, 96, 113, 137],
465
- 'quantile_levels': [0.02, 0.06, 0.1, 0.14, 0.18, 0.22, 0.26, 0.3, 0.34, 0.38, 0.42, 0.46, 0.5, 0.54, 0.58, 0.62,
466
- 0.66, 0.7, 0.74, 0.78, 0.82, 0.86, 0.9, 0.94, 0.98],
467
- 'quantiles': {'normal': [[-10.43, -6.69, -5.33, -4.07, -3.31, -2.69, -2.33, -1.98, -1.6, -1.26, -0.98, -0.68, -0.31,
468
- 0.15, 0.59, 1.13, 1.66, 2.24, 2.7, 3.12, 3.6, 4.06, 4.78, 6.92, 11.1],
469
- [-17.63, -11.58, -9.17, -7.6, -6.17, -5.18, -4.48, -3.76, -3.02, -2.44, -1.81, -1.15, -0.45,
470
- 0.27, 0.89, 1.63, 2.25, 3.15, 4.18, 5.15, 6.26, 7.48, 9.54, 12.08, 19.38],
471
- [-28.46, -18.99, -14.97, -12.84, -10.9, -9.07, -7.59, -6.47, -5.24, -4.12, -3.22, -2.23,
472
- -1.15, -0.26, 0.98, 2.08, 3.54, 5.27, 6.96, 8.68, 10.66, 12.96, 16.63, 21.97, 32.53],
473
- [-33.51, -24.34, -19.33, -16.14, -13.86, -12.34, -10.71, -9.14, -7.74, -6.31, -4.81, -3.49,
474
- -2.04, -0.63, 0.59, 2.48, 4.16, 6.53, 8.57, 11.28, 14.21, 17.73, 22.08, 28.16, 41.22],
475
- [-36.89, -27.0, -22.05, -19.55, -16.98, -14.65, -12.91, -11.04, -9.39, -7.87, -6.57, -5.02,
476
- -3.4, -1.69, 0.45, 1.97, 4.4, 7.23, 10.21, 13.38, 17.19, 20.9, 26.13, 34.91, 49.16],
477
- [-39.35, -29.83, -26.33, -23.19, -20.56, -18.09, -16.16, -14.49, -12.55, -10.49, -8.99,
478
- -7.48, -5.61, -3.39, -1.38, 0.99, 3.87, 7.26, 11.38, 15.74, 20.65, 28.42, 35.69, 43.84,
479
- 58.55],
480
- [-38.57, -31.41, -28.27, -24.46, -22.85, -20.85, -18.32, -16.22, -14.36, -12.56, -10.89,
481
- -9.21, -6.77, -4.81, -2.88, -0.7, 3.04, 6.3, 11.39, 17.49, 23.12, 31.18, 40.74, 49.6,
482
- 62.99],
483
- [-37.33, -31.89, -28.37, -26.21, -23.91, -21.92, -20.11, -17.59, -15.7, -14.22, -12.66,
484
- -10.29, -7.86, -5.55, -3.55, -1.47, 3.22, 8.11, 12.88, 19.0, 25.83, 33.36, 43.15, 52.55,
485
- 66.84]],
486
- 'rapid': [[-11.97, -7.07, -6.32, -5.62, -5.07, -4.66, -2.96, -1.93, -1.54, -1.23, -0.93, -0.7, -0.54,
487
- -0.37, -0.22, 0.14, 1.16, 2.41, 3.15, 3.71, 4.29, 5.29, 7.8, 9.06, 13.53],
488
- [-22.3, -14.41, -11.14, -8.95, -7.55, -6.86, -6.27, -5.7, -5.11, -3.96, -2.42, -1.85, -1.08,
489
- -0.26, 0.72, 2.3, 3.1, 3.78, 4.77, 6.61, 7.97, 9.29, 12.56, 18.26, 27.67],
490
- [-36.06, -23.24, -19.03, -16.06, -14.01, -11.51, -10.45, -8.46, -7.17, -5.52, -4.4, -3.39,
491
- -1.41, 0.06, 1.8, 3.5, 5.11, 6.16, 8.07, 10.75, 13.56, 16.72, 21.24, 26.66, 41.8],
492
- [-40.76, -33.14, -25.88, -22.72, -19.23, -17.13, -15.15, -12.7, -11.19, -9.29, -6.28, -4.27,
493
- -1.27, 1.02, 3.85, 5.66, 8.15, 10.2, 13.61, 17.42, 19.78, 25.54, 30.02, 36.7, 49.91],
494
- [-49.34, -39.31, -31.15, -28.72, -25.44, -22.51, -18.39, -15.23, -11.37, -8.74, -6.12, -3.8,
495
- -0.84, 1.3, 4.76, 6.62, 10.26, 13.5, 17.08, 21.95, 24.74, 27.54, 35.25, 42.19, 55.38],
496
- [-53.39, -42.0, -34.72, -31.21, -26.81, -24.07, -20.73, -17.13, -14.77, -12.89, -9.57, -6.58,
497
- -4.33, -0.04, 3.64, 7.79, 10.28, 15.26, 20.12, 23.03, 28.46, 34.22, 40.92, 46.85, 66.41],
498
- [-48.14, -39.93, -34.17, -27.87, -25.33, -23.18, -21.87, -18.84, -15.88, -13.81, -12.1,
499
- -8.68, -6.41, -3.4, 0.82, 6.0, 10.37, 14.95, 18.98, 21.9, 27.54, 36.55, 44.2, 48.48,
500
- 60.49],
501
- [-48.96, -37.56, -32.83, -28.22, -25.98, -23.5, -19.21, -16.26, -14.51, -12.72, -10.36,
502
- -7.66, -5.96, -4.35, -1.14, 1.09, 4.98, 11.67, 17.23, 23.53, 27.91, 35.48, 43.4, 49.2,
503
- 59.64]]}}
504
-
505
- OCEAN = {'d26_reference_m': 50.0,
506
- 'd26_power': 0.0,
507
- 'sst_anomaly_per_c': 0.0,
508
- 'source': 'WOA23 decadal-average monthly temperature, 1 degree'}
509
-
510
- RI_PATH = {'leads': [6, 12, 24, 36, 48, 72, 96, 120],
511
- 'threshold_kt': 30.0,
512
- 'cases': 217,
513
- 'mean_change_kt': [8.89, 19.31, 39.79, 44.19, 37.28, 22.71, 12.6, 8.41],
514
- 'p75_change_kt': [10.0, 25.0, 45.0, 60.0, 60.0, 46.25, 35.0, 30.0]}
515
-
516
- PEAK = {'thresholds_kt': [64.0, 96.0, 113.0, 137.0],
517
- 'features': ['one', 'v0', 'cons_rise', 'cons_max', 'headroom72', 'tchp48', 'shear48', 'rh48', 'p_ri', 'logit_p',
518
- 'dp24', 'has_dp', 'land72', 'abslat'],
519
- 'mean': [54.35838, 12.04692, 66.4053, 91.12471, 28.25157, 10.53359, 0.6865, 0.07466, -3.27858, -0.88102, 0.95414,
520
- 0.12388, 19.46433],
521
- 'std': [28.01184, 14.80723, 24.62294, 48.01961, 26.90911, 6.39609, 0.15142, 0.08733, 1.53582, 4.07612, 0.20917,
522
- 0.25617, 7.26468],
523
- 'weights': {'64': [-0.34656, 0.40907, 0.07791, 0.51222, 0.03019, 0.2608, -0.43828, 0.13844, 0.49819, 0.37792,
524
- -0.45497, 0.2156, -0.2234, 0.04331],
525
- '96': [-1.53113, 0.51111, 0.17933, 0.6893, 0.07005, 0.08913, -0.26839, -0.09395, 0.26191, 0.29463,
526
- -0.19445, 0.14694, -0.10294, -0.02554],
527
- '113': [-2.35718, 0.46554, 0.23991, 0.67389, -0.01198, 0.18414, -0.08776, -0.15367, 0.27805, 0.27366,
528
- -0.19527, 0.09465, -0.15963, -0.21935],
529
- '137': [-3.97029, 0.3479, 0.08509, 0.44695, -0.02151, 0.487, -0.00123, -0.32133, 0.21329, 0.21602,
530
- 0.00164, 0.0329, -0.15872, -0.24077]}}
531
-
532
- TRACK_FEEDBACK = {'leads': [6, 12, 24, 36, 48, 72, 96, 120],
533
- 'max_lead_h': 48,
534
- 'beta': {'12': [0.043, 0.239, 0.624, 0.653, 0.632, 0.0, 0.0, 0.0],
535
- '24': [0.01, 0.083, 0.254, 0.335, 0.342, 0.0, 0.0, 0.0]},
536
- 'note': "share of the previous cycle's error (its forecast for the analysis time minus the analysis) subtracted "
537
- 'from the new blended track; fitted on the 2016/2017/2019 HRES blends; held-out gain on the multi-model '
538
- 'blend about 1% at 12-36 h, none beyond 48 h'}
539
-
540
- RAIN_SCALE = {'multiplier': 1.19,
541
- 'tail': 1.5,
542
- 'asymmetry': 0.3,
543
- 'asymmetry_phase_deg': 45.0,
544
- 'rule': 'R-CLIPER along the observed track of 24 US landfalls against MRMS gauge-corrected radar; the exponential '
545
- 'tail widened 1.5x and a front-right wavenumber-1 asymmetry of 0.3 added, the multiplier then set so the 8 '
546
- "tuning storms' total rain is unbiased",
547
- 'dev_storms': ['hermine2016', 'matthew2016', 'harvey2017', 'irma2017', 'nate2017', 'barry2019', 'imelda2019',
548
- 'dorian2019'],
549
- 'test_storms': ['gordon2018', 'florence2018', 'michael2018', 'hanna2020', 'isaias2020', 'laura2020', 'sally2020',
550
- 'delta2020', 'claudette2021', 'elsa2021', 'fred2021', 'henri2021', 'ida2021', 'nicholas2021',
551
- 'ian2022', 'nicole2022'],
552
- 'held_out': {'as_published_x1': {'mae': 37.7527,
553
- 'bias': 11.7949,
554
- 'area100': 1.353,
555
- 'area250': 1.7405,
556
- 'csi100': 0.2876,
557
- 'csi250': 0.2952,
558
- 'corr': 0.5733,
559
- 'mae_heavy': 72.2621,
560
- 'p995': 1.1062},
561
- 'standard_profile_x1.91': {'mae': 37.7527,
562
- 'bias': 11.7949,
563
- 'area100': 1.353,
564
- 'area250': 1.7405,
565
- 'csi100': 0.2876,
566
- 'csi250': 0.2952,
567
- 'corr': 0.5733,
568
- 'mae_heavy': 72.2621,
569
- 'p995': 1.1062},
570
- 'shipped': {'mae': 37.512,
571
- 'bias': 13.6903,
572
- 'area100': 1.4198,
573
- 'area250': 1.5873,
574
- 'csi100': 0.2958,
575
- 'csi250': 0.3477,
576
- 'corr': 0.5876,
577
- 'mae_heavy': 65.9236,
578
- 'p995': 1.017},
579
- 'strong_storms_standard_x1.91': {'mae': 40.5859,
580
- 'bias': 5.2125,
581
- 'area100': 1.2833,
582
- 'area250': 3.1788,
583
- 'csi100': 0.2734,
584
- 'csi250': 0.1706,
585
- 'corr': 0.4895,
586
- 'mae_heavy': 67.5825,
587
- 'p995': 1.2093},
588
- 'strong_storms_shipped': {'mae': 40.4345,
589
- 'bias': 7.7117,
590
- 'area100': 1.4292,
591
- 'area250': 1.7325,
592
- 'csi100': 0.2781,
593
- 'csi250': 0.1213,
594
- 'corr': 0.4687,
595
- 'mae_heavy': 58.6596,
596
- 'p995': 0.9877}}}
597
-
598
- PEAK_QUANTILES = {'features': ['one', 'v0', 'cons_rise', 'cons_max', 'headroom72', 'tchp48', 'shear48', 'rh48', 'p_ri', 'logit_p',
599
- 'dp24', 'has_dp', 'land72', 'abslat', 'scen_rise'],
600
- 'mean': [54.35838, 12.04692, 66.4053, 91.12471, 28.25157, 10.53359, 0.6865, 0.07466, -3.27858, -0.88102, 0.95414,
601
- 0.12388, 19.46433, 33.36314],
602
- 'std': [28.01184, 14.80723, 24.62294, 48.01961, 26.90911, 6.39609, 0.15142, 0.08733, 1.53582, 4.07612, 0.20917,
603
- 0.25617, 7.26468, 16.53708],
604
- 'quantiles': {'0.5': [15.98992, -3.98112, 11.54093, 2.4112, 1.02477, 2.45392, -2.12953, 0.56947, 5.62092, -1.67897,
605
- -1.14164, 5.05584, -1.45962, -0.18116, -0.22434],
606
- '0.75': [30.39761, -5.33318, 14.14135, 2.43683, 2.82383, 2.93821, -1.81273, 0.17093, 1.75926, 2.4545,
607
- -1.39718, 5.06781, -1.54468, -0.22826, 0.02534],
608
- '0.9': [46.41835, -6.40701, 12.41642, 0.17791, 4.61604, 4.64533, -2.62747, -1.07527, -2.5048, 6.03149,
609
- -0.94617, 9.08334, -2.70622, -1.98146, 0.77281],
610
- '0.95': [55.89355, -5.76505, 8.15522, -1.65429, 6.7332, 8.27783, -3.56078, -1.46701, -1.37289, 4.86184,
611
- -0.45709, 7.24641, -5.0844, -1.33421, -0.45249]},
612
- 'note': 'linear pinball-loss fits of (true 120 h peak - analysis wind) on the standardised PEAK features plus the '
613
- 'RI-path peak rise; dev 2016/2017/2019'}
614
-
615
- PEAK_IF_RI = {'features': ['one', 'v0', 'cons_rise', 'cons_max', 'headroom72', 'tchp48', 'shear48', 'rh48', 'p_ri', 'logit_p',
616
- 'dp24', 'has_dp', 'land72', 'abslat', 'scen_rise'],
617
- 'weights': [53.1333, -3.89744, 9.0371, 1.00069, 8.10017, 3.15362, 1.76969, -1.45009, 0.2314, -6.3153, 0.86357, -0.0,
618
- -8.76211, -4.40894, -4.77421],
619
- 'mean': [54.35838, 12.04692, 66.4053, 91.12471, 28.25157, 10.53359, 0.6865, 0.07466, -3.27858, -0.88102, 0.95414,
620
- 0.12388, 19.46433, 33.36314],
621
- 'std': [28.01184, 14.80723, 24.62294, 48.01961, 26.90911, 6.39609, 0.15142, 0.08733, 1.53582, 4.07612, 0.20917,
622
- 0.25617, 7.26468, 16.53708]}
623
-
624
- ARRIVAL_TIME_ERROR = {'hours': {'24': {'all': {'mean_h': 1.2, 'median_h': 0.3, 'mae_h': 3.55, 'n': 3432, 'never_crosses': 1015},
625
- 'by_forecast_speed': {'< 3 m/s': {'mean_h': 3.54, 'mae_h': 7.3, 'n': 794},
626
- '3-5': {'mean_h': 0.82, 'mae_h': 3.02, 'n': 1152},
627
- '5-8': {'mean_h': 0.29, 'mae_h': 1.88, 'n': 1213},
628
- '> 8': {'mean_h': 0.09, 'mae_h': 2.22, 'n': 273}}},
629
- '48': {'all': {'mean_h': 0.87, 'median_h': 0.66, 'mae_h': 5.65, 'n': 2685, 'never_crosses': 1762},
630
- 'by_forecast_speed': {'< 3 m/s': {'mean_h': -0.05, 'mae_h': 9.91, 'n': 666},
631
- '3-5': {'mean_h': 1.67, 'mae_h': 5.28, 'n': 949},
632
- '5-8': {'mean_h': 0.95, 'mae_h': 3.24, 'n': 935},
633
- '> 8': {'mean_h': -0.84, 'mae_h': 3.99, 'n': 135}}}},
634
- 'speed_bins_ms': {'< 3 m/s': [0, 3], '3-5': [3, 5], '5-8': [5, 8], '> 8': [8, 99]},
635
- 'sign': 'forecast arrival minus truth, hours; positive = the forecast arrives late',
636
- 'source': 'held-out 2018 / 2020-2022, multi-model blend, crossing of the line through the truth position at 24 / 48 '
637
- "h perpendicular to the truth motion; speed = the forecast's mean speed to that lead"}
638
-
639
- ADECK_TRACK = {'members': ['ours', 'HCCA', 'TVCN', 'AEMI', 'AVNI'],
640
- 'leads': [6, 12, 24, 36, 48, 72, 96, 120],
641
- 'weights': [[0.5, 0.5, 0.0, 0.0, 0.0], [0.5, 0.5, 0.0, 0.0, 0.0], [0.5, 0.5, 0.0, 0.0, 0.0],
642
- [0.5, 0.5, 0.0, 0.0, 0.0], [0.5, 0.5, 0.0, 0.0, 0.0], [0.5, 0.5, 0.0, 0.0, 0.0],
643
- [0.5, 0.5, 0.0, 0.0, 0.0], [0.5, 0.5, 0.0, 0.0, 0.0]],
644
- 'choice': 'mean(ours, HCCA)',
645
- 'fitted_weights': [[0.7215, 0.1061, 0.0928, 0.0, 0.0796], [0.5115, 0.3462, 0.1423, 0.0, 0.0],
646
- [0.5168, 0.385, 0.0982, 0.0, 0.0], [0.4952, 0.4472, 0.0576, 0.0, 0.0],
647
- [0.5233, 0.3573, 0.1195, 0.0, 0.0], [0.4718, 0.3265, 0.2017, 0.0, 0.0],
648
- [0.4351, 0.4838, 0.0, 0.0811, 0.0], [0.3687, 0.279, 0.3523, 0.0, 0.0]],
649
- 'dev_loso_gain_pct': {'mean(ours, HCCA)': [0.09, 1.69, 2.1, 2.38, 2.15, 2.05, 2.34, 1.96],
650
- 'mean(ours, HCCA, TVCN)': [-1.33, 1.49, 2.02, 2.31, 1.91, 2.0, 2.52, 2.33],
651
- 'mean(ours, all aids)': [-3.21, 0.32, 0.85, 1.03, 0.57, 0.61, 1.43, 1.21],
652
- 'ours 1/3 + HCCA 2/3': [-1.52, 1.29, 1.87, 2.24, 1.88, 1.82, 2.32, 1.98],
653
- 'fitted': [1.06, 1.73, 2.08, 2.18, 1.89, 1.81, 2.24, 2.16]},
654
- 'note': "our blended track and the NHC a-deck's early-cycle consensus aids, weights renormalised over the members "
655
- 'present; the rule chosen on the tuning seasons (2016/2017/2019, our own track cross-fitted by season) was '
656
- 'the plain mean of ours and HCCA; TVCN stands in when HCCA is absent (the three-way mean scored within 0.2% '
657
- 'of the pair on the tuning seasons)',
658
- 'substitutes': {'HCCA': ['TVCN']},
659
- 'held_out_gain_pct': [2.8, 11.0, 13.0, 11.9, 6.4, 11.4, 12.6, 12.7],
660
- 'held_out_vs_ofcl_pct': [22.0, 15.9, 15.5, 14.6, 18.1, 11.7, 5.8, 4.7],
661
- 'adaptive': {'b': [0.5, 0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0],
662
- 'cap': 0.75,
663
- 'eps_km': 5.0,
664
- 'note': 'w_ours = clip(sigmoid(b log((e_HCCA + eps)/(e_ours + eps))), 1 - cap, cap) from the previous '
665
- "cycle's 6-24 h forecast errors for the position now analysed; b and cap chosen per lead on "
666
- 'the tuning seasons (zero from 36 h on, where nothing was earned); held-out gain +0.9% at 12 h '
667
- '(significant), +1.1% at 24 h'},
668
- 'gate': {'gamma': 0.1,
669
- 'start_cone_radii': 1.5,
670
- 'min_lead_h': 12,
671
- 'tiebreaker': 'TVCN',
672
- 'note': 'up to 10% of the ours/HCCA share moves away from whichever of the two is further from TVCN, once '
673
- 'they sit more than 1.5 cone radii apart (the full shift at 2.5); on the tuning seasons a tie with '
674
- 'the plain mean (88.5 vs 88.4 km mean error at 12-72 h) and a loss at 6 h in every season, hence '
675
- 'from 12 h; held-out with the earned weights: 1.4-2.5% lower error at 12-36 h, 1-2% at 48-120 h, '
676
- 'every lead significant'}}
677
-
678
- ADECK_INTENSITY = {'members': ['ours', 'IVCN', 'HWFI', 'HMNI', 'DSHP', 'LGEM', 'AVNI', 'HCCA'],
679
- 'leads': [6, 12, 24, 36, 48, 72, 96, 120],
680
- 'weights': [[0.7767, 0.0, 0.1386, 0.0847, 0.0, 0.0, 0.0, 0.0], [0.5388, 0.0, 0.1083, 0.2756, 0.0, 0.0, 0.0, 0.0773],
681
- [0.2001, 0.0, 0.2488, 0.2078, 0.0, 0.0, 0.0, 0.3433],
682
- [0.0657, 0.1857, 0.25, 0.2599, 0.0017, 0.0, 0.0, 0.2372],
683
- [0.0, 0.0003, 0.0, 0.275, 0.008, 0.0, 0.0085, 0.7082],
684
- [0.0, 0.7262, 0.0994, 0.034, 0.0, 0.0, 0.0801, 0.0603],
685
- [0.0, 0.175, 0.2236, 0.0002, 0.0, 0.0, 0.0, 0.6012],
686
- [0.0454, 0.3921, 0.1216, 0.1015, 0.0, 0.0, 0.0581, 0.2813]],
687
- 'loso_gain_pct': {'2016': [2.81, 0.94, 13.24, 21.77, 28.5, 26.92, 28.2, 21.85],
688
- '2017': [3.44, 9.74, 17.61, 23.98, 26.54, 32.53, 30.57, 23.09],
689
- '2019': [0.43, 5.66, 13.28, 23.5, 22.03, 28.94, 24.6, 1.91]},
690
- 'note': "simplex weights per lead over the 0.15 consensus and the a-deck's early-cycle intensity aids, smooth-L1 "
691
- 'fit on the tuning seasons 2016/2017/2019 (leave-one-season-out gains 13-28% at 24-96 h in every season); '
692
- 'members missing live are renormalised away; HAFS-A and HAFS-B stand in for HWRF and HMON when those are '
693
- 'absent (successor models, a lineage stand-in that the archive could not verify)',
694
- 'substitutes': {'HWFI': ['HFAI'], 'HMNI': ['HFBI']},
695
- 'held_out_gain_pct': [2.9, 9.9, 26.0, 30.6, 31.4, 35.1, 32.0, 32.3],
696
- 'held_out_vs_ofcl_pct': [6.4, -7.9, 4.9, 4.5, 5.9, 6.1, -0.7, 4.6]}
697
-
698
- PROVENANCE = {'generated': '2026-10-19 15:26 UTC',
699
- 'CLIPER': 'IBTrACS v04r01 best tracks 1980-2014, all basins (82,816 fixes at 00/06/12/18 UTC)',
700
- 'SHIFOR': 'IBTrACS v04r01 best tracks 1980-2014, all basins',
701
- 'CONSENSUS': 'hindcasts of the 2016, 2017 and 2019 seasons (3,053 forecasts, NA EP WP SI SP)',
702
- 'ANALOGS': 'built at run time from 1980-2014 best tracks (100 neighbours, bandwidth 1.5)',
703
- 'held_out': '2018, 2020, 2021, 2022 -- never used for fitting',
704
- 'CONSENSUS_BASIC': 'same hindcasts as CONSENSUS; the members that need neither a climatology nor an analog database',
705
- 'GENESIS': 'northern-hemisphere candidates of June-November 2016, 2017, 2019 (16,929 candidates, 194 formations); '
706
- 'second-order terms chosen by leave-one-season-out cross-validation',
707
- 'CONE_KM': 'hindcasts of the 2016, 2017 and 2019 seasons',
708
- 'SHIPS': 'hindcasts of the 2016, 2017 and 2019 seasons',
709
- 'LGEM': 'hindcasts of the 2016, 2017 and 2019 seasons',
710
- 'INTENSITY_CONSENSUS': 'simplex weights per lead on leave-one-season-out members of 2016, 2017 and 2019; the rapid '
711
- 'regime fitted where P(RI) >= 0.15; per-basin bias from the dev residuals, shrunk to zero '
712
- 'where the three seasons disagree on its sign',
713
- 'SWARM': 'hindcasts of the 2016, 2017 and 2019 seasons',
714
- 'CONSENSUS_FF': "hindcasts of the 2016, 2017 and 2019 seasons with ECMWF HRES forecast steering and HRES's own "
715
- 'storm tracks (WeatherBench 2, 1.5 degrees); blend weights fitted on leave-one-season-out consensus '
716
- 'tracks',
717
- 'CONSENSUS_BASIC_FF': "hindcasts of the 2016, 2017 and 2019 seasons with ECMWF HRES forecast steering and HRES's "
718
- 'own storm tracks (WeatherBench 2, 1.5 degrees); blend weights fitted on leave-one-season-out '
719
- 'consensus tracks',
720
- 'CONE_KM_FF': "hindcasts of the 2016, 2017 and 2019 seasons with ECMWF HRES forecast steering and HRES's own storm "
721
- 'tracks (WeatherBench 2, 1.5 degrees); blend weights fitted on leave-one-season-out consensus tracks',
722
- 'CONE_KM_BASIC_FF': "hindcasts of the 2016, 2017 and 2019 seasons with ECMWF HRES forecast steering and HRES's own "
723
- 'storm tracks (WeatherBench 2, 1.5 degrees); blend weights fitted on leave-one-season-out '
724
- 'consensus tracks',
725
- 'SWARM_FF': "hindcasts of the 2016, 2017 and 2019 seasons with ECMWF HRES forecast steering and HRES's own storm "
726
- 'tracks (WeatherBench 2, 1.5 degrees); blend weights fitted on leave-one-season-out consensus tracks; '
727
- 'the 0.13 perturbation priors kept: quieter ones matched the consensus error on the tuning seasons but '
728
- 'were worse-calibrated for scenarios on the held-out ones',
729
- 'RI': 'logistic regression, P(+30 kt in 24 h), hindcasts of the 2016, 2017 and 2019 seasons; predictors: the '
730
- "SHIPS-style set, HRES's central-pressure deepening along its own track, ocean heat content and 26 C isotherm "
731
- 'depth from the WOA23 climatology, the gale radius; held-out Brier skill 0.154, AUC 0.864',
732
- 'TREND': 'hindcasts of the 2016, 2017 and 2019 seasons',
733
- 'CONE_FLOW': "hindcasts of the 2016, 2017 and 2019 seasons with ECMWF HRES forecast steering and HRES's own storm "
734
- 'tracks (WeatherBench 2, 1.5 degrees); blend weights fitted on leave-one-season-out consensus tracks; '
735
- 'Goerss-style regression of the blended consensus error on member spread, model disagreement, speed, '
736
- 'latitude and intensity',
737
- 'MODEL_INTENSITY': "kt per hPa of HRES's central-pressure-deficit change along its track, per lead, least squares "
738
- 'on the 2016, 2017 and 2019 seasons',
739
- 'INTENSITY_SPREAD': "quantiles of the intensity consensus's leave-one-season-out errors on 2016, 2017 and 2019, per "
740
- 'lead and regime',
741
- 'OCEAN': 'WOA23 decadal-average monthly temperature on 1 degree: depth of the 26 C isotherm and tropical cyclone '
742
- 'heat potential; depth-aware wake cooling and the SST-anomaly depth were tried and rejected on the tuning '
743
- 'seasons (power 0)',
744
- 'RI_PATH': 'mean and 75th-percentile intensity change of the 2016, 2017 and 2019 storms whose 24 h change reached '
745
- '30 kt (217 cases)',
746
- 'PEAK': 'logistic regression of P(peak intensity within 120 h >= 64 / 96 / 113 / 137 kt) on the 2016, 2017 and 2019 '
747
- 'seasons; held-out Brier skill 0.32 / 0.31 / 0.29 / 0.27 against 0.26 / 0.22 / 0.15 / 0.16 for the best '
748
- 'per-lead category probability',
749
- 'TRACK_FEEDBACK': "share of the previous cycle's blended-track error subtracted from the new track, least squares "
750
- 'per lead on the 2016, 2017 and 2019 HRES blends; about 1% on the held-out multi-model blend at '
751
- '12-36 h, zero beyond 48 h',
752
- 'RAIN_SCALE': 'R-CLIPER storm totals along the observed track against NOAA MRMS gauge-corrected radar for US '
753
- 'landfalls of 2016, 2017 and 2019 (fit) and 2018, 2020-2022 (held-out)',
754
- 'PEAK_QUANTILES': 'linear pinball-loss fits of the 120 h peak rise on the PEAK features plus the RI-path rise, '
755
- '2016/2017/2019; held-out coverage 0.53 / 0.77 / 0.91 / 0.95 at the 50 / 75 / 90 / 95% levels',
756
- 'PEAK_IF_RI': "ridge fit of the 120 h peak on the tuning seasons' rapid intensifiers (+30 kt / 24 h); held-out RI "
757
- 'cases bias +0.6 kt, MAE 13 kt',
758
- 'ARRIVAL_TIME_ERROR': "held-out 2018 / 2020-2022: error of the blended track's crossing of the line through the "
759
- "truth position at 24 and 48 h, by the forecast's translation speed; no systematic bias, slow "
760
- 'movers the uncertain ones',
761
- 'ADECK_TRACK': 'NHC a-deck aids (HCCA, TVCN, AEMI, AVNI) matched to the 2016, 2017 and 2019 Atlantic cases; the '
762
- 'rule chosen on them (our track cross-fitted by season) is the plain mean of ours and HCCA; held-out '
763
- 'Atlantic 2018 / 2020-2022: 11-13% lower track error than ours at 12-120 h, 12-22% lower than the '
764
- "official forecast at 6-72 h; the split between ours and HCCA earned from the previous cycle's "
765
- 'verified errors at 6-24 h and moved away from the member that stands alone against TVCN beyond 1.5 '
766
- 'cone radii from 12 h (together +1.4-2.5% held-out at 12-36 h, +1-2% at 48-120 h)',
767
- 'ADECK_INTENSITY': 'NHC a-deck intensity aids (IVCN, HWRF, HMON, SHIPS, LGEM, GFS, HCCA) matched to the 2016, 2017 '
768
- 'and 2019 Atlantic cases, smooth-L1 simplex weights per lead; held-out Atlantic 2018 / '
769
- '2020-2022: 26-35% lower intensity error than ours at 24-120 h and 4-6% lower than the official '
770
- 'forecast at 24-120 h'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/consensus.py DELETED
@@ -1,234 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- A consensus that weighs speed and direction separately.
4
-
5
- The idea: forecast members are good at different things. Extrapolation knows how fast the storm is going
6
- right now; a steering trajectory knows where the flow will turn it; climatology knows that storms slow down
7
- near the ridge. Averaging whole positions throws that away -- a member with excellent direction but poor
8
- speed drags the consensus off in both. So each member's displacement (in the storm-centred frame, km) is
9
- split into an **along-track** part (along the direction of a reference forecast) and a **cross-track** part,
10
- and the two get their own weights at every lead, fitted on past seasons:
11
-
12
- * cross-track weights are non-negative and sum to one (a convex blend of directions);
13
- * along-track weights are non-negative but free in sum, which lets the consensus correct the systematic
14
- slowness or quickness that every physics member has (a scale of 1.05 is a 5% speed-up);
15
- * members missing for a case drop out and the rest renormalise (cross-track) or rescale by the fitted
16
- total (along-track).
17
-
18
- Weights are fitted by projected gradient descent on the squared error in km (convex; the projection is onto the
19
- simplex for cross-track and onto the non-negative orthant for along-track). Then, optionally, a
20
- **selective** step (Goerss's "selective consensus") drops members that disagree with the median by more
21
- than ``k`` times the members' spread before blending.
22
- """
23
-
24
- from __future__ import annotations
25
-
26
- import numpy as np
27
-
28
- from . import geometry
29
-
30
- from .geometry import from_local, to_local
31
-
32
-
33
- def to_displacements(cases, forecasts: np.ndarray) -> np.ndarray:
34
- """``(N, L, M, 2)`` lat/lon -> ``(N, L, M, 2)`` (east, north) km from each case's initial position."""
35
- lat0 = np.array([c.lat for c in cases])[:, None, None]
36
- lon0 = np.array([c.lon for c in cases])[:, None, None]
37
- east, north = to_local(lat0, lon0, forecasts[..., 0], forecasts[..., 1])
38
- out = np.stack([east, north], -1)
39
- out[~np.isfinite(forecasts).all(-1)] = np.nan
40
- return out
41
-
42
-
43
- def from_displacements(cases, displacement: np.ndarray) -> np.ndarray:
44
- """``(N, L, 2)`` km -> lat/lon."""
45
- lat0 = np.array([c.lat for c in cases])[:, None]
46
- lon0 = np.array([c.lon for c in cases])[:, None]
47
- lat, lon = from_local(lat0, lon0, displacement[..., 0], displacement[..., 1])
48
- out = np.stack([lat, lon], -1)
49
- out[~np.isfinite(displacement).all(-1)] = np.nan
50
- return out
51
-
52
-
53
- def project_simplex(w: np.ndarray) -> np.ndarray:
54
- """Nearest point on the probability simplex (Duchi et al. 2008)."""
55
- u = np.sort(w)[::-1]
56
- css = np.cumsum(u) - 1.0
57
- k = np.arange(1, w.size + 1)
58
- rho = np.nonzero(u - css / k > 0)[0][-1]
59
- return np.maximum(w - css[rho] / (rho + 1.0), 0.0)
60
-
61
-
62
- def _reference_direction(displacements: np.ndarray, reference: int) -> np.ndarray:
63
- """``(N, L, 2)`` unit vector of the reference member (falling back to the member mean)."""
64
- ref = displacements[:, :, reference]
65
- mean = np.nanmean(displacements, axis=2)
66
- ref = np.where(np.isfinite(ref), ref, mean)
67
- length = np.linalg.norm(ref, axis=-1, keepdims=True)
68
- unit = ref / np.maximum(length, 1e-6)
69
- unit[(length[..., 0] < 1e-6)] = np.array([0.0, 1.0])
70
- return unit
71
-
72
-
73
- def _fit_one(a: np.ndarray, b: np.ndarray, simplex: bool, steps: int = 4000) -> np.ndarray:
74
- """
75
- Weights minimising the mean of ``(a w - b)^2`` (members missing from a case, NaN in ``a``, are renormalised
76
- away), by accelerated projected gradient (FISTA) with the exact Lipschitz step ``1 / lambda_max(a^T a / n)`` --
77
- nearly collinear members make a cruder step diverge.
78
- """
79
- n, m = a.shape
80
- ok = np.isfinite(a)
81
- filled = np.where(ok, a, 0.0)
82
- lipschitz = float(np.linalg.eigvalsh(filled.T @ filled / n)[-1])
83
- lr = 1.0 / max(lipschitz, 1e-12)
84
- project = project_simplex if simplex else (lambda x: np.maximum(x, 0.0))
85
- w = np.full(m, 1.0 / m)
86
- z, t = w.copy(), 1.0
87
- for _ in range(steps):
88
- active = ok @ z
89
- norm = np.where(active > 0, active / max(float(z.sum()), 1e-9), 1.0)
90
- resid = (filled @ z) / norm - b
91
- grad = (filled / norm[:, None]).T @ resid / n
92
- w_next = project(z - lr * grad)
93
- t_next = 0.5 * (1.0 + np.sqrt(1.0 + 4.0 * t * t))
94
- z = w_next + ((t - 1.0) / t_next) * (w_next - w)
95
- if not simplex:
96
- z = np.maximum(z, 0.0)
97
- w, t = w_next, t_next
98
- return w
99
-
100
-
101
- def fit_consensus(cases, forecasts: np.ndarray, reference: int, valid_members: np.ndarray | None = None) -> dict:
102
- """
103
- Fit along- and cross-track weights per lead. ``forecasts``: ``(N, L, M, 2)`` lat/lon of every member.
104
-
105
- Returns ``{"along": (L, M), "cross": (L, M), "reference": int}``.
106
- """
107
- disp = to_displacements(cases, forecasts)
108
- truth = to_displacements(cases, np.stack([np.stack([c.truth["lat"], c.truth["lon"]], -1) for c in cases])[:, :, None])[:, :, 0]
109
- unit = _reference_direction(disp, reference)
110
- normal = np.stack([-unit[..., 1], unit[..., 0]], -1)
111
- along = (disp * unit[:, :, None]).sum(-1) # (N, L, M)
112
- cross = (disp * normal[:, :, None]).sum(-1)
113
- t_along = (truth * unit).sum(-1)
114
- t_cross = (truth * normal).sum(-1)
115
- leads, members = disp.shape[1], disp.shape[2]
116
- wa, wc = np.zeros((leads, members)), np.zeros((leads, members))
117
- for k in range(leads):
118
- ok = np.isfinite(t_along[:, k]) & np.isfinite(along[:, k]).any(-1)
119
- if valid_members is not None:
120
- ok &= valid_members
121
- if ok.sum() < 20:
122
- wa[k] = wc[k] = 1.0 / members
123
- continue
124
- wa[k] = _fit_one(along[ok, k], t_along[ok, k], simplex=False)
125
- wc[k] = _fit_one(cross[ok, k], t_cross[ok, k], simplex=True)
126
- return {"along": wa, "cross": wc, "reference": int(reference)}
127
-
128
-
129
- def apply_consensus(cases, forecasts: np.ndarray, weights: dict, selective_k: float | None = None) -> np.ndarray:
130
- """``(N, L, 2)`` lat/lon: the fitted blend of whichever members each case has."""
131
- disp = to_displacements(cases, forecasts)
132
- if selective_k is not None:
133
- median = np.nanmedian(disp, axis=2, keepdims=True)
134
- distance = np.linalg.norm(disp - median, axis=-1)
135
- spread = np.nanmedian(distance, axis=2, keepdims=True)
136
- drop = distance > selective_k * np.maximum(spread, 25.0)
137
- disp = np.where(drop[..., None], np.nan, disp)
138
- unit = _reference_direction(disp, weights["reference"])
139
- normal = np.stack([-unit[..., 1], unit[..., 0]], -1)
140
- along = (disp * unit[:, :, None]).sum(-1)
141
- cross = (disp * normal[:, :, None]).sum(-1)
142
- ok = np.isfinite(along)
143
- wa = np.asarray(weights["along"])[None]
144
- wc = np.asarray(weights["cross"])[None]
145
- total_a = wa.sum(-1)
146
- cover_a = (wa * ok).sum(-1)
147
- cover_c = (wc * ok).sum(-1)
148
- with np.errstate(invalid="ignore", divide="ignore"):
149
- a = (np.where(ok, along, 0.0) * wa).sum(-1) * total_a / np.where(cover_a > 0, cover_a, np.nan)
150
- c = (np.where(ok, cross, 0.0) * wc).sum(-1) / np.where(cover_c > 0, cover_c, np.nan)
151
- blended = a[..., None] * unit + c[..., None] * normal
152
- return from_displacements(cases, blended)
153
-
154
-
155
- def blend_with_model(case, track: np.ndarray, model_track: np.ndarray, spec: dict, leads, n_models: int = 1) -> np.ndarray:
156
- """
157
- The consensus pulled toward a dynamical model's own track of the storm (or several models' mean), by one weight
158
- per lead (``spec["alpha"]``, fitted on past seasons: a fifth at 6 h, nine tenths by day 3, where the model knows
159
- about the troughs and ridges the physics cannot see yet). With two or more models the mean deserves more
160
- (``spec["alpha_multi"]``, fitted where two models existed: 0.78 at 24 h, 0.95 from 48 h; held-out gain 1-3%).
161
-
162
- The model's track may first lose its starting offset (``spec["offset_tau_h"]``; None, as shipped: measured, it
163
- made the blend worse). Where the model has lost the storm, the consensus stands.
164
- """
165
- hours = np.asarray(leads, np.float64)
166
- weights = spec.get("alpha_multi") if n_models >= 2 and spec.get("alpha_multi") is not None else spec["alpha"]
167
- alpha = np.interp(hours, spec.get("leads", hours), weights)
168
- east, north = to_local(case.lat, case.lon, track[:, 0], track[:, 1])
169
- me, mn = to_local(case.lat, case.lon, model_track[:, 0], model_track[:, 1])
170
- tau = spec.get("offset_tau_h")
171
- if tau:
172
- f = hours[0] / max(hours[1] - hours[0], 1e-9)
173
- p0e, p0n = me[0] - (me[1] - me[0]) * f, mn[0] - (mn[1] - mn[0]) * f
174
- decay = np.exp(-hours / float(tau))
175
- me, mn = me - p0e * decay, mn - p0n * decay
176
- model_ok = np.isfinite(me) & np.isfinite(mn)
177
- e = np.where(model_ok, (1 - alpha) * east + alpha * me, east)
178
- n = np.where(model_ok, (1 - alpha) * north + alpha * mn, north)
179
- lat, lon = from_local(case.lat, case.lon, e, n)
180
- out = np.stack([lat, lon], -1)
181
- out[~np.isfinite(track).all(-1)] = np.nan
182
- return out
183
-
184
-
185
-
186
- def apply_track_feedback(case, track, previous_track, previous_time, leads, table: dict | None = None, previous_position=None):
187
- """
188
- Error feedback from the storm's own previous cycle (``coefficients.TRACK_FEEDBACK``): the previous forecast's
189
- position for the time now being analysed, minus the analysis, is an error known at analysis time; a fitted share
190
- of it per lead is subtracted from the new track at leads up to ``max_lead_h``. Model track errors persist from
191
- cycle to cycle (a ridge too strong is still too strong six hours later); fitted on the HRES blends of the tuning
192
- seasons, worth about 1% on the held-out multi-model blend at 12-36 h -- small, and nothing beyond 48 h, which
193
- is why it stops there.
194
-
195
- Args:
196
- track: the new blended track ``(L, 2)``.
197
- previous_track: the previous cycle's blended track ``(L, 2)`` at the same ``leads``.
198
- previous_time: the previous cycle's analysis time (numpy datetime64 or ISO string).
199
- previous_position: that cycle's analysis ``(lat, lon)``, so a gap shorter than the first lead interpolates
200
- from the right place.
201
- Returns the corrected track; the input unchanged when the previous cycle is not usable.
202
- """
203
- if table is None:
204
- from . import coefficients
205
-
206
- table = getattr(coefficients, "TRACK_FEEDBACK", None)
207
- if not table or previous_track is None or previous_time is None:
208
- return track
209
- gap_h = float((np.datetime64(case.time, "m") - np.datetime64(previous_time, "m")) / np.timedelta64(60, "m"))
210
- leads = np.asarray(leads, np.float64)
211
- prev = np.asarray(previous_track, np.float64)
212
- if gap_h <= 0 or gap_h > 48 or not np.isfinite(prev[:, 0]).any():
213
- return track
214
- fin = np.isfinite(prev[:, 0])
215
- knots, plat, plon = leads[fin], prev[fin, 0], prev[fin, 1]
216
- if previous_position is not None and np.all(np.isfinite(previous_position)): # the previous analysis: lead 0
217
- knots, plat, plon = np.r_[0.0, knots], np.r_[previous_position[0], plat], np.r_[previous_position[1], plon]
218
- pl = np.interp(gap_h, knots, plat)
219
- po = np.degrees(np.interp(gap_h, knots, np.unwrap(np.radians(plon))))
220
- e_prev, n_prev = geometry.local_displacement_km(case.lat, case.lon, pl, po) # the previous error, km
221
- keys = sorted(table["beta"], key=lambda k: abs(float(k) - gap_h))
222
- beta = np.interp(leads, table["leads"], table["beta"][keys[0]])
223
- beta = np.where(leads <= table.get("max_lead_h", 48), beta, 0.0)
224
- if gap_h < 9: # a 6 h gap: the 12 h share, scaled
225
- beta = beta * gap_h / 12.0
226
- out = np.asarray(track, np.float64).copy()
227
- ok = np.isfinite(out[:, 0])
228
- e, n = geometry.local_displacement_km(case.lat, case.lon, out[ok, 0], out[ok, 1])
229
- lat, lon = geometry.move(case.lat, case.lon, e - beta[ok] * e_prev, n - beta[ok] * n_prev)
230
- out[ok, 0], out[ok, 1] = lat, lon
231
- return out
232
-
233
- __all__ = ["apply_track_feedback", "apply_consensus", "blend_with_model", "fit_consensus", "from_displacements", "project_simplex",
234
- "to_displacements"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/data.py DELETED
@@ -1,361 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Hindcast cases: every best-track fix that a forecast centre would have issued a forecast from, with the
4
- analysis around the storm at that moment and what the storm actually did afterwards.
5
-
6
- The idea: a track model is judged the way the National Hurricane Center judges its own -- homogeneous
7
- samples, forecasts only from tropical or subtropical cyclones (depressions included), verified only while
8
- the system is still one, at fixed leads -- on thousands of cases across every basin, never on a single
9
- storm. Each case stores only what was knowable at issue time (the best track up to now and the analysis
10
- now and 12 hours ago), so nothing downstream can peek at the future; the verifying positions are kept
11
- apart, in ``truth``.
12
-
13
- Analysis boxes come from WeatherBench 2's 1.5-degree ERA5 (the grid NatureV1 runs on). Reading is
14
- chunk-aware -- the store keeps 8 times per chunk, so cases are processed in time order and every chunk is
15
- read once for all the storms active in it -- and threaded. Boxes are stored as float16 after a per-case,
16
- per-channel standardisation (geopotential would overflow float16 otherwise), in one memory-mapped array per
17
- season, resumable season by season.
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import concurrent.futures as cf
23
- import json
24
- from collections import OrderedDict
25
- from dataclasses import dataclass, field
26
- from pathlib import Path
27
-
28
- import numpy as np
29
-
30
- from .fields import Environment
31
-
32
- WB2_ERA5 = "gs://weatherbench2/datasets/era5/1959-2023_01_10-6h-240x121_equiangular_with_poles_conservative.zarr"
33
- LEVELS = (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)
34
- PREVIOUS_LEVELS = (200, 250, 300, 400, 500, 600, 700, 850)
35
- LEVEL_VARIABLES = {"u": "u_component_of_wind", "v": "v_component_of_wind", "z": "geopotential",
36
- "t": "temperature", "q": "specific_humidity"}
37
- SURFACE_VARIABLES = {"msl": "mean_sea_level_pressure", "sst": "sea_surface_temperature",
38
- "tcwv": "total_column_water_vapour", "u10": "10m_u_component_of_wind",
39
- "v10": "10m_v_component_of_wind"}
40
- STATIC_VARIABLES = {"lsm": "land_sea_mask", "zs": "geopotential_at_surface"}
41
- #: Leads verified, hours.
42
- LEADS = (6, 12, 24, 36, 48, 72, 96, 120)
43
- #: Box half-widths, degrees, and the grid step.
44
- HALF_LAT, HALF_LON, STEP = 30.0, 45.0, 1.5
45
- TROPICAL = ("TD", "TS", "HU", "SS")
46
- HISTORY_HOURS = (-48, -42, -36, -30, -24, -18, -12, -6, 0)
47
-
48
-
49
- def channel_names() -> list[str]:
50
- names = [f"{k}@{p}" for k in LEVEL_VARIABLES for p in LEVELS]
51
- names += list(SURFACE_VARIABLES) + list(STATIC_VARIABLES)
52
- names += [f"prev_{k}@{p}" for k in ("u", "v") for p in PREVIOUS_LEVELS] + ["prev_z@500", "prev_msl"]
53
- return names
54
-
55
-
56
- @dataclass
57
- class Case:
58
- """One forecast opportunity. Everything outside ``truth`` was known at ``time``."""
59
-
60
- storm_id: str
61
- name: str
62
- basin: str
63
- time: np.datetime64
64
- lat: float
65
- lon: float
66
- wind_kt: float
67
- pressure_hpa: float
68
- status: str
69
- history: dict = field(default_factory=dict) # HISTORY_HOURS -> lat, lon, wind, pressure (NaN where absent)
70
- rmw_nmi: float = float("nan")
71
- r34_nmi: float = float("nan") # mean of the reported quadrants
72
- distance_to_land_km: float = float("nan")
73
- truth: dict = field(default_factory=dict) # LEADS -> lat, lon, wind, pressure, valid
74
- index: int = -1 # row in the season's box array
75
-
76
- @property
77
- def year(self) -> int:
78
- return int(str(self.time)[:4])
79
-
80
- @property
81
- def key(self) -> str:
82
- return f"{self.storm_id}@{str(self.time)[:13]}"
83
-
84
-
85
- def _fix_lookup(track) -> dict[int, int]:
86
- seconds = track.time.astype("datetime64[s]").astype(np.int64)
87
- return {int(s): k for k, s in enumerate(seconds)}
88
-
89
-
90
- def cases_from_tracks(tracks, years, init_hours=(0, 12), leads=LEADS) -> list[Case]:
91
- """
92
- Every fix at ``init_hours`` UTC in ``years`` (calendar year of the fix) where the system is a tropical or
93
- subtropical cyclone and at least one earlier fix 6 or 12 hours back exists (so extrapolation is defined).
94
- """
95
- years = set(int(y) for y in years)
96
- out = []
97
- for track in tracks:
98
- lookup = _fix_lookup(track)
99
- seconds = track.time.astype("datetime64[s]").astype(np.int64)
100
- for k in range(len(track)):
101
- t = int(seconds[k])
102
- if (t // 3600) % 24 not in init_hours or track.status[k] not in TROPICAL:
103
- continue
104
- if int(str(track.time[k])[:4]) not in years:
105
- continue
106
- if not (np.isfinite(track.latitude[k]) and np.isfinite(track.longitude[k])):
107
- continue
108
- if lookup.get(t - 6 * 3600) is None and lookup.get(t - 12 * 3600) is None:
109
- continue
110
- history = {"lat": [], "lon": [], "wind": [], "pressure": []}
111
- for h in HISTORY_HOURS:
112
- j = lookup.get(t + h * 3600)
113
- for key, arr in (("lat", track.latitude), ("lon", track.longitude), ("wind", track.max_wind_kt),
114
- ("pressure", track.min_pressure_hpa)):
115
- history[key].append(float(arr[j]) if j is not None else float("nan"))
116
- truth = {"lat": [], "lon": [], "wind": [], "pressure": [], "valid": []}
117
- for lead in leads:
118
- j = lookup.get(t + lead * 3600)
119
- ok = j is not None and track.status[j] in TROPICAL and np.isfinite(track.latitude[j])
120
- for key, arr in (("lat", track.latitude), ("lon", track.longitude), ("wind", track.max_wind_kt),
121
- ("pressure", track.min_pressure_hpa)):
122
- truth[key].append(float(arr[j]) if ok else float("nan"))
123
- truth["valid"].append(bool(ok))
124
- radii = track.wind_radii_nmi[k, 0]
125
- out.append(Case(
126
- storm_id=track.storm_id, name=track.name, basin=track.basin, time=track.time[k].astype("datetime64[s]"),
127
- lat=float(track.latitude[k]), lon=float(track.longitude[k]), wind_kt=float(track.max_wind_kt[k]),
128
- pressure_hpa=float(track.min_pressure_hpa[k]), status=str(track.status[k]),
129
- history={key: np.asarray(v, np.float64) for key, v in history.items()},
130
- rmw_nmi=float(track.rmw_nmi[k]), r34_nmi=float(np.nanmean(radii)) if np.isfinite(radii).any() else float("nan"),
131
- distance_to_land_km=float(track.distance_to_land_km[k]) if track.distance_to_land_km is not None else float("nan"),
132
- truth={key: np.asarray(v, np.float64 if key != "valid" else bool) for key, v in truth.items()},
133
- ))
134
- out.sort(key=lambda c: (c.time, c.storm_id))
135
- return out
136
-
137
-
138
- # ------------------------------------------------------------------------------------- serialise --
139
-
140
- _SCALARS = ("storm_id", "name", "basin", "lat", "lon", "wind_kt", "pressure_hpa", "status", "rmw_nmi", "r34_nmi",
141
- "distance_to_land_km", "index")
142
-
143
-
144
- def save_cases(cases: list[Case], path: str | Path) -> None:
145
- payload = {key: np.asarray([getattr(c, key) for c in cases]) for key in _SCALARS}
146
- payload["time"] = np.asarray([c.time for c in cases], dtype="datetime64[s]").astype(np.int64)
147
- for key in ("lat", "lon", "wind", "pressure"):
148
- payload[f"history_{key}"] = np.stack([c.history[key] for c in cases]) if cases else np.zeros((0, 9))
149
- for key in ("lat", "lon", "wind", "pressure", "valid"):
150
- payload[f"truth_{key}"] = np.stack([c.truth[key] for c in cases]) if cases else np.zeros((0, len(LEADS)))
151
- np.savez(path, **payload)
152
-
153
-
154
- def load_cases(path: str | Path) -> list[Case]:
155
- data = np.load(path, allow_pickle=False)
156
- out = []
157
- for i in range(len(data["time"])):
158
- out.append(Case(
159
- storm_id=str(data["storm_id"][i]), name=str(data["name"][i]), basin=str(data["basin"][i]),
160
- time=np.datetime64(int(data["time"][i]), "s"), lat=float(data["lat"][i]), lon=float(data["lon"][i]),
161
- wind_kt=float(data["wind_kt"][i]), pressure_hpa=float(data["pressure_hpa"][i]), status=str(data["status"][i]),
162
- history={key: data[f"history_{key}"][i] for key in ("lat", "lon", "wind", "pressure")},
163
- rmw_nmi=float(data["rmw_nmi"][i]), r34_nmi=float(data["r34_nmi"][i]),
164
- distance_to_land_km=float(data["distance_to_land_km"][i]),
165
- truth={key: data[f"truth_{key}"][i] for key in ("lat", "lon", "wind", "pressure", "valid")},
166
- index=int(data["index"][i]),
167
- ))
168
- return out
169
-
170
-
171
- # --------------------------------------------------------------------------------------- reading --
172
-
173
- class WB2Reader:
174
- """
175
- Chunk-cached, threaded reads of WeatherBench 2 ERA5. ``fields(t)`` returns every variable at time ``t`` as
176
- ``(level, lat, lon)`` / ``(lat, lon)`` arrays (latitude ascending, longitude 0..358.5).
177
- """
178
-
179
- def __init__(self, store: str = WB2_ERA5, threads: int = 8, cache_chunks: int = 3) -> None:
180
- import xarray as xr
181
-
182
- self.ds = xr.open_zarr(store, storage_options={"token": "anon"}, chunks=None)
183
- self.t0 = np.datetime64(self.ds.time.values[0], "s").astype(np.int64)
184
- self.step = int((np.datetime64(self.ds.time.values[1], "s") - np.datetime64(self.ds.time.values[0], "s"))
185
- / np.timedelta64(1, "s"))
186
- self.chunk = int(self.ds["u_component_of_wind"].encoding.get("chunks", (8,))[0])
187
- self.levels = tuple(int(p) for p in self.ds.level.values)
188
- self.lat = self.ds.latitude.values.astype(np.float64)
189
- self.lon = self.ds.longitude.values.astype(np.float64)
190
- self.pool = cf.ThreadPoolExecutor(threads)
191
- self.cache: OrderedDict[int, dict] = OrderedDict()
192
- self.cache_chunks = cache_chunks
193
- self.static = {k: self._orient(np.asarray(self.ds[v].values, np.float32)) for k, v in STATIC_VARIABLES.items()}
194
-
195
- def _orient(self, array: np.ndarray) -> np.ndarray:
196
- """(..., lon, lat) -> (..., lat, lon), latitude ascending."""
197
- array = np.swapaxes(array, -1, -2)
198
- if self.lat[0] > self.lat[-1]:
199
- array = array[..., ::-1, :]
200
- return array
201
-
202
- def _load_chunk(self, number: int) -> dict:
203
- start, stop = number * self.chunk, (number + 1) * self.chunk
204
-
205
- def read(item):
206
- key, name = item
207
- return key, self._orient(np.asarray(self.ds[name].isel(time=slice(start, stop)).values, np.float32))
208
-
209
- items = list(LEVEL_VARIABLES.items()) + list(SURFACE_VARIABLES.items())
210
- return dict(self.pool.map(read, items))
211
-
212
- def fields(self, when: np.datetime64) -> dict:
213
- index = int((np.datetime64(when, "s").astype(np.int64) - self.t0) // self.step)
214
- number, offset = divmod(index, self.chunk)
215
- if number not in self.cache:
216
- self.cache[number] = self._load_chunk(number)
217
- while len(self.cache) > self.cache_chunks:
218
- self.cache.popitem(last=False)
219
- self.cache.move_to_end(number)
220
- chunk = self.cache[number]
221
- out = {key: value[offset] for key, value in chunk.items()}
222
- out.update(self.static)
223
- return out
224
-
225
-
226
- def _box_indices(lat0: float, lon0: float, lat_axis: np.ndarray, lon_step: float, rows: int, cols: int):
227
- """Row range (kept inside the globe) and wrapped column indices with continuous box longitudes."""
228
- step = lat_axis[1] - lat_axis[0]
229
- centre = int(round((lat0 - lat_axis[0]) / step))
230
- start = int(np.clip(centre - rows // 2, 0, lat_axis.size - rows))
231
- j0 = int(round((lon0 % 360.0) / lon_step))
232
- offsets = np.arange(cols) - cols // 2
233
- columns = (j0 + offsets) % int(round(360.0 / lon_step))
234
- box_lon = (j0 + offsets) * lon_step
235
- if box_lon[cols // 2] - lon0 > 180.0:
236
- box_lon = box_lon - 360.0
237
- return slice(start, start + rows), columns, lat_axis[start:start + rows], box_lon
238
-
239
-
240
- def build_box(now: dict, before: dict | None, lat0: float, lon0: float, lat_axis: np.ndarray, levels) -> tuple:
241
- """``(boxes (C, H, W) float32, lat (H,), lon (W,))`` for one case, channels in :func:`channel_names` order."""
242
- rows, cols = int(round(2 * HALF_LAT / STEP)) + 1, int(round(2 * HALF_LON / STEP)) + 1
243
- rs, cs, box_lat, box_lon = _box_indices(lat0, lon0, lat_axis, STEP, rows, cols)
244
- order = [list(levels).index(p) for p in LEVELS]
245
- planes = []
246
- for key in LEVEL_VARIABLES:
247
- planes.extend(now[key][order][:, rs][:, :, cs])
248
- for key in list(SURFACE_VARIABLES) + list(STATIC_VARIABLES):
249
- planes.append(now[key][rs][:, cs])
250
- previous_order = [list(levels).index(p) for p in PREVIOUS_LEVELS]
251
- source = before if before is not None else now
252
- for key in ("u", "v"):
253
- planes.extend(source[key][previous_order][:, rs][:, :, cs])
254
- planes.append(source["z"][list(levels).index(500)][rs][:, cs])
255
- planes.append(source["msl"][rs][:, cs])
256
- return np.stack(planes).astype(np.float32), box_lat, box_lon
257
-
258
-
259
- def _standardise(box: np.ndarray):
260
- mean = np.nanmean(box, axis=(1, 2))
261
- std = np.nanstd(box, axis=(1, 2))
262
- mean = np.where(np.isfinite(mean), mean, 0.0)
263
- std = np.where(np.isfinite(std) & (std > 0), std, 1.0)
264
- scaled = (box - mean[:, None, None]) / std[:, None, None]
265
- return scaled.astype(np.float16), np.stack([mean, std], -1).astype(np.float32)
266
-
267
-
268
- def build_season(cases: list[Case], directory: str | Path, reader: WB2Reader | None = None, log=print) -> Path:
269
- """
270
- Read the analysis box for every case (in time order, one chunk read for all storms in it) into
271
- ``directory``: ``cases.npz``, ``boxes.npy`` (N, C, H, W) float16, ``affine.npy`` (N, C, 2), ``grid.npz``
272
- (per-case box latitude and longitude), ``channels.json``. Resumable: a finished directory is left alone.
273
- """
274
- directory = Path(directory)
275
- directory.mkdir(parents=True, exist_ok=True)
276
- if (directory / "done").exists():
277
- return directory
278
- reader = reader or WB2Reader()
279
- names = channel_names()
280
- rows, cols = int(round(2 * HALF_LAT / STEP)) + 1, int(round(2 * HALF_LON / STEP)) + 1
281
- boxes = np.lib.format.open_memmap(directory / "boxes.npy", mode="w+", dtype=np.float16,
282
- shape=(len(cases), len(names), rows, cols))
283
- affine = np.zeros((len(cases), len(names), 2), np.float32)
284
- grid_lat = np.zeros((len(cases), rows), np.float32)
285
- grid_lon = np.zeros((len(cases), cols), np.float32)
286
- for i, case in enumerate(cases):
287
- now = reader.fields(case.time)
288
- before = reader.fields(case.time - np.timedelta64(12, "h"))
289
- box, lat, lon = build_box(now, before, case.lat, case.lon, reader.lat, reader.levels)
290
- boxes[i], affine[i] = _standardise(box)
291
- grid_lat[i], grid_lon[i] = lat, lon
292
- case.index = i
293
- if log and (i % 200 == 0 or i == len(cases) - 1):
294
- log(f"[cyclone-data] {directory.name}: {i + 1}/{len(cases)} {case.key}")
295
- boxes.flush()
296
- np.save(directory / "affine.npy", affine)
297
- np.savez(directory / "grid.npz", lat=grid_lat, lon=grid_lon)
298
- (directory / "channels.json").write_text(json.dumps(names))
299
- save_cases(cases, directory / "cases.npz")
300
- (directory / "done").write_text("ok")
301
- return directory
302
-
303
-
304
- class Season:
305
- """A built season: its cases and, on demand, each case's :class:`Environment`."""
306
-
307
- def __init__(self, directory: str | Path) -> None:
308
- self.directory = Path(directory)
309
- self.cases = load_cases(self.directory / "cases.npz")
310
- self.boxes = np.load(self.directory / "boxes.npy", mmap_mode="r")
311
- self.affine = np.load(self.directory / "affine.npy")
312
- grid = np.load(self.directory / "grid.npz")
313
- self.grid_lat, self.grid_lon = grid["lat"], grid["lon"]
314
- self.names = json.loads((self.directory / "channels.json").read_text())
315
- self.slot = {n: k for k, n in enumerate(self.names)}
316
-
317
- def __len__(self) -> int:
318
- return len(self.cases)
319
-
320
- def environment(self, i: int) -> Environment:
321
- case = self.cases[i]
322
- box = self.boxes[case.index].astype(np.float32)
323
- a = self.affine[case.index]
324
- box = box * a[:, 1, None, None] + a[:, 0, None, None]
325
- take = lambda name: box[self.slot[name]].astype(np.float64)
326
- fields = {k: np.stack([take(f"{k}@{p}") for p in LEVELS]) for k in LEVEL_VARIABLES}
327
- for k in list(SURFACE_VARIABLES) + list(STATIC_VARIABLES):
328
- fields[k] = take(k)
329
- previous = {k: np.stack([take(f"prev_{k}@{p}") for p in PREVIOUS_LEVELS]) for k in ("u", "v")}
330
- previous["z500"] = take("prev_z@500")
331
- previous["msl"] = take("prev_msl")
332
- env = Environment(lat=self.grid_lat[case.index].astype(np.float64), lon=self.grid_lon[case.index].astype(np.float64),
333
- levels=LEVELS, fields=fields, previous=previous, time=case.time)
334
- env.previous_levels = PREVIOUS_LEVELS
335
- return env
336
-
337
-
338
- def build_seasons(ibtracs_paths: dict[str, str], root: str | Path, years, basins=("NA", "EP", "WP", "SI", "SP"),
339
- log=print) -> dict[int, Path]:
340
- """Build (or reuse) one :func:`build_season` directory per calendar year, all basins together."""
341
- from ..ibtracs import parse_ibtracs
342
-
343
- years = sorted(int(y) for y in years)
344
- tracks = []
345
- for basin in basins:
346
- tracks += parse_ibtracs(ibtracs_paths[basin], since=years[0] - 1, until=years[-1] + 1, basins=(basin,))
347
- reader = None
348
- out = {}
349
- for year in years:
350
- directory = Path(root) / str(year)
351
- if not (directory / "done").exists():
352
- reader = reader or WB2Reader()
353
- cases = cases_from_tracks(tracks, [year])
354
- log(f"[cyclone-data] {year}: {len(cases)} cases")
355
- build_season(cases, directory, reader, log)
356
- out[year] = directory
357
- return out
358
-
359
-
360
- __all__ = ["Case", "HISTORY_HOURS", "LEADS", "LEVELS", "Season", "WB2Reader", "build_box", "build_season",
361
- "build_seasons", "cases_from_tracks", "channel_names", "load_cases", "save_cases"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/ensemble.py DELETED
@@ -1,136 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- An ensemble that knows where the analysis is uncertain -- the Bermuda High problem.
4
-
5
- The idea: Dorian's turn depended on the exact strength and edge of the Bermuda High, which sits over open ocean
6
- where no radiosondes are launched; models had to infer it from satellite retrievals. A single physics forecast
7
- from that analysis inherits whatever error it holds there. An ensemble spreads the forecast over the
8
- analysis's plausible errors -- and the ensemble *mean* is, on average, more accurate than any one member,
9
- because it averages out the unpredictable part. Each member:
10
-
11
- * adds a smooth, non-divergent perturbation to the steering flow: a few Gaussian streamfunction blobs
12
- (600-1100 km scale) scattered within 2,500 km of the storm, with amplitudes scaled to the climatological
13
- day-to-day variability of the 500 hPa wind at each blob (big where the weather is variable, small in the
14
- steady trades), **inflated over open ocean far from land** (the data voids where the analysis is least
15
- constrained), and growing with lead time as forecast errors do;
16
- * draws its own steering layer (from the family in :data:`naturev1.cyclone.steering.LAYERS`), its own beta
17
- drift strength and its own memory of the initial-motion residual -- the structural uncertainty of the
18
- physics, not just of the initial state.
19
-
20
- Perturbation winds are evaluated analytically at the members' positions (derivatives of Gaussians), so the
21
- whole ensemble integrates as one vectorised trajectory.
22
- """
23
-
24
- from __future__ import annotations
25
-
26
- import numpy as np
27
-
28
- from .geometry import destination, great_circle_km, to_local
29
-
30
- MEMBERS = 24
31
- BLOBS = 6
32
- #: Perturbation wind as a fraction of the climatological 500 hPa variability, and the open-ocean inflation.
33
- AMPLITUDE = 0.3
34
- OCEAN_INFLATION = 1.35
35
- #: Error growth: the perturbation's size doubles after this many hours.
36
- DOUBLING_H = 60.0
37
-
38
-
39
- class FlowPerturbations:
40
- """``(M, K)`` Gaussian streamfunction blobs; ``velocity(member_lat, member_lon, hours)`` -> ``(M,)`` (u, v)."""
41
-
42
- def __init__(self, lat0: float, lon0: float, env, climate=None, members: int = MEMBERS, blobs: int = BLOBS,
43
- amplitude: float = AMPLITUDE, seed: int = 0) -> None:
44
- rng = np.random.default_rng(seed)
45
- distance = 2500.0 * np.sqrt(rng.uniform(0.0, 1.0, (members, blobs)))
46
- bearing = rng.uniform(0.0, 360.0, (members, blobs))
47
- self.lat, self.lon = destination(lat0, lon0, bearing, distance)
48
- self.scale_km = rng.uniform(600.0, 1100.0, (members, blobs))
49
- sigma = np.full((members, blobs), 3.0)
50
- if climate is not None and "ustd500" in climate.fields:
51
- std = np.hypot(climate.sample(climate.fields["ustd500"], self.lat, self.lon),
52
- climate.sample(climate.fields["vstd500"], self.lat, self.lon)) / np.sqrt(2.0)
53
- sigma = np.where(np.isfinite(std), std, 3.0)
54
- land = env.sample(env.fields["lsm"], self.lat, self.lon) if "lsm" in env.fields else np.zeros_like(sigma)
55
- sigma = sigma * np.where(land < 0.2, OCEAN_INFLATION, 1.0)
56
- # Peak wind of a Gaussian streamfunction blob is psi0 / (L sqrt(e)); choose psi0 for a peak of amplitude*sigma.
57
- wind = amplitude * sigma * rng.choice([-1.0, 1.0], (members, blobs))
58
- self.psi0 = wind * self.scale_km * 1000.0 * np.sqrt(np.e)
59
-
60
- def velocity(self, lat, lon, hours):
61
- lat = np.atleast_1d(lat)
62
- lon = np.atleast_1d(lon)
63
- x, y = to_local(self.lat, self.lon, lat[:, None], lon[:, None]) # member position relative to blobs
64
- x, y = x * 1000.0, y * 1000.0
65
- l2 = (self.scale_km * 1000.0) ** 2
66
- g = self.psi0 * np.exp(-0.5 * (x * x + y * y) / l2)
67
- u = (g * y / l2).sum(1) # u = -dpsi/dy
68
- v = (-g * x / l2).sum(1) # v = dpsi/dx
69
- growth = 2.0 ** (np.asarray(hours, np.float64) / DOUBLING_H)
70
- return u * growth, v * growth
71
-
72
-
73
- def ensemble_mean(tracks: np.ndarray) -> np.ndarray:
74
- """``(M, L, 2)`` -> ``(L, 2)``: the mean position of the members alive at each lead (local-frame average)."""
75
- from .geometry import from_local
76
-
77
- out = np.full(tracks.shape[1:], np.nan)
78
- for k in range(tracks.shape[1]):
79
- ok = np.isfinite(tracks[:, k, 0])
80
- if not ok.any():
81
- continue
82
- ref = tracks[ok, k][0]
83
- x, y = to_local(ref[0], ref[1], tracks[ok, k, 0], tracks[ok, k, 1])
84
- out[k] = np.stack(from_local(ref[0], ref[1], x.mean(), y.mean()), -1)
85
- return out
86
-
87
-
88
- def ensemble_spread_km(tracks: np.ndarray, mean: np.ndarray) -> np.ndarray:
89
- """``(L,)`` mean distance of the members from the ensemble mean."""
90
- return np.nanmean(great_circle_km(tracks[..., 0], tracks[..., 1], mean[None, :, 0], mean[None, :, 1]), axis=0)
91
-
92
-
93
- def physics_ensemble(case, env_clean, flows: dict, beta, motion_ms, leads, climate=None, climate_flows=None,
94
- members: int = MEMBERS, seed: int = 0, tau_range=(12.0, 48.0), beta_range=(0.6, 1.4),
95
- anomaly_tau_h: float = 72.0) -> dict:
96
- """
97
- Run the ensemble. ``flows``: ``{layer name: SteeringFlow}`` of the vortex-removed analysis (members pick one
98
- each); ``climate_flows`` the matching climatological flows (then each member's steering anomaly decays with
99
- ``anomaly_tau_h``). Returns ``{"members": (M, L, 2), "mean": (L, 2), "spread_km": (L,)}``.
100
- """
101
- from .trajectory import integrate
102
-
103
- rng = np.random.default_rng(seed + 7919)
104
- names = list(flows)
105
- choice = rng.integers(0, len(names), members)
106
- tau = rng.uniform(*tau_range, members)
107
- beta_scale = rng.uniform(*beta_range, members)
108
- perturb = FlowPerturbations(case.lat, case.lon, env_clean, climate, members, seed=seed)
109
- start = {n: tuple(float(a[0]) for a in flows[n].velocity(case.lat, case.lon)) for n in names}
110
- b0 = tuple(float(a[0]) for a in beta.velocity(case.lat, case.lon))
111
- residual = np.array([[motion_ms[0] - start[names[c]][0] - beta_scale[m] * b0[0],
112
- motion_ms[1] - start[names[c]][1] - beta_scale[m] * b0[1]] for m, c in enumerate(choice)])
113
-
114
- def velocity(lat, lon, hours):
115
- u = np.zeros(members)
116
- v = np.zeros(members)
117
- for k, name in enumerate(names):
118
- sel = choice == k
119
- if sel.any():
120
- us, vs = flows[name].velocity(lat[sel], lon[sel])
121
- if climate_flows is not None and name in climate_flows:
122
- uc, vc = climate_flows[name].velocity(lat[sel], lon[sel])
123
- memory = np.exp(-hours / anomaly_tau_h)
124
- us, vs = uc + memory * (us - uc), vc + memory * (vs - vc)
125
- u[sel], v[sel] = us, vs
126
- bu, bv = beta.velocity(lat, lon)
127
- pu, pv = perturb.velocity(lat, lon, hours)
128
- decay = np.exp(-hours / tau)
129
- return (u + beta_scale * bu + pu + residual[:, 0] * decay, v + beta_scale * bv + pv + residual[:, 1] * decay)
130
-
131
- tracks = integrate(np.full(members, case.lat), np.full(members, case.lon), velocity, leads)
132
- mean = ensemble_mean(tracks)
133
- return {"members": tracks, "mean": mean, "spread_km": ensemble_spread_km(tracks, mean)}
134
-
135
-
136
- __all__ = ["AMPLITUDE", "FlowPerturbations", "MEMBERS", "ensemble_mean", "ensemble_spread_km", "physics_ensemble"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/field_tracker.py DELETED
@@ -1,127 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Following a storm through a gridded forecast: a GFDL-style tracker for any model's output.
4
-
5
- The idea: comparing NatureV1's physics with GraphCast, Pangu, HRES, Aurora or FGN on hurricanes needs their
6
- tracks, and none of them outputs a track -- they output fields. The tracker used by operational centres
7
- (Marchok's GFDL vortex tracker) finds the storm in each forecast step from several fields and averages the
8
- fixes; this one does the same with what every model in WeatherBench 2 provides:
9
-
10
- * **sea-level pressure minimum** (every model has it), refined below the grid spacing by fitting a quadratic
11
- surface to the 3x3 neighbourhood of the lowest grid value -- at 1.5 degrees the refinement matters (a grid
12
- point can be 80 km from the true minimum);
13
- * **850 hPa relative vorticity maximum** (cyclonic sign per hemisphere) and **850/700 hPa geopotential
14
- minima** when the model provides those fields;
15
- * each step searches only within a radius of the position the previous motion predicts (first guess
16
- = previous position + previous displacement), so a nearby low is not grabbed;
17
- * the fix is the mean of the parameter fixes that agree (within 250 km of their median);
18
- * the track stops when the storm is lost: no closed low (the minimum is not at least ``depth_hpa`` below the
19
- mean pressure on a ring 300-500 km out) or the fixes disagree.
20
-
21
- Works on any ``(time, lat, lon)`` field arrays on a regular grid (latitude ascending, longitude 0..360).
22
- """
23
-
24
- from __future__ import annotations
25
-
26
- import numpy as np
27
-
28
- from .geometry import great_circle_km, move, local_displacement_km
29
-
30
-
31
- def _refine(field: np.ndarray, i: int, j: int) -> tuple[float, float]:
32
- """Sub-grid offset (di, dj) of an extremum from a quadratic fit to its 3x3 neighbourhood (wrapped in j)."""
33
- h, w = field.shape
34
- if i <= 0 or i >= h - 1:
35
- return 0.0, 0.0
36
- c = field[i, j]
37
- n, s = field[i + 1, j], field[i - 1, j]
38
- e, wv = field[i, (j + 1) % w], field[i, (j - 1) % w]
39
- di = 0.5 * (s - n) / (s - 2 * c + n) if (s - 2 * c + n) != 0 else 0.0
40
- dj = 0.5 * (wv - e) / (wv - 2 * c + e) if (wv - 2 * c + e) != 0 else 0.0
41
- return float(np.clip(di, -0.5, 0.5)), float(np.clip(dj, -0.5, 0.5))
42
-
43
-
44
- def find_extremum(field: np.ndarray, lat_axis: np.ndarray, lon_axis: np.ndarray, guess: tuple[float, float],
45
- radius_km: float, minimum: bool = True) -> tuple[float, float, float] | None:
46
- """``(lat, lon, value)`` of the field's extremum within ``radius_km`` of ``guess``, sub-grid refined."""
47
- lat_g, lon_g = np.meshgrid(lat_axis, lon_axis, indexing="ij")
48
- d = great_circle_km(guess[0], guess[1], lat_g, lon_g)
49
- inside = d < radius_km
50
- if not inside.any():
51
- return None
52
- data = field if minimum else -field
53
- masked = np.where(inside, data, np.inf)
54
- i, j = np.unravel_index(int(np.argmin(masked)), masked.shape)
55
- di, dj = _refine(data, i, j)
56
- step_lat = lat_axis[1] - lat_axis[0]
57
- step_lon = lon_axis[1] - lon_axis[0]
58
- lat = float(lat_axis[i] + di * step_lat)
59
- lon = float(((lon_axis[j] + dj * step_lon + 180.0) % 360.0) - 180.0)
60
- return lat, lon, float(field[i, j])
61
-
62
-
63
- def closed_low_depth(msl: np.ndarray, lat_axis, lon_axis, lat: float, lon: float, centre_value: float,
64
- ring=(300.0, 500.0)) -> float:
65
- """Ring-mean pressure minus the centre value (Pa) -- how closed the low is."""
66
- lat_g, lon_g = np.meshgrid(lat_axis, lon_axis, indexing="ij")
67
- d = great_circle_km(lat, lon, lat_g, lon_g)
68
- band = (d >= ring[0]) & (d <= ring[1])
69
- return float(msl[band].mean() - centre_value) if band.any() else 0.0
70
-
71
-
72
- def track_in_fields(start: tuple[float, float], hours, msl: np.ndarray, lat_axis, lon_axis,
73
- vorticity850: np.ndarray | None = None, z850: np.ndarray | None = None,
74
- z700: np.ndarray | None = None, first_motion_kmh: tuple[float, float] | None = None,
75
- search_km: float = 350.0, depth_pa: float = 100.0, agree_km: float = 250.0) -> np.ndarray:
76
- """
77
- Track a storm from ``start`` through forecast fields at ``hours`` (``msl`` is ``(T, H, W)`` Pa).
78
-
79
- Returns ``(T, 2)`` lat/lon, NaN after the storm is lost. ``first_motion_kmh`` (east, north) seeds the first
80
- guess; later guesses extrapolate the tracked motion.
81
- """
82
- hours = np.asarray(hours, np.float64)
83
- out = np.full((len(hours), 2), np.nan)
84
- position = (float(start[0]), float(start[1]))
85
- motion = first_motion_kmh or (0.0, 0.0)
86
- last_hours = 0.0
87
- hemisphere = 1.0 if start[0] >= 0 else -1.0
88
- for k, h in enumerate(hours):
89
- if not np.isfinite(msl[k]).any():
90
- continue # a lead this model does not provide (12-hourly output)
91
- dt = h - last_hours
92
- guess = move(position[0], position[1], motion[0] * dt, motion[1] * dt) if dt > 0 else position
93
- guess = (float(guess[0]), float(guess[1]))
94
- fixes = []
95
- found = find_extremum(msl[k], lat_axis, lon_axis, guess, search_km, minimum=True)
96
- if found is None:
97
- break
98
- depth = closed_low_depth(msl[k], lat_axis, lon_axis, found[0], found[1], found[2])
99
- if depth < depth_pa:
100
- break
101
- fixes.append(found[:2])
102
- if vorticity850 is not None:
103
- f = find_extremum(hemisphere * vorticity850[k], lat_axis, lon_axis, guess, search_km, minimum=False)
104
- if f is not None:
105
- fixes.append(f[:2])
106
- for z in (z850, z700):
107
- if z is not None:
108
- f = find_extremum(z[k], lat_axis, lon_axis, guess, search_km, minimum=True)
109
- if f is not None:
110
- fixes.append(f[:2])
111
- fixes = np.asarray(fixes)
112
- median = np.median(fixes, axis=0)
113
- near = great_circle_km(median[0], median[1], fixes[:, 0], fixes[:, 1]) < agree_km
114
- use = fixes[near] if near.any() else fixes[:1]
115
- # Average positions in the local frame of the pressure fix (safe across the dateline).
116
- east, north = local_displacement_km(fixes[0, 0], fixes[0, 1], use[:, 0], use[:, 1])
117
- lat, lon = move(fixes[0, 0], fixes[0, 1], east.mean(), north.mean())
118
- new = (float(lat), float(lon))
119
- if dt > 0:
120
- e, n = local_displacement_km(position[0], position[1], new[0], new[1])
121
- motion = (float(e) / dt, float(n) / dt)
122
- position, last_hours = new, h
123
- out[k] = new
124
- return out
125
-
126
-
127
- __all__ = ["closed_low_depth", "find_extremum", "track_in_fields"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/fields.py DELETED
@@ -1,231 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- The atmosphere around one storm: a latitude-longitude box of analysis fields with spherical calculus.
4
-
5
- The idea: every physical diagnostic a track model needs -- layer-mean winds, vorticity and its gradient,
6
- divergence, the Laplacian for inverting vorticity -- is computed with the sphere's metric terms (the
7
- ``1 / (a cos(lat))`` on every east-west derivative, the ``cos(lat)`` inside the curl and the divergence),
8
- not as finite differences in degrees. The box is stored with continuous (unwrapped) longitudes, so a storm
9
- box straddling the dateline is just a box. Sampling between grid points is bilinear in the box's own
10
- coordinates, with any longitude mapped into the box first; points outside the box clamp to its edge rather
11
- than wrapping onto the far side of the planet.
12
-
13
- Layer means are pressure-weighted (trapezoid in pressure), the vertical average a steering-layer argument
14
- actually calls for; levels between the stored ones interpolate in log-pressure.
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- from dataclasses import dataclass, field
20
-
21
- import numpy as np
22
-
23
- from .geometry import EARTH_RADIUS_KM, coriolis, to_local
24
-
25
- A_M = EARTH_RADIUS_KM * 1000.0
26
-
27
- #: Variables stored on pressure levels, and single-level ones.
28
- LEVEL_FIELDS = ("u", "v", "z", "t", "q")
29
- SURFACE_FIELDS = ("msl", "sst", "tcwv", "lsm", "zs")
30
-
31
-
32
- @dataclass
33
- class Environment:
34
- """
35
- Analysis fields in a box around a storm.
36
-
37
- Attributes:
38
- lat: ``(H,)`` ascending latitudes, degrees.
39
- lon: ``(W,)`` ascending, continuous longitudes (may run past 180), degrees.
40
- levels: pressure levels, hPa, in the order the level fields are stored.
41
- fields: ``name -> (L, H, W)`` for level fields, ``(H, W)`` for single-level ones. SI units (m/s,
42
- m^2/s^2 geopotential, K, kg/kg, Pa, 0-1 land fraction).
43
- previous: the same kind of mapping 12 hours earlier, for the fields that were stored (u, v, z).
44
- time: the analysis time.
45
- """
46
-
47
- lat: np.ndarray
48
- lon: np.ndarray
49
- levels: tuple[int, ...]
50
- fields: dict[str, np.ndarray]
51
- previous: dict[str, np.ndarray] = field(default_factory=dict)
52
- time: np.datetime64 | None = None
53
-
54
- # ------------------------------------------------------------------------------- access --
55
-
56
- @property
57
- def shape(self) -> tuple[int, int]:
58
- return self.lat.size, self.lon.size
59
-
60
- @property
61
- def spacing_deg(self) -> float:
62
- return float(self.lat[1] - self.lat[0])
63
-
64
- def has(self, name: str) -> bool:
65
- return name in self.fields
66
-
67
- def level(self, name: str, pressure: float, previous: bool = False) -> np.ndarray:
68
- """A level field at ``pressure`` hPa, interpolated in log-pressure between stored levels."""
69
- source = self.previous if previous else self.fields
70
- data = source[name]
71
- levels = np.asarray(self.levels if data.shape[0] == len(self.levels) else self.previous_levels, np.float64)
72
- if pressure in levels:
73
- return data[int(np.nonzero(levels == pressure)[0][0])]
74
- order = np.argsort(levels)
75
- lv = levels[order]
76
- p = float(np.clip(pressure, lv[0], lv[-1]))
77
- j = int(np.clip(np.searchsorted(lv, p) - 1, 0, len(lv) - 2))
78
- w = (np.log(p) - np.log(lv[j])) / (np.log(lv[j + 1]) - np.log(lv[j]))
79
- return (1.0 - w) * data[order[j]] + w * data[order[j + 1]]
80
-
81
- #: Levels of the stored previous-time level fields (set by the loader when they differ).
82
- previous_levels: tuple[int, ...] = ()
83
-
84
- def layer_mean(self, name: str, top: float, bottom: float, previous: bool = False,
85
- weights: dict[float, float] | None = None) -> np.ndarray:
86
- """
87
- Pressure-weighted mean of a level field between ``top`` and ``bottom`` hPa (trapezoid in pressure),
88
- or a weighted mean over explicit ``weights`` ``{level: weight}`` when given.
89
- """
90
- if weights is not None:
91
- total = sum(weights.values())
92
- return sum(w * self.level(name, p, previous) for p, w in weights.items()) / total
93
- source = self.previous if previous else self.fields
94
- levels = np.asarray(self.levels if source[name].shape[0] == len(self.levels) else self.previous_levels,
95
- np.float64)
96
- inside = sorted({float(p) for p in levels if top <= p <= bottom} | {float(top), float(bottom)})
97
- values = [self.level(name, p, previous) for p in inside]
98
- total = np.zeros_like(values[0], dtype=np.float64)
99
- span = 0.0
100
- for k in range(len(inside) - 1):
101
- dp = inside[k + 1] - inside[k]
102
- total = total + 0.5 * dp * (values[k] + values[k + 1])
103
- span += dp
104
- return total / max(span, 1e-9)
105
-
106
- # ----------------------------------------------------------------------------- sampling --
107
-
108
- def _fractional_index(self, lat, lon):
109
- lat = np.asarray(lat, np.float64)
110
- lon = np.asarray(lon, np.float64)
111
- step = self.lat[1] - self.lat[0]
112
- fi = (lat - self.lat[0]) / step
113
- offset = (lon - self.lon[0]) % 360.0
114
- fj = offset / (self.lon[1] - self.lon[0])
115
- fi = np.clip(fi, 0.0, self.lat.size - 1.000001)
116
- fj = np.clip(fj, 0.0, self.lon.size - 1.000001)
117
- return fi, fj
118
-
119
- def sample(self, grid: np.ndarray, lat, lon) -> np.ndarray:
120
- """Bilinear value of a ``(H, W)`` field at points (degrees); outside the box clamps to its edge."""
121
- fi, fj = self._fractional_index(lat, lon)
122
- i0, j0 = np.floor(fi).astype(int), np.floor(fj).astype(int)
123
- di, dj = fi - i0, fj - j0
124
- return ((1 - di) * (1 - dj) * grid[i0, j0] + (1 - di) * dj * grid[i0, j0 + 1]
125
- + di * (1 - dj) * grid[i0 + 1, j0] + di * dj * grid[i0 + 1, j0 + 1])
126
-
127
- def inside(self, lat, lon, margin_deg: float = 0.0):
128
- """Whether points lie inside the box (with a margin)."""
129
- lat = np.asarray(lat, np.float64)
130
- offset = (np.asarray(lon, np.float64) - self.lon[0]) % 360.0
131
- return ((lat >= self.lat[0] + margin_deg) & (lat <= self.lat[-1] - margin_deg)
132
- & (offset >= margin_deg) & (offset <= self.lon[-1] - self.lon[0] - margin_deg))
133
-
134
- # ---------------------------------------------------------------------------- calculus --
135
-
136
- @property
137
- def coslat(self) -> np.ndarray:
138
- return np.cos(np.radians(self.lat))[:, None]
139
-
140
- def ddx(self, f: np.ndarray) -> np.ndarray:
141
- """East derivative, per metre: (1 / (a cos(lat))) d/dlambda."""
142
- dl = np.radians(self.lon[1] - self.lon[0])
143
- g = np.gradient(f, dl, axis=-1)
144
- return g / (A_M * np.maximum(self.coslat, 1e-3))
145
-
146
- def ddy(self, f: np.ndarray) -> np.ndarray:
147
- """North derivative, per metre: (1 / a) d/dphi."""
148
- dp = np.radians(self.lat[1] - self.lat[0])
149
- return np.gradient(f, dp, axis=-2) / A_M
150
-
151
- def vorticity(self, u: np.ndarray, v: np.ndarray) -> np.ndarray:
152
- """Relative vorticity on the sphere: (1 / (a cos)) [dv/dlambda - d(u cos)/dphi]."""
153
- dl = np.radians(self.lon[1] - self.lon[0])
154
- dp = np.radians(self.lat[1] - self.lat[0])
155
- c = np.maximum(self.coslat, 1e-3)
156
- return (np.gradient(v, dl, axis=-1) - np.gradient(u * c, dp, axis=-2)) / (A_M * c)
157
-
158
- def divergence(self, u: np.ndarray, v: np.ndarray) -> np.ndarray:
159
- """Horizontal divergence on the sphere: (1 / (a cos)) [du/dlambda + d(v cos)/dphi]."""
160
- dl = np.radians(self.lon[1] - self.lon[0])
161
- dp = np.radians(self.lat[1] - self.lat[0])
162
- c = np.maximum(self.coslat, 1e-3)
163
- return (np.gradient(u, dl, axis=-1) + np.gradient(v * c, dp, axis=-2)) / (A_M * c)
164
-
165
- def laplacian(self, f: np.ndarray) -> np.ndarray:
166
- """Spherical Laplacian: div(grad f)."""
167
- return self.divergence(self.ddx(f), self.ddy(f))
168
-
169
- def coriolis(self) -> np.ndarray:
170
- """f on the box, ``(H, 1)``."""
171
- return coriolis(self.lat)[:, None]
172
-
173
- def absolute_vorticity(self, u: np.ndarray, v: np.ndarray) -> np.ndarray:
174
- return self.vorticity(u, v) + self.coriolis()
175
-
176
- def advect(self, u: np.ndarray, v: np.ndarray, f: np.ndarray) -> np.ndarray:
177
- """-(u . grad) f."""
178
- return -(u * self.ddx(f) + v * self.ddy(f))
179
-
180
- # ------------------------------------------------------------------------------ frames --
181
-
182
- def local_coordinates(self, lat0: float, lon0: float) -> tuple[np.ndarray, np.ndarray]:
183
- """``(x, y)`` km of every grid point in the azimuthal-equidistant frame centred on (lat0, lon0)."""
184
- lat_g, lon_g = np.meshgrid(self.lat, self.lon, indexing="ij")
185
- return to_local(lat0, lon0, lat_g, lon_g)
186
-
187
- def distance_km(self, lat0: float, lon0: float) -> np.ndarray:
188
- x, y = self.local_coordinates(lat0, lon0)
189
- return np.hypot(x, y)
190
-
191
- def cell_area_m2(self) -> np.ndarray:
192
- """Area of each grid cell, ``(H, W)``."""
193
- dl = np.radians(self.lon[1] - self.lon[0])
194
- dp = np.radians(self.lat[1] - self.lat[0])
195
- return np.broadcast_to(A_M ** 2 * dl * dp * self.coslat, self.shape).copy()
196
-
197
-
198
- def synthetic_environment(lat0: float = 20.0, lon0: float = -60.0, half_lat: float = 30.0, half_lon: float = 45.0,
199
- step: float = 1.5, levels=(1000, 925, 850, 700, 600, 500, 400, 300, 250, 200, 150, 100, 50),
200
- u=None, v=None) -> Environment:
201
- """
202
- A constructed environment for tests and scenarios: uniform or functional winds ``u(lat, lon, p)`` and
203
- ``v(lat, lon, p)`` (callables or constants), a standard-atmosphere column and a warm ocean.
204
- """
205
- lat = np.arange(lat0 - half_lat, lat0 + half_lat + 1e-9, step)
206
- lon = np.arange(lon0 - half_lon, lon0 + half_lon + 1e-9, step)
207
- lat_g, lon_g = np.meshgrid(lat, lon, indexing="ij")
208
- levels = tuple(int(p) for p in levels)
209
- shape = (len(levels), lat.size, lon.size)
210
-
211
- def build(spec):
212
- if spec is None:
213
- return np.zeros(shape)
214
- if callable(spec):
215
- return np.stack([np.broadcast_to(spec(lat_g, lon_g, p), lat_g.shape) for p in levels]).astype(np.float64)
216
- return np.full(shape, float(spec))
217
-
218
- p = np.asarray(levels, np.float64)
219
- t_col = np.clip(288.15 * (p / 1013.25) ** 0.190263, 210.0, None)
220
- z_col = 9.80665 * 44330.8 * (1 - (p / 1013.25) ** 0.190263)
221
- q_col = 0.018 * (p / 1000.0) ** 3
222
- fields = {"u": build(u), "v": build(v),
223
- "z": np.broadcast_to(z_col[:, None, None], shape).copy(),
224
- "t": np.broadcast_to(t_col[:, None, None], shape).copy(),
225
- "q": np.broadcast_to(q_col[:, None, None], shape).copy(),
226
- "msl": np.full(lat_g.shape, 101300.0), "sst": np.full(lat_g.shape, 302.0),
227
- "tcwv": np.full(lat_g.shape, 50.0), "lsm": np.zeros(lat_g.shape), "zs": np.zeros(lat_g.shape)}
228
- return Environment(lat=lat, lon=lon, levels=levels, fields=fields)
229
-
230
-
231
- __all__ = ["Environment", "LEVEL_FIELDS", "SURFACE_FIELDS", "synthetic_environment"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/fit.py DELETED
@@ -1,85 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Fitting the hard-coded constants once, and writing them into the package as code.
4
-
5
- The idea: "no training" should mean the forecast system needs nothing but an analysis and a best track when it
6
- runs -- not that it was never calibrated. Every number the system relies on is fitted here, once, on data
7
- strictly older than (or disjoint from) the seasons it is judged on, and then *written into*
8
- :mod:`naturev1.cyclone.coefficients` as Python literals with their provenance:
9
-
10
- * CLIPER and SHIFOR on 1980-2014 best tracks (35 years, ~80,000 fixes, all basins);
11
- * the consensus weights, SHIPS-style regression and LGEM growth rates on the 2016/2017/2019 hindcast seasons;
12
- * the genesis logistic model on the same three seasons;
13
- * none of it on 2018/2020/2021/2022, the seasons every reported result comes from.
14
-
15
- The written module is plain data: reading it needs no fitting code, no data and no randomness.
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- import datetime as dt
21
- import json
22
- import pprint
23
- from pathlib import Path
24
-
25
- import numpy as np
26
-
27
- HEADER = '''# Copyright 2026 Nathan. Apache-2.0.
28
- """
29
- Hard-coded constants of the tropical-cyclone physics, generated by :mod:`naturev1.cyclone.fit`.
30
-
31
- Do not edit by hand: regenerate with ``naturev1.cyclone.fit.write_coefficients``. Provenance of every table is in
32
- :data:`PROVENANCE`; nothing here was fitted on the 2018, 2020, 2021 or 2022 seasons the benchmark reports.
33
- """
34
-
35
- '''
36
-
37
-
38
- def _plain(value):
39
- """Literal-safe, rounded copy (numpy to lists; NaN stays NaN, written as ``nan``)."""
40
- if isinstance(value, dict):
41
- return {str(k): _plain(v) for k, v in value.items()}
42
- if isinstance(value, (list, tuple, np.ndarray)):
43
- return [_plain(v) for v in (value.tolist() if isinstance(value, np.ndarray) else value)]
44
- if isinstance(value, (float, np.floating)):
45
- return float("nan") if not np.isfinite(value) else round(float(value), 5)
46
- if isinstance(value, (np.integer,)):
47
- return int(value)
48
- return value
49
-
50
-
51
- def write_coefficients(tables: dict, path: str | Path, provenance: dict) -> Path:
52
- """Write ``{NAME: table}`` as module-level constants (plus ``PROVENANCE``) into ``path``."""
53
- path = Path(path)
54
- lines = [HEADER, "nan = float(\"nan\")\n\n"]
55
- for name, table in tables.items():
56
- literal = pprint.pformat(_plain(table), width=118, compact=True, sort_dicts=False)
57
- lines.append(f"{name} = {literal}\n\n")
58
- stamp = {"generated": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), **provenance}
59
- lines.append(f"PROVENANCE = {pprint.pformat(_plain(stamp), width=118, sort_dicts=False)}\n")
60
- path.write_text("".join(lines))
61
- return path
62
-
63
-
64
- def fit_climatology_tables(history_cases, leads) -> dict:
65
- """CLIPER and SHIFOR from historical cases (best tracks only)."""
66
- from .cliper import fit_cliper
67
- from .intensity import fit_shifor
68
-
69
- return {"CLIPER": fit_cliper(history_cases, leads), "SHIFOR": fit_shifor(history_cases, leads)}
70
-
71
-
72
- def fit_consensus_table(cases, forecasts: dict, members, reference: str = "cliper") -> dict:
73
- """Along/cross-track consensus weights over ``members`` (``forecasts[name]``: ``(N, L, 2)``)."""
74
- from .consensus import fit_consensus
75
-
76
- stack = np.stack([forecasts[m] for m in members], 2)
77
- w = fit_consensus(cases, stack, reference=list(members).index(reference))
78
- return {"members": list(members), "reference": w["reference"], "along": w["along"], "cross": w["cross"]}
79
-
80
-
81
- def load_json(path: str | Path) -> dict:
82
- return json.loads(Path(path).read_text())
83
-
84
-
85
- __all__ = ["fit_climatology_tables", "fit_consensus_table", "load_json", "write_coefficients"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/forecast.py DELETED
@@ -1,284 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- One call, the whole physics forecast: track consensus, its members and cone, the regime, the Dorian-type stall
4
- warning, intensity and the environment that drives it -- from an analysis and the best track so far, with no
5
- neural network and nothing fitted at run time.
6
-
7
- :func:`physics_forecast` is what the rest of NatureV1 uses: the consensus track is a far stronger prior for the
8
- learned track head than a frozen steering flow, and every diagnostic here is something a forecaster would want
9
- next to the model's own answer.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import numpy as np
15
-
16
- from .data import LEADS
17
-
18
-
19
- def physics_forecast(case, env, climatology=None, analogs=None, leads=LEADS, intensity: bool = True,
20
- swarm: int = 0, swarm_fixes: list | None = None, swarm_device="cpu", forecast_flow=None,
21
- model_track=None, model_extras=None, previous=None, aids=None) -> dict:
22
- """
23
- Args:
24
- case: a :class:`naturev1.cyclone.data.Case` (position, intensity and the last 48 hours of best track; no
25
- ``truth`` needed).
26
- env: the analysis around the storm (:class:`naturev1.cyclone.fields.Environment`, with the 12-hour-earlier
27
- winds in ``previous`` if available).
28
- climatology: a :class:`naturev1.cyclone.climatology.Climatology` (members that need it are skipped
29
- without it, and the consensus renormalises over the rest).
30
- analogs: an :class:`naturev1.cyclone.analogs.AnalogDatabase`.
31
- previous: the previous cycle's result, ``{"time": its analysis time, "track": its (L, 2) track}``: its error
32
- for the time now analysed feeds back into the new track (:func:`naturev1.cyclone.consensus.apply_track_feedback`).
33
- aids: the NHC's operational aids for the cycle, ``{"track": {tech: (L, 2)}, "intensity": {tech: (L,)}}``
34
- (:func:`naturev1.swarm.adeck.aids_at`): blended into the track and the intensity with the tables fitted on
35
- the tuning seasons (:mod:`naturev1.cyclone.aids`); our own stay in ``track_ours`` / ``intensity_ours``,
36
- what was used in ``aids_track`` / ``aids_intensity``. The peak products keep our own consensus as their
37
- input (they were fitted on it); the swarm, the categories and the headline follow the blend. With
38
- ``aids["previous_errors"]`` (:func:`naturev1.cyclone.aids.previous_errors`, the previous cycle's verified
39
- errors of ours and of HCCA) the split between the two is earned rather than fixed.
40
- swarm: if positive, also run the particle-filter sandbox (:mod:`naturev1.swarm`) with this many
41
- worlds, re-centred on the consensus track: scenario probabilities, a strike-probability map and a
42
- flow-dependent cone under ``result["swarm"]``.
43
- swarm_fixes: centre fixes (:mod:`naturev1.swarm.fixes`) to filter the swarm with instead of the 6-hourly
44
- history -- the satellite path, minute by minute.
45
- forecast_flow, model_track: dynamical models' forecast steering
46
- (:class:`naturev1.cyclone.forecastflow.ForecastFlow`, one or ``{name: flow}``) and their own tracks of the
47
- storm at ``leads`` (one ``(L, 2)`` or ``{name: track}``): they add the ``fflow`` and ``model`` members
48
- (equal-weight means over the models; the consensus fitted with them is blended with the models' mean
49
- track) and steer the swarm's worlds, each world following one model.
50
- model_extras: the models' ``extras`` from :func:`naturev1.swarm.models.fetch_models` (``{name: extras}``):
51
- their central-pressure deepening feeds the rapid-intensification probability and the model-trend
52
- intensity member.
53
-
54
- Returns:
55
- ``{"leads", "track" (L, 2), "members": {name: (L, 2)}, "cone_km" (L,), "regime", "stall", "ridge",
56
- "shear", "intensity_kt": {member: (L,)}, "intensity" (L,), "ri_probability" (P(+30 kt in 24 h)),
57
- "intensity_regime" ("rapid" when the rapid-intensification weights were used), "intensity_if_ri" (the path if
58
- the storm intensifies rapidly, to read with "ri_probability"), "peak_probability" (P(peak within the window >=
59
- 64/96/113/137 kt)), "peak_quantiles" (the 50/75/90/95% quantiles of the peak), "peak_if_ri_kt" (E[peak | RI]),
60
- "category_probability"
61
- (P(>= 64 / 96 / 113 / 137 kt) per lead), "cone_fixed_km", "model_spread_km" and "model_deepening_hpa" (with
62
- models: the climatological cone behind the flow-dependent one, the models' disagreement, their deepening)}``.
63
- """
64
- from .consensus import apply_consensus
65
- from .members import run_members
66
- from .regimes import classify
67
- from .ridge import ridge_diagnostics
68
- from .shear import shear_diagnostics
69
- from .stall import stall_diagnostics
70
-
71
- out = run_members(case, env, climatology, analogs, leads=leads, forecast_flow=forecast_flow,
72
- model_track=model_track)
73
- ctx = out.pop("_context", None)
74
- ensemble = out.pop("_ensemble", None)
75
- table = consensus_table(out)
76
- nan = np.full((len(leads), 2), np.nan)
77
- stack = np.stack([out.get(m, nan) for m in table["members"]], 1)[None]
78
- physics_track = apply_consensus([case], stack, table)[0]
79
- track = physics_track
80
- result_models = 0
81
- if table.get("model_blend") is not None and out.get("model") is not None:
82
- from .consensus import blend_with_model
83
-
84
- n_models = (sum(1 for t in model_track.values() if t is not None and np.isfinite(np.asarray(t)[:, 0]).any())
85
- if isinstance(model_track, dict) else 1)
86
- track = blend_with_model(case, physics_track, np.asarray(out["model"], np.float64), table["model_blend"], leads, n_models)
87
- result_models = n_models
88
- if previous is not None and isinstance(previous, dict) and previous.get("track") is not None:
89
- from .consensus import apply_track_feedback
90
-
91
- track = apply_track_feedback(case, track, previous["track"], previous.get("time"), leads,
92
- previous_position=(previous["lat"], previous["lon"]) if "lat" in previous and "lon" in previous else None)
93
- result = {"leads": list(leads), "track": track, "members": out, "consensus_table": table["name"],
94
- "n_models": result_models}
95
- from . import coefficients
96
-
97
- # each consensus table has its own cone: the forecast-flow consensus errs less, so its cone is narrower
98
- cone_name = {"CONSENSUS_FF": "CONE_KM_FF", "CONSENSUS_BASIC_FF": "CONE_KM_BASIC_FF"}.get(table["name"], "CONE_KM")
99
- cone = getattr(coefficients, cone_name, None) or getattr(coefficients, "CONE_KM", None)
100
- if cone is not None:
101
- # The 67th percentile of the consensus's out-of-sample errors on past seasons -- NHC's cone construction --
102
- # widened where the physics ensemble disagrees more than usual for that lead.
103
- climatological = np.interp(np.asarray(leads, np.float64), cone["leads"], cone["radius_km"])
104
- radius = climatological
105
- if ensemble is not None:
106
- radius = np.maximum(radius, 1.2 * np.nan_to_num(ensemble["spread_km"], nan=0.0))
107
- result["cone_km"] = radius
108
- flow_table = getattr(coefficients, "CONE_FLOW", None)
109
- if flow_table is not None and out.get("model") is not None:
110
- # the cone sized for this forecast: member spread, model disagreement, speed, latitude, intensity
111
- # (fitted and checked without the ensemble-spread floor, so it stands on its own)
112
- from .multimodel import cone_predictors, flow_dependent_cone
113
-
114
- result["cone_fixed_km"] = climatological
115
- result["cone_predictors"] = cone_predictors(case, physics_track, out, table["members"], out.get("model"),
116
- out.get("fflow"), leads)
117
- result["cone_km"] = flow_dependent_cone(result["cone_predictors"], flow_table, leads)
118
- if isinstance(model_track, dict):
119
- from .multimodel import model_spread_km
120
-
121
- result["model_spread_km"] = model_spread_km(case, model_track)
122
- if aids and aids.get("track") and getattr(coefficients, "ADECK_TRACK", None):
123
- # the NHC guidance blended in after the cone is known: the split between ours and HCCA is earned from the
124
- # previous cycle and moved away from the member that stands alone beyond the cone (cyclone.aids)
125
- from .aids import blend_track
126
-
127
- blended_track, used_aids = blend_track(case, track, aids["track"], leads, coefficients.ADECK_TRACK,
128
- previous_errors=aids.get("previous_errors"), cone_km=result.get("cone_km"))
129
- if used_aids:
130
- result["track_ours"], result["aids_track"] = track, used_aids
131
- track = blended_track
132
- result["track"] = track
133
- if ctx is not None:
134
- stall = stall_diagnostics(ctx.flows["med"], np.where(np.isfinite(track), track, case.lat), leads, case.lat, case.lon)
135
- ridge = ridge_diagnostics(ctx.clean, case.lat, case.lon)
136
- result.update({"stall": {k: v for k, v in stall.items() if k != "cols"}, "cols": stall["cols"], "ridge": ridge,
137
- "regime": classify(case, stall, ridge), "shear": shear_diagnostics(ctx.clean, case.lat, case.lon, ctx.motion),
138
- "outer_strength_ms": ctx.outer_ms})
139
- if intensity:
140
- result.update(_intensity(case, env, ctx, track, leads, model_extras, aids=aids))
141
- if swarm and ctx is not None:
142
- from ..swarm.core import swarm_products
143
-
144
- # the swarm sized to this forecast's own cone where the flow-dependent cone could be made
145
- result["swarm"] = swarm_products(case, ctx, track, leads, particles=int(swarm), fixes=swarm_fixes,
146
- intensity_kt=result.get("intensity"), device=swarm_device,
147
- forecast_flow=forecast_flow,
148
- target_cone_km=result.get("cone_km") if "cone_predictors" in result else None)
149
- return result
150
-
151
-
152
- def consensus_table(members: dict) -> dict:
153
- """
154
- The shipped consensus whose members are all present: the full table when a climatology and an analog
155
- database were given, else the basic one fitted for exactly the members that need neither -- renormalising
156
- the full table over half its members would lean on combinations it was never fitted to trust. With a dynamical
157
- model's forecast flow and track (the ``fflow`` and ``model`` members), the tables fitted with them come first.
158
- """
159
- from . import coefficients
160
-
161
- best, best_share = None, -1.0
162
- for name in ("CONSENSUS_FF", "CONSENSUS_BASIC_FF", "CONSENSUS", "CONSENSUS_BASIC"):
163
- table = getattr(coefficients, name, None)
164
- if table is None:
165
- continue
166
- have = [np.isfinite(np.asarray(members.get(m, [[np.nan]]))[0]).all() for m in table["members"]]
167
- if all(have):
168
- return {**table, "name": name}
169
- # none complete (say, no analysis 12 hours earlier): the table missing the smallest share of its members,
170
- # renormalised over the rest, in the same order of preference
171
- if np.mean(have) > best_share:
172
- best, best_share = name, float(np.mean(have))
173
- name = best or "CONSENSUS_BASIC"
174
- return {**getattr(coefficients, name), "name": name}
175
-
176
-
177
- def _with_aids(out: dict, aids, leads) -> dict:
178
- """The aid blend on a fallback intensity (SHIFOR only), when the table and the aids are there."""
179
- from . import coefficients
180
-
181
- if aids and aids.get("intensity") and getattr(coefficients, "ADECK_INTENSITY", None):
182
- from .aids import blend_intensity
183
-
184
- blended, used = blend_intensity(out["intensity"], aids["intensity"], leads, coefficients.ADECK_INTENSITY)
185
- if used:
186
- out.update({"intensity_ours": out["intensity"], "intensity": blended, "aids_intensity": used})
187
- return out
188
-
189
-
190
- def _intensity(case, env, ctx, track, leads, model_extras=None, aids=None) -> dict:
191
- from . import coefficients
192
- from .intensity import (category_probabilities, decay_along, environment_along_track, lgem_intensity,
193
- model_deficit_change, model_trend_intensity, peak_if_ri, peak_probability, peak_quantiles, ri_probability,
194
- ri_scenario_intensity,
195
- shifor_intensity, trend_intensity)
196
-
197
- members = {"persistence": np.full(len(leads), case.wind_kt)}
198
- shifor = shifor_intensity(case, leads)
199
- members["shifor"] = shifor
200
- if ctx is None or not np.isfinite(track[0, 0]):
201
- return _with_aids({"intensity_kt": members, "intensity": shifor}, aids, leads)
202
- filled = track.copy()
203
- good = np.isfinite(filled[:, 0])
204
- filled[~good] = filled[np.nonzero(good)[0][-1]]
205
- ocean = None
206
- if getattr(coefficients, "OCEAN", None): # the tables were fitted with the ocean climatology
207
- from .ocean import OceanClimatology
208
-
209
- ocean = OceanClimatology.default()
210
- try:
211
- along = environment_along_track(env, ctx.clean, case, filled, leads, np.nan_to_num(shifor, nan=case.wind_kt), ocean=ocean)
212
- except Exception as error: # missing fields in a live analysis: degrade, don't fail
213
- return _with_aids({"intensity_kt": members, "intensity": shifor, "intensity_note": f"environment along the track unavailable ({type(error).__name__}: {error}); SHIFOR only"}, aids, leads)
214
- members["decay_shifor"] = decay_along(case, along, leads, np.nan_to_num(shifor, nan=case.wind_kt))
215
- members["lgem"] = lgem_intensity(case, along, leads)
216
- members["trend"] = trend_intensity(case, along, leads)
217
- model_dp = model_deficit_change(model_extras, leads)
218
- extra = {}
219
- if model_dp is not None and np.isfinite(model_dp).any():
220
- # the dynamical models' own deepening, as an intensity member and (below) as a rapid-intensification predictor
221
- members["mdl"] = model_trend_intensity(case, along, leads, model_dp)
222
- extra["model_deepening_hpa"] = model_dp
223
- try:
224
- from .coefficients import INTENSITY_CONSENSUS
225
- from .intensity import ships_intensity
226
-
227
- members["ships"] = decay_along(case, along, leads, ships_intensity(case, along, leads))
228
- table = INTENSITY_CONSENSUS
229
- regime = table.get("ri_regime")
230
- if regime is not None:
231
- # A storm primed for rapid intensification gets the weights fitted on such storms: they lean on LGEM and
232
- # the trend, and correct the consensus's habit of pulling an intensifying storm back toward the mean.
233
- p = ri_probability(case, along, model_dp=model_dp, leads=leads)
234
- extra.update({"ri_probability": p, "intensity_regime": "rapid" if p >= regime["cut"] else "normal"})
235
- if p >= regime["cut"]:
236
- table = {**table, "weights": regime["weights"], "bias": regime["bias"]}
237
- weights = np.asarray(table["weights"], np.float64) # (L, members)
238
- nan = np.full(len(leads), np.nan)
239
- stack = np.stack([members.get(n, nan) for n in table["members"]], -1) # a member without its model: dropped
240
- blended = (np.nan_to_num(stack) * weights).sum(-1) / np.maximum((np.isfinite(stack) * weights).sum(-1), 1e-9)
241
- blended = blended + np.asarray(table.get("bias", np.zeros(len(leads))), np.float64)
242
- # a basin's own residual bias (wind conventions and storm sizes differ between basins), when the table has it
243
- blended = blended + np.asarray(INTENSITY_CONSENSUS.get("basin_bias", {}).get(case.basin, np.zeros(len(leads))), np.float64)
244
- except ImportError:
245
- blended = np.nanmean(np.stack([members["decay_shifor"], members["lgem"]]), 0)
246
- intensity = np.clip(blended, 15.0, 200.0)
247
- own = intensity
248
- if aids and aids.get("intensity") and getattr(coefficients, "ADECK_INTENSITY", None):
249
- # the operational aids beside our consensus, with the weights fitted on the tuning seasons; the peak products
250
- # below keep our own consensus as their input, which is what they were fitted on
251
- from .aids import blend_intensity
252
-
253
- intensity, used = blend_intensity(intensity, aids["intensity"], leads, coefficients.ADECK_INTENSITY)
254
- if used:
255
- extra["aids_intensity"] = used
256
- extra["intensity_ours"] = own
257
- # the branch beside the expected value: the storm's path if it intensifies rapidly, with its probability
258
- if_ri = ri_scenario_intensity(case, along, leads)
259
- if np.isfinite(if_ri).any():
260
- extra["intensity_if_ri"] = np.clip(if_ri, 15.0, 200.0)
261
- categories = category_probabilities(intensity, extra.get("intensity_regime", "normal"), ri_probability=extra.get("ri_probability"),
262
- ri_intensity=extra.get("intensity_if_ri"))
263
- try:
264
- peak = peak_probability(case, along, own, extra.get("ri_probability"), model_dp, leads=leads)
265
- except Exception as error:
266
- peak = {}
267
- extra["intensity_note"] = f"peak probability unavailable ({type(error).__name__}: {error})"
268
- if peak:
269
- extra["peak_probability"] = peak
270
- try:
271
- pq = peak_quantiles(case, along, own, extra.get("ri_probability"), model_dp, extra.get("intensity_if_ri"), leads=leads)
272
- if pq:
273
- extra["peak_quantiles"] = pq
274
- e_ri = peak_if_ri(case, along, own, extra.get("ri_probability"), model_dp, extra.get("intensity_if_ri"), leads=leads)
275
- if np.isfinite(e_ri):
276
- extra["peak_if_ri_kt"] = e_ri
277
- except Exception as error:
278
- extra["intensity_note"] = f"peak quantiles unavailable ({type(error).__name__}: {error})"
279
- if categories:
280
- extra["category_probability"] = categories
281
- return {"intensity_kt": members, "intensity": intensity, "environment": along, **extra}
282
-
283
-
284
- __all__ = ["consensus_table", "physics_forecast"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/forecastflow.py DELETED
@@ -1,88 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Steering that evolves the way a dynamical model forecasts it -- for the turns.
4
-
5
- A storm turns when its steering changes: a trough digs in, a ridge erodes, a weakness opens. The analysis plus its
6
- 12-hour tendency (:class:`naturev1.cyclone.trajectory.EvolvingFlow`) cannot know about a trough that is still
7
- 2,000 km away and two days off. A dynamical model's forecast can. This is the idea of NHC's Beta and Advection Model:
8
- carry the storm through the model's *forecast* layer-mean winds, plus beta drift.
9
-
10
- :class:`ForecastFlow` holds those winds at forecast hours (every 12 h to 120 h) for three layers -- 850-700,
11
- 850-400 and 850-200 hPa -- with the model's own storm removed at each hour (Biot-Savart removal around where the
12
- model has it, :func:`naturev1.cyclone.vortex.remove_vortex`), so a trajectory is steered by the environment, not by
13
- the model's copy of the hurricane. Between forecast hours it interpolates linearly.
14
-
15
- :func:`fflow_member` is the trajectory member; :mod:`naturev1.swarm` steers its worlds with the same flow. In
16
- hindcasts the flow comes from the HRES forecasts of WeatherBench 2; live, from GFS
17
- (:meth:`naturev1.swarm.analysis.GFSAnalysis.forecast_flow`). No neural network is involved: the model is the ECMWF or
18
- NOAA physics model.
19
- """
20
-
21
- from __future__ import annotations
22
-
23
- import numpy as np
24
-
25
- #: Layers stored, in channel order (u, v each): shallow, medium, deep.
26
- LAYERS = (("shal", 700, 850), ("med", 400, 850), ("dlm", 200, 850))
27
-
28
-
29
- class ForecastFlow:
30
- """Layer-mean steering at forecast hours: ``data`` ``(T, 6, H, W)`` (shal u, v, med u, v, dlm u, v) on the
31
- ``lat`` x ``lon`` box, at ``hours`` ``(T,)``."""
32
-
33
- def __init__(self, lat: np.ndarray, lon: np.ndarray, hours, data: np.ndarray, source: str = "") -> None:
34
- self.lat = np.asarray(lat, np.float64)
35
- self.lon = np.asarray(lon, np.float64)
36
- self.hours = np.asarray(hours, np.float64)
37
- self.data = np.asarray(data, np.float32)
38
- self.source = source
39
-
40
- def _bracket(self, hours: float):
41
- h = float(np.clip(hours, self.hours[0], self.hours[-1]))
42
- k = int(np.clip(np.searchsorted(self.hours, h, side="right") - 1, 0, self.hours.size - 2))
43
- a = (h - self.hours[k]) / max(self.hours[k + 1] - self.hours[k], 1e-9)
44
- return k, a
45
-
46
- def field(self, hours: float, layer: str = "med") -> tuple[np.ndarray, np.ndarray]:
47
- """``(u, v)`` grids of ``layer`` at ``hours``."""
48
- j = [name for name, _, _ in LAYERS].index(layer)
49
- k, a = self._bracket(hours)
50
- u = (1 - a) * self.data[k, 2 * j] + a * self.data[k + 1, 2 * j]
51
- v = (1 - a) * self.data[k, 2 * j + 1] + a * self.data[k + 1, 2 * j + 1]
52
- return u.astype(np.float64), v.astype(np.float64)
53
-
54
- def velocity(self, env, lat, lon, hours: float, depth: float = 0.5):
55
- """Steering at points for a steering depth (0 shallow .. 0.5 medium .. 1 deep), interpolated in time."""
56
- w_sh, w_dp = max(1.0 - 2.0 * depth, 0.0), max(2.0 * depth - 1.0, 0.0)
57
- w_md = 1.0 - w_sh - w_dp
58
- u = np.zeros(np.shape(lat))
59
- v = np.zeros(np.shape(lat))
60
- for name, w in (("shal", w_sh), ("med", w_md), ("dlm", w_dp)):
61
- if w <= 0:
62
- continue
63
- gu, gv = self.field(hours, name)
64
- u = u + w * env.sample(gu, lat, lon)
65
- v = v + w * env.sample(gv, lat, lon)
66
- return u, v
67
-
68
-
69
- def fflow_member(ctx, flow: ForecastFlow, leads, tau_h: float = 24.0, depth: float | None = 0.5) -> np.ndarray:
70
- """
71
- The storm carried through the forecast flow, plus beta drift, plus the part of its present motion the flow does
72
- not explain, fading with ``tau_h`` (``(L, 2)`` lat, lon). ``depth`` 0.5 is the 850-400 hPa layer, which beat the
73
- intensity-dependent depth (``None``) and the shallow and deep layers on the tuning seasons.
74
- """
75
- from ..swarm.core import depth_for_wind
76
- from .trajectory import DecayingResidual, combined, integrate
77
-
78
- case = ctx.case
79
- depth = depth_for_wind(case.wind_kt) if depth is None else float(depth)
80
- env = ctx.clean
81
- steer = lambda lat, lon, hours: flow.velocity(env, lat, lon, hours, depth)
82
- u0, v0 = steer(np.array([case.lat]), np.array([case.lon]), 0.0)
83
- b0 = ctx.beta0()
84
- residual = DecayingResidual(ctx.motion[0] - float(u0[0]) - b0[0], ctx.motion[1] - float(v0[0]) - b0[1], tau_h)
85
- return integrate(case.lat, case.lon, combined(steer, ctx.beta_velocity(), residual), leads)
86
-
87
-
88
- __all__ = ["ForecastFlow", "LAYERS", "fflow_member"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/genesis.py DELETED
@@ -1,347 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Seeing storms before they are storms: genesis detection from the analysis, verified against what formed.
4
-
5
- The idea: a tropical cyclone forms from a disturbance that has been visible for days -- a tropical wave, a
6
- monsoon-trough vortex -- and the question a forecaster answers (NHC's two- and seven-day outlooks) is which
7
- disturbances will become cyclones and when. The ingredients are known physics (Gray 1968; Emanuel and Nolan
8
- 2004; Dunkerton et al. 2009): low-level spin that is rotation-dominated rather than shear (Okubo-Weiss),
9
- a moist mid-troposphere, weak vertical shear, warm water, a closed circulation in the frame moving with the
10
- wave (:mod:`naturev1.cyclone.pouch`), a mid-level vortex stacking above it, and a falling surface pressure. So:
11
-
12
- 1. **Candidates**: local maxima of smoothed 850 hPa cyclonic vorticity above a threshold over warm ocean in the
13
- tropics, at every analysis time, away from cyclones that already exist.
14
- 2. **Candidate tracks**: candidates linked in time (nearest within 600 km of where the 700 hPa flow carries
15
- them), so each disturbance has an age and a history -- intensifying spin matters more than strong spin.
16
- 3. **Features** per candidate and time: vorticity (850, 600), its 24-hour change, Okubo-Weiss, 600 hPa
17
- humidity, 850-200 shear, SST, a closed-low depth, the 300 hPa warm-core anomaly, the genesis potential
18
- index, and the wave-relative pouch's closedness.
19
- 4. **Probability** of genesis within 48 hours: a logistic model on those features, fitted on the 2016/2017/2019
20
- seasons against IBTrACS (genesis = the first tropical or subtropical fix within 300 km of the candidate in the
21
- next 48 hours) and verified on 2018/2020-2022 with the Brier score, its skill against climatology, detection
22
- rate and false alarms, and how many hours of warning the detector gives.
23
-
24
- The basin store (:func:`build_genesis_store`) keeps the northern-hemisphere tropics (0-45 N, all longitudes)
25
- at 00 and 12 UTC through each season: the 850, 600 and 200 hPa winds, 600 hPa temperature and humidity, the
26
- 300 and 850 hPa temperatures, sea-level pressure and SST.
27
- """
28
-
29
- from __future__ import annotations
30
-
31
- import json
32
- from dataclasses import dataclass, field
33
- from pathlib import Path
34
-
35
- import numpy as np
36
-
37
- from .fields import Environment
38
- from .geometry import great_circle_km, hemisphere
39
- from .pouch import phase_from_history, pouch_closedness
40
-
41
- CHANNELS = ("u850", "v850", "u600", "v600", "u200", "v200", "t600", "q600", "t300", "t850", "msl", "sst")
42
- LAT_RANGE = (0.0, 45.0)
43
- SEASON = ("06-01", "11-30")
44
- VORTICITY_MIN = 2.0e-5
45
- WINDOW_H = 48
46
- FEATURES = ("one", "vort850", "vort600", "dvort24", "okubo_weiss", "rh600", "shear", "sst", "depth", "warm_core",
47
- "gpi", "age", "lat", "pouch")
48
-
49
-
50
- def build_genesis_store(years, root: str | Path, reader=None, log=print) -> dict[int, Path]:
51
- """One memmap per season: ``(T, C, H, 240)`` float16 (standardised per channel) plus ``times.npy``."""
52
- from .data import WB2Reader
53
-
54
- out = {}
55
- for year in years:
56
- directory = Path(root) / f"genesis_{year}"
57
- out[year] = directory
58
- if (directory / "done").exists():
59
- continue
60
- directory.mkdir(parents=True, exist_ok=True)
61
- reader = reader or WB2Reader()
62
- rows = np.nonzero((reader.lat >= LAT_RANGE[0]) & (reader.lat <= LAT_RANGE[1]))[0]
63
- start, stop = np.datetime64(f"{year}-{SEASON[0]}T00", "s"), np.datetime64(f"{year}-{SEASON[1]}T12", "s")
64
- times = np.arange(start, stop + np.timedelta64(1, "s"), np.timedelta64(12, "h"))
65
- store = np.lib.format.open_memmap(directory / "fields.npy", mode="w+", dtype=np.float32,
66
- shape=(len(times), len(CHANNELS), rows.size, reader.lon.size))
67
- levels = list(reader.levels)
68
- for i, when in enumerate(times):
69
- f = reader.fields(when)
70
- planes = [f["u"][levels.index(850)], f["v"][levels.index(850)], f["u"][levels.index(600)],
71
- f["v"][levels.index(600)], f["u"][levels.index(200)], f["v"][levels.index(200)],
72
- f["t"][levels.index(600)], f["q"][levels.index(600)], f["t"][levels.index(300)],
73
- f["t"][levels.index(850)], f["msl"], f["sst"]]
74
- store[i] = np.stack([p[rows] for p in planes])
75
- if log and i % 60 == 0:
76
- log(f"[genesis-data] {year}: {i + 1}/{len(times)}")
77
- store.flush()
78
- np.save(directory / "times.npy", times.astype(np.int64))
79
- np.save(directory / "lat.npy", reader.lat[rows])
80
- np.save(directory / "lon.npy", reader.lon)
81
- (directory / "done").write_text("ok")
82
- return out
83
-
84
-
85
- class GenesisSeason:
86
- def __init__(self, directory: str | Path) -> None:
87
- directory = Path(directory)
88
- self.fields = np.load(directory / "fields.npy", mmap_mode="r")
89
- self.times = np.load(directory / "times.npy").astype("datetime64[s]")
90
- self.lat = np.load(directory / "lat.npy").astype(np.float64)
91
- self.lon = np.load(directory / "lon.npy").astype(np.float64)
92
- self.slot = {c: k for k, c in enumerate(CHANNELS)}
93
-
94
- def environment(self, i: int) -> Environment:
95
- block = np.asarray(self.fields[i], np.float64)
96
- f = {c: block[self.slot[c]] for c in CHANNELS}
97
- # Wrap one column so derivatives are periodic-ish at the seam; the Environment is the whole band.
98
- return Environment(lat=self.lat, lon=self.lon, levels=(850,), fields=f, time=self.times[i])
99
-
100
-
101
- @dataclass
102
- class Candidate:
103
- time: np.datetime64
104
- lat: float
105
- lon: float
106
- features: dict
107
- track_id: int = -1
108
- label: float = float("nan")
109
- history: list = field(default_factory=list)
110
-
111
-
112
- def _smooth(field: np.ndarray, passes: int = 2) -> np.ndarray:
113
- out = field.copy()
114
- for _ in range(passes):
115
- padded = np.pad(out, ((1, 1), (0, 0)), mode="edge")
116
- padded = np.concatenate([padded[:, -1:], padded, padded[:, :1]], 1)
117
- out = (4 * padded[1:-1, 1:-1] + padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:]) / 8.0
118
- return out
119
-
120
-
121
- def _relative_humidity(t, q, p):
122
- es = 6.112 * np.exp(17.67 * (t - 273.15) / (t - 29.65))
123
- return np.clip(q * p / (0.622 + 0.378 * q) / es, 0.0, 1.2)
124
-
125
-
126
- def diagnostics(env: Environment) -> dict:
127
- """Gridded genesis ingredients for one analysis of the band."""
128
- f = env.fields
129
- zeta850 = env.vorticity(f["u850"], f["v850"])
130
- zeta600 = env.vorticity(f["u600"], f["v600"])
131
- sign = hemisphere(env.lat)[:, None]
132
- dudx, dudy = env.ddx(f["u850"]), env.ddy(f["u850"])
133
- dvdx, dvdy = env.ddx(f["v850"]), env.ddy(f["v850"])
134
- strain2 = (dudx - dvdy) ** 2 + (dvdx + dudy) ** 2
135
- ow = zeta850 ** 2 - strain2
136
- rh = _relative_humidity(f["t600"], f["q600"], 600.0)
137
- shear = np.hypot(f["u200"] - f["u850"], f["v200"] - f["v850"])
138
- sst = np.asarray(f["sst"], np.float64) # NaN over land
139
- eta = np.abs(zeta850 + env.coriolis())
140
- potential = np.clip(np.nan_to_num(sst, nan=273.15) - 273.15 - 26.0, 0.0, None) * 10.0 # stand-in for PI, m/s
141
- gpi = np.abs(1e5 * eta) ** 1.5 * (100 * rh / 50.0) ** 3 * (potential / 70.0) ** 3 * (1 + 0.1 * shear) ** -2
142
- t300_anom = f["t300"] - _smooth(f["t300"], 20)
143
- return {"vort850": sign * _smooth(zeta850), "vort600": sign * _smooth(zeta600), "ow": _smooth(ow), "rh600": rh,
144
- "shear": shear, "sst": sst, "gpi": gpi, "warm": t300_anom, "msl": f["msl"]}
145
-
146
-
147
- def find_candidates(env: Environment, diag: dict, existing: list[tuple[float, float]], exclude_km: float = 600.0) -> list[dict]:
148
- vort = diag["vort850"]
149
- out = []
150
- h, w = vort.shape
151
- for i in range(2, h - 2):
152
- if abs(env.lat[i]) < 3.0 or abs(env.lat[i]) > 35.0:
153
- continue
154
- for j in range(w):
155
- v = vort[i, j]
156
- if v < VORTICITY_MIN or not (diag["sst"][i, j] >= 299.15):
157
- continue
158
- block = vort[i - 1:i + 2, [(j - 1) % w, j, (j + 1) % w]]
159
- if v < block.max():
160
- continue
161
- lat, lon = float(env.lat[i]), float(((env.lon[j] + 180.0) % 360.0) - 180.0)
162
- if any(great_circle_km(lat, lon, a, b) < exclude_km for a, b in existing):
163
- continue
164
- out.append({"lat": lat, "lon": lon, "i": i, "j": j})
165
- return out
166
-
167
-
168
- def candidate_features(env: Environment, diag: dict, cand: dict) -> dict:
169
- i, j = cand["i"], cand["j"]
170
- lat, lon = cand["lat"], cand["lon"]
171
- d = great_circle_km(lat, lon, *np.meshgrid(env.lat, env.lon, indexing="ij"))
172
- disc = d < 500.0
173
- ring = (d >= 500.0) & (d < 900.0)
174
- msl = diag["msl"]
175
- depth = float(msl[ring].mean() - msl[disc].min()) / 100.0 if ring.any() and disc.any() else 0.0
176
- return {"vort850": float(diag["vort850"][i, j]) * 1e5, "vort600": float(diag["vort600"][disc].max()) * 1e5,
177
- "okubo_weiss": float(np.clip(diag["ow"][disc].max() * 1e10, -50, 50)), "rh600": float(diag["rh600"][disc].mean()),
178
- "shear": float(diag["shear"][disc].mean()) / 10.0,
179
- "sst": (float(np.nanmean(diag["sst"][disc])) - 301.15) / 2.0 if np.isfinite(diag["sst"][disc]).any() else -1.0,
180
- "depth": depth, "warm_core": float(diag["warm"][disc].max()), "gpi": float(np.log1p(diag["gpi"][disc].mean())),
181
- "lat": abs(lat) / 20.0}
182
-
183
-
184
- def genesis_events(tracks, years) -> list[tuple[np.datetime64, float, float]]:
185
- """IBTrACS genesis: each storm's first tropical/subtropical fix (time, lat, lon)."""
186
- out = []
187
- for t in tracks:
188
- tropical = np.isin(t.status, ("TD", "TS", "HU", "SS"))
189
- if not tropical.any():
190
- continue
191
- k = int(np.argmax(tropical))
192
- if int(str(t.time[k])[:4]) in set(years) and t.latitude[k] >= 0:
193
- out.append((t.time[k].astype("datetime64[s]"), float(t.latitude[k]), float(t.longitude[k])))
194
- return out
195
-
196
-
197
- def active_storms(tracks, when: np.datetime64) -> list[tuple[float, float]]:
198
- out = []
199
- for t in tracks:
200
- hit = np.nonzero(t.time.astype("datetime64[s]") == when)[0]
201
- if hit.size and t.status[hit[0]] in ("TD", "TS", "HU", "SS"):
202
- out.append((float(t.latitude[hit[0]]), float(t.longitude[hit[0]])))
203
- return out
204
-
205
-
206
- def detect_season(season: GenesisSeason, tracks, link_km: float = 600.0, log=None) -> list[Candidate]:
207
- """Candidates at every time, linked into disturbance tracks, with 24-hour trends and ages."""
208
- candidates: list[Candidate] = []
209
- previous: list[Candidate] = []
210
- next_id = 0
211
- for i in range(len(season.times)):
212
- env = season.environment(i)
213
- diag = diagnostics(env)
214
- when = season.times[i]
215
- found = find_candidates(env, diag, active_storms(tracks, when))
216
- current = []
217
- for cand in found:
218
- feats = candidate_features(env, diag, cand)
219
- c = Candidate(when, cand["lat"], cand["lon"], feats)
220
- best = None
221
- for p in previous:
222
- d = great_circle_km(p.lat, p.lon, c.lat, c.lon)
223
- if d < link_km and (best is None or d < best[0]):
224
- best = (d, p)
225
- if best is not None:
226
- c.track_id = best[1].track_id
227
- c.history = best[1].history + [best[1]]
228
- else:
229
- c.track_id, next_id = next_id, next_id + 1
230
- c.features["pouch"] = pouch_closedness(env, c.lat, c.lon, phase_from_history(c))["closedness"]
231
- current.append(c)
232
- for c in current:
233
- back = [h for h in c.history if (c.time - h.time) == np.timedelta64(24, "h")]
234
- c.features["dvort24"] = c.features["vort850"] - back[0].features["vort850"] if back else 0.0
235
- c.features["age"] = min(len(c.history), 10) / 10.0
236
- candidates.extend(current)
237
- previous = current
238
- if log and i % 60 == 0:
239
- log(f"[genesis] {str(when)[:10]}: {len(current)} candidates")
240
- return candidates
241
-
242
-
243
- def label_candidates(candidates: list[Candidate], events, window_h: int = WINDOW_H, radius_km: float = 300.0) -> None:
244
- """1 if a genesis event happens within ``window_h`` hours (and 300 km of the candidate's track) after it."""
245
- for c in candidates:
246
- c.label = 0.0
247
- for when, lat, lon in events:
248
- dt = (when - c.time) / np.timedelta64(1, "h")
249
- if 0 <= dt <= window_h and great_circle_km(c.lat, c.lon, lat, lon) < radius_km + 15.0 * dt:
250
- c.label = 1.0
251
- break
252
-
253
-
254
- def feature_matrix(candidates: list[Candidate]) -> np.ndarray:
255
- return np.array([[1.0] + [c.features.get(k, 0.0) for k in FEATURES[1:]] for c in candidates], np.float64)
256
-
257
-
258
- def _pairs() -> list[tuple[str, str]]:
259
- import itertools
260
-
261
- base = list(FEATURES[1:])
262
- return list(itertools.combinations(base, 2)) + [(f, f) for f in base]
263
-
264
-
265
- #: Second-order terms of the shipped model: every pairwise product and square of the ingredients -- humidity
266
- #: matters more when the shear is weak, spin matters more inside a closed pouch. Chosen over the linear model by
267
- #: leave-one-season-out cross-validation on the 2016/2017/2019 seasons (Brier skill 0.193 against 0.184).
268
- PAIRS = _pairs()
269
-
270
-
271
- def design_matrix(candidates: list[Candidate]) -> np.ndarray:
272
- """``[1, features, pairwise products and squares]`` per candidate -- the shipped model's inputs."""
273
- rows = []
274
- for c in candidates:
275
- f = c.features
276
- rows.append([1.0] + [f.get(k, 0.0) for k in FEATURES[1:]] + [f.get(a, 0.0) * f.get(b, 0.0) for a, b in PAIRS])
277
- return np.asarray(rows, np.float64)
278
-
279
-
280
- def candidate_probability(candidates: list[Candidate], weights=None) -> np.ndarray:
281
- """Genesis-within-48-hours probability from the shipped coefficients (:data:`naturev1.cyclone.coefficients.GENESIS`)."""
282
- if weights is None:
283
- from .coefficients import GENESIS
284
-
285
- weights = GENESIS["weights"]
286
- return genesis_probability(design_matrix(candidates), weights)
287
-
288
-
289
- def fit_logistic(x: np.ndarray, y: np.ndarray, l2: float = 1e-2, steps: int = 400) -> np.ndarray:
290
- """Newton-Raphson L2-regularised logistic regression (intercept unpenalised)."""
291
- mean, std = x[:, 1:].mean(0), x[:, 1:].std(0) + 1e-9
292
- z = np.concatenate([x[:, :1], (x[:, 1:] - mean) / std], 1)
293
- w = np.zeros(z.shape[1])
294
- penalty = np.diag([0.0] + [l2 * len(y)] * (z.shape[1] - 1))
295
- for _ in range(steps):
296
- p = 1.0 / (1.0 + np.exp(-z @ w))
297
- grad = z.T @ (p - y) + penalty @ w
298
- hess = (z * (p * (1 - p))[:, None]).T @ z + penalty
299
- step = np.linalg.solve(hess, grad)
300
- w -= step
301
- if np.abs(step).max() < 1e-8:
302
- break
303
- # Fold the standardisation back so the weights apply to raw features.
304
- raw = w.copy()
305
- raw[1:] = w[1:] / std
306
- raw[0] = w[0] - (w[1:] * mean / std).sum()
307
- return raw
308
-
309
-
310
- def genesis_probability(x: np.ndarray, weights) -> np.ndarray:
311
- return 1.0 / (1.0 + np.exp(-x @ np.asarray(weights, np.float64)))
312
-
313
-
314
- def verify_genesis(candidates: list[Candidate], probability: np.ndarray, events, threshold: float = 0.5) -> dict:
315
- """Brier score and skill, reliability-in-thirds, detection rate, false alarms and warning time."""
316
- y = np.array([c.label for c in candidates])
317
- brier = float(np.mean((probability - y) ** 2))
318
- climo = float(np.mean((y.mean() - y) ** 2))
319
- hits, lead_hours = 0, []
320
- for when, lat, lon in events:
321
- warned = [c for c, p in zip(candidates, probability) if p >= threshold
322
- and 0 <= (when - c.time) / np.timedelta64(1, "h") <= WINDOW_H
323
- and great_circle_km(c.lat, c.lon, lat, lon) < 300.0 + 15.0 * (when - c.time) / np.timedelta64(1, "h")]
324
- if warned:
325
- hits += 1
326
- lead_hours.append(max((when - c.time) / np.timedelta64(1, "h") for c in warned))
327
- alarms = probability >= threshold
328
- false_alarm = float(np.mean(y[alarms] == 0)) if alarms.any() else float("nan")
329
- bins = np.quantile(probability, [0, 1 / 3, 2 / 3, 1])
330
- reliability = []
331
- for lo, hi in zip(bins[:-1], bins[1:]):
332
- sel = (probability >= lo) & (probability <= hi)
333
- reliability.append((float(probability[sel].mean()), float(y[sel].mean()), int(sel.sum())))
334
- return {"brier": brier, "brier_skill": 1.0 - brier / max(climo, 1e-9), "events": len(events), "detected": hits,
335
- "detection_rate": hits / max(len(events), 1), "false_alarm_ratio": false_alarm,
336
- "median_warning_h": float(np.median(lead_hours)) if lead_hours else float("nan"), "reliability": reliability,
337
- "candidates": len(candidates), "base_rate": float(y.mean())}
338
-
339
-
340
- def save_weights(weights, path: str | Path) -> None:
341
- Path(path).write_text(json.dumps({"features": list(FEATURES), "weights": [float(w) for w in weights]}))
342
-
343
-
344
- __all__ = ["CHANNELS", "Candidate", "FEATURES", "GenesisSeason", "PAIRS", "build_genesis_store", "candidate_features",
345
- "candidate_probability", "design_matrix", "detect_season", "diagnostics", "feature_matrix", "find_candidates",
346
- "fit_logistic", "genesis_events",
347
- "genesis_probability", "label_candidates", "save_weights", "verify_genesis"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/geometry.py DELETED
@@ -1,154 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Spherical geometry for storm tracks: every move, distance and frame on the sphere, never on a lat-lon sheet.
4
-
5
- The idea: a track model that steps a storm east by ``u * dt / (R cos(lat))`` degrees is right in the
6
- deep tropics and quietly wrong everywhere a storm actually recurves -- the error grows with latitude and
7
- blows up near the poles, and a longitude step taken in degrees across the dateline is a 360-degree jump.
8
- Here every displacement is a great-circle move from a bearing and a distance, every local calculation
9
- happens in an azimuthal-equidistant frame centred on the storm (distances from the centre are exact, and
10
- the frame turns with the storm), and verification splits errors into along- and cross-track parts the way
11
- the National Hurricane Center does.
12
-
13
- All functions take and return numpy arrays (broadcasting), degrees and kilometres.
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import numpy as np
19
-
20
- EARTH_RADIUS_KM = 6371.0088
21
- KM_PER_DEGREE = np.pi * EARTH_RADIUS_KM / 180.0
22
- OMEGA = 7.2921159e-5 # Earth's rotation, s^-1
23
-
24
-
25
- def wrap_longitude(lon):
26
- """Longitude back into [-180, 180)."""
27
- return (np.asarray(lon, np.float64) + 180.0) % 360.0 - 180.0
28
-
29
-
30
- def great_circle_km(lat0, lon0, lat1, lon1):
31
- """Haversine distance in kilometres."""
32
- p0, p1 = np.radians(lat0), np.radians(lat1)
33
- dp = p1 - p0
34
- dl = np.radians(np.asarray(lon1, np.float64) - np.asarray(lon0, np.float64))
35
- h = np.sin(dp / 2) ** 2 + np.cos(p0) * np.cos(p1) * np.sin(dl / 2) ** 2
36
- return 2.0 * EARTH_RADIUS_KM * np.arcsin(np.sqrt(np.clip(h, 0.0, 1.0)))
37
-
38
-
39
- def bearing_deg(lat0, lon0, lat1, lon1):
40
- """Initial great-circle bearing from point 0 to point 1, degrees clockwise from north, in [0, 360)."""
41
- p0, p1 = np.radians(lat0), np.radians(lat1)
42
- dl = np.radians(np.asarray(lon1, np.float64) - np.asarray(lon0, np.float64))
43
- x = np.sin(dl) * np.cos(p1)
44
- y = np.cos(p0) * np.sin(p1) - np.sin(p0) * np.cos(p1) * np.cos(dl)
45
- return np.degrees(np.arctan2(x, y)) % 360.0
46
-
47
-
48
- def destination(lat, lon, bearing, distance_km):
49
- """The point ``distance_km`` along the great circle leaving (lat, lon) on ``bearing``; (lat, lon)."""
50
- p, l0 = np.radians(lat), np.radians(lon)
51
- b = np.radians(bearing)
52
- d = np.asarray(distance_km, np.float64) / EARTH_RADIUS_KM
53
- p1 = np.arcsin(np.clip(np.sin(p) * np.cos(d) + np.cos(p) * np.sin(d) * np.cos(b), -1.0, 1.0))
54
- l1 = l0 + np.arctan2(np.sin(b) * np.sin(d) * np.cos(p), np.cos(d) - np.sin(p) * np.sin(p1))
55
- return np.degrees(p1), wrap_longitude(np.degrees(l1))
56
-
57
-
58
- def move(lat, lon, east_km, north_km):
59
- """Move by a local (east, north) displacement in km, as one great-circle step."""
60
- east_km, north_km = np.asarray(east_km, np.float64), np.asarray(north_km, np.float64)
61
- distance = np.hypot(east_km, north_km)
62
- bearing = np.degrees(np.arctan2(east_km, north_km))
63
- return destination(lat, lon, bearing, distance)
64
-
65
-
66
- def local_displacement_km(lat0, lon0, lat1, lon1):
67
- """(east, north) km from point 0 to point 1 in point 0's azimuthal-equidistant frame (exact distance)."""
68
- distance = great_circle_km(lat0, lon0, lat1, lon1)
69
- b = np.radians(bearing_deg(lat0, lon0, lat1, lon1))
70
- return distance * np.sin(b), distance * np.cos(b)
71
-
72
-
73
- def to_local(lat0, lon0, lat, lon):
74
- """Azimuthal-equidistant (x east, y north) km of points around a centre -- alias of the displacement."""
75
- return local_displacement_km(lat0, lon0, lat, lon)
76
-
77
-
78
- def from_local(lat0, lon0, x_km, y_km):
79
- """Inverse of :func:`to_local`."""
80
- return move(lat0, lon0, x_km, y_km)
81
-
82
-
83
- def velocity_from_positions(lat0, lon0, lat1, lon1, hours):
84
- """Mean (east, north) motion in m/s that takes a storm from point 0 to point 1 in ``hours``."""
85
- east, north = local_displacement_km(lat0, lon0, lat1, lon1)
86
- scale = 1000.0 / (np.asarray(hours, np.float64) * 3600.0)
87
- return east * scale, north * scale
88
-
89
-
90
- def coriolis(lat):
91
- """f = 2 Omega sin(lat), s^-1."""
92
- return 2.0 * OMEGA * np.sin(np.radians(lat))
93
-
94
-
95
- def beta(lat):
96
- """df/dy = 2 Omega cos(lat) / R, m^-1 s^-1."""
97
- return 2.0 * OMEGA * np.cos(np.radians(lat)) / (EARTH_RADIUS_KM * 1000.0)
98
-
99
-
100
- def hemisphere(lat):
101
- """+1 north (and on the equator), -1 south."""
102
- return np.where(np.asarray(lat) < 0.0, -1.0, 1.0)
103
-
104
-
105
- def rotate(east, north, degrees):
106
- """Rotate a vector counter-clockwise (mathematical sense) by ``degrees``."""
107
- a = np.radians(degrees)
108
- c, s = np.cos(a), np.sin(a)
109
- return c * east - s * north, s * east + c * north
110
-
111
-
112
- def along_cross_track(lat_init, lon_init, lat_truth, lon_truth, lat_fcst, lon_fcst):
113
- """
114
- NHC-style decomposition of a forecast error, km: along-track (positive = forecast ahead of the storm)
115
- and cross-track (positive = forecast to the right of the storm's actual motion).
116
-
117
- The reference direction is the actual motion from the initial position to the verifying position; for
118
- a storm that did not move (under 10 km) the decomposition is undefined and NaN is returned.
119
- """
120
- te, tn = local_displacement_km(lat_init, lon_init, lat_truth, lon_truth)
121
- fe, fn = local_displacement_km(lat_init, lon_init, lat_fcst, lon_fcst)
122
- length = np.hypot(te, tn)
123
- with np.errstate(invalid="ignore", divide="ignore"):
124
- ue, un = te / length, tn / length
125
- de, dn = fe - te, fn - tn
126
- along = de * ue + dn * un
127
- cross = de * un - dn * ue # right of motion is positive
128
- bad = length < 10.0
129
- return np.where(bad, np.nan, along), np.where(bad, np.nan, cross)
130
-
131
-
132
- def interpolate_track(hours, lat, lon, at_hours):
133
- """
134
- Positions at ``at_hours`` along a track given at ``hours``, interpolating along great circles in each
135
- segment (exact for a constant-bearing segment, unlike linear interpolation of latitude and longitude).
136
- """
137
- hours = np.asarray(hours, np.float64)
138
- at = np.atleast_1d(np.asarray(at_hours, np.float64))
139
- out_lat, out_lon = np.full(at.shape, np.nan), np.full(at.shape, np.nan)
140
- for k, h in enumerate(at):
141
- if h < hours[0] - 1e-9 or h > hours[-1] + 1e-9:
142
- continue
143
- j = int(np.clip(np.searchsorted(hours, h, side="right") - 1, 0, len(hours) - 2))
144
- span = hours[j + 1] - hours[j]
145
- frac = 0.0 if span <= 0 else (h - hours[j]) / span
146
- distance = great_circle_km(lat[j], lon[j], lat[j + 1], lon[j + 1])
147
- b = bearing_deg(lat[j], lon[j], lat[j + 1], lon[j + 1])
148
- out_lat[k], out_lon[k] = destination(lat[j], lon[j], b, frac * distance)
149
- return out_lat, out_lon
150
-
151
-
152
- __all__ = ["EARTH_RADIUS_KM", "KM_PER_DEGREE", "OMEGA", "along_cross_track", "bearing_deg", "beta", "coriolis",
153
- "destination", "from_local", "great_circle_km", "hemisphere", "interpolate_track",
154
- "local_displacement_km", "move", "rotate", "to_local", "velocity_from_positions", "wrap_longitude"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/intensity.py DELETED
@@ -1,746 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Intensity without a neural network: three statistical-dynamical models that read the atmosphere along the
4
- *forecast track*, not just where the storm is now.
5
-
6
- The idea: an intensity forecast is a forecast of the conditions the storm will meet -- warmer or cooler water,
7
- more or less shear, drier air, land -- and the track forecast says where it will meet them. So every
8
- environmental predictor is sampled along the physics consensus track, hour by hour, from the analysis
9
- (:func:`environment_along_track`), with two corrections no reanalysis carries: the **cold wake** under a slow
10
- storm (:mod:`naturev1.cyclone.ocean`) lowers the sea-surface temperature the core feels, and **land and
11
- terrain** (:mod:`naturev1.cyclone.land`) are applied as decay after the fact.
12
-
13
- * :func:`shifor_intensity` -- SHIFOR (Jarvinen and Neumann 1979) rebuilt like CLIPER: climatology and
14
- persistence (current wind, its 12- and 24-hour trends, position, season, motion), per basin, 1980-2014.
15
- * :func:`ships_intensity` -- SHIPS-style (DeMaria and Kaplan 1994): the change at each lead regressed on
16
- predictors averaged along the track up to that lead -- potential intensity minus the current wind (the
17
- room to grow), shear in two layers, mid-level humidity, effective ocean temperature, land, persistence.
18
- * :func:`lgem_intensity` -- LGEM-style (DeMaria 2009): ``dV/dt = kappa V - beta (V / V_pi)^n V`` integrated
19
- hourly along the track, the growth rate ``kappa`` a linear function of the environment, the potential
20
- intensity a hard ceiling the storm approaches logistically.
21
-
22
- Coefficients: SHIFOR on 1980-2014 best tracks; SHIPS and LGEM on the 2016/2017/2019 hindcast seasons -- never
23
- on the 2018/2020-2022 seasons the benchmark reports.
24
- """
25
-
26
- from __future__ import annotations
27
-
28
- import numpy as np
29
-
30
- from .geometry import great_circle_km, wrap_longitude
31
- from .land import apply_land_decay, land_along_track
32
- from .ocean import effective_sst
33
-
34
- KT_PER_MS = 1.0 / 0.514444
35
- SHIFOR_PREDICTORS = ("one", "wind", "dwind12", "dwind24", "lat", "lon", "season_s", "season_c", "u6", "v6", "wind2",
36
- "wind_lat")
37
-
38
-
39
- def _season_phase(case) -> float:
40
- day = (np.datetime64(case.time, "D") - np.datetime64(str(case.time)[:4] + "-01-01", "D")).astype(int)
41
- shift = 182 if case.lat < 0 else 0
42
- return 2 * np.pi * ((day + shift) % 365) / 365.0
43
-
44
-
45
- def shifor_predictors(case) -> np.ndarray | None:
46
- from .cliper import GROUPS, _motion
47
-
48
- if not np.isfinite(case.wind_kt):
49
- return None
50
- w = case.history["wind"]
51
- w12, w24 = w[-3], w[-5]
52
- d12 = (case.wind_kt - w12) / 10.0 if np.isfinite(w12) else 0.0
53
- d24 = (case.wind_kt - w24) / 10.0 if np.isfinite(w24) else d12
54
- sign = -1.0 if case.lat < 0 else 1.0
55
- m = _motion(case, -6, 0) or _motion(case, -12, 0) or (0.0, 0.0)
56
- _, reference = GROUPS.get(case.basin, ("NA", -60.0))
57
- phase = _season_phase(case)
58
- wind = case.wind_kt / 100.0
59
- lat = abs(case.lat) / 30.0
60
- return np.array([1.0, wind, d12, d24, lat, wrap_longitude(case.lon - reference) / 60.0, np.sin(phase), np.cos(phase),
61
- m[0] / 20.0, sign * m[1] / 20.0, wind * wind, wind * lat])
62
-
63
-
64
- def fit_shifor(cases, leads, ridge: float = 1e-2) -> dict:
65
- from .cliper import group_of
66
-
67
- rows: dict[str, list] = {}
68
- for c in cases:
69
- x = shifor_predictors(c)
70
- if x is None:
71
- continue
72
- target = np.where(c.truth["valid"] & np.isfinite(c.truth["wind"]), c.truth["wind"] - c.wind_kt, np.nan)
73
- rows.setdefault(group_of(c), []).append((x, target))
74
- table = {"leads": [int(h) for h in leads]}
75
- for group, items in rows.items():
76
- x = np.stack([i[0] for i in items])
77
- y = np.stack([i[1] for i in items])
78
- weights = np.full((len(leads), x.shape[1]), np.nan)
79
- for k in range(len(leads)):
80
- ok = np.isfinite(y[:, k])
81
- if ok.sum() < 5 * x.shape[1]:
82
- continue
83
- a = x[ok]
84
- gram = a.T @ a + ridge * ok.sum() * np.diag([0.0] + [1.0] * (x.shape[1] - 1))
85
- weights[k] = np.linalg.solve(gram, a.T @ y[ok, k])
86
- table[group] = weights.round(5).tolist()
87
- return table
88
-
89
-
90
- def shifor_intensity(case, leads, table: dict | None = None) -> np.ndarray:
91
- from .cliper import group_of
92
-
93
- if table is None:
94
- from .coefficients import SHIFOR as table
95
- x = shifor_predictors(case)
96
- out = np.full(len(leads), np.nan)
97
- group = table.get(group_of(case))
98
- if x is None or group is None:
99
- return out
100
- stored = list(table["leads"])
101
- w = np.asarray(group, np.float64)
102
- for k, lead in enumerate(leads):
103
- if lead in stored:
104
- out[k] = max(case.wind_kt + float(w[stored.index(lead)] @ x), 15.0)
105
- return out
106
-
107
-
108
- # ------------------------------------------------------------------------------- along the track --
109
-
110
- def _relative_humidity(t_k: np.ndarray, q: np.ndarray, p_hpa: float) -> np.ndarray:
111
- es = 6.112 * np.exp(17.67 * (t_k - 273.15) / (t_k - 29.65))
112
- e = q * p_hpa / (0.622 + 0.378 * q)
113
- return np.clip(e / es, 0.0, 1.2)
114
-
115
-
116
- def potential_intensity_at(env, lat, lon, sst_k) -> np.ndarray:
117
- """Bister-Emanuel potential intensity (m/s, surface) at points, from the analysis column there."""
118
- import torch
119
-
120
- from ..thermo import potential_intensity
121
-
122
- levels = [p for p in env.levels if p >= 100]
123
- idx = [list(env.levels).index(p) for p in levels]
124
- t = np.stack([env.sample(env.fields["t"][k], lat, lon) for k in idx], -1)
125
- q = np.stack([env.sample(env.fields["q"][k], lat, lon) for k in idx], -1)
126
- msl = env.sample(env.fields["msl"], lat, lon) / 100.0
127
- out = potential_intensity(torch.tensor(np.asarray(sst_k, np.float64)), torch.tensor(msl),
128
- torch.tensor(np.asarray(levels, np.float64)), torch.tensor(t), torch.tensor(q))
129
- return np.nan_to_num(out["vmax_ms"].numpy(), nan=0.0)
130
-
131
-
132
- def environment_along_track(env, env_clean, case, track: np.ndarray, leads, wind_guess_kt: np.ndarray | None = None,
133
- ocean=None) -> dict:
134
- """
135
- Hourly environment along the forecast track: effective SST (cold wake), potential intensity, shear (850-200
136
- and 850-500, annulus 200-800 km), 700-500 hPa humidity, land and terrain, translation speed -- and, with an
137
- :class:`naturev1.cyclone.ocean.OceanClimatology` (``ocean``), the depth of the 26 C isotherm and the heat potential
138
- along the track, with the cold wake sized by that depth.
139
- """
140
- from .shear import annulus_profile
141
-
142
- land = land_along_track(env, case.lat, case.lon, track, leads)
143
- hours, la, lo = land["hours"], land["lat"], land["lon"]
144
- step = np.gradient(hours)
145
- dist = np.concatenate([[0.0], great_circle_km(la[:-1], lo[:-1], la[1:], lo[1:])])
146
- speed = np.convolve(dist, np.ones(6) / 6.0, mode="same") / np.maximum(step, 1e-6) / 3.6
147
- sst = env.sample(np.nan_to_num(env.fields["sst"], nan=float(np.nanmean(env.fields["sst"]))), la, lo)
148
- wind = np.interp(hours, np.concatenate([[0.0], np.asarray(leads, float)]),
149
- np.concatenate([[case.wind_kt], wind_guess_kt if wind_guess_kt is not None else np.full(len(leads), case.wind_kt)]))
150
- d26 = tchp = np.full(hours.size, np.nan)
151
- power = None
152
- if ocean is not None:
153
- from . import coefficients
154
-
155
- from .ocean import anomaly_adjusted_depth
156
-
157
- sampled = ocean.sample(la, lo, case.time)
158
- d26, tchp = sampled["d26_m"], sampled["tchp_kj_cm2"]
159
- table = getattr(coefficients, "OCEAN", None) or {}
160
- power = table.get("d26_power")
161
- sst_clim = sampled.get("sst_c", np.full(hours.size, np.nan))
162
- d26_used = anomaly_adjusted_depth(d26, sst, sst_clim, table.get("sst_anomaly_per_c", 0.0))
163
- sst_eff = effective_sst(sst, wind, speed, hours, d26_used if ocean is not None else None, power)
164
- sample_hours = np.arange(0.0, hours[-1] + 1e-9, 6.0)
165
- pick = np.searchsorted(hours, sample_hours).clip(0, len(hours) - 1)
166
- pi = potential_intensity_at(env, la[pick], lo[pick], sst_eff[pick]) * KT_PER_MS
167
- # the potential intensity over the uncooled sea as well, so a different wake can be priced without the profiles
168
- pi_warm = potential_intensity_at(env, la[pick], lo[pick], sst[pick]) * KT_PER_MS if ocean is not None else pi
169
- shear_l, shear_m, rh = [], [], []
170
- for j in pick:
171
- prof = annulus_profile(env_clean, la[j], lo[j])
172
- shear_l.append(np.hypot(prof[200][0] - prof[850][0], prof[200][1] - prof[850][1]))
173
- shear_m.append(np.hypot(prof[500][0] - prof[850][0], prof[500][1] - prof[850][1]))
174
- rhs = []
175
- for p in (700, 600, 500):
176
- k = list(env.levels).index(p)
177
- rhs.append(float(env.sample(_relative_humidity(env.fields["t"][k], env.fields["q"][k], p), la[j], lo[j])))
178
- rh.append(np.mean(rhs))
179
- return {"hours": hours, "lat": la, "lon": lo, "land": land["land"], "height_m": land["height_m"], "speed_ms": speed,
180
- "sst_k": sst, "sst_eff_k": sst_eff, "six_hours": sample_hours, "pi_kt": pi, "shear_ms": np.asarray(shear_l),
181
- "shear_mid_ms": np.asarray(shear_m), "rh_mid": np.asarray(rh), "landfall_hour": land["landfall_hour"],
182
- "d26_m": d26, "tchp_kj_cm2": tchp, "pi_warm_kt": pi_warm,
183
- "sst_clim_c": sampled.get("sst_c", np.full(hours.size, np.nan)) if ocean is not None else np.full(hours.size, np.nan)}
184
-
185
-
186
- SHIPS_PREDICTORS = ("one", "wind", "dwind12", "potential", "potential2", "shear", "shear_mid", "rh", "sst", "land",
187
- "wind_shear", "lat", "speed", "heat_potential", "size", "has_size")
188
-
189
-
190
- def _size_features(case) -> list:
191
- """The storm's observed 34-kt radius (mean quadrant, in units of 150 km) and whether it was reported: small storms
192
- intensify faster, and the radius is in the best track for 2004 on (``Case.r34_nmi``, from IBTrACS or the b-deck)."""
193
- r = getattr(case, "r34_nmi", float("nan"))
194
- return [float(r) * 1.852 / 150.0 if np.isfinite(r) and r > 0 else 0.0, 1.0 if np.isfinite(r) and r > 0 else 0.0]
195
-
196
-
197
- def _heat_potential(along: dict, mask) -> float:
198
- """Mean tropical-cyclone heat potential along the track (kJ/cm2) scaled to ~1 at 50, NaN -> 0 (unknown)."""
199
- tchp = np.asarray(along.get("tchp_kj_cm2", np.full(np.count_nonzero(mask), np.nan)), np.float64)
200
- if tchp.size != np.size(mask):
201
- return 0.0
202
- v = tchp[mask]
203
- return float(np.nanmean(v)) / 50.0 if np.isfinite(v).any() else 0.0
204
-
205
-
206
- def ships_predictors(case, along: dict, lead: float) -> np.ndarray:
207
- """Predictors averaged along the track from now to ``lead`` (the heat potential last: a table fitted without the
208
- ocean climatology simply ignores it)."""
209
- six = along["six_hours"]
210
- use = six <= lead + 1e-9
211
- hourly = along["hours"] <= lead + 1e-9
212
- wind = case.wind_kt
213
- w12 = case.history["wind"][-3]
214
- pi = float(np.mean(along["pi_kt"][use]))
215
- room = (pi - wind) / 50.0
216
- shear = float(np.mean(along["shear_ms"][use])) / 10.0
217
- return np.array([1.0, wind / 100.0, ((wind - w12) / 10.0) if np.isfinite(w12) else 0.0, room, room * abs(room),
218
- shear, float(np.mean(along["shear_mid_ms"][use])) / 10.0, float(np.mean(along["rh_mid"][use])),
219
- (float(np.mean(along["sst_eff_k"][hourly])) - 300.15) / 2.0, float(np.mean(along["land"][hourly])),
220
- wind / 100.0 * shear, abs(case.lat) / 30.0, float(np.mean(along["speed_ms"][hourly])) / 5.0,
221
- _heat_potential(along, hourly), *_size_features(case)])
222
-
223
-
224
- def _fit_length(features: np.ndarray, weights: np.ndarray) -> np.ndarray:
225
- """Features cut or zero-padded to a table's length: newer predictors are always appended last."""
226
- if features.size == weights.size:
227
- return features
228
- if features.size > weights.size:
229
- return features[:weights.size]
230
- return np.concatenate([features, np.zeros(weights.size - features.size)])
231
-
232
-
233
- def fit_ships(cases, alongs: list, leads, ridge: float = 3e-2) -> dict:
234
- """Ridge regression of the wind change at each lead on :func:`ships_predictors`; one model for all basins."""
235
- table = {"leads": [int(h) for h in leads], "weights": []}
236
- for k, lead in enumerate(leads):
237
- xs, ys = [], []
238
- for c, a in zip(cases, alongs):
239
- if a is None or not c.truth["valid"][k] or not np.isfinite(c.truth["wind"][k]) or not np.isfinite(c.wind_kt):
240
- continue
241
- xs.append(ships_predictors(c, a, lead))
242
- ys.append(c.truth["wind"][k] - c.wind_kt)
243
- x, y = np.asarray(xs), np.asarray(ys)
244
- if len(y) < 10 * x.shape[1]:
245
- table["weights"].append([float("nan")] * len(SHIPS_PREDICTORS))
246
- continue
247
- gram = x.T @ x + ridge * len(y) * np.diag([0.0] + [1.0] * (x.shape[1] - 1))
248
- table["weights"].append(np.linalg.solve(gram, x.T @ y).round(5).tolist())
249
- return table
250
-
251
-
252
- def ships_intensity(case, along: dict, leads, table: dict | None = None) -> np.ndarray:
253
- if table is None:
254
- from .coefficients import SHIPS as table
255
- stored = list(table["leads"])
256
- out = np.full(len(leads), np.nan)
257
- for k, lead in enumerate(leads):
258
- if lead in stored and along is not None:
259
- w = np.asarray(table["weights"][stored.index(lead)], np.float64)
260
- out[k] = max(case.wind_kt + float(w @ _fit_length(ships_predictors(case, along, lead), w)), 15.0)
261
- return out
262
-
263
-
264
- #: LGEM: the exponent of the logistic ceiling, the damping rate (per hour) and the growth-rate coefficients
265
- #: (per hour) on [1, shear/10, rh, (sst_eff - 28.5 C)/2, (pi - v)/50].
266
- LGEM_N = 2.5
267
- LGEM_BETA = 1.0 / 24.0
268
- LGEM_KAPPA = (0.010, -0.012, 0.035, 0.006, 0.015)
269
-
270
-
271
- def lgem_table() -> tuple[np.ndarray, float, float]:
272
- """The shipped LGEM fit (``coefficients.LGEM``) if there is one, else the hand-set defaults above."""
273
- from . import coefficients
274
-
275
- table = getattr(coefficients, "LGEM", None)
276
- if table is None:
277
- return np.asarray(LGEM_KAPPA, np.float64), LGEM_N, LGEM_BETA
278
- return np.asarray(table["kappa"], np.float64), float(table["n"]), float(table["beta_per_h"])
279
-
280
-
281
- def lgem_intensity(case, along: dict, leads, kappa=None, n: float | None = None, beta: float | None = None) -> np.ndarray:
282
- """Logistic growth to potential intensity, integrated hourly along the track; land decay applied after."""
283
- fitted, fitted_n, fitted_beta = lgem_table()
284
- kappa = fitted if kappa is None else np.asarray(kappa, np.float64)
285
- n = fitted_n if n is None else n
286
- beta = fitted_beta if beta is None else beta
287
- hours = along["hours"]
288
- six = along["six_hours"]
289
- pi = np.maximum(np.interp(hours, six, along["pi_kt"]), 20.0)
290
- shear = np.interp(hours, six, along["shear_ms"]) / 10.0
291
- rh = np.interp(hours, six, along["rh_mid"])
292
- sst = (along["sst_eff_k"] - 301.65) / 2.0
293
- v = np.empty_like(hours)
294
- v[0] = max(case.wind_kt, 15.0)
295
- for k in range(1, len(hours)):
296
- dt = hours[k] - hours[k - 1]
297
- rate = kappa @ np.array([1.0, shear[k], rh[k], sst[k], (pi[k] - v[k - 1]) / 50.0])
298
- growth = rate * v[k - 1] - beta * (v[k - 1] / pi[k]) ** n * v[k - 1]
299
- v[k] = float(np.clip(v[k - 1] + dt * growth, 15.0, 200.0))
300
- v = apply_land_decay(hours, v, along["land"], along["height_m"])
301
- return np.interp(np.asarray(leads, float), hours, v, right=np.nan)
302
-
303
-
304
- def lgem_batch(start_kt: np.ndarray, alongs: list, leads, kappa=None, n: float = LGEM_N, beta: float = LGEM_BETA,
305
- land: bool = True) -> np.ndarray:
306
- """
307
- :func:`lgem_intensity` for many storms at once (``(N, L)``): the environment arrays are stacked over cases on
308
- the common hourly axis, so fitting the growth-rate coefficients over thousands of hindcasts takes seconds.
309
- """
310
- kappa = np.asarray(LGEM_KAPPA if kappa is None else kappa, np.float64)
311
- hours = alongs[0]["hours"]
312
- t = len(hours)
313
- six = alongs[0]["six_hours"]
314
-
315
- def stack(key, six_hourly):
316
- rows = []
317
- for a in alongs:
318
- x = np.asarray(a[key], np.float64)
319
- x = np.interp(hours, six, x) if six_hourly else x
320
- rows.append(x[:t] if x.size >= t else np.pad(x, (0, t - x.size), mode="edge"))
321
- return np.stack(rows)
322
-
323
- pi = np.maximum(stack("pi_kt", True), 20.0)
324
- shear = stack("shear_ms", True) / 10.0
325
- rh = stack("rh_mid", True)
326
- sst = (stack("sst_eff_k", False) - 301.65) / 2.0
327
- v = np.empty((len(alongs), t))
328
- v[:, 0] = np.maximum(np.asarray(start_kt, np.float64), 15.0)
329
- for k in range(1, t):
330
- dt = hours[k] - hours[k - 1]
331
- rate = kappa[0] + kappa[1] * shear[:, k] + kappa[2] * rh[:, k] + kappa[3] * sst[:, k] + kappa[4] * (pi[:, k] - v[:, k - 1]) / 50.0
332
- growth = rate * v[:, k - 1] - beta * (v[:, k - 1] / pi[:, k]) ** n * v[:, k - 1]
333
- v[:, k] = np.clip(v[:, k - 1] + dt * growth, 15.0, 200.0)
334
- if land:
335
- lands, heights = stack("land", False), stack("height_m", False)
336
- v = np.stack([apply_land_decay(hours, v[i], lands[i], heights[i]) for i in range(len(alongs))])
337
- idx = [int(np.argmin(np.abs(hours - h))) for h in leads]
338
- return v[:, idx]
339
-
340
-
341
- def fit_lgem(cases, alongs: list, leads, start=None, iterations: int = 300) -> np.ndarray:
342
- """Growth-rate coefficients minimising the mean absolute wind error over all leads (Nelder-Mead)."""
343
- keep = [i for i, a in enumerate(alongs) if a is not None and np.isfinite(cases[i].wind_kt)]
344
- al = [alongs[i] for i in keep]
345
- start_kt = np.array([cases[i].wind_kt for i in keep])
346
- truth = np.stack([cases[i].truth["wind"] for i in keep])
347
- valid = np.stack([cases[i].truth["valid"] for i in keep]) & np.isfinite(truth)
348
-
349
- def loss(k):
350
- pred = lgem_batch(start_kt, al, leads, k, land=False)
351
- return float(np.mean(np.abs(pred - truth)[valid]))
352
-
353
- x0 = np.asarray(LGEM_KAPPA if start is None else start, np.float64)
354
- simplex = [x0] + [x0 + np.eye(len(x0))[j] * (0.5 * abs(x0[j]) + 0.005) for j in range(len(x0))]
355
- values = [loss(x) for x in simplex]
356
- for _ in range(iterations):
357
- order = np.argsort(values)
358
- simplex = [simplex[i] for i in order]
359
- values = [values[i] for i in order]
360
- centroid = np.mean(simplex[:-1], axis=0)
361
- reflected = centroid + (centroid - simplex[-1])
362
- fr = loss(reflected)
363
- if fr < values[0]:
364
- expanded = centroid + 2.0 * (centroid - simplex[-1])
365
- fe = loss(expanded)
366
- simplex[-1], values[-1] = (expanded, fe) if fe < fr else (reflected, fr)
367
- elif fr < values[-2]:
368
- simplex[-1], values[-1] = reflected, fr
369
- else:
370
- contracted = centroid + 0.5 * (simplex[-1] - centroid)
371
- fc = loss(contracted)
372
- if fc < values[-1]:
373
- simplex[-1], values[-1] = contracted, fc
374
- else:
375
- simplex = [simplex[0] + 0.5 * (x - simplex[0]) for x in simplex]
376
- values = [loss(x) for x in simplex]
377
- return simplex[int(np.argmin(values))]
378
-
379
-
380
- def decay_along(case, along: dict, leads, water_kt: np.ndarray) -> np.ndarray:
381
- """Apply inland and terrain decay to an over-water forecast given at ``leads``."""
382
- hours = along["hours"]
383
- water = np.interp(hours, np.concatenate([[0.0], np.asarray(leads, float)]), np.concatenate([[case.wind_kt], water_kt]))
384
- decayed = apply_land_decay(hours, water, along["land"], along["height_m"])
385
- return np.interp(np.asarray(leads, float), hours, decayed, right=np.nan)
386
-
387
-
388
- #: The trend member's memory (hours): how long a storm keeps intensifying (or weakening) the way it has been.
389
- TREND_TAU_H = 24.0
390
-
391
-
392
- def recent_change(case, hours: int = 24) -> float:
393
- """Intensity change (kt) over the last ``hours`` from the best-track history (NaN when unknown)."""
394
- from .data import HISTORY_HOURS
395
-
396
- if -hours not in HISTORY_HOURS:
397
- return float("nan")
398
- past = case.history["wind"][HISTORY_HOURS.index(-hours)]
399
- return float(case.wind_kt - past) if np.isfinite(past) else float("nan")
400
-
401
-
402
- def trend_intensity(case, along: dict, leads, tau_h: float | None = None) -> np.ndarray:
403
- """
404
- The storm keeps doing what it has been doing: the last 24 hours' change continued with an e-folding memory of
405
- ``tau_h`` (``V0 + r tau (1 - exp(-t / tau))``), never above the potential intensity along the track, then land and
406
- terrain decay. This is the member that can follow a storm through rapid intensification, which the regressions
407
- (built to minimise the average error) pull back toward the mean.
408
- """
409
- from . import coefficients
410
-
411
- tau = tau_h if tau_h is not None else getattr(coefficients, "TREND", {}).get("tau_h", TREND_TAU_H)
412
- change = recent_change(case, 24)
413
- if not np.isfinite(change):
414
- change = 2.0 * recent_change(case, 12) if np.isfinite(recent_change(case, 12)) else 0.0
415
- rate = change / 24.0
416
- hours = along["hours"]
417
- path = case.wind_kt + rate * tau * (1.0 - np.exp(-hours / tau))
418
- pi = np.interp(hours, along["six_hours"], along["pi_kt"])
419
- path = np.where(rate > 0, np.minimum(path, np.maximum(pi, case.wind_kt)), path)
420
- path = apply_land_decay(hours, np.maximum(path, 15.0), along["land"], along["height_m"])
421
- return np.interp(np.asarray(leads, float), hours, path, right=np.nan)
422
-
423
-
424
- #: Rapid intensification (+30 kt in 24 h): predictor names, in order.
425
- RI_FEATURES = ("one", "dv12", "dv24", "wind", "headroom", "shear", "rh", "sst", "lat", "land24")
426
-
427
-
428
- #: With a dynamical model's forecast: its central-pressure deficit change by 24 and 48 h, and whether it was there.
429
- RI_MODEL_FEATURES = ("model_dp24", "model_dp48", "has_model")
430
- #: With the ocean climatology: the heat potential over the first 24 hours (kJ/cm2 / 50) and the 26 C depth (m / 50).
431
- RI_OCEAN_FEATURES = ("heat_potential", "warm_depth")
432
- #: The storm's observed 34-kt radius (units of 150 km) and whether it was reported.
433
- RI_SIZE_FEATURES = ("size", "has_size")
434
-
435
-
436
- def ri_features(case, along: dict, model_dp=None, leads=None) -> np.ndarray:
437
- """
438
- SHIPS-RII-style predictors over the first 24 hours along the forecast track; with ``model_dp`` (the models' mean
439
- change in central-pressure deficit at the standard leads, hPa, negative = deepening, from
440
- :func:`model_deficit_change`) three more: the deepening by 24 h, by 48 h, and that a model was there.
441
- """
442
- h = along["hours"]
443
- six = along["six_hours"]
444
- first = six <= 24.0 + 1e-9
445
- pi = float(np.nanmean(np.asarray(along["pi_kt"])[first]))
446
- dv12, dv24 = recent_change(case, 12), recent_change(case, 24)
447
- base = np.array([1.0, np.nan_to_num(dv12) / 10.0, np.nan_to_num(dv24) / 10.0, case.wind_kt / 50.0,
448
- (pi - case.wind_kt) / 50.0, float(np.nanmean(np.asarray(along["shear_ms"])[first])) / 10.0,
449
- float(np.nanmean(np.asarray(along["rh_mid"])[first])),
450
- (float(np.nanmean(np.asarray(along["sst_eff_k"])[h <= 24.0])) - 301.65) / 2.0,
451
- abs(case.lat) / 20.0, float(np.mean(np.asarray(along["land"])[h <= 24.0]))])
452
- from .data import LEADS
453
-
454
- if model_dp is None:
455
- model = np.zeros(3)
456
- else:
457
- dp = np.asarray(model_dp, np.float64)
458
- L = np.asarray(leads if leads is not None else LEADS, np.float64)
459
- k24, k48 = int(np.argmin(np.abs(L - 24.0))), int(np.argmin(np.abs(L - 48.0)))
460
- have = np.isfinite(dp[k24])
461
- model = np.array([np.nan_to_num(-dp[k24]) / 10.0 if have else 0.0, np.nan_to_num(-dp[k48]) / 20.0 if have else 0.0, float(have)])
462
- first_day = h <= 24.0
463
- tchp = np.asarray(along.get("tchp_kj_cm2", np.full(h.size, np.nan)), np.float64)
464
- d26 = np.asarray(along.get("d26_m", np.full(h.size, np.nan)), np.float64)
465
- ocean = np.array([float(np.nanmean(tchp[first_day])) / 50.0 if np.isfinite(tchp[first_day]).any() else 0.0,
466
- float(np.nanmean(d26[first_day])) / 50.0 if np.isfinite(d26[first_day]).any() else 0.0])
467
- return np.concatenate([base, model, ocean, _size_features(case)])
468
-
469
-
470
- def model_deficit_change(extras: dict | list | None, leads) -> np.ndarray | None:
471
- """
472
- ``(L,)`` the models' mean change in central-pressure deficit (central minus environmental pressure, hPa) from
473
- the analysis to each lead -- negative when the model deepens its storm -- from the ``extras`` of
474
- :func:`naturev1.swarm.models.fetch_models` (each with ``hours``, ``msl_min_hpa``, ``msl_env_hpa``). None without any.
475
- """
476
- if not extras:
477
- return None
478
- rows = []
479
- for e in (extras.values() if isinstance(extras, dict) else extras):
480
- e = e.get("extras", e)
481
- hours, pmin, penv = np.asarray(e["hours"], float), np.asarray(e["msl_min_hpa"], float), np.asarray(e["msl_env_hpa"], float)
482
- deficit = pmin - penv
483
- if hours.size == 0 or not np.isfinite(deficit[0]):
484
- continue
485
- at = np.array([deficit[np.argmin(np.abs(hours - h))] if np.any(np.abs(hours - h) < 1e-6) else np.nan for h in leads])
486
- rows.append(at - deficit[0])
487
- if not rows:
488
- return None
489
- return np.nanmean(np.stack(rows), axis=0) if len(rows) > 1 else rows[0]
490
-
491
-
492
- def model_trend_intensity(case, along: dict, leads, model_dp, table: dict | None = None) -> np.ndarray:
493
- """
494
- The dynamical models' own intensification, as an intensity member: ``V0 + k_lead * deepening`` (kt per hPa of
495
- central-pressure-deficit change fitted per lead on the tuning seasons, ``coefficients.MODEL_INTENSITY``), capped
496
- at the potential intensity, then land and terrain decay. A model that deepens its storm by 30 hPa is saying
497
- something the statistical members cannot.
498
- """
499
- if table is None:
500
- from . import coefficients
501
-
502
- table = getattr(coefficients, "MODEL_INTENSITY", None) or {"leads": list(leads), "kt_per_hpa": [1.0] * len(leads)}
503
- hours = along["hours"]
504
- k = np.interp(np.asarray(leads, float), table["leads"], table["kt_per_hpa"])
505
- dp = np.asarray(model_dp, np.float64)
506
- water = case.wind_kt + k * (-np.nan_to_num(dp))
507
- pi = np.interp(np.asarray(leads, float), along["six_hours"], along["pi_kt"])
508
- water = np.where(np.isfinite(dp), np.minimum(water, np.maximum(pi, case.wind_kt)), np.nan)
509
- knots = np.concatenate([[0.0], np.asarray(leads, float)])
510
- path = np.interp(hours, knots, np.concatenate([[case.wind_kt], np.nan_to_num(water, nan=case.wind_kt)]))
511
- path = apply_land_decay(hours, np.maximum(path, 15.0), along["land"], along["height_m"])
512
- return np.where(np.isfinite(water), np.interp(np.asarray(leads, float), hours, path), np.nan)
513
-
514
-
515
- def fit_ri_path(cases, alongs: list, leads, threshold_kt: float = 30.0) -> dict:
516
- """
517
- The intensity path of the storms that *did* intensify rapidly: the mean and 75th percentile of their change from
518
- the analysis at each lead (kt), over the tuning seasons' cases whose 24-hour change reached ``threshold_kt``.
519
- """
520
- from .data import LEADS
521
-
522
- k24 = list(LEADS).index(24)
523
- rows = []
524
- for c, a in zip(cases, alongs):
525
- if a is None or not c.truth["valid"][k24] or not np.isfinite(c.truth["wind"][k24]) or not np.isfinite(c.wind_kt):
526
- continue
527
- if c.truth["wind"][k24] - c.wind_kt >= threshold_kt:
528
- rows.append(np.where(c.truth["valid"], c.truth["wind"], np.nan) - c.wind_kt)
529
- rows = np.asarray(rows)
530
- return {"leads": [int(h) for h in leads], "threshold_kt": threshold_kt, "cases": int(rows.shape[0]),
531
- "mean_change_kt": [round(float(np.nanmean(rows[:, k])), 2) for k in range(len(leads))],
532
- "p75_change_kt": [round(float(np.nanquantile(rows[:, k], 0.75)), 2) for k in range(len(leads))]}
533
-
534
-
535
- def ri_scenario_intensity(case, along: dict, leads, table: dict | None = None, which: str = "mean_change_kt") -> np.ndarray:
536
- """
537
- The intensity *if the storm intensifies rapidly*: the analysis plus the rapid intensifiers' mean change at each
538
- lead (``coefficients.RI_PATH``), capped at the potential intensity along the track, then land and terrain decay.
539
- Issued beside the consensus with its probability (``ri_probability``): the consensus is the expected value, this
540
- is the branch a parish cannot afford to ignore when the probability is real.
541
- """
542
- if table is None:
543
- from . import coefficients
544
-
545
- table = getattr(coefficients, "RI_PATH", None)
546
- if table is None:
547
- return np.full(len(leads), np.nan)
548
- hours = along["hours"]
549
- change = np.interp(np.asarray(leads, float), table["leads"], table[which])
550
- water = case.wind_kt + change
551
- pi = np.interp(np.asarray(leads, float), along["six_hours"], along["pi_kt"])
552
- water = np.minimum(water, np.maximum(pi, case.wind_kt))
553
- knots = np.concatenate([[0.0], np.asarray(leads, float)])
554
- path = np.interp(hours, knots, np.concatenate([[case.wind_kt], water]))
555
- path = apply_land_decay(hours, np.maximum(path, 15.0), along["land"], along["height_m"])
556
- return np.interp(np.asarray(leads, float), hours, path)
557
-
558
-
559
- def category_probabilities(intensity: np.ndarray, regime: str, table: dict | None = None, ri_probability: float | None = None,
560
- ri_intensity: np.ndarray | None = None) -> dict:
561
- """
562
- ``{"thresholds_kt": [...], "probability": (L, T)}`` -- P(intensity >= each threshold) at each lead, from the
563
- forecast plus the empirical distribution of the consensus's out-of-sample errors on the tuning seasons
564
- (``coefficients.INTENSITY_SPREAD``: 25 quantiles per lead, separately for the normal and the rapid regime).
565
- """
566
- if table is None:
567
- from . import coefficients
568
-
569
- table = getattr(coefficients, "INTENSITY_SPREAD", None)
570
- if table is None:
571
- return {}
572
- q = np.asarray(table["quantiles"].get(regime, table["quantiles"]["normal"]), np.float64) # (L, 25)
573
- thresholds = np.asarray(table.get("thresholds_kt", (64, 96, 113, 137)), np.float64)
574
- v = np.asarray(intensity, np.float64)
575
- samples = v[:, None] + q # (L, 25)
576
- prob = np.stack([(samples >= t).mean(1) for t in thresholds], -1)
577
- mixed = table.get("ri_mixture") and ri_probability is not None and ri_intensity is not None and np.isfinite(ri_intensity).any()
578
- if mixed:
579
- # the rapid-intensification branch: with probability P(RI) the storm follows the rapid intensifiers' path,
580
- # spread by the rapid regime's own error quantiles (switched on by the table only where it scored better)
581
- qr = np.asarray(table["quantiles"]["rapid"], np.float64)
582
- branch = np.asarray(ri_intensity, np.float64)[:, None] + qr
583
- prob_ri = np.stack([(branch >= t).mean(1) for t in thresholds], -1)
584
- p = float(np.clip(ri_probability, 0.0, 1.0))
585
- prob = (1.0 - p) * prob + p * np.where(np.isfinite(prob_ri), prob_ri, prob)
586
- prob[~np.isfinite(v)] = np.nan
587
- return {"thresholds_kt": thresholds.tolist(), "probability": prob, "ri_mixture": bool(mixed)}
588
-
589
-
590
- def fit_ri(cases, alongs: list, threshold_kt: float = 30.0, l2: float = 1e-2, iterations: int = 30,
591
- model_dp: dict | None = None, n_features: int | None = None) -> dict:
592
- """Logistic regression for P(intensity rises by >= ``threshold_kt`` in 24 h), by Newton's method, on
593
- :func:`ri_features` (``model_dp``: ``{case.key: (L,) deficit change}`` when the models' deepening is a predictor;
594
- ``n_features`` cuts the predictor list, e.g. 10 for the environment alone)."""
595
- from .data import LEADS
596
-
597
- k24 = list(LEADS).index(24)
598
- x, y = [], []
599
- for c, a in zip(cases, alongs):
600
- if a is None or not c.truth["valid"][k24] or not np.isfinite(c.truth["wind"][k24]):
601
- continue
602
- f = ri_features(c, a, None if model_dp is None else model_dp.get(c.key))
603
- if n_features is not None:
604
- f = _fit_length(f, np.zeros(n_features))
605
- if np.isfinite(f).all():
606
- x.append(f)
607
- y.append(float(c.truth["wind"][k24] - c.wind_kt >= threshold_kt))
608
- x, y = np.asarray(x), np.asarray(y)
609
- w = np.zeros(x.shape[1])
610
- reg = l2 * len(y) * np.diag([0.0] + [1.0] * (x.shape[1] - 1))
611
- for _ in range(iterations):
612
- p = 1.0 / (1.0 + np.exp(-x @ w))
613
- grad = x.T @ (p - y) + reg @ w
614
- hess = (x * (p * (1 - p))[:, None]).T @ x + reg
615
- w = w - np.linalg.solve(hess, grad)
616
- names = list(RI_FEATURES) + list(RI_MODEL_FEATURES) + list(RI_OCEAN_FEATURES) + list(RI_SIZE_FEATURES)
617
- return {"features": names[:x.shape[1]], "threshold_kt": threshold_kt, "weights": w.round(5).tolist(),
618
- "base_rate": float(y.mean()), "cases": int(len(y))}
619
-
620
-
621
- def ri_probability(case, along: dict, table: dict | None = None, model_dp=None, leads=None) -> float:
622
- """P(+30 kt in 24 h) for a storm on its forecast track (``coefficients.RI`` by default; a table fitted with the
623
- model-deepening predictors uses them when ``model_dp`` is given, and counts the model as absent otherwise)."""
624
- if table is None:
625
- from .coefficients import RI as table
626
- w = np.asarray(table["weights"], np.float64)
627
- f = _fit_length(ri_features(case, along, model_dp, leads), w) # a table fitted with fewer predictors ignores the rest
628
- if not np.isfinite(f).all():
629
- return float(table.get("base_rate", 0.05))
630
- return float(1.0 / (1.0 + np.exp(-w @ f)))
631
-
632
-
633
-
634
- #: Predictors of the peak-intensity logistic (``coefficients.PEAK``), after the intercept: the analysis intensity, the
635
- #: consensus's rise and maximum, the warm-ocean potential-intensity headroom over 72 h, mean ocean heat content, shear
636
- #: and mid-level humidity over 48 h, P(RI) and its logit, the model's 24 h deepening and whether there is one, the
637
- #: share of the first 72 h over land, latitude.
638
- PEAK_FEATURES = ("one", "v0", "cons_rise", "cons_max", "headroom72", "tchp48", "shear48", "rh48", "p_ri", "logit_p", "dp24", "has_dp",
639
- "land72", "abslat")
640
-
641
-
642
- def _series_upto(along: dict, key: str, upto_h: float) -> np.ndarray:
643
- v = np.asarray(along[key], np.float64)
644
- t = np.asarray(along["six_hours"] if v.size == len(along["six_hours"]) else along["hours"], np.float64)
645
- return v[t <= upto_h]
646
-
647
-
648
- def peak_features(case, along: dict, consensus_kt, ri_probability: float, model_dp=None, leads=None) -> np.ndarray:
649
- """The :data:`PEAK_FEATURES` row for one case (raw, before the table's standardisation)."""
650
- from .data import LEADS
651
-
652
- cv = np.asarray(consensus_kt, np.float64)
653
- p = float(np.clip(ri_probability if ri_probability is not None and np.isfinite(ri_probability) else 0.05, 1e-3, 1 - 1e-3))
654
- pi = _series_upto(along, "pi_warm_kt" if "pi_warm_kt" in along else "pi_kt", 72.0)
655
- L = np.asarray(leads if leads is not None else LEADS, np.float64)
656
- k24 = int(np.argmin(np.abs(L - 24.0)))
657
- dp = np.asarray(model_dp, np.float64) if model_dp is not None else np.full(L.size, np.nan)
658
- has_dp = bool(dp.size > k24 and np.isfinite(dp[k24]))
659
- tchp = _series_upto(along, "tchp_kj_cm2", 48.0) if "tchp_kj_cm2" in along else np.array([np.nan])
660
- with np.errstate(all="ignore"):
661
- row = [1.0, case.wind_kt, np.nanmax(cv) - case.wind_kt, np.nanmax(cv), np.nanmax(pi) - case.wind_kt, np.nanmean(tchp),
662
- np.nanmean(_series_upto(along, "shear_ms", 48.0)), np.nanmean(_series_upto(along, "rh_mid", 48.0)), p, np.log(p / (1 - p)),
663
- dp[k24] if has_dp else 0.0, float(has_dp), np.nanmean(_series_upto(along, "land", 72.0)), abs(case.lat)]
664
- return np.nan_to_num(np.asarray(row, np.float64))
665
-
666
-
667
- def peak_probability(case, along: dict, consensus_kt, ri_probability: float, model_dp=None, table: dict | None = None, leads=None) -> dict:
668
- """
669
- ``{"thresholds_kt": [64, 96, 113, 137], "probability": [...]}`` -- the probability that the storm's **peak**
670
- intensity within the forecast window reaches each threshold (hurricane, major, category 4, category 5): a logistic
671
- on :data:`PEAK_FEATURES` fitted on the tuning seasons (``coefficients.PEAK``). This is the question a point
672
- forecast cannot answer: the consensus is the expected intensity, and for the storms that do explode it sits
673
- 30 kt under their peak; this says how likely the explosion is. 1 where the storm is already at the threshold.
674
- """
675
- if table is None:
676
- from . import coefficients
677
-
678
- table = getattr(coefficients, "PEAK", None)
679
- if not table:
680
- return {}
681
- x = peak_features(case, along, consensus_kt, ri_probability, model_dp, leads)
682
- mu, sd = np.asarray(table["mean"], np.float64), np.asarray(table["std"], np.float64)
683
- z = np.concatenate([x[:1], (x[1:] - mu) / np.where(sd > 1e-9, sd, 1.0)])
684
- out = []
685
- for thr in table["thresholds_kt"]:
686
- w = np.asarray(table["weights"][str(int(thr))], np.float64)
687
- out.append(1.0 if case.wind_kt >= thr else float(1.0 / (1.0 + np.exp(-float(z @ w)))))
688
- return {"thresholds_kt": [float(t) for t in table["thresholds_kt"]], "probability": out}
689
-
690
-
691
- def _peak_row(case, along: dict, consensus_kt, ri_probability, model_dp, intensity_if_ri, table: dict, leads=None) -> np.ndarray:
692
- """The standardised PEAK features plus the rapid-intensification path's rise, as the peak-quantile tables were fitted."""
693
- x = peak_features(case, along, consensus_kt, ri_probability, model_dp, leads)
694
- mu, sd = np.asarray(table["mean"], np.float64), np.asarray(table["std"], np.float64)
695
- n = x.size - 1
696
- z = np.concatenate([x[:1], (x[1:] - mu[:n]) / np.where(sd[:n] > 1e-9, sd[:n], 1.0)])
697
- scen = np.asarray(intensity_if_ri, np.float64) if intensity_if_ri is not None else np.array([np.nan])
698
- rise = (np.nanmax(scen) - case.wind_kt) if np.isfinite(scen).any() else 0.0
699
- return np.append(z, (rise - mu[n]) / (sd[n] if sd[n] > 1e-9 else 1.0))
700
-
701
-
702
- def peak_quantiles(case, along: dict, consensus_kt, ri_probability, model_dp=None, intensity_if_ri=None, table: dict | None = None,
703
- leads=None) -> dict:
704
- """
705
- ``{"levels": [0.5, 0.75, 0.9, 0.95], "peak_kt": [...]}`` -- calibrated quantiles of the storm's **peak** intensity
706
- within the forecast window (``coefficients.PEAK_QUANTILES``: linear pinball-loss fits on the tuning seasons). The
707
- point forecast cannot reach an explosive storm's top end and should not try; the 90% quantile can: on the held-out
708
- storms that reached 137 kt it sits 4 kt above their true peak where the consensus maximum sits 37 kt below, with
709
- 91% of all held-out peaks under it. Quantiles are made monotone at prediction time.
710
- """
711
- if table is None:
712
- from . import coefficients
713
-
714
- table = getattr(coefficients, "PEAK_QUANTILES", None)
715
- if not table:
716
- return {}
717
- z = _peak_row(case, along, consensus_kt, ri_probability, model_dp, intensity_if_ri, table, leads)
718
- levels = sorted(float(q) for q in table["quantiles"])
719
- rise = np.maximum.accumulate(np.array([float(z @ np.asarray(table["quantiles"][str(q)], np.float64)) for q in levels]))
720
- return {"levels": levels, "peak_kt": [float(np.clip(case.wind_kt + r, 15.0, 200.0)) for r in rise]}
721
-
722
-
723
- def peak_if_ri(case, along: dict, consensus_kt, ri_probability, model_dp=None, intensity_if_ri=None, table: dict | None = None,
724
- leads=None) -> float:
725
- """
726
- E[peak within the window | the storm intensifies rapidly], kt (``coefficients.PEAK_IF_RI``, a ridge fit on the
727
- tuning seasons' rapid intensifiers): unbiased on the held-out storms that did go rapid (+0.6 kt, MAE 13) where the
728
- rapid-intensification path's peak sat 17 kt low. To be read with ``ri_probability``, never as the forecast: on the
729
- storms that do not go rapid it runs 35 kt high.
730
- """
731
- if table is None:
732
- from . import coefficients
733
-
734
- table = getattr(coefficients, "PEAK_IF_RI", None)
735
- if not table:
736
- return float("nan")
737
- z = _peak_row(case, along, consensus_kt, ri_probability, model_dp, intensity_if_ri, table, leads)
738
- return float(np.clip(case.wind_kt + float(z @ np.asarray(table["weights"], np.float64)), 15.0, 200.0))
739
-
740
- __all__ = ["LGEM_KAPPA", "PEAK_FEATURES", "RI_FEATURES", "RI_MODEL_FEATURES", "RI_OCEAN_FEATURES", "RI_SIZE_FEATURES", "SHIFOR_PREDICTORS",
741
- "SHIPS_PREDICTORS",
742
- "TREND_TAU_H",
743
- "category_probabilities", "decay_along", "environment_along_track", "fit_ri", "fit_ri_path", "model_deficit_change",
744
- "model_trend_intensity", "peak_features", "peak_if_ri", "peak_probability", "peak_quantiles", "recent_change", "ri_features", "ri_probability", "ri_scenario_intensity", "trend_intensity",
745
- "fit_lgem", "fit_shifor", "fit_ships", "lgem_batch", "lgem_intensity", "lgem_table", "potential_intensity_at", "shifor_intensity",
746
- "shifor_predictors", "ships_intensity", "ships_predictors"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/kalman.py DELETED
@@ -1,137 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- How the storm is moving right now, estimated rather than read off two fixes.
4
-
5
- The idea: extrapolation is nearly unbeatable at 6-12 hours because the current motion is the best
6
- predictor there is -- but extrapolation *measures* that motion with two noisy best-track fixes. A best-track
7
- position carries 20-50 km of uncertainty (more for a depression seen only by satellite), so a 12-hour motion
8
- from two fixes is uncertain by several km/h before the forecast even starts. A Kalman filter over the last 48
9
- hours of fixes, with a constant-acceleration motion model, estimates position, velocity and acceleration
10
- jointly and weighs each fix by how much it can be trusted. Two refinements:
11
-
12
- * **Robust updates** (Huber): a fix that disagrees with the motion by more than a few standard deviations
13
- -- a centre reformation, a relocated depression -- is down-weighted instead of dragging the velocity.
14
- * **Damped acceleration** forecast (Gardner-McKenzie damped trend): the measured turn or speed-up carries
15
- on but fades with an e-folding time, because storms that are accelerating now rarely keep accelerating for
16
- days.
17
-
18
- All in the storm-centred azimuthal-equidistant frame, km and hours.
19
- """
20
-
21
- from __future__ import annotations
22
-
23
- from dataclasses import dataclass
24
-
25
- import numpy as np
26
-
27
- from .geometry import from_local, to_local
28
-
29
- #: Per-axis position uncertainty of a best-track fix, km, by status (after Landsea and Franklin 2013).
30
- FIX_SIGMA_KM = {"HU": 15.0, "TS": 24.0, "SS": 30.0, "TD": 32.0}
31
- #: White-jerk process noise, km^2 h^-5 (tuned on the 2016/2017/2019 seasons only).
32
- PROCESS_NOISE = 2.0e-4
33
- #: Huber threshold, standard deviations.
34
- HUBER = 2.0
35
- #: E-folding time of the measured acceleration in a forecast, hours.
36
- ACCELERATION_TAU_H = 12.0
37
-
38
-
39
- @dataclass
40
- class MotionEstimate:
41
- position_km: np.ndarray # (2,) filtered offset from the best-track fix now (x east, y north)
42
- velocity_kmh: np.ndarray # (2,)
43
- acceleration_kmh2: np.ndarray # (2,)
44
- covariance: np.ndarray # (6, 6) state covariance
45
- fixes: int
46
-
47
- @property
48
- def velocity_ms(self) -> np.ndarray:
49
- return self.velocity_kmh / 3.6
50
-
51
- @property
52
- def speed_sigma_kmh(self) -> float:
53
- return float(np.sqrt(0.5 * (self.covariance[2, 2] + self.covariance[3, 3])))
54
-
55
-
56
- def _transition(dt: float) -> np.ndarray:
57
- f = np.eye(6)
58
- for axis in range(2):
59
- p, v, a = axis, 2 + axis, 4 + axis
60
- f[p, v], f[p, a], f[v, a] = dt, 0.5 * dt * dt, dt
61
- return f
62
-
63
-
64
- def _process(dt: float, q: float) -> np.ndarray:
65
- block = q * np.array([[dt ** 5 / 20, dt ** 4 / 8, dt ** 3 / 6],
66
- [dt ** 4 / 8, dt ** 3 / 3, dt ** 2 / 2],
67
- [dt ** 3 / 6, dt ** 2 / 2, dt]])
68
- out = np.zeros((6, 6))
69
- for axis in range(2):
70
- idx = [axis, 2 + axis, 4 + axis]
71
- out[np.ix_(idx, idx)] = block
72
- return out
73
-
74
-
75
- def estimate_motion(case, process_noise: float | None = None, huber: float | None = None) -> MotionEstimate | None:
76
- """Filter the storm's last 48 hours of fixes into position, velocity and acceleration now."""
77
- process_noise = PROCESS_NOISE if process_noise is None else process_noise
78
- huber = HUBER if huber is None else huber
79
- from .data import HISTORY_HOURS
80
-
81
- hours = np.asarray(HISTORY_HOURS, np.float64)
82
- lat, lon = case.history["lat"], case.history["lon"]
83
- ok = np.isfinite(lat) & np.isfinite(lon)
84
- if ok.sum() < 2:
85
- return None
86
- x, y = to_local(case.lat, case.lon, lat[ok], lon[ok])
87
- t = hours[ok]
88
- sigma = FIX_SIGMA_KM.get(case.status, 28.0)
89
- r = np.eye(2) * sigma ** 2
90
- h = np.zeros((2, 6))
91
- h[0, 0] = h[1, 1] = 1.0
92
- # Start at the first fix with a vague velocity.
93
- state = np.array([x[0], y[0], 0.0, 0.0, 0.0, 0.0])
94
- cov = np.diag([sigma ** 2, sigma ** 2, 30.0 ** 2, 30.0 ** 2, 2.0 ** 2, 2.0 ** 2])
95
- if len(t) > 1:
96
- dt0 = t[1] - t[0]
97
- state[2:4] = [(x[1] - x[0]) / dt0, (y[1] - y[0]) / dt0]
98
- previous = t[0]
99
- for k in range(len(t)):
100
- dt = t[k] - previous
101
- if dt > 0:
102
- f = _transition(dt)
103
- state = f @ state
104
- cov = f @ cov @ f.T + _process(dt, process_noise)
105
- previous = t[k]
106
- innovation = np.array([x[k], y[k]]) - h @ state
107
- s = h @ cov @ h.T + r
108
- distance = float(np.sqrt(innovation @ np.linalg.solve(s, innovation)))
109
- inflate = max(1.0, distance / huber)
110
- s = h @ cov @ h.T + r * inflate
111
- gain = cov @ h.T @ np.linalg.inv(s)
112
- state = state + gain @ innovation
113
- cov = (np.eye(6) - gain @ h) @ cov
114
- return MotionEstimate(state[:2], state[2:4], state[4:6], cov, int(ok.sum()))
115
-
116
-
117
- def damped_offsets(estimate: MotionEstimate, leads, tau_h: float | None = None,
118
- from_fix: bool = True) -> np.ndarray:
119
- """``(L, 2)`` (x, y) km offsets at each lead, the acceleration fading with ``tau_h``."""
120
- tau_h = ACCELERATION_TAU_H if tau_h is None else tau_h
121
- t = np.asarray(leads, np.float64)[:, None]
122
- decay = tau_h * (t - tau_h * (1.0 - np.exp(-t / tau_h)))
123
- start = np.zeros(2) if from_fix else estimate.position_km
124
- return start + estimate.velocity_kmh * t + estimate.acceleration_kmh2 * decay
125
-
126
-
127
- def kalman_track(case, leads, tau_h: float | None = None, from_fix: bool = True) -> np.ndarray:
128
- """``(L, 2)`` lat/lon: the filtered motion carried forward with damped acceleration."""
129
- estimate = estimate_motion(case)
130
- if estimate is None:
131
- return np.full((len(leads), 2), np.nan)
132
- offsets = damped_offsets(estimate, leads, tau_h, from_fix)
133
- lat, lon = from_local(case.lat, case.lon, offsets[:, 0], offsets[:, 1])
134
- return np.stack([lat, lon], -1)
135
-
136
-
137
- __all__ = ["ACCELERATION_TAU_H", "FIX_SIGMA_KM", "MotionEstimate", "damped_offsets", "estimate_motion", "kalman_track"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/land.py DELETED
@@ -1,83 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Land and mountains along a forecast track: when the storm meets them, and what they do to it.
4
-
5
- The idea: a storm's intensity forecast is decided as much by *when it reaches land* as by anything over the
6
- ocean, and the track forecast says when. So land is read along the forecast track, hour by hour, from the
7
- analysis's land-sea mask and surface height:
8
-
9
- * **landfall time** -- the first hour the centre's land fraction passes one half;
10
- * **inland decay** -- Kaplan and DeMaria (1995): over land the wind decays exponentially towards a background
11
- ``V_b`` with rate ``alpha``, starting from the landfall intensity times a reduction factor for the coastal
12
- roughness jump;
13
- * **terrain** -- mountains disrupt the circulation far faster than flat land (Hispaniola, Taiwan, Luzon): the
14
- decay rate grows with the surface height under the centre (``alpha * (1 + h / TERRAIN_SCALE_M)``), the effect
15
- that tore up storms on Hispaniola's 3,000 m peaks and that Dorian avoided by re-forming east of it.
16
-
17
- :func:`land_along_track` samples the fields; :func:`apply_land_decay` turns an over-water intensity forecast
18
- into one that knows where the coast and the mountains are.
19
- """
20
-
21
- from __future__ import annotations
22
-
23
- import numpy as np
24
-
25
- from .fields import Environment
26
- from .geometry import interpolate_track
27
-
28
- BACKGROUND_KT = 26.7
29
- DECAY_PER_H = 0.095
30
- COAST_FACTOR = 0.9
31
- TERRAIN_SCALE_M = 1000.0
32
-
33
-
34
- def land_along_track(env: Environment, lat0: float, lon0: float, track: np.ndarray, leads, step_h: float = 1.0) -> dict:
35
- """Hourly land fraction and surface height (m) along the forecast track (great-circle interpolated)."""
36
- hours = np.concatenate([[0.0], np.asarray(leads, np.float64)])
37
- lats = np.concatenate([[lat0], track[:, 0]])
38
- lons = np.concatenate([[lon0], track[:, 1]])
39
- good = np.isfinite(lats)
40
- hourly = np.arange(0.0, hours[good][-1] + 1e-9, step_h)
41
- la, lo = interpolate_track(hours[good], lats[good], lons[good], hourly)
42
- land = np.clip(env.sample(env.fields["lsm"], la, lo), 0.0, 1.0)
43
- height = np.maximum(env.sample(env.fields["zs"], la, lo) / 9.80665, 0.0)
44
- landfall = hourly[np.argmax(land > 0.5)] if (land > 0.5).any() else np.nan
45
- return {"hours": hourly, "lat": la, "lon": lo, "land": land, "height_m": height, "landfall_hour": float(landfall)}
46
-
47
-
48
- def land_weight(land: np.ndarray) -> np.ndarray:
49
- """How much of the inland decay applies: none below 40% land, all of it above 90%. A 1.5-degree cell over the
50
- Bahamas is partly land; a storm over it is still over water almost everywhere that matters."""
51
- return np.clip((np.asarray(land, np.float64) - 0.4) / 0.5, 0.0, 1.0)
52
-
53
-
54
- def apply_land_decay(hours: np.ndarray, water_kt: np.ndarray, land: np.ndarray, height_m: np.ndarray) -> np.ndarray:
55
- """
56
- Intensity (kt) along ``hours`` given an over-water forecast ``water_kt``: over land the wind decays
57
- Kaplan-DeMaria style from where it came ashore -- at a rate scaled by how much of the cell is land, faster over
58
- high terrain; back over water it resumes from the decayed value and follows the over-water forecast's *changes*
59
- (it does not jump back to it).
60
- """
61
- water = np.asarray(water_kt, np.float64)
62
- out = water.copy()
63
- weight = land_weight(land)
64
- current = water[0]
65
- ashore = False
66
- for k in range(1, len(hours)):
67
- dt = hours[k] - hours[k - 1]
68
- change = water[k] - water[k - 1]
69
- if weight[k] > 0.0:
70
- if not ashore:
71
- current = BACKGROUND_KT + (1.0 - (1.0 - COAST_FACTOR) * weight[k]) * (current - BACKGROUND_KT)
72
- ashore = True
73
- rate = DECAY_PER_H * weight[k] * (1.0 + height_m[k] / TERRAIN_SCALE_M)
74
- current = BACKGROUND_KT + (current - BACKGROUND_KT) * np.exp(-rate * dt)
75
- current = min(current + (1.0 - weight[k]) * max(change, 0.0), water[k])
76
- else:
77
- ashore = False
78
- current = min(current + change, water[k])
79
- out[k] = max(current, 15.0)
80
- return out
81
-
82
-
83
- __all__ = ["BACKGROUND_KT", "DECAY_PER_H", "apply_land_decay", "land_along_track", "land_weight"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/members.py DELETED
@@ -1,251 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- The member suite: every physics and statistical track forecast for one storm, computed from one analysis.
4
-
5
- Each member is a different answer to "where will it go", built from the modules of this package. They are
6
- deliberately diverse -- persistence, climatology, analogs, steering trajectories through frozen, evolving and
7
- climatology-relaxing flows, a barotropic model, an ensemble mean -- because a consensus gains most from members
8
- whose errors differ (:mod:`naturev1.cyclone.consensus`). All of them start from the analysis at the initial
9
- time and the best track up to it; nothing reads the future.
10
-
11
- Shared ingredients are computed once in :func:`build_context`: the vortex removed from the analysis
12
- (:mod:`.vortex`), the vortex's outer strength, the climatological environment for the date (:mod:`.climatology`),
13
- the steering flows of three layers in both, beta drift (:mod:`.beta`), the observed motion and the vorticity-
14
- budget motion (:mod:`.vorticity_advection`), and the 12-hour-earlier steering for the flow tendency.
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- from dataclasses import dataclass, field
20
-
21
- import numpy as np
22
-
23
- from .baselines import xtrp_latlon
24
- from .beta import BetaDrift, outer_strength
25
- from .data import HISTORY_HOURS, LEADS, PREVIOUS_LEVELS
26
- from .fields import Environment
27
- from .geometry import from_local, to_local, velocity_from_positions
28
- from .steering import SteeringFlow
29
- from .trajectory import DecayingResidual, EvolvingFlow, combined, frozen, integrate
30
- from .vortex import remove_vortex
31
-
32
- #: The members the shipped consensus weighs (order matters: it is the column order of the weights).
33
- DEFAULT_MEMBERS = ("xtrp6", "cliper", "analog", "budget_mix", "evo12", "med_anom48", "int_anom48", "budget_anom96",
34
- "evo_anom48", "evo_anom96", "ens48_mean", "baro_res12", "baro")
35
-
36
-
37
- def observed_motion(case) -> tuple[float, float] | None:
38
- """(east, north) m/s over the last 6 hours (else 12)."""
39
- for h in (6, 12):
40
- k = HISTORY_HOURS.index(-h)
41
- la, lo = case.history["lat"][k], case.history["lon"][k]
42
- if np.isfinite(la):
43
- return velocity_from_positions(la, lo, case.lat, case.lon, h)
44
- return None
45
-
46
-
47
- def previous_environment(env: Environment) -> Environment | None:
48
- """The analysis 12 hours earlier, as an :class:`Environment` of its stored winds."""
49
- if "u" not in env.previous:
50
- return None
51
- return Environment(lat=env.lat, lon=env.lon, levels=PREVIOUS_LEVELS,
52
- fields={"u": env.previous["u"], "v": env.previous["v"]})
53
-
54
-
55
- @dataclass
56
- class PhysicsContext:
57
- case: object
58
- env: Environment
59
- clean: Environment
60
- outer_ms: float
61
- motion: tuple[float, float]
62
- motion_mix: tuple[float, float]
63
- beta: BetaDrift
64
- flows: dict
65
- climate: Environment | None = None
66
- climate_flows: dict = field(default_factory=dict)
67
- before: SteeringFlow | None = None
68
- split: object = None
69
-
70
- def start_velocity(self, name: str) -> tuple[float, float]:
71
- u, v = self.flows[name].velocity(self.case.lat, self.case.lon)
72
- return float(u[0]), float(v[0])
73
-
74
- def beta0(self) -> tuple[float, float]:
75
- u, v = self.beta.velocity(self.case.lat, self.case.lon)
76
- return float(u[0]), float(v[0])
77
-
78
- def residual(self, name: str, motion=None, tau_h: float = 24.0) -> DecayingResidual:
79
- m = motion or self.motion
80
- u0, v0 = self.start_velocity(name)
81
- b0 = self.beta0()
82
- return DecayingResidual(m[0] - u0 - b0[0], m[1] - v0 - b0[1], tau_h)
83
-
84
- def beta_velocity(self):
85
- return lambda lat, lon, hours: self.beta.velocity(lat, lon)
86
-
87
-
88
- def build_context(case, env: Environment, climatology=None) -> PhysicsContext | None:
89
- """Everything the members share; None when the storm has no motion history."""
90
- from .climatology import DampedAnomalyFlow # noqa: F401 (imported for its side-effect-free availability)
91
- from .steering import intensity_layer
92
- from .vorticity_advection import budget_motion
93
-
94
- motion = observed_motion(case)
95
- if motion is None:
96
- return None
97
- split = remove_vortex(env, case.lat, case.lon)
98
- clean = split.environment
99
- ows = outer_strength(split)
100
- top, bottom = intensity_layer(case.wind_kt)
101
- layers = {"med": (400, 850), "dlm": (200, 850), "shal": (700, 850), "int": (top, bottom)}
102
- flows = {k: SteeringFlow(clean, *v) for k, v in layers.items()}
103
- climate, cflows = None, {}
104
- if climatology is not None:
105
- climate = climatology.environment(clean, case.time, shift_days=2)
106
- cflows = {k: SteeringFlow(climate, max(v[0], 200), min(v[1], 850)) for k, v in layers.items()}
107
- budget = budget_motion(env, case.lat, case.lon)
108
- mix = motion if budget is None else (0.5 * (budget["u"] + motion[0]), 0.5 * (budget["v"] + motion[1]))
109
- before = None
110
- k = HISTORY_HOURS.index(-12)
111
- la12, lo12 = case.history["lat"][k], case.history["lon"][k]
112
- prev = previous_environment(env)
113
- if prev is not None and np.isfinite(la12):
114
- before = SteeringFlow(remove_vortex(prev, la12, lo12).environment, 400, 850)
115
- return PhysicsContext(case, env, clean, ows, motion, mix, BetaDrift(clean, ows), flows, climate, cflows, before, split)
116
-
117
-
118
- class _EvolvingDamped:
119
- """Evolving flow (12-hour tendency) whose departure from climatology then decays."""
120
-
121
- def __init__(self, now, before, climate, tau_evo: float, tau_anom: float) -> None:
122
- self.evo = EvolvingFlow(frozen(now), frozen(before), tau_evo)
123
- self.climate, self.tau = climate, tau_anom
124
-
125
- def __call__(self, lat, lon, hours):
126
- u, v = self.evo(lat, lon, hours)
127
- uc, vc = self.climate.velocity(lat, lon)
128
- m = np.exp(-np.asarray(hours, np.float64) / self.tau)
129
- return uc + m * (u - uc), vc + m * (v - vc)
130
-
131
-
132
- def _anomaly_member(ctx: PhysicsContext, layer: str, tau: float, motion=None, leads=LEADS):
133
- from .climatology import DampedAnomalyFlow
134
-
135
- if layer not in ctx.climate_flows:
136
- return None
137
- flow = DampedAnomalyFlow(ctx.flows[layer], ctx.climate_flows[layer], tau)
138
- return integrate(ctx.case.lat, ctx.case.lon, combined(flow, ctx.beta_velocity(), ctx.residual(layer, motion)), leads)
139
-
140
-
141
- def run_members(case, env: Environment, climatology=None, analogs=None, cliper_table: dict | None = None,
142
- names=DEFAULT_MEMBERS, leads=LEADS, ensemble_members: int = 24, forecast_flow=None,
143
- model_track: np.ndarray | None = None) -> dict:
144
- """
145
- ``{member: (L, 2) lat/lon}`` for every requested member that can be computed, plus ``"_context"`` (the shared
146
- :class:`PhysicsContext`) and ``"_ensemble"`` (members, spread) when the ensemble ran.
147
-
148
- With dynamical models' forecasts (:class:`naturev1.cyclone.forecastflow.ForecastFlow`, one or ``{name: flow}``,
149
- and/or the models' own tracked storms at ``leads``, one or ``{name: (L, 2)}``) more members join: ``fflow``, the
150
- storm carried through the forecast steering (the equal-weight mean over the models, each also as
151
- ``fflow_<name>``), and ``model``, the models' mean track (each also as ``model_<name>``).
152
- """
153
- from .cliper import cliper_track
154
-
155
- out: dict = {}
156
- wanted = set(names)
157
- if "xtrp6" in wanted:
158
- out["xtrp6"] = xtrp_latlon(case, leads, 6)
159
- if "xtrp12" in wanted:
160
- out["xtrp12"] = xtrp_latlon(case, leads, 12)
161
- if "cliper" in wanted:
162
- out["cliper"] = cliper_track(case, leads, cliper_table)
163
- if "analog" in wanted and analogs is not None:
164
- out["analog"] = analogs.forecast(case, 100, 1.5)
165
- from .multimodel import model_mean
166
-
167
- if model_track is not None:
168
- # one model's track, or several ({name: (L, 2)}): each is a member, and their equal-weight mean is ``model``
169
- tracks = model_track if isinstance(model_track, dict) else {"model": np.asarray(model_track, np.float64)}
170
- for name, t in tracks.items():
171
- if t is not None and name != "model":
172
- out[f"model_{name}"] = np.asarray(t, np.float64)
173
- mean = model_mean(case, tracks, leads)
174
- if mean is not None:
175
- out["model"] = mean
176
- ctx = build_context(case, env, climatology)
177
- if ctx is None:
178
- return out
179
- out["_context"] = ctx
180
- if forecast_flow is not None:
181
- from .forecastflow import fflow_member
182
-
183
- flows = (forecast_flow if isinstance(forecast_flow, dict)
184
- else {str(i): f for i, f in enumerate(forecast_flow)} if isinstance(forecast_flow, (list, tuple))
185
- else {"fflow": forecast_flow})
186
- per = {}
187
- for name, flow in flows.items():
188
- try:
189
- per[name] = fflow_member(ctx, flow, leads)
190
- except Exception: # one unreadable model's flow must not drop the member
191
- continue
192
- if len(per) > 1:
193
- for name, t in per.items():
194
- out[f"fflow_{name}"] = t
195
- mean = model_mean(case, per, leads)
196
- if mean is not None:
197
- out["fflow"] = mean
198
- lat0, lon0 = case.lat, case.lon
199
- beta = ctx.beta_velocity()
200
- if "steer_med" in wanted:
201
- out["steer_med"] = integrate(lat0, lon0, combined(frozen(ctx.flows["med"]), beta, ctx.residual("med")), leads)
202
- if "budget_mix" in wanted:
203
- out["budget_mix"] = integrate(lat0, lon0, combined(frozen(ctx.flows["med"]), beta, ctx.residual("med", ctx.motion_mix)), leads)
204
- if "evo12" in wanted and ctx.before is not None:
205
- out["evo12"] = integrate(lat0, lon0, combined(EvolvingFlow(frozen(ctx.flows["med"]), frozen(ctx.before), 12.0), beta,
206
- ctx.residual("med")), leads)
207
- for name, (layer, tau, mot) in {"med_anom48": ("med", 48.0, None), "med_anom96": ("med", 96.0, None),
208
- "int_anom48": ("int", 48.0, None), "budget_anom48": ("med", 48.0, "mix"),
209
- "budget_anom96": ("med", 96.0, "mix")}.items():
210
- if name in wanted:
211
- track = _anomaly_member(ctx, layer, tau, ctx.motion_mix if mot == "mix" else None, leads)
212
- if track is not None:
213
- out[name] = track
214
- for name, tau in (("evo_anom48", 48.0), ("evo_anom96", 96.0)):
215
- if name in wanted and ctx.before is not None and "med" in ctx.climate_flows:
216
- flow = _EvolvingDamped(ctx.flows["med"], ctx.before, ctx.climate_flows["med"], 12.0, tau)
217
- out[name] = integrate(lat0, lon0, combined(flow, beta, ctx.residual("med", ctx.motion_mix)), leads)
218
- if {"ens48_mean", "ens96_mean"} & wanted:
219
- from .ensemble import physics_ensemble
220
-
221
- flows = {k: ctx.flows[k] for k in ("med", "dlm", "shal")}
222
- cflows = {k: ctx.climate_flows[k] for k in ("med", "dlm", "shal")} if ctx.climate_flows else None
223
- tau = 48.0 if "ens48_mean" in wanted else 96.0
224
- ens = physics_ensemble(case, ctx.clean, flows, ctx.beta, ctx.motion_mix, leads, climate=ctx.climate,
225
- climate_flows=cflows, anomaly_tau_h=tau, members=ensemble_members)
226
- out["ens48_mean" if tau == 48.0 else "ens96_mean"] = ens["mean"]
227
- out["_ensemble"] = ens
228
- if {"baro_res24", "baro_res12", "baro"} & wanted:
229
- from .barotropic import BarotropicModel
230
-
231
- try:
232
- baro = BarotropicModel(ctx.clean, lat0, lon0, ctx.outer_ms, case.r34_nmi).run(leads)["track"]
233
- except (FloatingPointError, np.linalg.LinAlgError, ValueError):
234
- baro = None
235
- if baro is not None:
236
- out["baro"] = baro
237
- first = list(leads).index(min(leads))
238
- x0, y0 = to_local(lat0, lon0, baro[first, 0], baro[first, 1])
239
- hours = float(leads[first])
240
- ru = ctx.motion_mix[0] - x0 / hours / 3.6
241
- rv = ctx.motion_mix[1] - y0 / hours / 3.6
242
- for name, tau in (("baro_res12", 12.0), ("baro_res24", 24.0)):
243
- if name in wanted:
244
- shift = tau * 3.6 * (1 - np.exp(-np.asarray(leads, float) / tau))
245
- bx, by = to_local(lat0, lon0, baro[:, 0], baro[:, 1])
246
- la, lo = from_local(lat0, lon0, bx + ru * shift, by + rv * shift)
247
- out[name] = np.stack([la, lo], -1)
248
- return out
249
-
250
-
251
- __all__ = ["DEFAULT_MEMBERS", "PhysicsContext", "build_context", "observed_motion", "previous_environment", "run_members"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/multimodel.py DELETED
@@ -1,134 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- Several models at once -- the one thing in track forecasting that always helps.
4
-
5
- NHC's best guidance is not any single model but their equal-weight average (TVCN, GUNA): the models' errors are
6
- partly independent, so the average errs less than the best of them. Measured here on the held-out 2018 and
7
- 2020-2022 seasons, replacing HRES alone by the equal-weight mean of every archived model available for a forecast
8
- (HRES, Pangu-Weather, GraphCast, FuXi, Aurora, FGN) cut the blended track error by 11% at 24 h and 19-23% at
9
- 48-120 h, with no weight fitted. Live, the models are GFS, ECMWF IFS, AIFS and GraphCast-GFS, read by
10
- :mod:`naturev1.swarm.models`.
11
-
12
- Two pieces live here:
13
-
14
- * :func:`model_mean`: the equal-weight mean of model tracks in the storm-centred frame, lead by lead over whichever
15
- models still have the storm (a model that lost it drops out at that lead, nothing else changes). It is what the
16
- consensus is blended with (:func:`naturev1.cyclone.consensus.blend_with_model`), and the forecast-flow members of
17
- several models are averaged the same way.
18
- * :func:`flow_dependent_cone`: the cone sized for *this* forecast rather than for the average one (Goerss's GPCE
19
- idea): a regression, fitted on the tuning seasons, of the consensus error on how far the physics members spread,
20
- how far the model mean sits from the consensus, how far the forecast-flow member sits from the model, the storm's
21
- speed, latitude and intensity. On the held-out seasons its radius ranks the actual errors with correlation
22
- 0.17-0.38 (the fixed cone, by construction, 0) at the same 67% coverage.
23
-
24
- Two things that did **not** help, measured the same way and left out: anchoring the model track to the observed
25
- position (it makes the model's error correlated with the consensus's and loses the averaging), and shrinking the
26
- model's weight when it disagrees with the forecast-flow member (when they disagree, the model is still the better
27
- of the two).
28
- """
29
-
30
- from __future__ import annotations
31
-
32
- import numpy as np
33
-
34
- from .geometry import from_local, to_local
35
-
36
-
37
- def model_mean(case, tracks: dict | list | np.ndarray | None, leads) -> np.ndarray | None:
38
- """
39
- ``(L, 2)`` equal-weight mean of model tracks (``{name: (L, 2)}``, a list, or one ``(L, 2)`` array), in the
40
- storm-centred frame; NaN at a lead no model reaches. ``None`` when there is nothing.
41
- """
42
- if tracks is None:
43
- return None
44
- arrays = list(tracks.values()) if isinstance(tracks, dict) else ([tracks] if isinstance(tracks, np.ndarray) else list(tracks))
45
- arrays = [np.asarray(t, np.float64) for t in arrays if t is not None]
46
- if not arrays:
47
- return None
48
- if len(arrays) == 1:
49
- return arrays[0]
50
- stack = np.stack(arrays) # (M, L, 2)
51
- east, north = to_local(case.lat, case.lon, stack[..., 0], stack[..., 1])
52
- ok = np.isfinite(east) & np.isfinite(north)
53
- count = ok.sum(0)
54
- with np.errstate(invalid="ignore"):
55
- e = np.where(ok, east, 0.0).sum(0) / np.where(count > 0, count, np.nan)
56
- n = np.where(ok, north, 0.0).sum(0) / np.where(count > 0, count, np.nan)
57
- lat, lon = from_local(case.lat, case.lon, e, n)
58
- out = np.stack([lat, lon], -1)
59
- out[count == 0] = np.nan
60
- return out
61
-
62
-
63
- def model_spread_km(case, tracks: dict | list | None) -> np.ndarray | None:
64
- """``(L,)`` mean distance of the model tracks from their mean (NaN where fewer than two models remain)."""
65
- if tracks is None:
66
- return None
67
- arrays = [np.asarray(t, np.float64) for t in (tracks.values() if isinstance(tracks, dict) else tracks) if t is not None]
68
- if len(arrays) < 2:
69
- return None
70
- stack = np.stack(arrays)
71
- east, north = to_local(case.lat, case.lon, stack[..., 0], stack[..., 1])
72
- ok = np.isfinite(east) & np.isfinite(north)
73
- count = ok.sum(0)
74
- me = np.where(ok, east, 0.0).sum(0) / np.maximum(count, 1)
75
- mn = np.where(ok, north, 0.0).sum(0) / np.maximum(count, 1)
76
- d = np.where(ok, np.hypot(east - me, north - mn), 0.0).sum(0) / np.maximum(count, 1)
77
- return np.where(count >= 2, d, np.nan)
78
-
79
-
80
- #: Predictors of :func:`flow_dependent_cone`, in order.
81
- CONE_PREDICTORS = ("one", "member_spread_km", "model_consensus_km", "fflow_model_km", "speed_kmh", "abs_lat", "wind_kt")
82
-
83
-
84
- def cone_predictors(case, consensus: np.ndarray, members: dict, member_names, model: np.ndarray | None,
85
- fflow: np.ndarray | None, leads) -> np.ndarray:
86
- """``(L, 7)`` predictor rows for one forecast (missing pieces fall back to typical values via NaN -> 0 / medians)."""
87
- from .baselines import motion_back
88
- from .geometry import local_displacement_km
89
-
90
- L = len(leads)
91
- ce, cn = to_local(case.lat, case.lon, consensus[:, 0], consensus[:, 1])
92
- spread = np.full(L, np.nan)
93
- stack = [np.asarray(members[m], np.float64) for m in member_names if m in members]
94
- if stack:
95
- arr = np.stack(stack) # (M, L, 2)
96
- e, n = to_local(case.lat, case.lon, arr[..., 0], arr[..., 1])
97
- d = np.hypot(e - ce[None], n - cn[None])
98
- spread = np.nanmean(d, axis=0)
99
- dmc = np.full(L, np.nan)
100
- if model is not None:
101
- me, mn = to_local(case.lat, case.lon, model[:, 0], model[:, 1])
102
- dmc = np.hypot(me - ce, mn - cn)
103
- dfm = np.full(L, np.nan)
104
- if model is not None and fflow is not None:
105
- fe, fn = to_local(case.lat, case.lon, fflow[:, 0], fflow[:, 1])
106
- dfm = np.hypot(fe - me, fn - mn)
107
- speed = np.nan
108
- rate = motion_back(case, 6)
109
- if rate is not None:
110
- e6, n6 = local_displacement_km(case.lat - rate[0] * 6, case.lon - rate[1] * 6, case.lat, case.lon)
111
- speed = float(np.hypot(e6, n6)) / 6.0
112
- rows = np.stack([np.ones(L), spread, dmc, dfm, np.full(L, speed), np.full(L, abs(case.lat)), np.full(L, case.wind_kt)], -1)
113
- return rows
114
-
115
-
116
- def flow_dependent_cone(predictors: np.ndarray, table: dict, leads) -> np.ndarray:
117
- """``(L,)`` cone radius (km) from the fitted regression ``table`` (``coefficients.CONE_FLOW``)."""
118
- coef = np.asarray(table["coef"], np.float64) # (L_table, 7)
119
- scale = np.asarray(table["scale"], np.float64)
120
- medians = np.asarray(table.get("medians", np.zeros(coef.shape[1])), np.float64)
121
- x = np.where(np.isfinite(predictors), predictors, medians[None])
122
- hours = np.asarray(leads, np.float64)
123
- tl = np.asarray(table["leads"], np.float64)
124
- out = np.empty(len(hours))
125
- for k, h in enumerate(hours):
126
- j = int(np.clip(np.searchsorted(tl, h, side="right") - 1, 0, tl.size - 2))
127
- a = float(np.clip((h - tl[j]) / max(tl[j + 1] - tl[j], 1e-9), 0.0, 1.0))
128
- c = (1 - a) * coef[j] + a * coef[j + 1]
129
- s = (1 - a) * scale[j] + a * scale[j + 1]
130
- out[k] = max(float(x[k] @ c), 5.0) * s
131
- return out
132
-
133
-
134
- __all__ = ["CONE_PREDICTORS", "cone_predictors", "flow_dependent_cone", "model_mean", "model_spread_km"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/neural.py DELETED
@@ -1,80 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- NatureV1's own forecast as consensus members: the storm followed through the model's rollout, and steered by it.
4
-
5
- The physics members start from a single analysis; their weakness is the flow they cannot forecast. A distilled
6
- NatureV1 forecasts that flow. This module runs the full-state rollout once per initial time of a hindcast season
7
- (:func:`naturev1.tracker.rollout_member_tracks`, one rollout for every storm active then) and turns the result
8
- into two more members on the same footing as the physics ones -- the vortex tracked in the forecast pressure
9
- field, and the storm carried by the forecast steering flow. :func:`hybrid_consensus` then blends them with the
10
- physics members by along/cross-track weights fitted on past seasons: physics where it is strong (the first
11
- hours, where persistence and the vorticity budget know more than any 1.5-degree field), the model's flow where
12
- it is strong (days two to five).
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import json
18
- from pathlib import Path
19
-
20
- import numpy as np
21
- import torch
22
-
23
-
24
- def rollout_tracks(model, era5: str | Path, stepper, variables, cases, leads, precision: str = "bf16",
25
- history: int | None = None, log=print) -> dict:
26
- """``{case key: {"rollout_vortex": (L, 2), "rollout_steering": (L, 2)}}`` lat/lon for every case."""
27
- from ..era5 import Normalizer
28
- from ..model import calendar_features
29
- from ..satdata import prepared_to_state
30
- from ..tracker import rollout_member_tracks
31
-
32
- era5 = Path(era5)
33
- meta = json.loads(era5.with_suffix(".json").read_text())
34
- normalizer = Normalizer.from_dict(meta["statistics"])
35
- times = np.asarray(meta["time"], np.int64)
36
- index = {int(t): i for i, t in enumerate(times)}
37
- values = np.load(era5, mmap_mode="r")
38
- history = history or model.config.history_frames
39
- device = next(model.parameters()).device
40
- by_time: dict[int, list] = {}
41
- for case in cases:
42
- by_time.setdefault(int(np.datetime64(case.time, "s").astype(np.int64)), []).append(case)
43
- out = {}
44
- for n, (t0, group) in enumerate(sorted(by_time.items()), start=1):
45
- i0 = index.get(t0)
46
- if i0 is None or i0 < history - 1:
47
- continue
48
- window = torch.stack([prepared_to_state(values[i0 - history + 1 + k], normalizer, model.config.analysis_channels)
49
- for k in range(history)])[None].to(device)
50
- stamps = torch.tensor(times[i0 - history + 1:i0 + 1], dtype=torch.float64)[None]
51
- batch = {"analysis": window, "calendar": calendar_features(stamps).to(device), "valid_time": stamps[:, -1].to(device)}
52
- centres = torch.tensor([[c.lat, c.lon] for c in group], dtype=torch.float32, device=device)
53
- wind = torch.tensor([c.wind_kt if np.isfinite(c.wind_kt) else 50.0 for c in group], device=device)
54
- with torch.no_grad():
55
- members = rollout_member_tracks(model, batch, stepper, centres, leads, variables, precision, wind_kt=wind)
56
- for s, case in enumerate(group):
57
- entry = {}
58
- for name in ("rollout_vortex", "rollout_steering"):
59
- d = members[name][s].detach().cpu().numpy().astype(np.float64) # (L, 2) degrees
60
- entry[name] = np.stack([case.lat + d[:, 0], ((case.lon + d[:, 1] + 180.0) % 360.0) - 180.0], -1)
61
- out[case.key] = entry
62
- if log and n % 100 == 0:
63
- log(f"[rollout-tracks] {n}/{len(by_time)} initial times")
64
- return out
65
-
66
-
67
- def hybrid_consensus(cases, physics: dict, rollout: dict, weights: dict) -> np.ndarray:
68
- """``(N, L, 2)``: physics members and rollout members blended by ``weights`` (``fit_consensus`` output plus
69
- ``"members"``, the column order)."""
70
- from .consensus import apply_consensus
71
-
72
- nan = np.full((len(weights["along"]), 2), np.nan)
73
- columns = []
74
- for name in weights["members"]:
75
- source = rollout if name.startswith("rollout_") else physics
76
- columns.append(np.stack([source.get(c.key, {}).get(name, nan) for c in cases]))
77
- return apply_consensus(cases, np.stack(columns, 2), weights)
78
-
79
-
80
- __all__ = ["hybrid_consensus", "rollout_tracks"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/ocean.py DELETED
@@ -1,182 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- The ocean fights back: the cold wake a storm stirs up, and how much of it the storm itself has to sit on.
4
-
5
- The idea: a hurricane's winds mix cold thermocline water up to the surface. A storm moving at 5 m/s leaves the
6
- cold water behind it; a storm that stalls -- Dorian over Grand Bahama, 1 mph for a day and a half -- sits on top
7
- of its own wake, its fuel supply drops, and it weakens even with everything else favourable. Potential
8
- intensity computed from the analysis's sea-surface temperature knows none of this. Here, after the
9
- observational fits of Lloyd and Vecchi (2011) and Mei et al. (2012):
10
-
11
- * **wake cooling** grows with intensity (wind stress goes as speed squared, mixing faster still) and falls
12
- with translation speed (less time over each patch of ocean), and is smaller over very warm water (a warmer
13
- surface usually means a deeper mixed layer, more water to stir before the cold arrives);
14
- * **the share under the core**: a fast storm feels a small fraction of its wake, a stalled one nearly all;
15
- * **residence time**: cooling under a stalled storm accumulates (e-folding ``RESIDENCE_H``), so the longer it
16
- sits, the colder its ocean.
17
-
18
- :func:`effective_sst` gives the sea-surface temperature the core actually feels at each forecast hour, which
19
- the intensity model passes to potential intensity (:mod:`naturev1.cyclone.intensity`).
20
- """
21
-
22
- from __future__ import annotations
23
-
24
- import numpy as np
25
-
26
- #: Peak wake cooling for a 100 kt storm moving at 5 m/s over 28.5 C water, K.
27
- COOLING_K = 1.4
28
- #: Speed scale of the translation dependence, m/s, and its exponent.
29
- SPEED_SCALE_MS = 5.0
30
- SPEED_POWER = 0.8
31
- #: E-folding time over which cooling under a slow storm accumulates, hours.
32
- RESIDENCE_H = 18.0
33
-
34
-
35
- #: Depth of the 26 C isotherm the cooling constants refer to (m), and how cooling falls with a deeper warm layer.
36
- D26_REFERENCE_M = 50.0
37
- D26_POWER = 0.5
38
-
39
-
40
- # With the climatology present and ``d26_power`` 0 (the shipped value), the depth branch is taken with a factor of exactly 1:
41
- # the 0.14 surface-temperature warm-water factor is replaced, and the 0.15 refit absorbed the difference.
42
- def wake_cooling_k(vmax_kt, translation_ms, sst_k, d26_m=None, d26_power: float | None = None) -> np.ndarray:
43
- """
44
- Peak cooling of the wake, K: rises with intensity^1.5, falls with translation speed and warm water -- and, when the
45
- depth of the 26 C isotherm ``d26_m`` is known, with the depth of warm water (there is more of it to stir before
46
- the cold arrives: a storm over 100 m of warm water cools its surface about 0.7 times what it would over 50 m).
47
- Without ``d26_m`` the surface temperature stands in for it, as before.
48
- """
49
- v = np.clip(np.asarray(vmax_kt, np.float64), 0.0, 200.0) / 100.0
50
- u = np.maximum(np.asarray(translation_ms, np.float64), 0.3)
51
- speed = np.clip((SPEED_SCALE_MS / u) ** SPEED_POWER, 0.4, 4.0)
52
- if d26_m is None:
53
- warm = np.clip(1.0 - 0.25 * (np.asarray(sst_k, np.float64) - 273.15 - 28.5), 0.5, 1.3)
54
- else:
55
- depth = np.where(np.isfinite(d26_m), np.asarray(d26_m, np.float64), D26_REFERENCE_M)
56
- power = D26_POWER if d26_power is None else float(d26_power)
57
- warm = np.clip((D26_REFERENCE_M / np.maximum(depth, 5.0)) ** power, 0.35, 1.8)
58
- return COOLING_K * v ** 1.5 * speed * warm
59
-
60
-
61
- def core_share(translation_ms) -> np.ndarray:
62
- """Fraction of the wake cooling felt under the core: ~1 for a stalled storm, ~0.25 at 7 m/s."""
63
- u = np.maximum(np.asarray(translation_ms, np.float64), 0.0)
64
- return 1.0 / (1.0 + u / 2.3)
65
-
66
-
67
- def effective_sst(sst_k: np.ndarray, vmax_kt: np.ndarray, translation_ms: np.ndarray, hours: np.ndarray,
68
- d26_m: np.ndarray | None = None, d26_power: float | None = None) -> np.ndarray:
69
- """
70
- Sea-surface temperature under the core along a forecast, K.
71
-
72
- All arguments are ``(T,)`` along the forecast (``hours`` from now). Slow stretches accumulate cooling with
73
- :data:`RESIDENCE_H`; moving off resets the residence (the storm reaches new water). With ``d26_m`` (the depth of
74
- the 26 C isotherm along the track, from :class:`OceanClimatology`) the cooling knows how deep the warm water is:
75
- Melissa crawled over the central Caribbean, where the 26 C water runs 100 m deep in October, and intensified --
76
- a surface-temperature-only wake would have had her cooling her own ocean.
77
- """
78
- sst_k = np.asarray(sst_k, np.float64)
79
- out = sst_k.copy()
80
- residence = 0.0
81
- hours = np.asarray(hours, np.float64)
82
- for k in range(sst_k.size):
83
- dt = hours[k] - hours[k - 1] if k else 0.0
84
- slow = translation_ms[k] < 3.0
85
- residence = residence + dt if slow else max(residence - 2.0 * dt, 0.0)
86
- build = 1.0 - np.exp(-(residence + 6.0) / RESIDENCE_H)
87
- depth = None if d26_m is None else d26_m[k]
88
- cooling = wake_cooling_k(vmax_kt[k], translation_ms[k], sst_k[k], depth, d26_power) * core_share(translation_ms[k]) * build
89
- out[k] = sst_k[k] - cooling
90
- return out
91
-
92
-
93
- def anomaly_adjusted_depth(d26_m, sst_k, sst_clim_c, per_c: float) -> np.ndarray:
94
- """
95
- The climatological 26 C depth deepened (or shoaled) by this year's surface anomaly: ``D26 * (1 + per_c * dSST)``,
96
- clipped to 0.5-2 times. The climatology cannot know a record-warm year; the analysis's sea-surface temperature can,
97
- and a warmer surface over the same water column usually means a deeper warm layer. ``per_c`` is fitted on the
98
- tuning seasons (``coefficients.OCEAN["sst_anomaly_per_c"]``); 0 leaves the climatology alone.
99
- """
100
- d = np.asarray(d26_m, np.float64)
101
- if not per_c:
102
- return d
103
- anomaly = np.asarray(sst_k, np.float64) - 273.15 - np.asarray(sst_clim_c, np.float64)
104
- factor = np.clip(1.0 + per_c * np.nan_to_num(anomaly, nan=0.0), 0.5, 2.0)
105
- return np.where(np.isfinite(d), d * factor, d)
106
-
107
-
108
- class OceanClimatology:
109
- """
110
- Monthly depth of the 26 C isotherm (m) and tropical-cyclone heat potential (kJ/cm2, the heat above 26 C down to
111
- that depth) on a 1-degree grid, computed from the World Ocean Atlas 2023 decadal-average temperature analysis
112
- (0-1500 m, 57 levels). ``ocean_climatology.npz`` ships with the package; :meth:`default` loads it once.
113
-
114
- Heat potential is what the SHIPS rapid-intensification index uses in place of surface temperature where it
115
- can, because a storm's fuel is the warm *layer*, not the skin: 28.5 C over 30 m of warm water and 28.5 C over
116
- 120 m are different oceans to a hurricane sitting on them for a day.
117
- """
118
-
119
- _default = None
120
-
121
- def __init__(self, d26_m: np.ndarray, tchp_kj_cm2: np.ndarray, lat: np.ndarray, lon: np.ndarray, sst_c=None) -> None:
122
- self.d26 = np.asarray(d26_m, np.float32) # (12, 180, 360)
123
- self.tchp = np.asarray(tchp_kj_cm2, np.float32)
124
- self.sst = None if sst_c is None else np.asarray(sst_c, np.float32) # the climatology's own surface, C
125
- self.lat = np.asarray(lat, np.float64)
126
- self.lon = np.asarray(lon, np.float64)
127
-
128
- @classmethod
129
- def default(cls) -> "OceanClimatology | None":
130
- """The packaged climatology (None when the package was installed without it)."""
131
- if cls._default is None:
132
- try:
133
- from importlib import resources
134
-
135
- with resources.files("naturev1.cyclone").joinpath("ocean_climatology.npz").open("rb") as handle:
136
- data = np.load(handle)
137
- cls._default = cls(data["d26_m"], data["tchp_kj_cm2"], data["lat"], data["lon"], data.get("sst_c"))
138
- except Exception:
139
- cls._default = False
140
- return cls._default or None
141
-
142
- def _month_weights(self, time) -> tuple[int, int, float]:
143
- """The two months around ``time`` and the weight of the second (monthly means sit mid-month)."""
144
- t = np.datetime64(time, "s")
145
- year = t.astype("datetime64[Y]").astype(int) + 1970
146
- day_of_year = (t - np.datetime64(f"{year}-01-01", "s")) / np.timedelta64(1, "D")
147
- pos = day_of_year / 365.25 * 12.0 - 0.5 # 0 at mid-January
148
- m0 = int(np.floor(pos)) % 12
149
- return m0, (m0 + 1) % 12, float(pos - np.floor(pos))
150
-
151
- def sample(self, lat, lon, time) -> dict:
152
- """``{"d26_m", "tchp_kj_cm2", "sst_c"}`` at points (degrees), bilinear in space between the two months around
153
- ``time``; land points take the nearest ocean value within a degree or two (NaN beyond)."""
154
- la = np.atleast_1d(np.asarray(lat, np.float64))
155
- lo = (np.atleast_1d(np.asarray(lon, np.float64)) + 180.0) % 360.0 - 180.0
156
- m0, m1, w = self._month_weights(time)
157
- fi = np.clip((la - self.lat[0]) / (self.lat[1] - self.lat[0]), 0, self.lat.size - 1.001)
158
- fj = ((lo - self.lon[0]) / (self.lon[1] - self.lon[0])) % self.lon.size
159
- i0, j0 = np.floor(fi).astype(int), np.floor(fj).astype(int) % self.lon.size
160
- di, dj = fi - i0, fj - j0
161
- i1, j1 = np.minimum(i0 + 1, self.lat.size - 1), (j0 + 1) % self.lon.size
162
- out = {}
163
- for name, grid in (("d26_m", self.d26), ("tchp_kj_cm2", self.tchp)) + ((("sst_c", self.sst),) if self.sst is not None else ()):
164
- g = (1.0 - w) * grid[m0] + w * grid[m1]
165
- corners = np.stack([g[i0, j0], g[i0, j1], g[i1, j0], g[i1, j1]])
166
- weights = np.stack([(1 - di) * (1 - dj), (1 - di) * dj, di * (1 - dj), di * dj])
167
- ok = np.isfinite(corners)
168
- total = (weights * ok).sum(0)
169
- value = np.where(total > 0, (np.where(ok, corners, 0.0) * weights).sum(0) / np.maximum(total, 1e-9), np.nan)
170
- missing = ~np.isfinite(value)
171
- if missing.any(): # coastal points: the nearest ocean cell within 2 degrees
172
- for k in np.nonzero(missing)[0]:
173
- ii = slice(max(i0[k] - 2, 0), min(i0[k] + 3, self.lat.size))
174
- jj = np.arange(j0[k] - 2, j0[k] + 3) % self.lon.size
175
- patch = g[ii][:, jj]
176
- value[k] = np.nanmean(patch) if np.isfinite(patch).any() else np.nan
177
- out[name] = value
178
- return out
179
-
180
-
181
- __all__ = ["COOLING_K", "D26_POWER", "D26_REFERENCE_M", "OceanClimatology", "RESIDENCE_H", "anomaly_adjusted_depth", "core_share",
182
- "effective_sst", "wake_cooling_k"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/ocean_climatology.npz DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:2144380a1fc589f8b77b376a92f6855a2946b2048e3052586f457f52c2879ac7
3
- size 1190344
 
 
 
 
naturev1/cyclone/outflow.py DELETED
@@ -1,75 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- The top of the storm: upper-tropospheric outflow, and the troughs that feed it or tear it.
4
-
5
- The idea: a hurricane is a heat engine whose exhaust is the 200 hPa outflow. How easily it can vent decides
6
- whether it intensifies, and the upper-level environment acts in two ways the low-level predictors miss:
7
-
8
- * **divergence aloft** -- strong 200 hPa divergence over the storm (an outflow jet, a trough's right entrance
9
- region nearby) evacuates mass and favours deepening;
10
- * **eddy flux convergence of angular momentum** (EFC; Molinari and Vollaro 1989, a SHIPS predictor) -- the
11
- convergence of the eddy flux of relative angular momentum by the environment's asymmetric flow at 200 hPa,
12
- 300-600 km out: ``EFC = -(1 / r^2) d(r^2 <u_L' v_T'>) / dr``. Large positive EFC from an approaching trough
13
- spins up the outflow anticyclone and can trigger intensification -- *if* shear stays moderate; with strong
14
- shear the same trough destroys the storm. The classification below encodes that distinction.
15
- * **outflow channels** -- the number of azimuthal sectors (of eight) with strong radial outflow at 500-1000 km:
16
- one channel is ordinary, two (poleward into a jet and equatorward) is the signature of the most intense storms;
17
- * **outflow temperature** comes from the potential intensity calculation (:mod:`naturev1.thermo`).
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import numpy as np
23
-
24
- from .fields import Environment
25
- from .geometry import destination
26
-
27
-
28
- def _ring_winds(env: Environment, lat0: float, lon0: float, radius_km: float, level: int, azimuths: int = 32):
29
- bearing = np.arange(azimuths) * 360.0 / azimuths
30
- plat, plon = destination(lat0, lon0, bearing, radius_km)
31
- u = env.sample(env.level("u", level), plat, plon)
32
- v = env.sample(env.level("v", level), plat, plon)
33
- b = np.radians(bearing)
34
- radial = u * np.sin(b) + v * np.cos(b)
35
- tangential = -u * np.cos(b) + v * np.sin(b)
36
- return radial, tangential
37
-
38
-
39
- def eddy_flux_convergence(env: Environment, lat0: float, lon0: float, level: int = 200,
40
- radii_km=(300.0, 400.0, 500.0, 600.0)) -> float:
41
- """EFC in m/s per day, averaged over 300-600 km (cyclonic sign per hemisphere)."""
42
- sign = 1.0 if lat0 >= 0 else -1.0
43
- radii = np.asarray(radii_km) * 1000.0
44
- flux = []
45
- for r in radii_km:
46
- radial, tangential = _ring_winds(env, lat0, lon0, r, level)
47
- flux.append(np.mean((radial - radial.mean()) * (sign * tangential - np.mean(sign * tangential))))
48
- flux = np.asarray(flux)
49
- d = np.gradient(radii ** 2 * flux, radii)
50
- efc = -(d / radii ** 2)
51
- return float(np.mean(efc) * 86400.0)
52
-
53
-
54
- def outflow_diagnostics(env: Environment, env_clean: Environment, lat0: float, lon0: float, shear_ms: float) -> dict:
55
- """Divergence aloft, EFC, outflow channels and a trough-interaction class (favourable / hostile / none)."""
56
- div = env.divergence(env.level("u", 200), env.level("v", 200))
57
- r = env.distance_km(lat0, lon0)
58
- disc = r < 1000.0
59
- divergence = float(div[disc].mean()) * 1e5 if disc.any() else 0.0
60
- efc = eddy_flux_convergence(env_clean, lat0, lon0)
61
- channels = 0
62
- for radius in (500.0, 750.0, 1000.0):
63
- radial, _ = _ring_winds(env, lat0, lon0, radius, 200, azimuths=32)
64
- sectors = radial.reshape(8, 4).mean(1)
65
- channels = max(channels, int((sectors > 6.0).sum()))
66
- if efc > 10.0 and shear_ms < 10.0:
67
- interaction = "favourable"
68
- elif efc > 10.0:
69
- interaction = "hostile"
70
- else:
71
- interaction = "none"
72
- return {"divergence_200": divergence, "efc_ms_day": efc, "outflow_channels": channels, "trough_interaction": interaction}
73
-
74
-
75
- __all__ = ["eddy_flux_convergence", "outflow_diagnostics"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
naturev1/cyclone/pouch.py DELETED
@@ -1,66 +0,0 @@
1
- # Copyright 2026 Nathan. Apache-2.0.
2
- """
3
- The marsupial pouch: a closed circulation in the frame that moves with the wave.
4
-
5
- The idea (Dunkerton, Montgomery and Wang 2009): most Atlantic and many Pacific storms form inside tropical
6
- waves, and the wave matters because of what it looks like *in its own moving frame*. In the Earth-relative
7
- frame a wave's circulation is usually open -- air blows straight through it. In the frame moving west with the
8
- wave, a critical layer forms where the flow speed equals the wave's phase speed, and the streamlines close
9
- into a "pouch" that protects the moist, rotating core from dry air, letting convection organise long enough
10
- for a vortex to spin up. A disturbance with a closed pouch is a disturbance that can become a storm; one
11
- without, usually cannot.
12
-
13
- :func:`pouch_closedness` evaluates the circulation of the wave-relative 850/700 hPa flow on rings around a
14
- disturbance: the share of the ring where the relative tangential wind is cyclonic, the mean relative tangential
15
- wind, and the relative circulation -- 1 for a perfectly closed, strongly cyclonic pouch, 0 for none. The phase
16
- speed comes from the disturbance's own motion (its candidate track) or a climatological -7 m/s westward.
17
- """
18
-
19
- from __future__ import annotations
20
-
21
- import numpy as np
22
-
23
- from .geometry import destination, hemisphere
24
-
25
- DEFAULT_PHASE_MS = (-7.0, 0.0)
26
-
27
-
28
- def pouch_closedness(env, lat0: float, lon0: float, phase_ms=DEFAULT_PHASE_MS, levels=("850", "600"),
29
- radii_km=(250.0, 400.0, 550.0), azimuths: int = 24) -> dict:
30
- """Closedness of the wave-relative circulation around (lat0, lon0) in an analysis band."""
31
- sign = float(hemisphere(lat0))
32
- shares, speeds = [], []
33
- for level in levels:
34
- u = env.fields[f"u{level}"] - phase_ms[0]
35
- v = env.fields[f"v{level}"] - phase_ms[1]
36
- for radius in radii_km:
37
- bearing = np.arange(azimuths) * 360.0 / azimuths
38
- plat, plon = destination(lat0, lon0, bearing, radius)
39
- uu, vv = env.sample(u, plat, plon), env.sample(v, plat, plon)
40
- b = np.radians(bearing)
41
- # Tangential unit vector for counter-clockwise rotation at a point bearing b from the centre.
42
- te, tn = -np.cos(b), np.sin(b)
43
- vt = sign * (uu * te + vv * tn)
44
- shares.append(float(np.mean(vt > 0)))
45
- speeds.append(float(np.mean(vt)))
46
- share = float(np.mean(shares))
47
- speed = float(np.mean(speeds))
48
- score = share * float(np.clip(speed / 5.0, 0.0, 1.0))
49
- return {"share_cyclonic": share, "mean_tangential_ms": speed, "closedness": score}
50
-
51
-
52
- def phase_from_history(candidate) -> tuple[float, float]:
53
- """Phase speed (m/s) of a genesis candidate from its last 12-24 hours of positions, or the default."""
54
- from .geometry import velocity_from_positions
55
-
56
- if not candidate.history:
57
- return DEFAULT_PHASE_MS
58
- earlier = candidate.history[-2] if len(candidate.history) >= 2 else candidate.history[-1]
59
- hours = (candidate.time - earlier.time) / np.timedelta64(1, "h")
60
- if hours <= 0:
61
- return DEFAULT_PHASE_MS
62
- u, v = velocity_from_positions(earlier.lat, earlier.lon, candidate.lat, candidate.lon, hours)
63
- return float(np.clip(u, -15, 5)), float(np.clip(v, -5, 5))
64
-
65
-
66
- __all__ = ["DEFAULT_PHASE_MS", "phase_from_history", "pouch_closedness"]