fm35-decoder 1.1__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.
- {fm35_decoder-1.1 → fm35_decoder-2.0}/.gitignore +4 -1
- fm35_decoder-2.0/PKG-INFO +168 -0
- fm35_decoder-2.0/README.md +151 -0
- {fm35_decoder-1.1 → fm35_decoder-2.0}/pyproject.toml +2 -3
- fm35_decoder-2.0/src/fm35_decoder/__init__.py +53 -0
- fm35_decoder-2.0/src/fm35_decoder/bulletin.py +224 -0
- fm35_decoder-2.0/src/fm35_decoder/decoder.py +226 -0
- fm35_decoder-2.0/src/fm35_decoder/groups.py +276 -0
- fm35_decoder-2.0/src/fm35_decoder/merge.py +301 -0
- fm35_decoder-2.0/src/fm35_decoder/models.py +175 -0
- fm35_decoder-2.0/src/fm35_decoder/parts.py +568 -0
- fm35_decoder-2.0/src/fm35_decoder/sections.py +72 -0
- fm35_decoder-2.0/src/fm35_decoder/stations.py +48 -0
- fm35_decoder-2.0/src/fm35_decoder/table_codes/CL_0513.csv +12 -0
- fm35_decoder-2.0/src/fm35_decoder/table_codes/Nh_2700.csv +12 -0
- fm35_decoder-2.0/src/fm35_decoder/table_codes/Sr_3849.csv +10 -0
- fm35_decoder-2.0/src/fm35_decoder/table_codes/h_1600.csv +12 -0
- {fm35_decoder-1.1 → fm35_decoder-2.0}/src/fm35_decoder/table_codes/rara_3685.csv +47 -103
- fm35_decoder-2.0/src/fm35_decoder/table_codes/sasa_3872.csv +102 -0
- fm35_decoder-2.0/src/fm35_decoder/tables.py +390 -0
- fm35_decoder-1.1/PKG-INFO +0 -171
- fm35_decoder-1.1/README.md +0 -153
- fm35_decoder-1.1/src/fm35_decoder/__init__.py +0 -3
- fm35_decoder-1.1/src/fm35_decoder/decoder.py +0 -1089
- fm35_decoder-1.1/src/fm35_decoder/table_codes/CL_0513.csv +0 -12
- fm35_decoder-1.1/src/fm35_decoder/table_codes/Nh_2700.csv +0 -12
- fm35_decoder-1.1/src/fm35_decoder/table_codes/Sr_3849.csv +0 -10
- fm35_decoder-1.1/src/fm35_decoder/table_codes/h_1600.csv +0 -12
- fm35_decoder-1.1/src/fm35_decoder/table_codes/sasa_3872.csv +0 -27
- fm35_decoder-1.1/src/fm35_decoder/tables.py +0 -291
- {fm35_decoder-1.1 → fm35_decoder-2.0}/LICENSE +0 -0
- {fm35_decoder-1.1 → fm35_decoder-2.0}/src/fm35_decoder/table_codes/CH_0509.csv +0 -0
- {fm35_decoder-1.1 → fm35_decoder-2.0}/src/fm35_decoder/table_codes/CM_0515.csv +0 -0
- {fm35_decoder-1.1 → fm35_decoder-2.0}/src/fm35_decoder/table_codes/D_0777.csv +0 -0
- {fm35_decoder-1.1 → fm35_decoder-2.0}/src/fm35_decoder/table_codes/T_3931.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
|
+
[](https://ko-fi.com/luciodaou)
|
|
21
|
+
[](https://pypi.org/project/fm35_decoder)
|
|
22
|
+
[](https://pypi.org/project/fm35_decoder)
|
|
23
|
+
[](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
|
+
[](https://ko-fi.com/luciodaou)
|
|
4
|
+
[](https://pypi.org/project/fm35_decoder)
|
|
5
|
+
[](https://pypi.org/project/fm35_decoder)
|
|
6
|
+
[](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 = "
|
|
4
|
-
description = "
|
|
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
|