pycontrails 0.59.0__cp314-cp314-macosx_10_15_x86_64.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.

Potentially problematic release.


This version of pycontrails might be problematic. Click here for more details.

Files changed (123) hide show
  1. pycontrails/__init__.py +70 -0
  2. pycontrails/_version.py +34 -0
  3. pycontrails/core/__init__.py +30 -0
  4. pycontrails/core/aircraft_performance.py +679 -0
  5. pycontrails/core/airports.py +228 -0
  6. pycontrails/core/cache.py +889 -0
  7. pycontrails/core/coordinates.py +174 -0
  8. pycontrails/core/fleet.py +483 -0
  9. pycontrails/core/flight.py +2185 -0
  10. pycontrails/core/flightplan.py +228 -0
  11. pycontrails/core/fuel.py +140 -0
  12. pycontrails/core/interpolation.py +702 -0
  13. pycontrails/core/met.py +2936 -0
  14. pycontrails/core/met_var.py +387 -0
  15. pycontrails/core/models.py +1321 -0
  16. pycontrails/core/polygon.py +549 -0
  17. pycontrails/core/rgi_cython.cpython-314-darwin.so +0 -0
  18. pycontrails/core/vector.py +2249 -0
  19. pycontrails/datalib/__init__.py +12 -0
  20. pycontrails/datalib/_met_utils/metsource.py +746 -0
  21. pycontrails/datalib/ecmwf/__init__.py +73 -0
  22. pycontrails/datalib/ecmwf/arco_era5.py +345 -0
  23. pycontrails/datalib/ecmwf/common.py +114 -0
  24. pycontrails/datalib/ecmwf/era5.py +554 -0
  25. pycontrails/datalib/ecmwf/era5_model_level.py +490 -0
  26. pycontrails/datalib/ecmwf/hres.py +804 -0
  27. pycontrails/datalib/ecmwf/hres_model_level.py +466 -0
  28. pycontrails/datalib/ecmwf/ifs.py +287 -0
  29. pycontrails/datalib/ecmwf/model_levels.py +435 -0
  30. pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
  31. pycontrails/datalib/ecmwf/variables.py +268 -0
  32. pycontrails/datalib/geo_utils.py +261 -0
  33. pycontrails/datalib/gfs/__init__.py +28 -0
  34. pycontrails/datalib/gfs/gfs.py +656 -0
  35. pycontrails/datalib/gfs/variables.py +104 -0
  36. pycontrails/datalib/goes.py +764 -0
  37. pycontrails/datalib/gruan.py +343 -0
  38. pycontrails/datalib/himawari/__init__.py +27 -0
  39. pycontrails/datalib/himawari/header_struct.py +266 -0
  40. pycontrails/datalib/himawari/himawari.py +671 -0
  41. pycontrails/datalib/landsat.py +589 -0
  42. pycontrails/datalib/leo_utils/__init__.py +5 -0
  43. pycontrails/datalib/leo_utils/correction.py +266 -0
  44. pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
  45. pycontrails/datalib/leo_utils/search.py +250 -0
  46. pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
  47. pycontrails/datalib/leo_utils/static/bq_roi_query.sql +6 -0
  48. pycontrails/datalib/leo_utils/vis.py +59 -0
  49. pycontrails/datalib/sentinel.py +650 -0
  50. pycontrails/datalib/spire/__init__.py +5 -0
  51. pycontrails/datalib/spire/exceptions.py +62 -0
  52. pycontrails/datalib/spire/spire.py +604 -0
  53. pycontrails/ext/bada.py +42 -0
  54. pycontrails/ext/cirium.py +14 -0
  55. pycontrails/ext/empirical_grid.py +140 -0
  56. pycontrails/ext/synthetic_flight.py +431 -0
  57. pycontrails/models/__init__.py +1 -0
  58. pycontrails/models/accf.py +425 -0
  59. pycontrails/models/apcemm/__init__.py +8 -0
  60. pycontrails/models/apcemm/apcemm.py +983 -0
  61. pycontrails/models/apcemm/inputs.py +226 -0
  62. pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
  63. pycontrails/models/apcemm/utils.py +437 -0
  64. pycontrails/models/cocip/__init__.py +29 -0
  65. pycontrails/models/cocip/cocip.py +2742 -0
  66. pycontrails/models/cocip/cocip_params.py +305 -0
  67. pycontrails/models/cocip/cocip_uncertainty.py +291 -0
  68. pycontrails/models/cocip/contrail_properties.py +1530 -0
  69. pycontrails/models/cocip/output_formats.py +2270 -0
  70. pycontrails/models/cocip/radiative_forcing.py +1260 -0
  71. pycontrails/models/cocip/radiative_heating.py +520 -0
  72. pycontrails/models/cocip/unterstrasser_wake_vortex.py +508 -0
  73. pycontrails/models/cocip/wake_vortex.py +396 -0
  74. pycontrails/models/cocip/wind_shear.py +120 -0
  75. pycontrails/models/cocipgrid/__init__.py +9 -0
  76. pycontrails/models/cocipgrid/cocip_grid.py +2552 -0
  77. pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
  78. pycontrails/models/dry_advection.py +602 -0
  79. pycontrails/models/emissions/__init__.py +21 -0
  80. pycontrails/models/emissions/black_carbon.py +599 -0
  81. pycontrails/models/emissions/emissions.py +1353 -0
  82. pycontrails/models/emissions/ffm2.py +336 -0
  83. pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
  84. pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
  85. pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
  86. pycontrails/models/extended_k15.py +1327 -0
  87. pycontrails/models/humidity_scaling/__init__.py +37 -0
  88. pycontrails/models/humidity_scaling/humidity_scaling.py +1075 -0
  89. pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
  90. pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
  91. pycontrails/models/issr.py +210 -0
  92. pycontrails/models/pcc.py +326 -0
  93. pycontrails/models/pcr.py +154 -0
  94. pycontrails/models/ps_model/__init__.py +18 -0
  95. pycontrails/models/ps_model/ps_aircraft_params.py +381 -0
  96. pycontrails/models/ps_model/ps_grid.py +701 -0
  97. pycontrails/models/ps_model/ps_model.py +1000 -0
  98. pycontrails/models/ps_model/ps_operational_limits.py +525 -0
  99. pycontrails/models/ps_model/static/ps-aircraft-params-20250328.csv +69 -0
  100. pycontrails/models/ps_model/static/ps-synonym-list-20250328.csv +104 -0
  101. pycontrails/models/sac.py +442 -0
  102. pycontrails/models/tau_cirrus.py +183 -0
  103. pycontrails/physics/__init__.py +1 -0
  104. pycontrails/physics/constants.py +117 -0
  105. pycontrails/physics/geo.py +1138 -0
  106. pycontrails/physics/jet.py +968 -0
  107. pycontrails/physics/static/iata-cargo-load-factors-20250221.csv +74 -0
  108. pycontrails/physics/static/iata-passenger-load-factors-20250221.csv +74 -0
  109. pycontrails/physics/thermo.py +551 -0
  110. pycontrails/physics/units.py +472 -0
  111. pycontrails/py.typed +0 -0
  112. pycontrails/utils/__init__.py +1 -0
  113. pycontrails/utils/dependencies.py +66 -0
  114. pycontrails/utils/iteration.py +13 -0
  115. pycontrails/utils/json.py +187 -0
  116. pycontrails/utils/temp.py +50 -0
  117. pycontrails/utils/types.py +163 -0
  118. pycontrails-0.59.0.dist-info/METADATA +179 -0
  119. pycontrails-0.59.0.dist-info/RECORD +123 -0
  120. pycontrails-0.59.0.dist-info/WHEEL +6 -0
  121. pycontrails-0.59.0.dist-info/licenses/LICENSE +178 -0
  122. pycontrails-0.59.0.dist-info/licenses/NOTICE +43 -0
  123. pycontrails-0.59.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,343 @@
1
+ """Support for accessing `GRUAN <https://www.gruan.org/>`_ data over FTP."""
2
+
3
+ import datetime
4
+ import ftplib
5
+ import functools
6
+ import os
7
+ import tempfile
8
+ from concurrent import futures
9
+
10
+ import xarray as xr
11
+
12
+ from pycontrails.core import cache
13
+
14
+ #: GRUAN FTP server address
15
+ FTP_SERVER = "ftp.ncdc.noaa.gov"
16
+
17
+ #: Base path for GRUAN data on the FTP server
18
+ FTP_BASE_PATH = "/pub/data/gruan/processing/level2"
19
+
20
+ #: All available GRUAN products and sites on the FTP server as of 2025-10
21
+ #: This is simply the hardcoded output of :func:`available_sites` at that time to
22
+ #: avoid a lookup that changes infrequently.
23
+ AVAILABLE_PRODUCTS_TO_SITES = {
24
+ "RS-11G-GDP.1": ["SYO", "TAT", "NYA", "LIN"],
25
+ "RS41-EDT.1": ["LIN", "POT", "SNG"],
26
+ "RS92-GDP.1": ["BOU", "CAB", "LIN", "PAY", "POT", "SOD", "TAT"],
27
+ "RS92-GDP.2": [
28
+ "BAR",
29
+ "BEL",
30
+ "BOU",
31
+ "CAB",
32
+ "DAR",
33
+ "GRA",
34
+ "LAU",
35
+ "LIN",
36
+ "MAN",
37
+ "NAU",
38
+ "NYA",
39
+ "PAY",
40
+ "POT",
41
+ "REU",
42
+ "SGP",
43
+ "SOD",
44
+ "TAT",
45
+ "TEN",
46
+ "GVN",
47
+ ],
48
+ "RS92-PROFILE-BETA.2": ["BOU", "CAB", "LIN", "POT", "SOD", "TAT"],
49
+ "RS92-PROFILE-BETA.3": ["BOU", "CAB", "LIN", "POT", "SOD", "TAT"],
50
+ }
51
+
52
+
53
+ def extract_gruan_time(filename: str) -> tuple[datetime.datetime, int]:
54
+ """Extract launch time and revision number from a GRUAN filename.
55
+
56
+ Parameters
57
+ ----------
58
+ filename : str
59
+ GRUAN filename, e.g. "LIN-RS-01_2_RS92-GDP_002_20210125T132400_1-000-001.nc"
60
+
61
+ Returns
62
+ -------
63
+ tuple[datetime.datetime, int]
64
+ Launch time as a datetime object and revision number as an integer.
65
+ """
66
+ parts = filename.split("_")
67
+ if len(parts) != 6:
68
+ raise ValueError(f"Unexpected filename format: {filename}")
69
+ time_part = parts[4]
70
+ try:
71
+ time = datetime.datetime.strptime(time_part, "%Y%m%dT%H%M%S")
72
+ except ValueError as e:
73
+ raise ValueError(f"Unexpected time segment: {time_part}") from e
74
+
75
+ revision_part = parts[5].removesuffix(".nc")
76
+ if not revision_part[-3:].isdigit():
77
+ raise ValueError(f"Unexpected revision segment: {revision_part}")
78
+ revision = int(revision_part[-3:])
79
+
80
+ return time, revision
81
+
82
+
83
+ def _fetch_product_tree(prod: str) -> dict[str, list[str]]:
84
+ result = {}
85
+ with ftplib.FTP(FTP_SERVER) as ftp:
86
+ ftp.login()
87
+ prod_path = f"{FTP_BASE_PATH}/{prod}"
88
+ versions = [v.split("/")[-1] for v in ftp.nlst(prod_path)]
89
+
90
+ for v in versions:
91
+ version_path = f"{prod_path}/{v}"
92
+ sites = [s.split("/")[-1] for s in ftp.nlst(version_path)]
93
+
94
+ key = f"{prod}.{int(v.split('-')[-1])}"
95
+ result[key] = sites
96
+ return result
97
+
98
+
99
+ @functools.cache
100
+ def available_sites() -> dict[str, list[str]]:
101
+ """Get a list of available GRUAN sites for each supported product.
102
+
103
+ The :attr:`GRUAN.AVAILABLE` is a hardcoded snapshot of this data. The data returned
104
+ by this function does not change frequently, so it is cached for efficiency.
105
+
106
+ Returns
107
+ -------
108
+ dict[str, list[str]]
109
+ Mapping of product names to lists of available site identifiers.
110
+ """
111
+ with ftplib.FTP(FTP_SERVER) as ftp:
112
+ ftp.login()
113
+ files = [p.split("/")[-1] for p in ftp.nlst(FTP_BASE_PATH)]
114
+ products = [p for p in files if "." not in p] # crude filter to exclude non-directories
115
+
116
+ # Compute each product tree in separate thread to speed up retrieval
117
+ # The FTP server only allows up to 5 connections from the same client
118
+ out = {}
119
+ with futures.ThreadPoolExecutor(max_workers=min(len(products), 5)) as tpe:
120
+ result = tpe.map(_fetch_product_tree, products)
121
+ for r in result:
122
+ out.update(r)
123
+
124
+ return out
125
+
126
+
127
+ class GRUAN:
128
+ """Access `GRUAN <https://www.gruan.org/>`_ data over anonymous FTP.
129
+
130
+ GRUAN is the Global Climate Observing System Reference Upper-Air Network. It provides
131
+ high-quality measurements of atmospheric variables from ground to stratosphere
132
+ through a global network of radiosonde stations.
133
+
134
+ .. versionadded:: 0.59.0
135
+
136
+ Parameters
137
+ ----------
138
+ product : str
139
+ GRUAN data product. See :attr:`AVAILABLE` for available products. These currently
140
+ include:
141
+ - ``RS92-GDP.2``
142
+ - ``RS92-GDP.1``
143
+ - ``RS92-PROFILE-BETA.2``
144
+ - ``RS92-PROFILE-BETA.3``
145
+ - ``RS41-EDT.1``
146
+ - ``RS-11G-GDP.1``
147
+ site : str
148
+ GRUAN station identifier. See :attr:`AVAILABLE` for available sites for each product.
149
+ cachestore : cache.CacheStore | None, optional
150
+ Cache store to use for downloaded files. If not provided, a disk cache store
151
+ will be created in the user cache directory under ``gruan/``. Set to ``None``
152
+ to disable caching.
153
+
154
+ Notes
155
+ -----
156
+ The FTP files have the following hierarchy::
157
+
158
+ /pub/data/gruan/processing/level2/
159
+ {product-root}/
160
+ version-{NNN}/
161
+ {SITE}/
162
+ {YYYY}/
163
+ <filename>.nc
164
+
165
+ - {product-root} is the product name without the trailing version integer (e.g. ``RS92-GDP``)
166
+ - version-{NNN} zero-pads to three digits (suffix ``.2`` -> ``version-002``)
167
+ - {SITE} is the station code (e.g. ``LIN``)
168
+ - {YYYY} is launch year
169
+ - Filenames encode launch time and revision (parsed by :func:`extract_gruan_time`)
170
+
171
+ Discovery helpers methods:
172
+
173
+ - :attr:`AVAILABLE` or :func:`available_sites` -> products and sites
174
+ - :meth:`years` -> list available years for (product, site)
175
+ - :meth:`list_files` -> list available NetCDF files for the given year
176
+ - :meth:`get` -> download and open a single NetCDF file as an :class:`xarray.Dataset`
177
+
178
+ Typical workflow:
179
+
180
+ 1. Inspect :attr:`AVAILABLE` (fast) or call :func:`available_sites` (live)
181
+ 2. Instantiate ``GRUAN(product, site)``
182
+ 3. Call ``years()``
183
+ 4. Call ``list_files(year)``
184
+ 5. Call ``get(filename)`` for an ``xarray.Dataset``
185
+
186
+ """
187
+
188
+ # Convenience access to available sites
189
+ available_sites = staticmethod(available_sites)
190
+ AVAILABLE = AVAILABLE_PRODUCTS_TO_SITES
191
+
192
+ __slots__ = ("_ftp", "cachestore", "product", "site")
193
+
194
+ __marker = object()
195
+
196
+ def __init__(
197
+ self,
198
+ product: str,
199
+ site: str,
200
+ cachestore: cache.CacheStore | None = __marker, # type: ignore[assignment]
201
+ ) -> None:
202
+ known = AVAILABLE_PRODUCTS_TO_SITES
203
+
204
+ if product not in known:
205
+ known = available_sites() # perhaps AVAILABLE_PRODUCTS_TO_SITES is outdated
206
+ if product not in known:
207
+ raise ValueError(f"Unknown GRUAN product: {product}. Known products: {list(known)}")
208
+ self.product = product
209
+
210
+ if site not in known[product]:
211
+ known = available_sites() # perhaps AVAILABLE_PRODUCTS_TO_SITES is outdated
212
+ if site not in known[product]:
213
+ raise ValueError(
214
+ f"Unknown GRUAN site '{site}' for product '{product}'. "
215
+ f"Known sites: {known[product]}"
216
+ )
217
+ self.site = site
218
+
219
+ if cachestore is self.__marker:
220
+ cache_root = cache._get_user_cache_dir()
221
+ cache_dir = f"{cache_root}/gruan"
222
+ cachestore = cache.DiskCacheStore(cache_dir=cache_dir)
223
+ self.cachestore = cachestore
224
+
225
+ self._ftp: ftplib.FTP | None = None
226
+
227
+ def __repr__(self) -> str:
228
+ return f"GRUAN(product='{self.product}', site='{self.site}')"
229
+
230
+ def _connect(self) -> ftplib.FTP:
231
+ """Connect to the GRUAN FTP server."""
232
+ if self._ftp is None or self._ftp.sock is None:
233
+ self._ftp = ftplib.FTP(FTP_SERVER)
234
+ self._ftp.login()
235
+ return self._ftp
236
+
237
+ try:
238
+ self._ftp.pwd() # check if connection is still alive
239
+ except (*ftplib.all_errors, ConnectionError): # type: ignore[misc]
240
+ # If we encounter any error, reset the connection and retry
241
+ self._ftp = None
242
+ return self._connect()
243
+ return self._ftp
244
+
245
+ @property
246
+ def base_path_product(self) -> str:
247
+ """Get the base path for GRUAN data product on the FTP server."""
248
+ product, version = self.product.rsplit(".")
249
+ return f"/pub/data/gruan/processing/level2/{product}/version-{version.zfill(3)}"
250
+
251
+ @property
252
+ def base_path_site(self) -> str:
253
+ """Get the base path for GRUAN data site on the FTP server."""
254
+ return f"{self.base_path_product}/{self.site}"
255
+
256
+ def years(self) -> list[int]:
257
+ """Get a list of available years for the selected product and site."""
258
+ ftp = self._connect()
259
+ ftp.cwd(self.base_path_site)
260
+ years = ftp.nlst()
261
+ return sorted(int(year) for year in years)
262
+
263
+ def list_files(self, year: int | None = None) -> list[str]:
264
+ """List available files for a given year.
265
+
266
+ Parameters
267
+ ----------
268
+ year : int | None, optional
269
+ Year to list files for. If ``None``, list files for all available years. The later
270
+ may be time-consuming.
271
+
272
+ Returns
273
+ -------
274
+ list[str]
275
+ List of available GRUAN filenames for the specified year.
276
+ """
277
+ if year is None:
278
+ years = self.years()
279
+ return sorted(file for y in years for file in self.list_files(y))
280
+
281
+ path = f"{self.base_path_site}/{year}"
282
+
283
+ ftp = self._connect()
284
+ try:
285
+ ftp.cwd(path)
286
+ except ftplib.error_perm as e:
287
+ available = self.years()
288
+ if year not in available:
289
+ msg = f"No data available for year {year}. Available years are: {available}"
290
+ raise ValueError(msg) from e
291
+ raise
292
+ return sorted(ftp.nlst())
293
+
294
+ def get(self, filename: str) -> xr.Dataset:
295
+ """Download a GRUAN dataset by filename.
296
+
297
+ Parameters
298
+ ----------
299
+ filename : str
300
+ GRUAN filename to download, e.g. "LIN-RS-01_2_RS92-GDP_002_20210125T132400_1-000-001.nc"
301
+
302
+ Returns
303
+ -------
304
+ xr.Dataset
305
+ The GRUAN dataset retrieved from the FTP server. If caching is enabled,
306
+ the file is downloaded to the cache store and loaded from there on subsequent calls.
307
+ """
308
+ if self.cachestore is None:
309
+ return self._get_no_cache(filename)
310
+ return self._get_with_cache(filename)
311
+
312
+ def _get_no_cache(self, filename: str) -> xr.Dataset:
313
+ t, _ = extract_gruan_time(filename)
314
+ path = f"{self.base_path_site}/{t.year}/{filename}"
315
+
316
+ ftp = self._connect()
317
+
318
+ try:
319
+ # On windows, NamedTemporaryFile cannot be reopened while still open.
320
+ # After python 3.11 support is dropped, we can use delete_on_close=False
321
+ # in NamedTemporaryFile to streamline this.
322
+ with tempfile.NamedTemporaryFile(delete=False) as tmp:
323
+ ftp.retrbinary(f"RETR {path}", tmp.write)
324
+ return xr.load_dataset(tmp.name)
325
+ finally:
326
+ os.remove(tmp.name)
327
+
328
+ def _get_with_cache(self, filename: str) -> xr.Dataset:
329
+ if self.cachestore is None:
330
+ raise ValueError("Cachestore is not configured.")
331
+
332
+ lpath = self.cachestore.path(filename)
333
+ if self.cachestore.exists(lpath):
334
+ return xr.open_dataset(lpath)
335
+
336
+ t, _ = extract_gruan_time(filename)
337
+ path = f"{self.base_path_site}/{t.year}/{filename}"
338
+
339
+ ftp = self._connect()
340
+ with open(lpath, "wb") as f:
341
+ ftp.retrbinary(f"RETR {path}", f.write)
342
+
343
+ return xr.open_dataset(lpath)
@@ -0,0 +1,27 @@
1
+ """Support for Himawari-8/9 satellite data access."""
2
+
3
+ from pycontrails.datalib.himawari.header_struct import (
4
+ HEADER_STRUCT_SCHEMA,
5
+ parse_himawari_header,
6
+ )
7
+ from pycontrails.datalib.himawari.himawari import (
8
+ HIMAWARI_8_9_SWITCH_DATE,
9
+ HIMAWARI_8_BUCKET,
10
+ HIMAWARI_9_BUCKET,
11
+ Himawari,
12
+ HimawariRegion,
13
+ extract_visualization,
14
+ to_true_color,
15
+ )
16
+
17
+ __all__ = [
18
+ "HEADER_STRUCT_SCHEMA",
19
+ "HIMAWARI_8_9_SWITCH_DATE",
20
+ "HIMAWARI_8_BUCKET",
21
+ "HIMAWARI_9_BUCKET",
22
+ "Himawari",
23
+ "HimawariRegion",
24
+ "extract_visualization",
25
+ "parse_himawari_header",
26
+ "to_true_color",
27
+ ]
@@ -0,0 +1,266 @@
1
+ """Support for parsing the Himawari-8/9 header structure.
2
+
3
+ See the latest user guide for details:
4
+ https://www.data.jma.go.jp/mscweb/en/himawari89/space_segment/hsd_sample/HS_D_users_guide_en_v13.pdf
5
+
6
+ If that link breaks, find the correct link here:
7
+ https://www.data.jma.go.jp/mscweb/en/himawari89/space_segment/sample_hisd.html
8
+ """
9
+
10
+ import struct
11
+ from typing import Any, TypedDict
12
+
13
+
14
+ class _HeaderBlock(TypedDict):
15
+ """An individual Himawari header block."""
16
+
17
+ name: str
18
+ fields: list[tuple[str, str, int, int, str | None]]
19
+
20
+
21
+ HEADER_STRUCT_SCHEMA: dict[int, _HeaderBlock] = {
22
+ 1: {
23
+ "name": "basic_information",
24
+ "fields": [
25
+ ("header_block_number", "I1", 1, 1, None),
26
+ ("block_length", "I2", 2, 1, None),
27
+ ("total_header_blocks", "I2", 2, 1, None),
28
+ ("byte_order", "I1", 1, 1, None),
29
+ ("satellite_name", "C", 1, 16, None),
30
+ ("processing_center_name", "C", 1, 16, None),
31
+ ("observation_area", "C", 1, 4, None),
32
+ ("other_obs_info", "C", 1, 2, None),
33
+ ("obs_timeline", "I2", 2, 1, None),
34
+ ("obs_start_time", "R8", 8, 1, None),
35
+ ("obs_end_time", "R8", 8, 1, None),
36
+ ("file_creation_time", "R8", 8, 1, None),
37
+ ("total_header_length", "I4", 4, 1, None),
38
+ ("total_data_length", "I4", 4, 1, None),
39
+ ("quality_flag_1", "I1", 1, 1, None),
40
+ ("quality_flag_2", "I1", 1, 1, None),
41
+ ("quality_flag_3", "I1", 1, 1, None),
42
+ ("quality_flag_4", "I1", 1, 1, None),
43
+ ("file_format_version", "C", 1, 32, None),
44
+ ("file_name", "C", 1, 128, None),
45
+ ("spare", "C", 40, 1, None),
46
+ ],
47
+ },
48
+ 2: {
49
+ "name": "data_information",
50
+ "fields": [
51
+ ("header_block_number", "I1", 1, 1, None),
52
+ ("block_length", "I2", 2, 1, None),
53
+ ("bits_per_pixel", "I2", 2, 1, None),
54
+ ("num_columns", "I2", 2, 1, None),
55
+ ("num_lines", "I2", 2, 1, None),
56
+ ("compression_flag", "I1", 1, 1, None),
57
+ ("spare", "C", 40, 1, None),
58
+ ],
59
+ },
60
+ 3: {
61
+ "name": "projection_information",
62
+ "fields": [
63
+ ("header_block_number", "I1", 1, 1, None),
64
+ ("block_length", "I2", 2, 1, None),
65
+ ("sub_lon", "R8", 8, 1, None),
66
+ ("cfac", "I4", 4, 1, None),
67
+ ("lfac", "I4", 4, 1, None),
68
+ ("coff", "R4", 4, 1, None),
69
+ ("loff", "R4", 4, 1, None),
70
+ ("dist_from_earth_center", "R8", 8, 1, None),
71
+ ("equatorial_radius", "R8", 8, 1, None),
72
+ ("polar_radius", "R8", 8, 1, None),
73
+ ("rec_minus_rpol_div_req_sq", "R8", 8, 1, None),
74
+ ("rpol_sq_div_req_sq", "R8", 8, 1, None),
75
+ ("req_sq_div_rpol_sq", "R8", 8, 1, None),
76
+ ("coeff_for_sd", "R8", 8, 1, None),
77
+ ("resampling_types", "I2", 2, 1, None),
78
+ ("resampling_size", "I2", 2, 1, None),
79
+ ("spare", "C", 40, 1, None),
80
+ ],
81
+ },
82
+ 4: {
83
+ "name": "navigation_information",
84
+ "fields": [
85
+ ("header_block_number", "I1", 1, 1, None),
86
+ ("block_length", "I2", 2, 1, None),
87
+ ("nav_info_time", "R8", 8, 1, None),
88
+ ("ssp_longitude", "R8", 8, 1, None),
89
+ ("ssp_latitude", "R8", 8, 1, None),
90
+ ("dist_from_earth_center_to_sat", "R8", 8, 1, None),
91
+ ("nadir_longitude", "R8", 8, 1, None),
92
+ ("nadir_latitude", "R8", 8, 1, None),
93
+ ("sun_position", "R8", 8, 3, None),
94
+ ("moon_position", "R8", 8, 3, None),
95
+ ("spare", "C", 40, 1, None),
96
+ ],
97
+ },
98
+ 5: {
99
+ "name": "calibration_information",
100
+ "fields": [
101
+ ("header_block_number", "I1", 1, 1, None),
102
+ ("block_length", "I2", 2, 1, None),
103
+ ("band_number", "I2", 2, 1, None),
104
+ ("central_wavelength", "R8", 8, 1, None),
105
+ ("valid_bits_per_pixel", "I2", 2, 1, None),
106
+ ("count_error_pixels", "I2", 2, 1, None),
107
+ ("count_outside_scan_area", "I2", 2, 1, None),
108
+ ("gain", "R8", 8, 1, None),
109
+ ("constant", "R8", 8, 1, None),
110
+ ("c0", "R8", 8, 1, "IR-BANDS"),
111
+ ("c1", "R8", 8, 1, "IR-BANDS"),
112
+ ("c2", "R8", 8, 1, "IR-BANDS"),
113
+ ("C0", "R8", 8, 1, "IR-BANDS"),
114
+ ("C1", "R8", 8, 1, "IR-BANDS"),
115
+ ("C2", "R8", 8, 1, "IR-BANDS"),
116
+ ("speed_of_light", "R8", 8, 1, "IR-BANDS"),
117
+ ("planck_constant", "R8", 8, 1, "IR-BANDS"),
118
+ ("boltzmann_constant", "R8", 8, 1, "IR-BANDS"),
119
+ ("spare", "C", 40, 1, "IR-BANDS"),
120
+ ("coeff_c_prime", "R8", 8, 1, "NIR-BANDS"),
121
+ ("spare", "C", 104, 1, "NIR-BANDS"),
122
+ ],
123
+ },
124
+ 6: {
125
+ "name": "inter_calibration_information",
126
+ "fields": [
127
+ ("header_block_number", "I1", 1, 1, None),
128
+ ("block_length", "I2", 2, 1, None),
129
+ ("gsics_intercept", "R8", 8, 1, None),
130
+ ("gsics_slope", "R8", 8, 1, None),
131
+ ("gsics_quad", "R8", 8, 1, None),
132
+ ("rad_bias_standard", "R8", 8, 1, None),
133
+ ("uncert_rad_bias", "R8", 8, 1, None),
134
+ ("rad_standard_scene", "R8", 8, 1, None),
135
+ ("gsics_validity_start", "R8", 8, 1, None),
136
+ ("gsics_validity_end", "R8", 8, 1, None),
137
+ ("rad_validity_upper", "R4", 4, 1, None),
138
+ ("rad_validity_lower", "R4", 4, 1, None),
139
+ ("gsics_file_name", "C", 1, 128, None),
140
+ ("spare", "C", 56, 1, None),
141
+ ],
142
+ },
143
+ 7: {
144
+ "name": "segment_information",
145
+ "fields": [
146
+ ("header_block_number", "I1", 1, 1, None),
147
+ ("block_length", "I2", 2, 1, None),
148
+ ("total_segments", "I1", 1, 1, None),
149
+ ("segment_seq_number", "I1", 1, 1, None),
150
+ ("first_line_number", "I2", 2, 1, None),
151
+ ("spare", "C", 40, 1, None),
152
+ ],
153
+ },
154
+ 8: {
155
+ "name": "navigation_correction_information",
156
+ "fields": [
157
+ ("header_block_number", "I1", 1, 1, None),
158
+ ("block_length", "I2", 2, 1, None),
159
+ ("center_col_rot", "R4", 4, 1, None),
160
+ ("center_line_rot", "R4", 4, 1, None),
161
+ ("rot_correction", "R8", 8, 1, None),
162
+ ("num_corr_data", "I2", 2, 1, None),
163
+ # The following fields are variable and depend on 'num_corr_data'
164
+ # These are not currently parsed
165
+ # ("line_after_rot", "I2", 2, 1, None),
166
+ # ("shift_amount_col", "R4", 4, 1, None),
167
+ # ("shift_amount_line", "R4", 4, 1, None),
168
+ # ("spare", "C", 40, 1, None),
169
+ ],
170
+ },
171
+ 9: {
172
+ "name": "observation_time_information",
173
+ "fields": [
174
+ ("header_block_number", "I1", 1, 1, None),
175
+ ("block_length", "I2", 2, 1, None),
176
+ ("num_obs_times", "I2", 2, 1, None),
177
+ # The following fields are variable and depend on 'num_obs_times'
178
+ # These are not currently parsed
179
+ # ("line_number", "I2", 2, 1, None),
180
+ # ("obs_time", "R8", 8, 1, None),
181
+ # ("spare", "C", 40, 1, None),
182
+ ],
183
+ },
184
+ 10: {
185
+ "name": "error_information",
186
+ "fields": [
187
+ ("header_block_number", "I1", 1, 1, None),
188
+ ("block_length", "I4", 4, 1, None),
189
+ ("num_error_data", "I2", 2, 1, None),
190
+ # The following fields are variable and depend on 'num_error_data'
191
+ # These are not currently parsed
192
+ # ("line_number", "I2", 2, 1, None),
193
+ # ("num_error_pixels", "I2", 2, 1, None),
194
+ # ("spare", "C", 40, 1, None),
195
+ ],
196
+ },
197
+ 11: {
198
+ "name": "spare",
199
+ "fields": [
200
+ ("header_block_number", "I1", 1, 1, None),
201
+ ("block_length", "I2", 2, 1, None),
202
+ ("spare", "C", 256, 1, None),
203
+ ],
204
+ },
205
+ }
206
+
207
+
208
+ def parse_himawari_header(content: bytes) -> dict[str, dict[str, Any]]:
209
+ """Parse the Himawari header data.
210
+
211
+ Skips variable-length fields and spares.
212
+ """
213
+ out = {}
214
+ offset = 0
215
+
216
+ # everything is little-endian (see the byte_order field in block #1)
217
+ typ_map = {
218
+ "I1": "B",
219
+ "I2": "H",
220
+ "I4": "I",
221
+ "R4": "f",
222
+ "R8": "d",
223
+ "C": "s",
224
+ }
225
+
226
+ for block_num, block_info in HEADER_STRUCT_SCHEMA.items():
227
+ offset_block_start = offset # blocks 8, 9, 10 are dynamic
228
+ block_data: dict[str, Any] = {}
229
+ block_name = block_info["name"]
230
+ fields = block_info["fields"]
231
+ block_length_value: int | None = None
232
+
233
+ for name, typ, size, count, cond in fields:
234
+ if block_num == 5 and cond: # deal with dynamic block 5
235
+ band_number = block_data["band_number"]
236
+ if cond == "IR-BANDS" and band_number <= 6:
237
+ continue
238
+ if cond == "NIR-BANDS" and band_number >= 7:
239
+ continue
240
+
241
+ if name == "spare": # skip spare fields
242
+ offset += size * count
243
+ continue
244
+
245
+ fmt = typ_map[typ]
246
+ if typ == "C":
247
+ raw = struct.unpack_from(f"{size * count}s", content, offset)[0]
248
+ value = raw.rstrip(b"\x00").decode("ascii", errors="ignore")
249
+ else:
250
+ value = struct.unpack_from(f"{count}{fmt}", content, offset)
251
+ if count == 1:
252
+ value = value[0]
253
+
254
+ block_data[name] = value
255
+ offset += size * count
256
+
257
+ if name == "block_length":
258
+ block_length_value = value
259
+
260
+ if block_length_value is None:
261
+ raise ValueError(f"Missing block_length in {block_name}")
262
+ offset = offset_block_start + block_length_value # only needed for blocks 8, 9, 10
263
+
264
+ out[block_name] = block_data
265
+
266
+ return out