gpuwm-global 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. arwen_global/__init__.py +61 -0
  2. arwen_global/__main__.py +4 -0
  3. arwen_global/_version.py +35 -0
  4. arwen_global/abi_fast_model.py +456 -0
  5. arwen_global/abi_operator.py +1214 -0
  6. arwen_global/abi_radiance_operator.py +714 -0
  7. arwen_global/abi_reference.py +722 -0
  8. arwen_global/analysis_fetch.py +188 -0
  9. arwen_global/analysis_initial.py +1127 -0
  10. arwen_global/assimilate.py +2107 -0
  11. arwen_global/bands.py +1035 -0
  12. arwen_global/cards.py +1606 -0
  13. arwen_global/checkpoint.py +651 -0
  14. arwen_global/cli.py +2930 -0
  15. arwen_global/column_sounding.py +488 -0
  16. arwen_global/config.py +1535 -0
  17. arwen_global/configs/arwen_global_gdas_t255_imex_dry.toml +61 -0
  18. arwen_global/configs/arwen_global_gdas_t255_imex_dry_dt40.toml +61 -0
  19. arwen_global/configs/arwen_global_gdas_t255_jet48_24h.toml +133 -0
  20. arwen_global/configs/arwen_global_gdas_t255_native_24h.toml +135 -0
  21. arwen_global/configs/arwen_global_gdas_t255_native_24h_bare.toml +89 -0
  22. arwen_global/configs/arwen_global_gdas_t255_native_closure_24h.toml +144 -0
  23. arwen_global/configs/arwen_global_gdas_t255_native_imex_24h.toml +129 -0
  24. arwen_global/configs/arwen_global_gdas_t255_native_imex_24h_dt120.toml +119 -0
  25. arwen_global/configs/arwen_global_gdas_t255_native_imex_24h_dt135.toml +120 -0
  26. arwen_global/configs/arwen_global_gdas_t255_native_imex_wall.toml +113 -0
  27. arwen_global/configs/arwen_global_gdas_t255_native_sl_si_24h.toml +138 -0
  28. arwen_global/configs/arwen_global_gdas_t255_native_sl_si_24h_alpha050.toml +133 -0
  29. arwen_global/configs/arwen_global_gdas_t255_native_sl_si_24h_alpha053.toml +133 -0
  30. arwen_global/configs/arwen_global_gdas_t255_native_sl_si_24h_alpha060.toml +133 -0
  31. arwen_global/configs/arwen_global_gdas_t255_native_sl_si_24h_dt100.toml +130 -0
  32. arwen_global/configs/arwen_global_gdas_t255_native_sl_si_24h_dt200.toml +130 -0
  33. arwen_global/configs/arwen_global_gdas_t255_native_sl_si_24h_dt450.toml +130 -0
  34. arwen_global/configs/arwen_global_gdas_t255_native_sl_si_24h_dt600.toml +130 -0
  35. arwen_global/configs/arwen_global_gdas_t255_native_sl_si_24h_fluxform.toml +147 -0
  36. arwen_global/configs/arwen_global_gdas_t255_native_sl_si_6h.toml +135 -0
  37. arwen_global/configs/arwen_global_gdas_t255_native_sl_si_6h_unclipped.toml +140 -0
  38. arwen_global/configs/arwen_global_gdas_t255_native_sl_si_wall.toml +122 -0
  39. arwen_global/configs/arwen_global_gdas_t255_sl_si_dry.toml +66 -0
  40. arwen_global/configs/arwen_global_gdas_t383_native_24h.toml +90 -0
  41. arwen_global/configs/arwen_global_gdas_t383_native_imex_24h.toml +99 -0
  42. arwen_global/configs/arwen_global_gdas_t383_native_imex_24h_dt80.toml +114 -0
  43. arwen_global/configs/arwen_global_gdas_t383_native_sl_si_24h.toml +114 -0
  44. arwen_global/configs/arwen_global_gdas_t383_native_sl_si_wall.toml +122 -0
  45. arwen_global/configs/arwen_global_gdas_t533_24h.toml +61 -0
  46. arwen_global/configs/arwen_global_gdas_t533_native_24h.toml +90 -0
  47. arwen_global/configs/arwen_global_gdas_t533_native_imex_24h.toml +131 -0
  48. arwen_global/configs/arwen_global_gdas_t533_native_sl_si_24h.toml +114 -0
  49. arwen_global/configs/arwen_global_gdas_t533_native_sl_si_wall.toml +122 -0
  50. arwen_global/configs/arwen_global_gdas_t63_48h.toml +60 -0
  51. arwen_global/configs/arwen_global_gdas_t799_native_sl_si_wall.toml +122 -0
  52. arwen_global/configs/arwen_global_held_suarez_t42_imex.toml +75 -0
  53. arwen_global/configs/arwen_global_held_suarez_t42_sl_si.toml +79 -0
  54. arwen_global/configs/arwen_global_hybrid_6h_reference.toml +69 -0
  55. arwen_global/configs/arwen_global_jw06_t255_imex.toml +71 -0
  56. arwen_global/configs/arwen_global_jw06_t255_imex_steady.toml +71 -0
  57. arwen_global/configs/arwen_global_jw06_t255_sl_si.toml +76 -0
  58. arwen_global/configs/arwen_global_jw06_t255_sl_si_steady.toml +76 -0
  59. arwen_global/configs/arwen_global_jw06_t85_imex.toml +63 -0
  60. arwen_global/configs/arwen_global_jw06_t85_imex_steady.toml +63 -0
  61. arwen_global/configs/arwen_global_jw06_t85_sl_si.toml +68 -0
  62. arwen_global/configs/arwen_global_jw06_t85_sl_si_steady.toml +68 -0
  63. arwen_global/configs/arwen_global_level5_native_smoke.toml +73 -0
  64. arwen_global/configs/arwen_global_moist_smoke.toml +84 -0
  65. arwen_global/configs/arwen_global_native_physics_bridge_example.toml +63 -0
  66. arwen_global/configs/arwen_global_t21_baroclinic_ten_day.toml +68 -0
  67. arwen_global/configs/arwen_global_t255_quickstart.toml +137 -0
  68. arwen_global/configs/arwen_global_t85_vertical_modes_ab.toml +87 -0
  69. arwen_global/configs/global_spectral_held_suarez_smoke.toml +42 -0
  70. arwen_global/configs/global_spectral_primitive_smoke.toml +42 -0
  71. arwen_global/configs/global_spectral_williamson2.toml +36 -0
  72. arwen_global/configs_dir.py +110 -0
  73. arwen_global/constants.py +99 -0
  74. arwen_global/core/__init__.py +46 -0
  75. arwen_global/core/gf.py +527 -0
  76. arwen_global/core/kernels/__init__.py +170 -0
  77. arwen_global/core/kernels/common.cuh +7 -0
  78. arwen_global/core/kernels/gf.cu +4325 -0
  79. arwen_global/core/kernels/glibc_flt32.cuh +649 -0
  80. arwen_global/core/kernels/morrison.cu +1207 -0
  81. arwen_global/core/kernels/noah.cu +1526 -0
  82. arwen_global/core/kernels/ntiedtke.cu +3840 -0
  83. arwen_global/core/kernels/rrtmgp_cloud.cu +131 -0
  84. arwen_global/core/kernels/rrtmgp_gas.cu +411 -0
  85. arwen_global/core/kernels/rrtmgp_mcica.cu +159 -0
  86. arwen_global/core/kernels/rrtmgp_planck_common.cuh +94 -0
  87. arwen_global/core/kernels/rrtmgp_rte.cu +574 -0
  88. arwen_global/core/kernels/rrtmgp_validation.cu +119 -0
  89. arwen_global/core/kernels/sfclay.cu +554 -0
  90. arwen_global/core/kernels/ysu.cu +728 -0
  91. arwen_global/core/kernels/ysu_validation.cu +34 -0
  92. arwen_global/core/landuse.py +468 -0
  93. arwen_global/core/morrison.py +244 -0
  94. arwen_global/core/noah.py +513 -0
  95. arwen_global/core/npref.py +11296 -0
  96. arwen_global/core/ntiedtke.py +1590 -0
  97. arwen_global/core/physics.py +5486 -0
  98. arwen_global/core/physics_inventory.py +370 -0
  99. arwen_global/core/rrtmgp.py +5125 -0
  100. arwen_global/core/sfclay.py +181 -0
  101. arwen_global/core/ysu.py +264 -0
  102. arwen_global/core/ysu_contract.py +57 -0
  103. arwen_global/cycle.py +1233 -0
  104. arwen_global/da/__init__.py +106 -0
  105. arwen_global/da/analysis.py +1993 -0
  106. arwen_global/da/ensemble.py +767 -0
  107. arwen_global/da/letkf_point.py +1310 -0
  108. arwen_global/da/localisation.py +518 -0
  109. arwen_global/da/measure.py +166 -0
  110. arwen_global/da/observation_errors.py +131 -0
  111. arwen_global/da/observations.py +326 -0
  112. arwen_global/da/operators.py +685 -0
  113. arwen_global/da/options.py +819 -0
  114. arwen_global/da/osse.py +1189 -0
  115. arwen_global/da/perturbations.py +661 -0
  116. arwen_global/da/static_covariance.py +724 -0
  117. arwen_global/da/window.py +234 -0
  118. arwen_global/da_anchor.py +312 -0
  119. arwen_global/da_control.py +243 -0
  120. arwen_global/da_door.py +1105 -0
  121. arwen_global/da_filter.py +1597 -0
  122. arwen_global/da_scorecard.py +967 -0
  123. arwen_global/da_static.py +247 -0
  124. arwen_global/da_streams.py +697 -0
  125. arwen_global/da_twin.py +533 -0
  126. arwen_global/da_window.py +75 -0
  127. arwen_global/data/authorities/rw-wps-ecmwf-open-data-global-forecast-grib2.mapping.json +174 -0
  128. arwen_global/data/authorities/rw-wps-gdas-global-analysis-grib2.mapping.json +932 -0
  129. arwen_global/data/authorities/rw-wps-gdas-pgrb2-0p25-microwave-columns.mapping.json +1026 -0
  130. arwen_global/data/authorities/rw-wps-gfs-pgrb2-0p25-cloud-cover.mapping.json +1084 -0
  131. arwen_global/data/authorities/rw-wps-gfs-surface-flux-grib2.mapping.json +1090 -0
  132. arwen_global/data/authorities/rw-wps-gfs-surface-state-grib2.mapping.json +1046 -0
  133. arwen_global/data/door-pins.json +125 -0
  134. arwen_global/data/engine-seam.json +285 -0
  135. arwen_global/data/noah_tables/GENPARM.TBL +36 -0
  136. arwen_global/data/noah_tables/LANDUSE.TBL +586 -0
  137. arwen_global/data/noah_tables/PROVENANCE.md +28 -0
  138. arwen_global/data/noah_tables/SOILPARM.TBL +45 -0
  139. arwen_global/data/noah_tables/VEGPARM.TBL +291 -0
  140. arwen_global/data/render-catalog-global.json +3247 -0
  141. arwen_global/data/static-covariance-v1-receipt.json +503 -0
  142. arwen_global/data/static-covariance-v1.npz +0 -0
  143. arwen_global/device_memory.py +463 -0
  144. arwen_global/diurnal_phase.py +1099 -0
  145. arwen_global/doctor.py +686 -0
  146. arwen_global/doors.py +793 -0
  147. arwen_global/dynamics.py +3646 -0
  148. arwen_global/engine_compat.py +331 -0
  149. arwen_global/engine_seam.py +149 -0
  150. arwen_global/export.py +317 -0
  151. arwen_global/fetch_doors.py +281 -0
  152. arwen_global/go_door.py +192 -0
  153. arwen_global/imex.py +410 -0
  154. arwen_global/initial_conditions.py +161 -0
  155. arwen_global/insitu/__init__.py +39 -0
  156. arwen_global/insitu/budgets.py +285 -0
  157. arwen_global/insitu/capture.py +221 -0
  158. arwen_global/insitu/energy.py +425 -0
  159. arwen_global/insitu/ledger.py +567 -0
  160. arwen_global/insitu/options.py +91 -0
  161. arwen_global/insitu/spectra.py +95 -0
  162. arwen_global/insitu/tripwires.py +465 -0
  163. arwen_global/mapped_source_compat.py +503 -0
  164. arwen_global/massflux_diagnostic.py +731 -0
  165. arwen_global/microwave/__init__.py +88 -0
  166. arwen_global/microwave/__main__.py +266 -0
  167. arwen_global/microwave/absorption.py +211 -0
  168. arwen_global/microwave/atms_bridge.py +253 -0
  169. arwen_global/microwave/atms_fetch.py +568 -0
  170. arwen_global/microwave/calibrate.py +350 -0
  171. arwen_global/microwave/channels.py +101 -0
  172. arwen_global/microwave/columns.py +282 -0
  173. arwen_global/microwave/emissivity.py +105 -0
  174. arwen_global/microwave/entry.py +763 -0
  175. arwen_global/microwave/rte.py +447 -0
  176. arwen_global/microwave/score.py +568 -0
  177. arwen_global/migration.py +407 -0
  178. arwen_global/native_qualification.py +376 -0
  179. arwen_global/obs_doors.py +201 -0
  180. arwen_global/obs_operators.py +618 -0
  181. arwen_global/obs_pack.py +152 -0
  182. arwen_global/obs_scorecard.py +1637 -0
  183. arwen_global/obs_streams.py +1360 -0
  184. arwen_global/obs_table.py +854 -0
  185. arwen_global/pbl_free_atmosphere.py +1001 -0
  186. arwen_global/physics/__init__.py +18 -0
  187. arwen_global/physics/arwen_bridge.py +196 -0
  188. arwen_global/physics/arwen_massflux.py +844 -0
  189. arwen_global/physics/banding.py +201 -0
  190. arwen_global/physics/builtin_adapters.py +186 -0
  191. arwen_global/physics/exchange.py +203 -0
  192. arwen_global/physics/frozen_surface.py +464 -0
  193. arwen_global/physics/native_batch.py +384 -0
  194. arwen_global/physics/native_options.py +410 -0
  195. arwen_global/physics/native_runtime.py +1788 -0
  196. arwen_global/physics/native_state.py +297 -0
  197. arwen_global/physics/native_suite.py +634 -0
  198. arwen_global/physics/provenance.py +126 -0
  199. arwen_global/physics/reference.py +1596 -0
  200. arwen_global/physics/registry.py +224 -0
  201. arwen_global/physics/surface_diagnostics.py +217 -0
  202. arwen_global/pins.py +425 -0
  203. arwen_global/profile.py +552 -0
  204. arwen_global/radiance_streams.py +1013 -0
  205. arwen_global/radiance_tables/abi-fast-model.json +5321 -0
  206. arwen_global/radiance_tables/abi-operator-entries.json +351 -0
  207. arwen_global/radiance_tables/atms-noaa-20-2026-09-01.entry.json +325 -0
  208. arwen_global/radiance_tables/atms-noaa-21-2026-09-01.entry.json +325 -0
  209. arwen_global/radiation_scorecard.py +1392 -0
  210. arwen_global/receipt.py +174 -0
  211. arwen_global/regional/__init__.py +30 -0
  212. arwen_global/regional/artifact.py +379 -0
  213. arwen_global/regional/interpolation.py +234 -0
  214. arwen_global/regional/runtime.py +405 -0
  215. arwen_global/regional/translate.py +301 -0
  216. arwen_global/render_door.py +285 -0
  217. arwen_global/runner.py +1673 -0
  218. arwen_global/runplan.py +3005 -0
  219. arwen_global/semi_implicit.py +986 -0
  220. arwen_global/semilag/__init__.py +115 -0
  221. arwen_global/semilag/_cuda.py +60 -0
  222. arwen_global/semilag/cases.py +700 -0
  223. arwen_global/semilag/interpolate.py +479 -0
  224. arwen_global/semilag/kernels.cu +596 -0
  225. arwen_global/semilag/options.py +229 -0
  226. arwen_global/semilag/pins.py +64 -0
  227. arwen_global/semilag/rhs.py +220 -0
  228. arwen_global/semilag/state.py +102 -0
  229. arwen_global/semilag/step.py +592 -0
  230. arwen_global/semilag/tables.py +270 -0
  231. arwen_global/semilag/tracers.py +303 -0
  232. arwen_global/semilag/trajectory.py +963 -0
  233. arwen_global/semilag/vectors.py +147 -0
  234. arwen_global/sizing.py +3879 -0
  235. arwen_global/sources.py +565 -0
  236. arwen_global/spectral/__init__.py +55 -0
  237. arwen_global/spectral/__main__.py +4 -0
  238. arwen_global/spectral/backend.py +100 -0
  239. arwen_global/spectral/checkpoint.py +237 -0
  240. arwen_global/spectral/cli.py +420 -0
  241. arwen_global/spectral/compression.py +612 -0
  242. arwen_global/spectral/config.py +507 -0
  243. arwen_global/spectral/constants.py +16 -0
  244. arwen_global/spectral/diffusion.py +40 -0
  245. arwen_global/spectral/eddy_viscosity.py +193 -0
  246. arwen_global/spectral/export.py +211 -0
  247. arwen_global/spectral/fused.py +216 -0
  248. arwen_global/spectral/grid.py +231 -0
  249. arwen_global/spectral/initial_conditions.py +97 -0
  250. arwen_global/spectral/legendre.py +190 -0
  251. arwen_global/spectral/pins.py +72 -0
  252. arwen_global/spectral/primitive.py +437 -0
  253. arwen_global/spectral/receipt.py +47 -0
  254. arwen_global/spectral/runner.py +614 -0
  255. arwen_global/spectral/sampling.py +571 -0
  256. arwen_global/spectral/shallow_water.py +147 -0
  257. arwen_global/spectral/state.py +74 -0
  258. arwen_global/spectral/timestep.py +59 -0
  259. arwen_global/spectral/transform.py +1694 -0
  260. arwen_global/spectral/vector.py +137 -0
  261. arwen_global/spill.py +781 -0
  262. arwen_global/state.py +357 -0
  263. arwen_global/statics.py +972 -0
  264. arwen_global/statics_rows.py +172 -0
  265. arwen_global/status.py +162 -0
  266. arwen_global/storm_reader.py +845 -0
  267. arwen_global/surface_energy.py +1122 -0
  268. arwen_global/surface_seeding.py +694 -0
  269. arwen_global/testcases.py +373 -0
  270. arwen_global/transport.py +1191 -0
  271. arwen_global/trigger_diagnostic.py +717 -0
  272. arwen_global/tui_worker.py +153 -0
  273. arwen_global/upper_air_scorecard.py +2048 -0
  274. arwen_global/vertical.py +624 -0
  275. arwen_global/water.py +221 -0
  276. arwen_global/water_budget.py +1393 -0
  277. arwen_global/wrfout_export.py +515 -0
  278. gpuwm_global-0.1.0.dist-info/METADATA +476 -0
  279. gpuwm_global-0.1.0.dist-info/RECORD +284 -0
  280. gpuwm_global-0.1.0.dist-info/WHEEL +5 -0
  281. gpuwm_global-0.1.0.dist-info/entry_points.txt +2 -0
  282. gpuwm_global-0.1.0.dist-info/licenses/LICENSE +202 -0
  283. gpuwm_global-0.1.0.dist-info/licenses/NOTICE +98 -0
  284. gpuwm_global-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,61 @@
1
+ """Arwen Global: moist spectral model, native physics, and parent bridge."""
2
+ from ._version import (
3
+ CONSOLE_SCRIPT,
4
+ DISTRIBUTION_NAME,
5
+ IMPORT_NAME,
6
+ __version__,
7
+ )
8
+ from .config import ArwenGlobalConfig, load_config
9
+ from .dynamics import MoistHybridModel
10
+ from .export import export_parent, read_parent_export
11
+ from .initial_conditions import analytic_initial_state
12
+ from .migration import migrate_level4_checkpoint
13
+ from .native_qualification import qualify_native_adapter
14
+ from .pins import PINS_HASH, pins_hash, pins_receipt
15
+ from .regional import (
16
+ attach_parent_series,
17
+ install_initial_and_attach_parent,
18
+ translate_parent_to_regional_frame,
19
+ write_regional_target_from_state,
20
+ )
21
+ from .runner import build_model_and_cold_state, build_transform, run
22
+ from .semi_implicit import BarotropicSemiImplicit, VerticalModeSemiImplicit
23
+ from .state import (
24
+ ArwenGlobalState,
25
+ MoistHybridState,
26
+ PhysicsState,
27
+ SurfaceState,
28
+ )
29
+ from .vertical import HybridCoordinate
30
+
31
+ __all__ = [
32
+ "CONSOLE_SCRIPT",
33
+ "DISTRIBUTION_NAME",
34
+ "IMPORT_NAME",
35
+ "__version__",
36
+ "ArwenGlobalConfig",
37
+ "ArwenGlobalState",
38
+ "BarotropicSemiImplicit",
39
+ "VerticalModeSemiImplicit",
40
+ "HybridCoordinate",
41
+ "MoistHybridModel",
42
+ "MoistHybridState",
43
+ "PINS_HASH",
44
+ "PhysicsState",
45
+ "SurfaceState",
46
+ "analytic_initial_state",
47
+ "attach_parent_series",
48
+ "build_model_and_cold_state",
49
+ "build_transform",
50
+ "export_parent",
51
+ "install_initial_and_attach_parent",
52
+ "load_config",
53
+ "migrate_level4_checkpoint",
54
+ "pins_hash",
55
+ "pins_receipt",
56
+ "qualify_native_adapter",
57
+ "read_parent_export",
58
+ "run",
59
+ "translate_parent_to_regional_frame",
60
+ "write_regional_target_from_state",
61
+ ]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,35 @@
1
+ """The distribution's identity, read back rather than restated.
2
+
3
+ Kept in its own module so that asking what version is installed does not
4
+ import the model. ``arwen_global/__init__.py`` pulls in the dynamics, the
5
+ regional bridge and the semi-implicit solvers at module scope, all of which
6
+ need the engine; a packaging gate that only wants the number must not need a
7
+ GPU stack to get it.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from importlib.metadata import PackageNotFoundError
12
+ from importlib.metadata import version as _distribution_version
13
+
14
+ __all__ = ["DISTRIBUTION_NAME", "IMPORT_NAME", "CONSOLE_SCRIPT", "__version__"]
15
+
16
+ #: The name this package is published under.
17
+ DISTRIBUTION_NAME = "gpuwm-global"
18
+
19
+ #: The name a user types in an import line. It is the model's own name and
20
+ #: not the engine's: putting the engine's name in every user import line
21
+ #: would name the wrong owner for code the engine does not carry.
22
+ IMPORT_NAME = "arwen_global"
23
+
24
+ #: The one console script, and the prog name every parser and every refusal
25
+ #: sentence in this package uses.
26
+ CONSOLE_SCRIPT = "gpuwm-global"
27
+
28
+ # Read out of installed metadata. pyproject's [project].version is the single
29
+ # place the number is written; a second literal is a promise to update two
30
+ # files at every cut, and the engine already broke that promise once.
31
+ try:
32
+ __version__ = _distribution_version(DISTRIBUTION_NAME)
33
+ except PackageNotFoundError: # pragma: no cover - an uninstalled source tree
34
+ # Says so rather than inventing a number.
35
+ __version__ = "0+unknown"
@@ -0,0 +1,456 @@
1
+ """The ABI clear-sky fast forward model: its coefficient table and its trainer.
2
+
3
+ The operator the ensemble filter runs is Rust (``rw_goes forward``, in
4
+ ``tools/rustwx/crates/rw-goes/src/forward.rs``): it takes the model
5
+ columns of a ``gpuwm-da.abi-columns.v2`` stream, evaluates per-layer
6
+ optical depths from a coefficient table, marches the emission radiative
7
+ transfer, converts through the instrument's own band-corrected Planck
8
+ relation and writes brightness temperatures with finite-difference
9
+ Jacobians. This module is the table's trainer and its Python oracle:
10
+
11
+ * the **trainer** fits, per model layer and per band, the layer nadir
12
+ optical depth CRTM (the numerical reference, ``abi_reference``) computed
13
+ on the analysed columns of the case, as a function of named per-layer
14
+ features (the layer's vapor path, its temperature and pressure, the
15
+ slant vapor path above it, its neighbours' vapor) in one of two forms:
16
+ ``linear`` (the window band: dry and continuum terms add) or
17
+ ``two_term`` (the water-vapor band: ``exp(wet polynomial) + dry``,
18
+ fitted by damped Gauss-Newton on the log residual so the emitting layers
19
+ are fitted to relative accuracy);
20
+ * the **oracle** evaluates a table in numpy exactly as the Rust operator
21
+ does, so a test can pin the Rust to it bit for bit on a small column
22
+ set, and the validation numbers a table carries come from it.
23
+
24
+ The table is tied to the vertical coordinate it was trained on (per-layer
25
+ coefficients); it carries the coordinate and the Rust refuses a column set
26
+ on another. The Planck constants are the instrument's own, read from the
27
+ Level 1b granules (``fk1, fk2, bc1, bc2``); the transmittance model is
28
+ trained on CRTM with the GOES-16 ABI coefficients (the closest public
29
+ set), and the difference to the GOES-18 set is recorded as the sensor term
30
+ (0.04 K in band 13, 0.2 K in band 8 on this case).
31
+
32
+ Nothing here is a data path of the shipped system: the trainer runs once
33
+ per coordinate, the oracle runs in tests.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import datetime as dt
38
+ import hashlib
39
+ import json
40
+ import os
41
+ from pathlib import Path
42
+ from typing import Any
43
+
44
+ import numpy as np
45
+
46
+ FAST_MODEL_SCHEMA = "gpuwm-da.abi-fast-model.v1"
47
+
48
+ #: The feature vocabulary, by name. The Rust operator computes exactly
49
+ #: these (``forward.rs::features``); a table may only name terms built
50
+ #: from them.
51
+ FEATURE_NAMES: tuple[str, ...] = (
52
+ "one", "dp", "u", "e", "lnp", "Tn", "lu", "lUs", "Twn", "lP", "lsec", "lu_up", "lu_dn", "Tn_up", "Tn_dn",
53
+ )
54
+ FEATURE_DEFINITIONS: dict[str, str] = {
55
+ "one": "1",
56
+ "dp": "layer thickness, hPa (p_half below minus p_half above)",
57
+ "u": "layer vapor path, q[g/kg] * dp[hPa]",
58
+ "e": "vapor pressure proxy, q[g/kg] * p_full[hPa] * 1.608e-3 (hPa)",
59
+ "lnp": "ln(p_full[hPa])",
60
+ "Tn": "(T[K] - 250) / 50",
61
+ "lu": "ln(max(u, u_floor))",
62
+ "lUs": "ln(max(U_mid, u_floor) * sec(zenith)), U_mid the vapor path from the top of the atmosphere to the layer middle",
63
+ "Twn": "(Tw - 250) / 50, Tw the vapor-path-weighted temperature from the top to the layer middle",
64
+ "lP": "ln(max(Pw, 1e-3)), Pw the vapor-path-weighted pressure from the top to the layer middle (hPa)",
65
+ "lsec": "ln(sec(zenith))",
66
+ "lu_up": "lu of the layer above (the top layer repeats its own)",
67
+ "lu_dn": "lu of the layer below (the bottom layer repeats its own)",
68
+ "Tn_up": "Tn of the layer above",
69
+ "Tn_dn": "Tn of the layer below",
70
+ }
71
+ FEATURE_CONSTANTS = {"t_ref_k": 250.0, "t_scale_k": 50.0, "u_floor": 1.0e-8, "e_factor": 1.608e-3, "pw_floor_hpa": 1.0e-3}
72
+
73
+ #: The window band (13): dry (pressure, temperature) and vapor (continuum,
74
+ #: self and foreign) terms add; linear least squares per layer.
75
+ LINEAR_TERMS_WINDOW: tuple[tuple[str, ...], ...] = (
76
+ ("dp",), ("dp", "Tn"), ("dp", "Tn", "Tn"), ("u",), ("u", "e"), ("u", "e", "Tn"), ("u", "lnp"), ("u", "Tn"),
77
+ ("u", "Tn", "Tn"), ("u", "u"), ("u", "e", "e"),
78
+ )
79
+ #: The water-vapor band (8): ``exp(wet) + dry``.
80
+ WET_TERMS: tuple[tuple[str, ...], ...] = (
81
+ ("one",), ("lu",), ("lu", "lu"), ("lu", "lu", "lu"), ("lnp",), ("Tn",), ("lu", "Tn"), ("lu", "lnp"),
82
+ ("Tn", "Tn"), ("lUs",), ("lUs", "lUs"), ("lu", "lUs"), ("Twn",), ("Twn", "lu"), ("lP",), ("lUs", "Tn"),
83
+ ("lsec",), ("lsec", "lu"), ("lsec", "lsec"), ("lsec", "lUs"), ("lUs", "lUs", "lUs"), ("lsec", "Tn"),
84
+ ("lnp", "lnp"), ("Tn", "Tn", "Tn"), ("lu", "Tn", "Tn"),
85
+ ("lu_up",), ("lu_dn",), ("lu_up", "lu"), ("lu_dn", "lu"), ("lu_up", "lu_up"), ("lu_dn", "lu_dn"), ("Tn_up",), ("Tn_dn",),
86
+ ("lu_up", "lsec"), ("lu_dn", "lsec"),
87
+ )
88
+ DRY_TERMS: tuple[tuple[str, ...], ...] = (("dp",), ("dp", "Tn"), ("dp", "Tn", "Tn"), ("dp", "lnp"))
89
+
90
+ #: Finite-difference steps the Rust Jacobians use (and the oracle).
91
+ JACOBIAN_STEPS = {"temperature_k": 0.1, "vapor_relative": 0.01, "skin_k": 0.1}
92
+
93
+
94
+ class FastModelError(ValueError):
95
+ """The table cannot be built or applied as asked. Names the breakage."""
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # features and terms
100
+ # ---------------------------------------------------------------------------
101
+
102
+ def features(temperature_k: np.ndarray, q_gkg: np.ndarray, p_half_hpa: np.ndarray, p_full_hpa: np.ndarray,
103
+ zenith_deg: np.ndarray) -> dict[str, np.ndarray]:
104
+ """Every named feature, ``(n, nlay)``, top of atmosphere first."""
105
+ c = FEATURE_CONSTANTS
106
+ T = np.asarray(temperature_k, dtype=np.float64)
107
+ q = np.asarray(q_gkg, dtype=np.float64)
108
+ p_half = np.asarray(p_half_hpa, dtype=np.float64)
109
+ p_full = np.asarray(p_full_hpa, dtype=np.float64)
110
+ sec = 1.0 / np.cos(np.radians(np.asarray(zenith_deg, dtype=np.float64)))
111
+ dp = p_half[:, 1:] - p_half[:, :-1]
112
+ u = q * dp
113
+ lnp = np.log(p_full)
114
+ Tn = (T - c["t_ref_k"]) / c["t_scale_k"]
115
+ lu = np.log(np.maximum(u, c["u_floor"]))
116
+ cum = np.cumsum(u, axis=1)
117
+ U_mid = np.maximum(cum - 0.5 * u, c["u_floor"])
118
+ lUs = np.log(U_mid * sec[:, None])
119
+ Tw = (np.cumsum(u * T, axis=1) - 0.5 * u * T) / U_mid
120
+ Twn = (Tw - c["t_ref_k"]) / c["t_scale_k"]
121
+ Pw = (np.cumsum(u * p_full, axis=1) - 0.5 * u * p_full) / U_mid
122
+ lP = np.log(np.maximum(Pw, c["pw_floor_hpa"]))
123
+ lsec = np.log(sec)[:, None] * np.ones_like(u)
124
+ e = q * p_full * c["e_factor"]
125
+ lu_up = np.concatenate([lu[:, :1], lu[:, :-1]], axis=1)
126
+ lu_dn = np.concatenate([lu[:, 1:], lu[:, -1:]], axis=1)
127
+ Tn_up = np.concatenate([Tn[:, :1], Tn[:, :-1]], axis=1)
128
+ Tn_dn = np.concatenate([Tn[:, 1:], Tn[:, -1:]], axis=1)
129
+ return {"one": np.ones_like(u), "dp": dp, "u": u, "e": e, "lnp": lnp, "Tn": Tn, "lu": lu, "lUs": lUs, "Twn": Twn,
130
+ "lP": lP, "lsec": lsec, "lu_up": lu_up, "lu_dn": lu_dn, "Tn_up": Tn_up, "Tn_dn": Tn_dn}
131
+
132
+
133
+ def clip_features(feat: dict[str, np.ndarray], clip: dict[str, dict[str, list[float]]]) -> dict[str, np.ndarray]:
134
+ """Hold every clipped feature inside the per-layer range the table
135
+ recorded from its training set (the Rust does the same), so a column
136
+ outside the training envelope extrapolates no further than its edge."""
137
+ out = dict(feat)
138
+ for name, bounds in clip.items():
139
+ lo = np.asarray(bounds["min"], dtype=np.float64)[None, :]
140
+ hi = np.asarray(bounds["max"], dtype=np.float64)[None, :]
141
+ out[name] = np.minimum(np.maximum(feat[name], lo), hi)
142
+ return out
143
+
144
+
145
+ def design(feat: dict[str, np.ndarray], terms) -> np.ndarray:
146
+ """``(n, nlay, n_terms)`` products of named features."""
147
+ n, nlay = feat["one"].shape
148
+ X = np.ones((n, nlay, len(terms)))
149
+ for j, term in enumerate(terms):
150
+ for name in term:
151
+ if name not in feat:
152
+ raise FastModelError(f"term {term} names an unknown feature {name!r}; the vocabulary is {FEATURE_NAMES}")
153
+ X[:, :, j] *= feat[name]
154
+ return X
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Planck and the emission march (the arithmetic the Rust carries)
159
+ # ---------------------------------------------------------------------------
160
+
161
+ def planck_radiance(planck: dict, temperature_k: np.ndarray) -> np.ndarray:
162
+ """Band-corrected Planck radiance, the L1b convention: ``L = fk1 / (exp(fk2 / (bc1 + bc2 T)) - 1)``."""
163
+ teff = planck["bc1"] + planck["bc2"] * np.asarray(temperature_k, dtype=np.float64)
164
+ return planck["fk1"] / (np.exp(planck["fk2"] / teff) - 1.0)
165
+
166
+
167
+ def planck_temperature(planck: dict, radiance: np.ndarray) -> np.ndarray:
168
+ """The inverse: ``T = (fk2 / ln(fk1 / L + 1) - bc1) / bc2``."""
169
+ return (planck["fk2"] / np.log(planck["fk1"] / np.asarray(radiance, dtype=np.float64) + 1.0) - planck["bc1"]) / planck["bc2"]
170
+
171
+
172
+ def emission_radiance(planck: dict, od_slant: np.ndarray, temperature_k: np.ndarray, skin_k: np.ndarray,
173
+ emissivity: np.ndarray) -> np.ndarray:
174
+ """The clear-sky emission march CRTM's solver takes (specular
175
+ downwelling along the same slant path): upwelling layer emission plus
176
+ the surface's emission and its reflection of the downwelling."""
177
+ n = od_slant.shape[0]
178
+ cum = np.cumsum(od_slant, axis=1)
179
+ t_top = np.concatenate([np.ones((n, 1)), np.exp(-cum)], axis=1) # top of layer k to space
180
+ B = planck_radiance(planck, temperature_k)
181
+ up = np.sum(B * (t_top[:, :-1] - t_top[:, 1:]), axis=1)
182
+ t_sfc = t_top[:, -1]
183
+ rev = np.cumsum(od_slant[:, ::-1], axis=1)[:, ::-1] # layer k to the surface
184
+ t_bot_sfc = np.concatenate([np.exp(-rev[:, 1:]), np.ones((n, 1))], axis=1)
185
+ down = np.sum(B * (t_bot_sfc - np.exp(-rev)), axis=1)
186
+ return up + emissivity * planck_radiance(planck, skin_k) * t_sfc + (1.0 - emissivity) * down * t_sfc
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # the table: evaluation (the oracle)
191
+ # ---------------------------------------------------------------------------
192
+
193
+ def layer_optical_depth(band_table: dict, feat: dict[str, np.ndarray]) -> np.ndarray:
194
+ """Nadir layer optical depth ``(n, nlay)`` from a band's table."""
195
+ feat = clip_features(feat, band_table.get("clip", {}))
196
+ layers = band_table["layers"]
197
+ nlay = len(layers)
198
+ if feat["one"].shape[1] != nlay:
199
+ raise FastModelError(f"the columns carry {feat['one'].shape[1]} layers, the table {nlay}")
200
+ if band_table["form"] == "linear":
201
+ X = design(feat, [tuple(t) for t in band_table["terms_linear"]])
202
+ coef = np.asarray([row["linear"] for row in layers]) # (nlay, nt)
203
+ return np.maximum(np.einsum("nkt,kt->nk", X, coef), 0.0)
204
+ if band_table["form"] == "two_term":
205
+ Xw = design(feat, [tuple(t) for t in band_table["terms_wet"]])
206
+ Xd = design(feat, [tuple(t) for t in band_table["terms_dry"]])
207
+ w = np.asarray([row["wet"] for row in layers])
208
+ d = np.asarray([row["dry"] for row in layers])
209
+ cap = np.asarray([row["ln_od_max"] for row in layers])[None, :]
210
+ wet = np.exp(np.minimum(np.einsum("nkt,kt->nk", Xw, w), cap))
211
+ dry = np.maximum(np.einsum("nkt,kt->nk", Xd, d), 0.0)
212
+ return wet + dry
213
+ raise FastModelError(f"unknown table form {band_table['form']!r}")
214
+
215
+
216
+ def evaluate(table: dict, band: int, columns: dict, *, emissivity: np.ndarray | None = None) -> dict:
217
+ """Brightness temperature and the pieces, exactly as ``rw_goes forward``
218
+ computes them. ``columns`` carries ``temperature_k, q_gkg, p_half_hpa,
219
+ p_full_hpa, zenith, skin_k`` and, for the table's own emissivity,
220
+ ``land_fraction, landuse_category, sea_ice_fraction, snowh_m``."""
221
+ bt_table = table["bands"][str(int(band))]
222
+ feat = features(columns["temperature_k"], columns["q_gkg"], columns["p_half_hpa"], columns["p_full_hpa"], columns["zenith"])
223
+ od = layer_optical_depth(bt_table, feat)
224
+ sec = 1.0 / np.cos(np.radians(np.asarray(columns["zenith"], dtype=np.float64)))
225
+ if emissivity is None:
226
+ emissivity = table_emissivity(bt_table["emissivity"], columns)
227
+ rad = emission_radiance(bt_table["planck"], od * sec[:, None], columns["temperature_k"], columns["skin_k"], emissivity)
228
+ return {"bt": planck_temperature(bt_table["planck"], rad), "radiance": rad, "od_nadir": od, "emissivity": emissivity}
229
+
230
+
231
+ def table_emissivity(emis_table: dict, columns: dict) -> np.ndarray:
232
+ """The table's surface emissivity per column: water by the reference's
233
+ sea-water mean, land by IGBP class, snow-covered land and sea ice by
234
+ their own values; fractions composed linearly."""
235
+ land = np.clip(np.asarray(columns["land_fraction"], dtype=np.float64), 0.0, 1.0)
236
+ water = 1.0 - land
237
+ ice = np.clip(np.asarray(columns.get("sea_ice_fraction", np.zeros_like(land)), dtype=np.float64), 0.0, 1.0) * water
238
+ water = water - ice
239
+ cls = np.asarray(columns["landuse_category"], dtype=int)
240
+ by_class = np.asarray(emis_table["land_by_igbp_class"], dtype=np.float64)
241
+ land_e = by_class[np.clip(cls, 1, by_class.size) - 1]
242
+ snow = np.asarray(columns.get("snowh_m", np.zeros_like(land)), dtype=np.float64) > emis_table.get("snow_depth_threshold_m", 0.01)
243
+ snow = snow | (cls == 15)
244
+ land_e = np.where(snow, emis_table["snow"], land_e)
245
+ return water * emis_table["water"] + ice * emis_table["ice"] + land * land_e
246
+
247
+
248
+ def jacobians(table: dict, band: int, columns: dict, *, emissivity: np.ndarray | None = None) -> dict:
249
+ """Central finite differences in layer temperature, layer vapor
250
+ (relative) and skin, the steps of :data:`JACOBIAN_STEPS`; per g/kg for
251
+ the vapor row. The oracle for the Rust ``forward`` Jacobians."""
252
+ base = evaluate(table, band, columns, emissivity=emissivity)
253
+ emis = base["emissivity"]
254
+ n, nlay = columns["temperature_k"].shape
255
+ jac_t = np.zeros((n, nlay))
256
+ jac_q = np.zeros((n, nlay))
257
+ hT = JACOBIAN_STEPS["temperature_k"]
258
+ rq = JACOBIAN_STEPS["vapor_relative"]
259
+ for k in range(nlay):
260
+ for sign in (+1, -1):
261
+ c = dict(columns)
262
+ T = np.array(columns["temperature_k"], dtype=np.float64, copy=True)
263
+ T[:, k] += sign * hT
264
+ c["temperature_k"] = T
265
+ jac_t[:, k] += sign * evaluate(table, band, c, emissivity=emis)["bt"] / (2 * hT)
266
+ c = dict(columns)
267
+ q = np.array(columns["q_gkg"], dtype=np.float64, copy=True)
268
+ dq = np.maximum(q[:, k] * rq, 1e-9)
269
+ q[:, k] += sign * dq
270
+ c["q_gkg"] = q
271
+ jac_q[:, k] += sign * evaluate(table, band, c, emissivity=emis)["bt"] / (2 * dq)
272
+ hS = JACOBIAN_STEPS["skin_k"]
273
+ plus = dict(columns); plus["skin_k"] = np.asarray(columns["skin_k"]) + hS
274
+ minus = dict(columns); minus["skin_k"] = np.asarray(columns["skin_k"]) - hS
275
+ jac_skin = (evaluate(table, band, plus, emissivity=emis)["bt"] - evaluate(table, band, minus, emissivity=emis)["bt"]) / (2 * hS)
276
+ return {"bt": base["bt"], "jac_t": jac_t, "jac_q": jac_q, "jac_tskin": jac_skin, "emissivity": emis}
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # the trainer
281
+ # ---------------------------------------------------------------------------
282
+
283
+ def _fit_linear(X: np.ndarray, y: np.ndarray, ridge: float) -> np.ndarray:
284
+ scale = np.maximum(np.abs(X).max(axis=0), 1e-12)
285
+ A = X / scale
286
+ return np.linalg.solve(A.T @ A + ridge * np.eye(A.shape[1]) * A.shape[0], A.T @ y) / scale
287
+
288
+
289
+ def _fit_two_term(y: np.ndarray, Xw: np.ndarray, Xd: np.ndarray, *, iterations: int, ridge: float,
290
+ damping: float = 1e-3) -> tuple[np.ndarray, np.ndarray, float]:
291
+ """``OD = max(Xd d, 0) + exp(Xw w)`` by damped Gauss-Newton on ``ln OD``."""
292
+ ly = np.log(np.maximum(y, 1e-12))
293
+ sw = np.maximum(np.abs(Xw).max(axis=0), 1e-12)
294
+ sd = np.maximum(np.abs(Xd).max(axis=0), 1e-12)
295
+ A = Xw / sw
296
+ w = np.linalg.solve(A.T @ A + ridge * np.eye(A.shape[1]) * A.shape[0], A.T @ ly) / sw
297
+ d = np.zeros(Xd.shape[1])
298
+ best: tuple[float, np.ndarray, np.ndarray] | None = None
299
+ for _ in range(iterations):
300
+ wet = np.exp(np.minimum(Xw @ w, 50.0))
301
+ dry = np.maximum(Xd @ d, 0.0)
302
+ m = wet + dry
303
+ r = ly - np.log(np.maximum(m, 1e-12))
304
+ cost = float(np.mean(r * r))
305
+ if best is None or cost < best[0]:
306
+ best = (cost, w.copy(), d.copy())
307
+ J = np.concatenate([(wet / m)[:, None] * Xw / sw, (1.0 / m)[:, None] * Xd / sd], axis=1)
308
+ H = J.T @ J
309
+ step = np.linalg.solve(H + damping * np.diag(np.diag(H)) + 1e-12 * np.eye(H.shape[0]), J.T @ r)
310
+ w = w + step[:Xw.shape[1]] / sw
311
+ d = d + step[Xw.shape[1]:] / sd
312
+ assert best is not None
313
+ return best[1], best[2], best[0]
314
+
315
+
316
+ def _clip_ranges(feat: dict[str, np.ndarray], train: np.ndarray, margin: float = 0.05) -> dict:
317
+ """Per-layer min and max of every non-trivial feature over the
318
+ training columns, widened by ``margin`` of the range."""
319
+ clip: dict[str, dict[str, list[float]]] = {}
320
+ for name in FEATURE_NAMES:
321
+ if name in ("one", "dp", "u", "e", "lsec"):
322
+ continue
323
+ lo = feat[name][train].min(axis=0)
324
+ hi = feat[name][train].max(axis=0)
325
+ pad = margin * np.maximum(hi - lo, 1e-6)
326
+ clip[name] = {"min": (lo - pad).tolist(), "max": (hi + pad).tolist()}
327
+ return clip
328
+
329
+
330
+ def train_band(band: int, columns: dict, od_nadir: np.ndarray, *, train: np.ndarray, form: str,
331
+ planck: dict, ridge: float = 1e-12, iterations: int = 12) -> dict:
332
+ """One band's table: per-layer coefficients in ``form`` (``linear`` for
333
+ the window band, ``two_term`` for the vapor band), the clip ranges and
334
+ the validation on the held-out columns (BT of the oracle against the
335
+ reference's BT recomputed through the same march, so the number is
336
+ the transmittance model's alone)."""
337
+ feat = features(columns["temperature_k"], columns["q_gkg"], columns["p_half_hpa"], columns["p_full_hpa"], columns["zenith"])
338
+ clip = _clip_ranges(feat, train)
339
+ feat_c = clip_features(feat, clip)
340
+ n, nlay = od_nadir.shape
341
+ layers: list[dict] = []
342
+ if form == "linear":
343
+ X = design(feat_c, LINEAR_TERMS_WINDOW)
344
+ for k in range(nlay):
345
+ c = _fit_linear(X[train, k, :], od_nadir[train, k], ridge)
346
+ layers.append({"linear": c.tolist()})
347
+ table = {"form": form, "terms_linear": [list(t) for t in LINEAR_TERMS_WINDOW]}
348
+ elif form == "two_term":
349
+ Xw = design(feat_c, WET_TERMS)
350
+ Xd = design(feat_c, DRY_TERMS)
351
+ for k in range(nlay):
352
+ w, d, cost = _fit_two_term(od_nadir[train, k], Xw[train, k, :], Xd[train, k, :], iterations=iterations, ridge=ridge)
353
+ layers.append({"wet": w.tolist(), "dry": d.tolist(), "ln_od_max": float(np.log(od_nadir[train, k].max() * 3.0 + 1e-12)),
354
+ "fit_log_rms": float(np.sqrt(cost))})
355
+ table = {"form": form, "terms_wet": [list(t) for t in WET_TERMS], "terms_dry": [list(t) for t in DRY_TERMS]}
356
+ else:
357
+ raise FastModelError(f"form must be linear or two_term, got {form!r}")
358
+ table.update({"band": int(band), "planck": dict(planck), "layers": layers, "clip": clip})
359
+ return table
360
+
361
+
362
+ def validate_band(band_table: dict, columns: dict, od_nadir_reference: np.ndarray, *, test: np.ndarray,
363
+ emissivity: np.ndarray) -> dict:
364
+ """The oracle's BT against the BT of the reference's own optical depths
365
+ through the same march (isolating the transmittance model), on the
366
+ held-out columns; plus the layer relative error where the band emits."""
367
+ band = int(band_table["band"])
368
+ table = {"bands": {str(band): band_table}}
369
+ ours = evaluate(table, band, columns, emissivity=emissivity)
370
+ sec = 1.0 / np.cos(np.radians(columns["zenith"]))
371
+ ref_rad = emission_radiance(band_table["planck"], od_nadir_reference * sec[:, None], columns["temperature_k"],
372
+ columns["skin_k"], emissivity)
373
+ ref_bt = planck_temperature(band_table["planck"], ref_rad)
374
+ d = ours["bt"] - ref_bt
375
+ rel = np.abs(ours["od_nadir"] - od_nadir_reference) / np.maximum(od_nadir_reference, 1e-9)
376
+ land = np.asarray(columns["land_fraction"]) >= 0.5
377
+ zen = np.asarray(columns["zenith"])
378
+ by_zenith = {}
379
+ for lo, hi in ((0, 20), (20, 40), (40, 50), (50, 60), (60, 70)):
380
+ m = test & (zen >= lo) & (zen < hi)
381
+ if m.any():
382
+ by_zenith[f"{lo}_{hi}"] = {"n": int(m.sum()), "rms_k": float(np.sqrt(np.mean(d[m] ** 2))), "bias_k": float(d[m].mean())}
383
+ return {
384
+ "against": "the reference's own layer optical depths through the same emission march (the transmittance model alone)",
385
+ "test_columns": int(test.sum()), "train_columns": int((~test).sum()),
386
+ "test_rms_k": float(np.sqrt(np.mean(d[test] ** 2))), "test_bias_k": float(d[test].mean()),
387
+ "test_p99_abs_k": float(np.percentile(np.abs(d[test]), 99)), "test_max_abs_k": float(np.abs(d[test]).max()),
388
+ "train_rms_k": float(np.sqrt(np.mean(d[~test] ** 2))),
389
+ "test_water_rms_k": float(np.sqrt(np.mean(d[test & ~land] ** 2))) if np.any(test & ~land) else None,
390
+ "test_land_rms_k": float(np.sqrt(np.mean(d[test & land] ** 2))) if np.any(test & land) else None,
391
+ "by_zenith": by_zenith,
392
+ "median_relative_od_error_per_layer_test": np.median(rel[test], axis=0).round(4).tolist(),
393
+ }
394
+
395
+
396
+ def emissivity_table_from_reference(reference_run: dict, band: int, columns: dict) -> dict:
397
+ """The per-class emissivity the reference used on the case: sea water
398
+ (its Nalli model's mean at the case's winds and angles), land by IGBP
399
+ class (the class means; a class absent from the case takes the land
400
+ mean), snow and sea ice from CRTM's NPOESS tables' means where the
401
+ case had them, else stated defaults."""
402
+ e = np.asarray(reference_run["emissivity"][int(band)], dtype=np.float64)
403
+ land = np.asarray(columns["land_fraction"]) >= 0.5
404
+ cls = np.asarray(columns["landuse_category"], dtype=int)
405
+ snow = np.asarray(columns.get("snowh_m", np.zeros(cls.shape))) > 0.01
406
+ ice = (np.asarray(columns.get("sea_ice_fraction", np.zeros(cls.shape))) >= 0.5) & ~land
407
+ water = ~land & ~ice
408
+ land_mean = float(e[land & ~snow].mean()) if np.any(land & ~snow) else 0.96
409
+ by_class = []
410
+ counts = []
411
+ for c in range(1, 21):
412
+ m = land & ~snow & (cls == c)
413
+ counts.append(int(m.sum()))
414
+ by_class.append(float(e[m].mean()) if m.sum() >= 5 else land_mean)
415
+ return {
416
+ "water": float(e[water].mean()) if water.any() else 0.98,
417
+ "water_std": float(e[water].std()) if water.any() else None,
418
+ "land_by_igbp_class": by_class,
419
+ "land_class_counts": counts,
420
+ "snow": float(e[land & snow].mean()) if np.any(land & snow) else 0.985,
421
+ "ice": float(e[ice].mean()) if ice.any() else 0.98,
422
+ "snow_depth_threshold_m": 0.01,
423
+ "source": "the reference run's own surface emissivity on the case's columns (sea water: Nalli; land: IGBP table)",
424
+ }
425
+
426
+
427
+ def coordinate_identity(a_half_pa, b_half) -> dict:
428
+ a = np.asarray(a_half_pa, dtype=np.float64)
429
+ b = np.asarray(b_half, dtype=np.float64)
430
+ digest = hashlib.sha256(a.tobytes() + b.tobytes()).hexdigest()
431
+ return {"nlev": int(a.size - 1), "a_half_pa": a.tolist(), "b_half": b.tolist(), "sha256": digest}
432
+
433
+
434
+ def write_table(path: str | os.PathLike, table: dict) -> Path:
435
+ path = Path(path)
436
+ path.parent.mkdir(parents=True, exist_ok=True)
437
+ table = dict(table)
438
+ table.setdefault("schema", FAST_MODEL_SCHEMA)
439
+ table.setdefault("written_utc", dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"))
440
+ path.write_text(json.dumps(table, indent=1, sort_keys=True), encoding="utf-8")
441
+ return path
442
+
443
+
444
+ def read_table(path: str | os.PathLike) -> dict:
445
+ table = json.loads(Path(path).read_text(encoding="utf-8"))
446
+ if table.get("schema") != FAST_MODEL_SCHEMA:
447
+ raise FastModelError(f"{path} declares schema {table.get('schema')!r}, not {FAST_MODEL_SCHEMA}")
448
+ return table
449
+
450
+
451
+ __all__ = [
452
+ "DRY_TERMS", "FAST_MODEL_SCHEMA", "FEATURE_CONSTANTS", "FEATURE_DEFINITIONS", "FEATURE_NAMES", "JACOBIAN_STEPS",
453
+ "LINEAR_TERMS_WINDOW", "WET_TERMS", "FastModelError", "clip_features", "coordinate_identity", "design",
454
+ "emission_radiance", "emissivity_table_from_reference", "evaluate", "features", "jacobians", "layer_optical_depth",
455
+ "planck_radiance", "planck_temperature", "read_table", "table_emissivity", "train_band", "validate_band", "write_table",
456
+ ]