NatureV2-500: weights only; the code lives on PyPI
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- LICENSE +0 -203
- NOTICE +0 -11
- NatureV1_model_card.md +0 -0
- README.md +6 -35
- RESULTS_0.16.md +0 -208
- ROADMAP_0.16.md +0 -55
- b300_naturev1.py +0 -151
- colab_naturev1.py +0 -766
- docs/ARCHITECTURE.md +0 -282
- melissa_jamaica.png +0 -3
- melissa_landfall.png +0 -3
- molab_swarm_cell.py +0 -311
- naturev1/__init__.py +0 -253
- naturev1/_pipeline.py +0 -772
- naturev1/assimilation.py +0 -290
- naturev1/b300.py +0 -257
- naturev1/besttrack.py +0 -239
- naturev1/checkpoint.py +0 -408
- naturev1/corpora.py +0 -174
- naturev1/cyclone/__init__.py +0 -53
- naturev1/cyclone/aids.py +0 -213
- naturev1/cyclone/analogs.py +0 -136
- naturev1/cyclone/barotropic.py +0 -240
- naturev1/cyclone/baselines.py +0 -95
- naturev1/cyclone/benchmark.py +0 -105
- naturev1/cyclone/beta.py +0 -82
- naturev1/cyclone/bogus.py +0 -78
- naturev1/cyclone/climatology.py +0 -101
- naturev1/cyclone/cliper.py +0 -137
- naturev1/cyclone/coefficients.py +0 -770
- naturev1/cyclone/consensus.py +0 -234
- naturev1/cyclone/data.py +0 -361
- naturev1/cyclone/ensemble.py +0 -136
- naturev1/cyclone/field_tracker.py +0 -127
- naturev1/cyclone/fields.py +0 -231
- naturev1/cyclone/fit.py +0 -85
- naturev1/cyclone/forecast.py +0 -284
- naturev1/cyclone/forecastflow.py +0 -88
- naturev1/cyclone/genesis.py +0 -347
- naturev1/cyclone/geometry.py +0 -154
- naturev1/cyclone/intensity.py +0 -746
- naturev1/cyclone/kalman.py +0 -137
- naturev1/cyclone/land.py +0 -83
- naturev1/cyclone/members.py +0 -251
- naturev1/cyclone/multimodel.py +0 -134
- naturev1/cyclone/neural.py +0 -80
- naturev1/cyclone/ocean.py +0 -182
- naturev1/cyclone/ocean_climatology.npz +0 -3
- naturev1/cyclone/outflow.py +0 -75
- 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 |
-
|
| 16 |
-
|
| 17 |
-
[CorxLabs/NatureV1-500](https://huggingface.co/CorxLabs/NatureV1-500)
|
| 18 |
-
|
|
|
|
| 19 |
|
| 20 |
-
|
| 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
|
melissa_landfall.png
DELETED
Git LFS Details
|
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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|