fm35-decoder 1.2__tar.gz → 2.0__tar.gz

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 (29) hide show
  1. fm35_decoder-2.0/PKG-INFO +168 -0
  2. fm35_decoder-2.0/README.md +151 -0
  3. {fm35_decoder-1.2 → fm35_decoder-2.0}/pyproject.toml +2 -3
  4. fm35_decoder-2.0/src/fm35_decoder/__init__.py +53 -0
  5. fm35_decoder-2.0/src/fm35_decoder/bulletin.py +224 -0
  6. fm35_decoder-2.0/src/fm35_decoder/decoder.py +226 -0
  7. fm35_decoder-2.0/src/fm35_decoder/groups.py +276 -0
  8. fm35_decoder-2.0/src/fm35_decoder/merge.py +301 -0
  9. fm35_decoder-2.0/src/fm35_decoder/models.py +175 -0
  10. fm35_decoder-2.0/src/fm35_decoder/parts.py +568 -0
  11. fm35_decoder-2.0/src/fm35_decoder/sections.py +72 -0
  12. fm35_decoder-2.0/src/fm35_decoder/stations.py +48 -0
  13. {fm35_decoder-1.2 → fm35_decoder-2.0}/src/fm35_decoder/tables.py +34 -27
  14. fm35_decoder-1.2/PKG-INFO +0 -173
  15. fm35_decoder-1.2/README.md +0 -155
  16. fm35_decoder-1.2/src/fm35_decoder/__init__.py +0 -3
  17. fm35_decoder-1.2/src/fm35_decoder/decoder.py +0 -1152
  18. {fm35_decoder-1.2 → fm35_decoder-2.0}/.gitignore +0 -0
  19. {fm35_decoder-1.2 → fm35_decoder-2.0}/LICENSE +0 -0
  20. {fm35_decoder-1.2 → fm35_decoder-2.0}/src/fm35_decoder/table_codes/CH_0509.csv +0 -0
  21. {fm35_decoder-1.2 → fm35_decoder-2.0}/src/fm35_decoder/table_codes/CL_0513.csv +0 -0
  22. {fm35_decoder-1.2 → fm35_decoder-2.0}/src/fm35_decoder/table_codes/CM_0515.csv +0 -0
  23. {fm35_decoder-1.2 → fm35_decoder-2.0}/src/fm35_decoder/table_codes/D_0777.csv +0 -0
  24. {fm35_decoder-1.2 → fm35_decoder-2.0}/src/fm35_decoder/table_codes/Nh_2700.csv +0 -0
  25. {fm35_decoder-1.2 → fm35_decoder-2.0}/src/fm35_decoder/table_codes/Sr_3849.csv +0 -0
  26. {fm35_decoder-1.2 → fm35_decoder-2.0}/src/fm35_decoder/table_codes/T_3931.csv +0 -0
  27. {fm35_decoder-1.2 → fm35_decoder-2.0}/src/fm35_decoder/table_codes/h_1600.csv +0 -0
  28. {fm35_decoder-1.2 → fm35_decoder-2.0}/src/fm35_decoder/table_codes/rara_3685.csv +0 -0
  29. {fm35_decoder-1.2 → fm35_decoder-2.0}/src/fm35_decoder/table_codes/sasa_3872.csv +0 -0
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.5
2
+ Name: fm35_decoder
3
+ Version: 2.0
4
+ Summary: Decoder for WMO FM 35 TEMP and FM 32 PILOT upper-air reports, based on the GEMPAK upper-air decoder.
5
+ Project-URL: Homepage, https://github.com/luciodaou/fm35_decoder
6
+ Project-URL: Bug Tracker, https://github.com/luciodaou/fm35_decoder/issues
7
+ Author: Lucio Daou
8
+ License-File: LICENSE
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.12
13
+ Requires-Dist: pandas>=3.0.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # FM35 Decoder
19
+
20
+ [![Ko-fi](https://img.shields.io/badge/Ko--fi-F16061?style=flat&logo=ko-fi&logoColor=white)](https://ko-fi.com/luciodaou)
21
+ [![PyPI - Version](https://img.shields.io/pypi/v/fm35_decoder.svg)](https://pypi.org/project/fm35_decoder)
22
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fm35_decoder.svg)](https://pypi.org/project/fm35_decoder)
23
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
24
+
25
+ A Python decoder for **WMO FM 35 TEMP** and **FM 32 PILOT** upper-air reports (and their ship and
26
+ dropsonde forms, FM 36 and FM 37), based on the upper-air decoder of
27
+ [Unidata GEMPAK](https://github.com/Unidata/gempak) (`dcuair`, the `RU` bridge library).
28
+
29
+ For a plain-English guide to the code form, see [TEMP_DECODING.md](TEMP_DECODING.md).
30
+ Version 2.0 changes the decoding rules and part of the API: see [CHANGELOG.md](CHANGELOG.md).
31
+
32
+ ---
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install fm35_decoder
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Quickstart
43
+
44
+ ### The parts of one sounding, as DataFrames
45
+
46
+ ```python
47
+ from fm35_decoder import decode
48
+
49
+ ttaa = "TTAA 73121 83779 99938 21224 01008 00163 ///// ///// 92843 20019 07506 85570 18650 36008 ..."
50
+ ttbb = "TTBB 73128 83779 00938 21224 11882 16804 22870 19856 33735 11056 44712 08616 55615 01218 ..."
51
+ ttcc = "TTCC 73123 83779 70865 71568 15020 50064 67574 12519 30380 58383 08535 88906 77162 26018 ..."
52
+ ttdd = "TTDD 7312/ 83779 11906 77162 22585 70370 33542 70970 44445 63978 55283 57785 21212 11935 ..."
53
+
54
+ df_main, df_special = decode(ttaa, ttbb, ttcc, ttdd)
55
+ ```
56
+
57
+ - `df_main` is the profile, surface first: `Pressure` (hPa), `Height` (m), `Temp`, `DewPoint` (°C),
58
+ `WindDir` (degrees), `WindSpeed` (knots), `Source` (`Surface`, `Standard`, `SigTemp`, `SigWind`,
59
+ `Tropopause`, `MaxWind`) and `Derived`, which names the columns of the row that were computed
60
+ rather than reported (`Height,Temp,DewPoint`).
61
+ - `df_special` lists the tropopause, maximum wind, layers of missing data, sounding system and
62
+ cloud entries as text, with the part each came from.
63
+
64
+ Options: `decode(..., wind_unit="m/s")` for speeds in m/s, and `decode(..., interpolate=False)` to
65
+ leave temperature, dew point and wind empty where the report gives none. Any argument may hold a
66
+ whole bulletin; every report found in it is used.
67
+
68
+ ### A stream of bulletins, as typed objects
69
+
70
+ ```python
71
+ from datetime import datetime, timezone
72
+ from fm35_decoder import decode_soundings, load_station_table
73
+
74
+ stations = load_station_table("gempak/tables/stns/snstns.tbl") # optional
75
+ with open("uair.gts", encoding="latin-1") as stream:
76
+ soundings = decode_soundings(stream, reference_time=datetime(2026, 11, 13, tzinfo=timezone.utc),
77
+ station_table=stations)
78
+
79
+ for sounding in soundings:
80
+ print(sounding.station_id, sounding.timestamp_utc, [part.value for part in sounding.parts])
81
+ for level in sounding.profile:
82
+ print(level.pressure_hpa, level.height_m, level.temp_c, level.wind_speed_ms, level.derived)
83
+ ```
84
+
85
+ The steps are available one by one, as generators where the input allows it:
86
+
87
+ | Function | GEMPAK routines | Result |
88
+ | :--- | :--- | :--- |
89
+ | `iter_bulletins(source)` | `DC_GBUL`, `DC_GHDR` | `Bulletin` (WMO heading and text) |
90
+ | `iter_reports(text)` | `RU_GRPT` | the text of each report |
91
+ | `parse_report(text, header, reference_time)` | `RU_SHDR`, `RU_DECD` and the part routines | `SoundingReport` |
92
+ | `iter_decoded_reports(source, reference_time)` | the three above | `SoundingReport`, one at a time |
93
+ | `merge_reports(reports, station_table)` | — | `MergedSounding` per station and time |
94
+
95
+ A `SoundingReport` holds the part, the station, the day and hour, the wind unit, its levels
96
+ (`SoundingLevel`, wind speeds always in m/s), the layers of missing data, the sounding system
97
+ group and the regional sections as text. A report whose header cannot be decoded raises
98
+ `DecodeError` in `parse_report` and is logged and skipped by the functions that read a stream.
99
+ A `NIL` report comes back with `nil` set and no levels.
100
+
101
+ The observation day and hour come from the report; the year and the month come from
102
+ `reference_time` (when the report was received; now, if not given). Parts are merged when they
103
+ share the station and the time rounded to 3 hours.
104
+
105
+ ---
106
+
107
+ ## Decoding rules
108
+
109
+ The level data is decoded as GEMPAK does. The rules that matter most in practice:
110
+
111
+ - **Order of the code form.** Standard levels are expected in order (1000, 925, … 100 hPa; 70, 50,
112
+ … 1 hPa in Part C). After a group that is not the expected level, the decoder looks for the next
113
+ level that is itself followed by the level after it, and gives up after ten groups. Significant
114
+ levels are expected in the sequence of `nn`; one out of sequence is taken only if its pressure is
115
+ lower than the last, and dropped again if the next level does not follow it.
116
+ - **`Id`** gives the last standard level with a wind group (hundreds of hPa in Part A, tens in
117
+ Part C; `/` for none). In Part C, `1` and `0` give every level a wind group.
118
+ - **Heights** are restored by a fixed rule per level (`RU_HGHT`): `00515` is −15 m, `85570` is
119
+ 1570 m, `70980` is 2980 m, `30970` is 9700 m, `20241` is 12410 m, `50064` in Part C is 20640 m.
120
+ A standard level without a height is not kept.
121
+ - **Temperature**: negative when the last figure is odd. **Depression**: tenths up to 50, whole
122
+ degrees plus 50 above (51–55, which WMO does not use, are read as 1–5 °C).
123
+ - **Wind**: `ddfff` with 500 added to `fff` for a direction ending in 5. 360° is given as 0°. A
124
+ direction above 360° (`99`, variable) gives no wind. Speeds follow `YY` (knots when 50 is added).
125
+ - **Tropopause and maximum wind** (`88`, `77`, `66`) are numeric levels; their pressure is in
126
+ tenths of hPa in Part C. `88999` and `77999` mean none. A maximum wind without a wind is dropped.
127
+ - **Levels without data** (a significant level whose temperature or wind is `/////`) are not kept.
128
+
129
+ Two rules differ from GEMPAK, both about where a marker is recognised:
130
+
131
+ 1. `21212` opens the wind section of Parts B and D only where a level group is expected. After
132
+ `nnPPP` it is a temperature of 21.2 °C with a depression of 1.2 °C.
133
+ 2. `51515` after `PPhhh` is a temperature of −51.5 °C with a depression of 1.5 °C. Where a level
134
+ or a wind is expected it ends the level data.
135
+
136
+ Added to what GEMPAK decodes (from the WMO Manual on Codes): the `31313` section (radiation
137
+ correction, sounding system, tracking, launch time), the `41414` cloud group of Part B, the
138
+ `4vbvbvava` shear group of a maximum wind, and layers of missing data (`nn/// /////`), which are
139
+ listed and never interpolated across.
140
+
141
+ ---
142
+
143
+ ## Merged profile
144
+
145
+ GEMPAK stores the parts unmerged, so the merge is this library's own:
146
+
147
+ - Levels are combined by pressure, to the tenth of hPa. Standard levels come first, then
148
+ significant temperature levels, significant winds, PILOT winds, tropopause and maximum wind; a
149
+ value already there is never replaced.
150
+ - One report per part: the first received, unless a later one comes from a bulletin whose `BBB`
151
+ marks a correction, retransmission or amendment.
152
+ - **Heights** of levels that report none are computed with the hypsometric equation and the
153
+ virtual temperature. Above the last temperature a height is computed only within the limits of
154
+ Regulation 35.2.2.4 (25 hPa, and a quarter of the pressure of the level). With a station table,
155
+ the surface takes the station elevation.
156
+ - **Interpolation** (on request; on by default in `decode`) fills temperature and dew point
157
+ linearly in ln(p), and the wind by its components, only between observed levels and never inside
158
+ a layer reported as missing. The dew point never exceeds the temperature.
159
+ - PILOT winds on heights get a pressure from the heights of the profile when they fall inside it.
160
+
161
+ Every value computed here is named in `SoundingLevel.derived` (the `Derived` column).
162
+
163
+ ---
164
+
165
+ ## License
166
+
167
+ This project is licensed under the terms of the **MIT License**.
168
+ You are free to use, modify, and distribute this software with attribution to the original author.
@@ -0,0 +1,151 @@
1
+ # FM35 Decoder
2
+
3
+ [![Ko-fi](https://img.shields.io/badge/Ko--fi-F16061?style=flat&logo=ko-fi&logoColor=white)](https://ko-fi.com/luciodaou)
4
+ [![PyPI - Version](https://img.shields.io/pypi/v/fm35_decoder.svg)](https://pypi.org/project/fm35_decoder)
5
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fm35_decoder.svg)](https://pypi.org/project/fm35_decoder)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ A Python decoder for **WMO FM 35 TEMP** and **FM 32 PILOT** upper-air reports (and their ship and
9
+ dropsonde forms, FM 36 and FM 37), based on the upper-air decoder of
10
+ [Unidata GEMPAK](https://github.com/Unidata/gempak) (`dcuair`, the `RU` bridge library).
11
+
12
+ For a plain-English guide to the code form, see [TEMP_DECODING.md](TEMP_DECODING.md).
13
+ Version 2.0 changes the decoding rules and part of the API: see [CHANGELOG.md](CHANGELOG.md).
14
+
15
+ ---
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pip install fm35_decoder
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Quickstart
26
+
27
+ ### The parts of one sounding, as DataFrames
28
+
29
+ ```python
30
+ from fm35_decoder import decode
31
+
32
+ ttaa = "TTAA 73121 83779 99938 21224 01008 00163 ///// ///// 92843 20019 07506 85570 18650 36008 ..."
33
+ ttbb = "TTBB 73128 83779 00938 21224 11882 16804 22870 19856 33735 11056 44712 08616 55615 01218 ..."
34
+ ttcc = "TTCC 73123 83779 70865 71568 15020 50064 67574 12519 30380 58383 08535 88906 77162 26018 ..."
35
+ ttdd = "TTDD 7312/ 83779 11906 77162 22585 70370 33542 70970 44445 63978 55283 57785 21212 11935 ..."
36
+
37
+ df_main, df_special = decode(ttaa, ttbb, ttcc, ttdd)
38
+ ```
39
+
40
+ - `df_main` is the profile, surface first: `Pressure` (hPa), `Height` (m), `Temp`, `DewPoint` (°C),
41
+ `WindDir` (degrees), `WindSpeed` (knots), `Source` (`Surface`, `Standard`, `SigTemp`, `SigWind`,
42
+ `Tropopause`, `MaxWind`) and `Derived`, which names the columns of the row that were computed
43
+ rather than reported (`Height,Temp,DewPoint`).
44
+ - `df_special` lists the tropopause, maximum wind, layers of missing data, sounding system and
45
+ cloud entries as text, with the part each came from.
46
+
47
+ Options: `decode(..., wind_unit="m/s")` for speeds in m/s, and `decode(..., interpolate=False)` to
48
+ leave temperature, dew point and wind empty where the report gives none. Any argument may hold a
49
+ whole bulletin; every report found in it is used.
50
+
51
+ ### A stream of bulletins, as typed objects
52
+
53
+ ```python
54
+ from datetime import datetime, timezone
55
+ from fm35_decoder import decode_soundings, load_station_table
56
+
57
+ stations = load_station_table("gempak/tables/stns/snstns.tbl") # optional
58
+ with open("uair.gts", encoding="latin-1") as stream:
59
+ soundings = decode_soundings(stream, reference_time=datetime(2026, 11, 13, tzinfo=timezone.utc),
60
+ station_table=stations)
61
+
62
+ for sounding in soundings:
63
+ print(sounding.station_id, sounding.timestamp_utc, [part.value for part in sounding.parts])
64
+ for level in sounding.profile:
65
+ print(level.pressure_hpa, level.height_m, level.temp_c, level.wind_speed_ms, level.derived)
66
+ ```
67
+
68
+ The steps are available one by one, as generators where the input allows it:
69
+
70
+ | Function | GEMPAK routines | Result |
71
+ | :--- | :--- | :--- |
72
+ | `iter_bulletins(source)` | `DC_GBUL`, `DC_GHDR` | `Bulletin` (WMO heading and text) |
73
+ | `iter_reports(text)` | `RU_GRPT` | the text of each report |
74
+ | `parse_report(text, header, reference_time)` | `RU_SHDR`, `RU_DECD` and the part routines | `SoundingReport` |
75
+ | `iter_decoded_reports(source, reference_time)` | the three above | `SoundingReport`, one at a time |
76
+ | `merge_reports(reports, station_table)` | — | `MergedSounding` per station and time |
77
+
78
+ A `SoundingReport` holds the part, the station, the day and hour, the wind unit, its levels
79
+ (`SoundingLevel`, wind speeds always in m/s), the layers of missing data, the sounding system
80
+ group and the regional sections as text. A report whose header cannot be decoded raises
81
+ `DecodeError` in `parse_report` and is logged and skipped by the functions that read a stream.
82
+ A `NIL` report comes back with `nil` set and no levels.
83
+
84
+ The observation day and hour come from the report; the year and the month come from
85
+ `reference_time` (when the report was received; now, if not given). Parts are merged when they
86
+ share the station and the time rounded to 3 hours.
87
+
88
+ ---
89
+
90
+ ## Decoding rules
91
+
92
+ The level data is decoded as GEMPAK does. The rules that matter most in practice:
93
+
94
+ - **Order of the code form.** Standard levels are expected in order (1000, 925, … 100 hPa; 70, 50,
95
+ … 1 hPa in Part C). After a group that is not the expected level, the decoder looks for the next
96
+ level that is itself followed by the level after it, and gives up after ten groups. Significant
97
+ levels are expected in the sequence of `nn`; one out of sequence is taken only if its pressure is
98
+ lower than the last, and dropped again if the next level does not follow it.
99
+ - **`Id`** gives the last standard level with a wind group (hundreds of hPa in Part A, tens in
100
+ Part C; `/` for none). In Part C, `1` and `0` give every level a wind group.
101
+ - **Heights** are restored by a fixed rule per level (`RU_HGHT`): `00515` is −15 m, `85570` is
102
+ 1570 m, `70980` is 2980 m, `30970` is 9700 m, `20241` is 12410 m, `50064` in Part C is 20640 m.
103
+ A standard level without a height is not kept.
104
+ - **Temperature**: negative when the last figure is odd. **Depression**: tenths up to 50, whole
105
+ degrees plus 50 above (51–55, which WMO does not use, are read as 1–5 °C).
106
+ - **Wind**: `ddfff` with 500 added to `fff` for a direction ending in 5. 360° is given as 0°. A
107
+ direction above 360° (`99`, variable) gives no wind. Speeds follow `YY` (knots when 50 is added).
108
+ - **Tropopause and maximum wind** (`88`, `77`, `66`) are numeric levels; their pressure is in
109
+ tenths of hPa in Part C. `88999` and `77999` mean none. A maximum wind without a wind is dropped.
110
+ - **Levels without data** (a significant level whose temperature or wind is `/////`) are not kept.
111
+
112
+ Two rules differ from GEMPAK, both about where a marker is recognised:
113
+
114
+ 1. `21212` opens the wind section of Parts B and D only where a level group is expected. After
115
+ `nnPPP` it is a temperature of 21.2 °C with a depression of 1.2 °C.
116
+ 2. `51515` after `PPhhh` is a temperature of −51.5 °C with a depression of 1.5 °C. Where a level
117
+ or a wind is expected it ends the level data.
118
+
119
+ Added to what GEMPAK decodes (from the WMO Manual on Codes): the `31313` section (radiation
120
+ correction, sounding system, tracking, launch time), the `41414` cloud group of Part B, the
121
+ `4vbvbvava` shear group of a maximum wind, and layers of missing data (`nn/// /////`), which are
122
+ listed and never interpolated across.
123
+
124
+ ---
125
+
126
+ ## Merged profile
127
+
128
+ GEMPAK stores the parts unmerged, so the merge is this library's own:
129
+
130
+ - Levels are combined by pressure, to the tenth of hPa. Standard levels come first, then
131
+ significant temperature levels, significant winds, PILOT winds, tropopause and maximum wind; a
132
+ value already there is never replaced.
133
+ - One report per part: the first received, unless a later one comes from a bulletin whose `BBB`
134
+ marks a correction, retransmission or amendment.
135
+ - **Heights** of levels that report none are computed with the hypsometric equation and the
136
+ virtual temperature. Above the last temperature a height is computed only within the limits of
137
+ Regulation 35.2.2.4 (25 hPa, and a quarter of the pressure of the level). With a station table,
138
+ the surface takes the station elevation.
139
+ - **Interpolation** (on request; on by default in `decode`) fills temperature and dew point
140
+ linearly in ln(p), and the wind by its components, only between observed levels and never inside
141
+ a layer reported as missing. The dew point never exceeds the temperature.
142
+ - PILOT winds on heights get a pressure from the heights of the profile when they fall inside it.
143
+
144
+ Every value computed here is named in `SoundingLevel.derived` (the `Derived` column).
145
+
146
+ ---
147
+
148
+ ## License
149
+
150
+ This project is licensed under the terms of the **MIT License**.
151
+ You are free to use, modify, and distribute this software with attribution to the original author.
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "fm35_decoder"
3
- version = "1.2"
4
- description = "A Python tool to decode standard FM35 TEMP weather messages from upper-air observations."
3
+ version = "2.0"
4
+ description = "Decoder for WMO FM 35 TEMP and FM 32 PILOT upper-air reports, based on the GEMPAK upper-air decoder."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
7
7
  classifiers = [
@@ -14,7 +14,6 @@ authors = [
14
14
  ]
15
15
  dependencies = [
16
16
  "pandas>=3.0.0",
17
- "numpy>=2.0.0",
18
17
  ]
19
18
 
20
19
  [project.optional-dependencies]
@@ -0,0 +1,53 @@
1
+ """
2
+ Decoder of WMO FM 35 TEMP and FM 32 PILOT upper-air reports (and their ship and dropsonde
3
+ forms), based on the GEMPAK upper-air decoder.
4
+ """
5
+
6
+ from .bulletin import Bulletin, iter_bulletins, iter_reports
7
+ from .decoder import decode, decode_full, decode_soundings, iter_decoded_reports, to_dataframes
8
+ from .merge import merge_parts, merge_reports
9
+ from .models import (
10
+ DecodeError,
11
+ LevelType,
12
+ MergedSounding,
13
+ MissingLayer,
14
+ Platform,
15
+ ReportPart,
16
+ SoundingLevel,
17
+ SoundingReport,
18
+ SoundingSystem,
19
+ StationMetadata,
20
+ WindUnit,
21
+ WmoHeader,
22
+ )
23
+ from .parts import parse_report
24
+ from .stations import load_station_table
25
+
26
+ __version__ = "2.0"
27
+
28
+ __all__ = [
29
+ "Bulletin",
30
+ "DecodeError",
31
+ "LevelType",
32
+ "MergedSounding",
33
+ "MissingLayer",
34
+ "Platform",
35
+ "ReportPart",
36
+ "SoundingLevel",
37
+ "SoundingReport",
38
+ "SoundingSystem",
39
+ "StationMetadata",
40
+ "WindUnit",
41
+ "WmoHeader",
42
+ "decode",
43
+ "decode_full",
44
+ "decode_soundings",
45
+ "iter_bulletins",
46
+ "iter_decoded_reports",
47
+ "iter_reports",
48
+ "load_station_table",
49
+ "merge_parts",
50
+ "merge_reports",
51
+ "parse_report",
52
+ "to_dataframes",
53
+ ]
@@ -0,0 +1,224 @@
1
+ """
2
+ Bulletins, reports and report headers, ported from GEMPAK: DC_GBUL and DC_GHDR (bridge/dc),
3
+ RU_GRPT, RU_SHDR, RU_RTIM and RU_ADJT (bridge/ru).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timedelta, timezone
11
+ from typing import Iterable, Iterator, List, Optional, Tuple, Union
12
+
13
+ from .groups import Fields, clean_text, decode_position, to_int
14
+ from .models import DecodeError, Platform, ReportPart, SoundingReport, WindUnit, WmoHeader
15
+
16
+ SOH, ETX = "\x01", "\x03"
17
+
18
+ # TTAAii CCCC YYGGgg (BBB); DC_GHDR also takes MMDDHHMM, DDHHMMZ and "UTC" before BBB
19
+ _HEADING = re.compile(r"^([A-Z]{4}\d{0,2}) ([A-Z]{4}) (\d{6}Z?|\d{8})(?: UTC)?(?: ([A-Z]{3}))?$")
20
+ _PART_NAME = re.compile(r"^(TT|PP|UU|XX)(AA|BB|CC|DD)")
21
+ _SEQUENCE = re.compile(r"^\d{3,5}$")
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Bulletin:
26
+ header: Optional[WmoHeader]
27
+ text: str
28
+
29
+
30
+ def _parse_heading(line: str, sequence: str) -> Optional[WmoHeader]:
31
+ """DC_GHDR: the heading line, with a valid day (01-31) and time (0000-2359)."""
32
+ m = _HEADING.match(line)
33
+ if m is None or _PART_NAME.match(line):
34
+ return None
35
+ time = m.group(3)
36
+ time = time[2:] if len(time) == 8 else time[:6]
37
+ if not (1 <= int(time[:2]) <= 31 and int(time[2:]) <= 2359):
38
+ return None
39
+ return WmoHeader(m.group(1), m.group(2), time, m.group(4) or "", sequence)
40
+
41
+
42
+ def iter_bulletins(source: Union[str, Iterable[str]]) -> Iterator[Bulletin]:
43
+ """
44
+ The bulletins of a text, a file object or any iterable of lines, one at a time. A bulletin
45
+ starts at SOH or at a WMO abbreviated heading and ends at ETX, at the next start or at the
46
+ end of the input. Text without any of those is one bulletin without a header.
47
+ """
48
+ lines = source.splitlines() if isinstance(source, str) else source
49
+ header: Optional[WmoHeader] = None
50
+ sequence = ""
51
+ body: List[str] = []
52
+
53
+ def flush() -> Iterator[Bulletin]:
54
+ nonlocal header, sequence, body
55
+ text = clean_text(" ".join(body))
56
+ if text:
57
+ yield Bulletin(header, text)
58
+ header, sequence, body = None, "", []
59
+
60
+ for raw in lines:
61
+ for piece in re.split(f"({SOH}|{ETX})", raw):
62
+ if piece in (SOH, ETX):
63
+ yield from flush()
64
+ continue
65
+ line = clean_text(piece)
66
+ if not line:
67
+ continue
68
+ if header is None and not body and _SEQUENCE.match(line):
69
+ sequence = line
70
+ continue
71
+ heading = _parse_heading(line, sequence)
72
+ if heading is not None:
73
+ kept = sequence
74
+ yield from flush()
75
+ header = WmoHeader(heading.ttaaii, heading.cccc, heading.yygggg, heading.bbb, kept)
76
+ continue
77
+ body.append(line)
78
+ yield from flush()
79
+
80
+
81
+ _REPORT_START = re.compile(r"TT|PP|UU|XX")
82
+ _REPORT_END = re.compile(r"TT|PP|UU|XX|=")
83
+ # A dropsonde report may hold text, so only another dropsonde report or "=" ends it
84
+ _REPORT_END_XX = re.compile(r"XXAA|XXBB|XXCC|XXDD|=")
85
+
86
+
87
+ def iter_reports(text: str) -> Iterator[str]:
88
+ """
89
+ RU_GRPT: the reports of a bulletin. A report starts at TT, PP, UU or XX followed by AA, BB,
90
+ CC or DD and runs up to "=", the start of another report or the end of the bulletin.
91
+ """
92
+ text = clean_text(text)
93
+ pos, end = 0, len(text)
94
+ while pos < end:
95
+ search = pos
96
+ while True:
97
+ m = _REPORT_START.search(text, search)
98
+ if m is None:
99
+ return
100
+ start = m.start()
101
+ if text[start + 2 : start + 4] in ("AA", "BB", "CC", "DD"):
102
+ break
103
+ search = start + 1
104
+ pattern = _REPORT_END_XX if m.group() == "XX" else _REPORT_END
105
+ stop = pattern.search(text, start + 2)
106
+ if stop is None:
107
+ yield text[start:]
108
+ return
109
+ yield text[start : stop.start()]
110
+ pos = stop.start() + (1 if stop.group() == "=" else 0)
111
+
112
+
113
+ def parse_header(text: str, fields: Fields) -> SoundingReport:
114
+ """
115
+ RU_SHDR: MiMiMjMj (call sign) YYGGId IIiii, or the position groups of a ship or dropsonde.
116
+ `fields` is left on the first group after the header. XX and UU parts are decoded as TT.
117
+ Raises DecodeError when the header is not one.
118
+ """
119
+ name = text[fields.pos : fields.pos + 4]
120
+ fields.pos += 4
121
+ p1, p2 = name[:2], name[2:]
122
+ if p1 in ("TT", "PP") and p2 in ("AA", "BB", "CC", "DD"):
123
+ platform = Platform.LAND
124
+ elif p1 == "XX" and p2 in ("AA", "BB"):
125
+ platform = Platform.DROPSONDE
126
+ elif p1 == "UU" and p2 in ("AA", "BB", "CC", "DD"):
127
+ platform = Platform.SHIP
128
+ else:
129
+ raise DecodeError(f"not an upper-air part: {name!r}")
130
+ part = ReportPart(name if platform is Platform.LAND else "TT" + p2)
131
+
132
+ call_sign = ""
133
+ if platform is Platform.SHIP:
134
+ call_sign = fields.next() or ""
135
+ if not call_sign or len(call_sign) > 8:
136
+ raise DecodeError(f"invalid call sign in {text[:24]!r}")
137
+
138
+ fld = fields.next()
139
+ if fld is None or len(fld) < 5:
140
+ raise DecodeError(f"no YYGGId group in {text[:24]!r}")
141
+ day, hour = to_int(fld[0:2]), to_int(fld[2:4])
142
+ if day is None or hour is None:
143
+ raise DecodeError(f"invalid YYGGId group {fld!r}")
144
+ unit = WindUnit.METERS_PER_SECOND
145
+ if day > 50:
146
+ day -= 50
147
+ unit = WindUnit.KNOTS
148
+ report = SoundingReport(
149
+ part=part,
150
+ station_id="",
151
+ obs_day=day,
152
+ obs_hour=hour,
153
+ wind_unit=unit,
154
+ platform=platform,
155
+ top_wind_indicator=to_int(fld[4:5]),
156
+ text=text,
157
+ )
158
+
159
+ if platform is Platform.LAND:
160
+ fld = fields.next()
161
+ number = to_int(fld[0:5]) if fld is not None and len(fld) >= 5 else None
162
+ if number is None:
163
+ raise DecodeError(f"invalid station number in {text[:24]!r}")
164
+ report.station_id = f"{number:05d}"
165
+ else:
166
+ position = decode_position(fields)
167
+ if position is None:
168
+ raise DecodeError(f"invalid position groups in {text[:40]!r}")
169
+ report.latitude, report.longitude = position
170
+ if platform is Platform.DROPSONDE:
171
+ # The identifier comes from the 61616 section; until then it is the hour
172
+ report.station_id = str(hour)
173
+ elif call_sign == "SHIP":
174
+ lat, lon = position
175
+ report.station_id = f"S{int(abs(lat * 10)) * 10000 + int(abs(lon * 10)):07d}"
176
+ else:
177
+ report.station_id = f"{call_sign[:6]}{hour:02d}"
178
+
179
+ rest = text[fields.pos :].lstrip()
180
+ report.nil = rest[:3] == "NIL" and rest[3:].strip(" =") == ""
181
+ return report
182
+
183
+
184
+ def observation_time(reference: datetime, day: int, hour: int) -> Optional[datetime]:
185
+ """
186
+ RU_RTIM: the observation time from the day and hour of the report, taking the year and the
187
+ month from `reference` (the time the report was received). A day after the 27th received
188
+ on the 1st belongs to the month before; hour 24 is 00 of the next day. None when the hour
189
+ or the date is not valid.
190
+ """
191
+ add_day = hour == 24
192
+ if add_day:
193
+ hour = 0
194
+ elif not 0 <= hour < 24:
195
+ return None
196
+ year, month = reference.year, reference.month
197
+ if day > 27 and reference.day == 1:
198
+ month -= 1
199
+ if month == 0:
200
+ year, month = year - 1, 12
201
+ try:
202
+ obs = datetime(year, month, day, hour, tzinfo=timezone.utc)
203
+ except ValueError:
204
+ return None
205
+ return obs + timedelta(days=1) if add_day else obs
206
+
207
+
208
+ def nominal_time(obs: datetime) -> datetime:
209
+ """RU_ADJT: the time rounded to the nearest 3 hours."""
210
+ diff = obs.hour % 3
211
+ if diff == 1:
212
+ return obs - timedelta(hours=1)
213
+ if diff == 2:
214
+ return obs + timedelta(hours=1)
215
+ return obs
216
+
217
+
218
+ def station_key(report: SoundingReport) -> Tuple[str, Optional[datetime], int, int]:
219
+ """What the parts of one sounding share: station and nominal time (day and hour without one)."""
220
+ if report.nominal_time is not None:
221
+ return report.station_id, report.nominal_time, 0, 0
222
+ hour = report.obs_hour
223
+ hour += {0: 0, 1: -1, 2: 1}[hour % 3]
224
+ return report.station_id, None, report.obs_day, hour