fm35-decoder 1.0__tar.gz → 1.1__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.0 → fm35_decoder-1.1}/PKG-INFO +52 -31
- {fm35_decoder-1.0 → fm35_decoder-1.1}/README.md +51 -30
- {fm35_decoder-1.0 → fm35_decoder-1.1}/pyproject.toml +1 -1
- fm35_decoder-1.1/src/fm35_decoder/__init__.py +3 -0
- {fm35_decoder-1.0 → fm35_decoder-1.1}/src/fm35_decoder/decoder.py +270 -121
- fm35_decoder-1.0/src/fm35_decoder/__init__.py +0 -3
- {fm35_decoder-1.0 → fm35_decoder-1.1}/.gitignore +0 -0
- {fm35_decoder-1.0 → fm35_decoder-1.1}/LICENSE +0 -0
- {fm35_decoder-1.0 → fm35_decoder-1.1}/src/fm35_decoder/table_codes/CH_0509.csv +0 -0
- {fm35_decoder-1.0 → fm35_decoder-1.1}/src/fm35_decoder/table_codes/CL_0513.csv +0 -0
- {fm35_decoder-1.0 → fm35_decoder-1.1}/src/fm35_decoder/table_codes/CM_0515.csv +0 -0
- {fm35_decoder-1.0 → fm35_decoder-1.1}/src/fm35_decoder/table_codes/D_0777.csv +0 -0
- {fm35_decoder-1.0 → fm35_decoder-1.1}/src/fm35_decoder/table_codes/Nh_2700.csv +0 -0
- {fm35_decoder-1.0 → fm35_decoder-1.1}/src/fm35_decoder/table_codes/Sr_3849.csv +0 -0
- {fm35_decoder-1.0 → fm35_decoder-1.1}/src/fm35_decoder/table_codes/T_3931.csv +0 -0
- {fm35_decoder-1.0 → fm35_decoder-1.1}/src/fm35_decoder/table_codes/h_1600.csv +0 -0
- {fm35_decoder-1.0 → fm35_decoder-1.1}/src/fm35_decoder/table_codes/rara_3685.csv +0 -0
- {fm35_decoder-1.0 → fm35_decoder-1.1}/src/fm35_decoder/table_codes/sasa_3872.csv +0 -0
- {fm35_decoder-1.0 → fm35_decoder-1.1}/src/fm35_decoder/tables.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fm35_decoder
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1
|
|
4
4
|
Summary: A Python tool to decode standard FM35 TEMP weather messages from upper-air observations.
|
|
5
5
|
Project-URL: Homepage, https://github.com/luciodaou/fm35_decoder
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/luciodaou/fm35_decoder/issues
|
|
@@ -55,11 +55,14 @@ ttdd = "TTDD 7312/ 83779 11906 77162 22585 70370 33542 70970 44445 63978 55283 5
|
|
|
55
55
|
df_main, df_special = decode(ttaa, ttbb, ttcc, ttdd)
|
|
56
56
|
|
|
57
57
|
# df_main: Clean vertical sounding profile (Pressure, Height, Temp, DewPoint, WindDir, WindSpeed)
|
|
58
|
-
# df_special: Metadata, Tropopause, Maximum Wind, and Cloud groups
|
|
58
|
+
# df_special: Metadata, Tropopause, Maximum Wind, and Cloud groups, with the part each came from
|
|
59
59
|
print(df_main)
|
|
60
60
|
print(df_special)
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
Any argument may also hold a whole bulletin with several parts: `decode(bulletin)` splits it and
|
|
64
|
+
decodes each part once (`split_bulletin` and `route_parts` do the splitting on their own).
|
|
65
|
+
|
|
63
66
|
---
|
|
64
67
|
|
|
65
68
|
## Handling Non-Standard and Extreme Meteorological Data
|
|
@@ -72,6 +75,24 @@ Real-world atmospheric soundings and teletype GTS transmissions frequently conta
|
|
|
72
75
|
In WMO FM 35, when the units digit of wind direction is $5^\circ$ (e.g., $275^\circ$), $500$ is added to the reported wind speed ($fff$) across both standard levels and maximum wind groups ($77P_mP_mP_m$). The decoder accurately separates the $5^\circ$ direction component from the true wind speed, avoiding the common $500\text{ kt}$ speed inflation bug.
|
|
73
76
|
- **Calm Wind ($0^\circ, 0\text{ kt}$)**:
|
|
74
77
|
Calm wind is represented as `WindDir = 0.0, WindSpeed = 0.0`. True non-calm winds blowing from North are assigned `360.0°`.
|
|
78
|
+
- **Wind Groups Follow $I_d$**:
|
|
79
|
+
The header's $I_d$ (Code Table 1734) names the last standard level that carries a wind group ($2$ covers $250$ and $200\text{ hPa}$, $1$ covers $150$ and $100\text{ hPa}$; tens of hPa in Part C). Levels above it are read as `PPhhh TTTDD` only, so the next group is never taken for their wind; with $I_d$ = `/` only the surface has a wind group.
|
|
80
|
+
- **Temperatures That Look Like Indicators**:
|
|
81
|
+
$-66.x$ and $-77.x\,^\circ\text{C}$ (common at $150$ and $100\text{ hPa}$ in the tropics) are coded `66xxx` and `77xxx`, like the maximum wind indicators, and `21212` ($21.2\,^\circ\text{C}$) or `31313` ($-31.3\,^\circ\text{C}$) look like section markers. The group after `PPhhh` is read as the temperature by its position; only groups that cannot be a temperature and depression (`88999`, `77999`, `66999`, `41414`, `52525`, `54545`, `55555`, `56565`, `58585`) end a level early.
|
|
82
|
+
- **Maximum Wind Shear**:
|
|
83
|
+
The cloud section (`41414`) belongs to Part B, so in Parts A and C a `41414` after a maximum wind is the shear group ($14\text{ kt}$ below and above).
|
|
84
|
+
- **Wind Speed Units**:
|
|
85
|
+
`YY` is the day plus $50$ when speeds are in knots and the day itself when they are in m/s. m/s speeds are converted to knots in `df_main`; `df_special` keeps the reported value and unit (`240/23m/s`).
|
|
86
|
+
- **Regional and National Sections**:
|
|
87
|
+
`51515`–`59595` and `61616`–`69696` come last in a part; the groups after them are never read as levels (`55555` stays a significant level in Parts B and D, `66666` a maximum wind at $666\text{ hPa}$).
|
|
88
|
+
- **Part B Without Part A**:
|
|
89
|
+
A `TTBB` decodes on its own: the profile has no heights, which only Parts A and C report. A message with fewer than two temperatures (only the surface) raises `ValueError`.
|
|
90
|
+
- **Part C Special Levels in Tenths of hPa**:
|
|
91
|
+
In `TTCC`, `88PtPtPt` and `77PmPmPm` give the pressure in tenths of a hectopascal (`88906` is $90.6\text{ hPa}$).
|
|
92
|
+
- **Pressures Keep Their Tenths**:
|
|
93
|
+
Part D levels are reported in tenths of a hectopascal, and `df_main` keeps them (`70.7` and `70.0 hPa` stay two levels). Round for display in your own code.
|
|
94
|
+
- **Repeated Sections**:
|
|
95
|
+
The `31313` and cloud sections repeat in every part and are listed once. Tropopause and maximum wind entries are all kept: a sounding may report two tropopauses (one in Part A, one in Part C). The `Part` column of `df_special` tells them apart.
|
|
75
96
|
- **GTS Transmission Sequence Breaks**:
|
|
76
97
|
If network or teletype line noise drops an intermediate significant level in `TTBB` or `TTDD` (e.g., group sequence jumps directly from `22` to `44`), the decoder recovers automatically rather than terminating or discarding the remainder of the sounding.
|
|
77
98
|
- **Negative Geopotential Heights ($1000\text{ hPa}$ below sea level)**:
|
|
@@ -86,8 +107,8 @@ Real-world atmospheric soundings and teletype GTS transmissions frequently conta
|
|
|
86
107
|
## Meteorological Methodology
|
|
87
108
|
|
|
88
109
|
### Vertical Interpolation
|
|
89
|
-
- **Temperature & Dewpoint**: Missing intermediate levels are filled via linear interpolation with respect to the **natural logarithm of pressure** ($\ln P$). This aligns with atmospheric thermodynamics on Skew-T / Log-P diagrams where temperature varies approximately linearly with $\ln P$.
|
|
90
|
-
- **Wind Vectors**: Wind speed and direction are decomposed into orthogonal **$U$ (zonal)** and **$V$ (meridional)** vector components. Each component is interpolated independently in $\ln P$ space before being recombined, preventing boundary artifacts across the $0^\circ/360^\circ$ North transition.
|
|
110
|
+
- **Temperature & Dewpoint**: Missing intermediate levels are filled via linear interpolation with respect to the **natural logarithm of pressure** ($\ln P$), only between two observed levels: nothing is extrapolated above the last observation or below the first (a top level reported as `/////` stays empty). This aligns with atmospheric thermodynamics on Skew-T / Log-P diagrams where temperature varies approximately linearly with $\ln P$.
|
|
111
|
+
- **Wind Vectors**: Wind speed and direction are decomposed into orthogonal **$U$ (zonal)** and **$V$ (meridional)** vector components. Each component is interpolated independently in $\ln P$ space (again only between observed winds) before being recombined, preventing boundary artifacts across the $0^\circ/360^\circ$ North transition. A variable wind keeps its speed and no direction.
|
|
91
112
|
|
|
92
113
|
### Hypsometric Geopotential Calculation
|
|
93
114
|
Missing geopotential heights are computed using the Hypsometric Equation:
|
|
@@ -108,38 +129,38 @@ The decoder incorporates **virtual temperature ($T_v$)** when moisture data is a
|
|
|
108
129
|
### `df_main` (Vertical Profile)
|
|
109
130
|
```
|
|
110
131
|
Pressure Height Temp DewPoint WindDir WindSpeed
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
132
|
+
1000.0 163 20.4 18.0 10.0 8.0
|
|
133
|
+
938.0 714 21.2 18.8 10.0 8.0
|
|
134
|
+
925.0 843 20.0 18.1 75.0 6.0
|
|
135
|
+
882.0 1249 16.8 16.4 23.0 8.5
|
|
136
|
+
870.0 1366 19.8 13.8 15.0 10.0
|
|
137
|
+
850.0 1570 18.6 13.6 360.0 8.0
|
|
117
138
|
... ... ... ... ... ...
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
139
|
+
30.0 23800 -58.3 -91.3 85.0 35.0
|
|
140
|
+
20.0 26480 -55.2 -89.0 90.0 38.0
|
|
141
|
+
10.0 31020 -44.1 NaN 95.0 42.0
|
|
121
142
|
```
|
|
122
143
|
|
|
123
144
|
### `df_special` (Metadata & Special Levels)
|
|
124
145
|
```
|
|
125
|
-
Symbol Subject Description Value
|
|
126
|
-
h Cloud Base Height 600-1000m (2000-3300ft)
|
|
127
|
-
Nh Cloud Amount 8 oktas
|
|
128
|
-
CL Cloud Low Type Stratus nebulosus and/or Stratus fractus
|
|
129
|
-
CM Cloud Mid Type No CM clouds
|
|
130
|
-
CH Cloud High Type No CH clouds
|
|
131
|
-
sr Solar/Inst Solar Corr NOAA solar corrected
|
|
132
|
-
rara Solar/Inst Sonde Type Vaisala RS41/DigiCORA MW41 (Finland)
|
|
133
|
-
sasa Solar/Inst Tracking Radar (5 cm)
|
|
134
|
-
8GGgg Solar/Inst Time 11:31
|
|
135
|
-
PtPtPt Tropopause Pressure
|
|
136
|
-
TtTtTt Tropopause Temperature -77.1C
|
|
137
|
-
DtDt Tropopause Dewpoint -89.1C
|
|
138
|
-
dtdtftft Tropopause Wind 260/18kt
|
|
139
|
-
PmPmPm Max Wind Pressure 200hPa
|
|
140
|
-
dmdmfmfmfm Max Wind Wind 275/120kt
|
|
141
|
-
vbvb Max Wind Shear Below 14kt
|
|
142
|
-
vava Max Wind Shear Above 14kt
|
|
146
|
+
Symbol Subject Description Value Part
|
|
147
|
+
h Cloud Base Height 600-1000m (2000-3300ft) TTBB
|
|
148
|
+
Nh Cloud Amount 8 oktas TTBB
|
|
149
|
+
CL Cloud Low Type Stratus nebulosus and/or Stratus fractus TTBB
|
|
150
|
+
CM Cloud Mid Type No CM clouds TTBB
|
|
151
|
+
CH Cloud High Type No CH clouds TTBB
|
|
152
|
+
sr Solar/Inst Solar Corr NOAA solar corrected TTAA
|
|
153
|
+
rara Solar/Inst Sonde Type Vaisala RS41/DigiCORA MW41 (Finland) TTAA
|
|
154
|
+
sasa Solar/Inst Tracking Radar (5 cm) TTAA
|
|
155
|
+
8GGgg Solar/Inst Time 11:31 TTAA
|
|
156
|
+
PtPtPt Tropopause Pressure 90.6hPa TTCC
|
|
157
|
+
TtTtTt Tropopause Temperature -77.1C TTCC
|
|
158
|
+
DtDt Tropopause Dewpoint -89.1C TTCC
|
|
159
|
+
dtdtftft Tropopause Wind 260/18kt TTCC
|
|
160
|
+
PmPmPm Max Wind Pressure 200hPa TTAA
|
|
161
|
+
dmdmfmfmfm Max Wind Wind 275/120kt TTAA
|
|
162
|
+
vbvb Max Wind Shear Below 14kt TTAA
|
|
163
|
+
vava Max Wind Shear Above 14kt TTAA
|
|
143
164
|
```
|
|
144
165
|
|
|
145
166
|
---
|
|
@@ -37,11 +37,14 @@ ttdd = "TTDD 7312/ 83779 11906 77162 22585 70370 33542 70970 44445 63978 55283 5
|
|
|
37
37
|
df_main, df_special = decode(ttaa, ttbb, ttcc, ttdd)
|
|
38
38
|
|
|
39
39
|
# df_main: Clean vertical sounding profile (Pressure, Height, Temp, DewPoint, WindDir, WindSpeed)
|
|
40
|
-
# df_special: Metadata, Tropopause, Maximum Wind, and Cloud groups
|
|
40
|
+
# df_special: Metadata, Tropopause, Maximum Wind, and Cloud groups, with the part each came from
|
|
41
41
|
print(df_main)
|
|
42
42
|
print(df_special)
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
Any argument may also hold a whole bulletin with several parts: `decode(bulletin)` splits it and
|
|
46
|
+
decodes each part once (`split_bulletin` and `route_parts` do the splitting on their own).
|
|
47
|
+
|
|
45
48
|
---
|
|
46
49
|
|
|
47
50
|
## Handling Non-Standard and Extreme Meteorological Data
|
|
@@ -54,6 +57,24 @@ Real-world atmospheric soundings and teletype GTS transmissions frequently conta
|
|
|
54
57
|
In WMO FM 35, when the units digit of wind direction is $5^\circ$ (e.g., $275^\circ$), $500$ is added to the reported wind speed ($fff$) across both standard levels and maximum wind groups ($77P_mP_mP_m$). The decoder accurately separates the $5^\circ$ direction component from the true wind speed, avoiding the common $500\text{ kt}$ speed inflation bug.
|
|
55
58
|
- **Calm Wind ($0^\circ, 0\text{ kt}$)**:
|
|
56
59
|
Calm wind is represented as `WindDir = 0.0, WindSpeed = 0.0`. True non-calm winds blowing from North are assigned `360.0°`.
|
|
60
|
+
- **Wind Groups Follow $I_d$**:
|
|
61
|
+
The header's $I_d$ (Code Table 1734) names the last standard level that carries a wind group ($2$ covers $250$ and $200\text{ hPa}$, $1$ covers $150$ and $100\text{ hPa}$; tens of hPa in Part C). Levels above it are read as `PPhhh TTTDD` only, so the next group is never taken for their wind; with $I_d$ = `/` only the surface has a wind group.
|
|
62
|
+
- **Temperatures That Look Like Indicators**:
|
|
63
|
+
$-66.x$ and $-77.x\,^\circ\text{C}$ (common at $150$ and $100\text{ hPa}$ in the tropics) are coded `66xxx` and `77xxx`, like the maximum wind indicators, and `21212` ($21.2\,^\circ\text{C}$) or `31313` ($-31.3\,^\circ\text{C}$) look like section markers. The group after `PPhhh` is read as the temperature by its position; only groups that cannot be a temperature and depression (`88999`, `77999`, `66999`, `41414`, `52525`, `54545`, `55555`, `56565`, `58585`) end a level early.
|
|
64
|
+
- **Maximum Wind Shear**:
|
|
65
|
+
The cloud section (`41414`) belongs to Part B, so in Parts A and C a `41414` after a maximum wind is the shear group ($14\text{ kt}$ below and above).
|
|
66
|
+
- **Wind Speed Units**:
|
|
67
|
+
`YY` is the day plus $50$ when speeds are in knots and the day itself when they are in m/s. m/s speeds are converted to knots in `df_main`; `df_special` keeps the reported value and unit (`240/23m/s`).
|
|
68
|
+
- **Regional and National Sections**:
|
|
69
|
+
`51515`–`59595` and `61616`–`69696` come last in a part; the groups after them are never read as levels (`55555` stays a significant level in Parts B and D, `66666` a maximum wind at $666\text{ hPa}$).
|
|
70
|
+
- **Part B Without Part A**:
|
|
71
|
+
A `TTBB` decodes on its own: the profile has no heights, which only Parts A and C report. A message with fewer than two temperatures (only the surface) raises `ValueError`.
|
|
72
|
+
- **Part C Special Levels in Tenths of hPa**:
|
|
73
|
+
In `TTCC`, `88PtPtPt` and `77PmPmPm` give the pressure in tenths of a hectopascal (`88906` is $90.6\text{ hPa}$).
|
|
74
|
+
- **Pressures Keep Their Tenths**:
|
|
75
|
+
Part D levels are reported in tenths of a hectopascal, and `df_main` keeps them (`70.7` and `70.0 hPa` stay two levels). Round for display in your own code.
|
|
76
|
+
- **Repeated Sections**:
|
|
77
|
+
The `31313` and cloud sections repeat in every part and are listed once. Tropopause and maximum wind entries are all kept: a sounding may report two tropopauses (one in Part A, one in Part C). The `Part` column of `df_special` tells them apart.
|
|
57
78
|
- **GTS Transmission Sequence Breaks**:
|
|
58
79
|
If network or teletype line noise drops an intermediate significant level in `TTBB` or `TTDD` (e.g., group sequence jumps directly from `22` to `44`), the decoder recovers automatically rather than terminating or discarding the remainder of the sounding.
|
|
59
80
|
- **Negative Geopotential Heights ($1000\text{ hPa}$ below sea level)**:
|
|
@@ -68,8 +89,8 @@ Real-world atmospheric soundings and teletype GTS transmissions frequently conta
|
|
|
68
89
|
## Meteorological Methodology
|
|
69
90
|
|
|
70
91
|
### Vertical Interpolation
|
|
71
|
-
- **Temperature & Dewpoint**: Missing intermediate levels are filled via linear interpolation with respect to the **natural logarithm of pressure** ($\ln P$). This aligns with atmospheric thermodynamics on Skew-T / Log-P diagrams where temperature varies approximately linearly with $\ln P$.
|
|
72
|
-
- **Wind Vectors**: Wind speed and direction are decomposed into orthogonal **$U$ (zonal)** and **$V$ (meridional)** vector components. Each component is interpolated independently in $\ln P$ space before being recombined, preventing boundary artifacts across the $0^\circ/360^\circ$ North transition.
|
|
92
|
+
- **Temperature & Dewpoint**: Missing intermediate levels are filled via linear interpolation with respect to the **natural logarithm of pressure** ($\ln P$), only between two observed levels: nothing is extrapolated above the last observation or below the first (a top level reported as `/////` stays empty). This aligns with atmospheric thermodynamics on Skew-T / Log-P diagrams where temperature varies approximately linearly with $\ln P$.
|
|
93
|
+
- **Wind Vectors**: Wind speed and direction are decomposed into orthogonal **$U$ (zonal)** and **$V$ (meridional)** vector components. Each component is interpolated independently in $\ln P$ space (again only between observed winds) before being recombined, preventing boundary artifacts across the $0^\circ/360^\circ$ North transition. A variable wind keeps its speed and no direction.
|
|
73
94
|
|
|
74
95
|
### Hypsometric Geopotential Calculation
|
|
75
96
|
Missing geopotential heights are computed using the Hypsometric Equation:
|
|
@@ -90,38 +111,38 @@ The decoder incorporates **virtual temperature ($T_v$)** when moisture data is a
|
|
|
90
111
|
### `df_main` (Vertical Profile)
|
|
91
112
|
```
|
|
92
113
|
Pressure Height Temp DewPoint WindDir WindSpeed
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
114
|
+
1000.0 163 20.4 18.0 10.0 8.0
|
|
115
|
+
938.0 714 21.2 18.8 10.0 8.0
|
|
116
|
+
925.0 843 20.0 18.1 75.0 6.0
|
|
117
|
+
882.0 1249 16.8 16.4 23.0 8.5
|
|
118
|
+
870.0 1366 19.8 13.8 15.0 10.0
|
|
119
|
+
850.0 1570 18.6 13.6 360.0 8.0
|
|
99
120
|
... ... ... ... ... ...
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
121
|
+
30.0 23800 -58.3 -91.3 85.0 35.0
|
|
122
|
+
20.0 26480 -55.2 -89.0 90.0 38.0
|
|
123
|
+
10.0 31020 -44.1 NaN 95.0 42.0
|
|
103
124
|
```
|
|
104
125
|
|
|
105
126
|
### `df_special` (Metadata & Special Levels)
|
|
106
127
|
```
|
|
107
|
-
Symbol Subject Description Value
|
|
108
|
-
h Cloud Base Height 600-1000m (2000-3300ft)
|
|
109
|
-
Nh Cloud Amount 8 oktas
|
|
110
|
-
CL Cloud Low Type Stratus nebulosus and/or Stratus fractus
|
|
111
|
-
CM Cloud Mid Type No CM clouds
|
|
112
|
-
CH Cloud High Type No CH clouds
|
|
113
|
-
sr Solar/Inst Solar Corr NOAA solar corrected
|
|
114
|
-
rara Solar/Inst Sonde Type Vaisala RS41/DigiCORA MW41 (Finland)
|
|
115
|
-
sasa Solar/Inst Tracking Radar (5 cm)
|
|
116
|
-
8GGgg Solar/Inst Time 11:31
|
|
117
|
-
PtPtPt Tropopause Pressure
|
|
118
|
-
TtTtTt Tropopause Temperature -77.1C
|
|
119
|
-
DtDt Tropopause Dewpoint -89.1C
|
|
120
|
-
dtdtftft Tropopause Wind 260/18kt
|
|
121
|
-
PmPmPm Max Wind Pressure 200hPa
|
|
122
|
-
dmdmfmfmfm Max Wind Wind 275/120kt
|
|
123
|
-
vbvb Max Wind Shear Below 14kt
|
|
124
|
-
vava Max Wind Shear Above 14kt
|
|
128
|
+
Symbol Subject Description Value Part
|
|
129
|
+
h Cloud Base Height 600-1000m (2000-3300ft) TTBB
|
|
130
|
+
Nh Cloud Amount 8 oktas TTBB
|
|
131
|
+
CL Cloud Low Type Stratus nebulosus and/or Stratus fractus TTBB
|
|
132
|
+
CM Cloud Mid Type No CM clouds TTBB
|
|
133
|
+
CH Cloud High Type No CH clouds TTBB
|
|
134
|
+
sr Solar/Inst Solar Corr NOAA solar corrected TTAA
|
|
135
|
+
rara Solar/Inst Sonde Type Vaisala RS41/DigiCORA MW41 (Finland) TTAA
|
|
136
|
+
sasa Solar/Inst Tracking Radar (5 cm) TTAA
|
|
137
|
+
8GGgg Solar/Inst Time 11:31 TTAA
|
|
138
|
+
PtPtPt Tropopause Pressure 90.6hPa TTCC
|
|
139
|
+
TtTtTt Tropopause Temperature -77.1C TTCC
|
|
140
|
+
DtDt Tropopause Dewpoint -89.1C TTCC
|
|
141
|
+
dtdtftft Tropopause Wind 260/18kt TTCC
|
|
142
|
+
PmPmPm Max Wind Pressure 200hPa TTAA
|
|
143
|
+
dmdmfmfmfm Max Wind Wind 275/120kt TTAA
|
|
144
|
+
vbvb Max Wind Shear Below 14kt TTAA
|
|
145
|
+
vava Max Wind Shear Above 14kt TTAA
|
|
125
146
|
```
|
|
126
147
|
|
|
127
148
|
---
|
|
@@ -6,6 +6,7 @@ Decodes standard WMO FM 35 / FM 36 / FM 38 upper-air radiosonde observations.
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import functools
|
|
9
|
+
import math
|
|
9
10
|
import os
|
|
10
11
|
import re
|
|
11
12
|
from typing import Any, Dict, List, Optional, Tuple
|
|
@@ -248,7 +249,8 @@ def interpolate_data(df: pd.DataFrame) -> pd.DataFrame:
|
|
|
248
249
|
if col in df.columns:
|
|
249
250
|
df[col] = pd.to_numeric(df[col], errors="coerce")
|
|
250
251
|
mask = df[col].isna()
|
|
251
|
-
|
|
252
|
+
# only between observed levels: nothing is made up above the last or below the first
|
|
253
|
+
df[col] = df[col].interpolate(method="index", limit_area="inside")
|
|
252
254
|
df.loc[mask, col] = df.loc[mask, col].round(1)
|
|
253
255
|
|
|
254
256
|
# Physical constraint: Dewpoint cannot exceed dry temperature
|
|
@@ -272,8 +274,8 @@ def interpolate_data(df: pd.DataFrame) -> pd.DataFrame:
|
|
|
272
274
|
df.loc[valid_wind, "v"] = -df.loc[valid_wind, "WindSpeed"] * np.cos(rads)
|
|
273
275
|
|
|
274
276
|
# Interpolate U and V components across vertical profile
|
|
275
|
-
df["u"] = df["u"].interpolate(method="index")
|
|
276
|
-
df["v"] = df["v"].interpolate(method="index")
|
|
277
|
+
df["u"] = df["u"].interpolate(method="index", limit_area="inside")
|
|
278
|
+
df["v"] = df["v"].interpolate(method="index", limit_area="inside")
|
|
277
279
|
|
|
278
280
|
# Reconstruct speed and direction
|
|
279
281
|
reconstructed_speed = np.sqrt(df["u"] ** 2 + df["v"] ** 2)
|
|
@@ -287,9 +289,10 @@ def interpolate_data(df: pd.DataFrame) -> pd.DataFrame:
|
|
|
287
289
|
reconstructed_dir,
|
|
288
290
|
)
|
|
289
291
|
|
|
290
|
-
# Fill missing values in original columns
|
|
292
|
+
# Fill missing values in original columns; a variable wind (speed without direction) keeps
|
|
293
|
+
# its NaN direction
|
|
291
294
|
missing_speed = df["WindSpeed"].isna() & df["u"].notna()
|
|
292
|
-
missing_dir = df["WindDir"].isna() & df["u"].notna()
|
|
295
|
+
missing_dir = df["WindDir"].isna() & df["WindSpeed"].isna() & df["u"].notna()
|
|
293
296
|
|
|
294
297
|
df.loc[missing_speed, "WindSpeed"] = reconstructed_speed[missing_speed].round(1)
|
|
295
298
|
df.loc[missing_dir, "WindDir"] = pd.Series(reconstructed_dir, index=df.index)[missing_dir].round(0)
|
|
@@ -456,8 +459,8 @@ def decode_31313_group(
|
|
|
456
459
|
i = start_index
|
|
457
460
|
results: List[Dict[str, Any]] = []
|
|
458
461
|
|
|
459
|
-
# Check next group: sr rara sasa
|
|
460
|
-
if i + 1 < len(groups) and len(groups[i + 1]) == 5:
|
|
462
|
+
# Check next group: sr rara sasa (sr is 0-7 or /, so an 8GGgg group is not taken for it)
|
|
463
|
+
if i + 1 < len(groups) and len(groups[i + 1]) == 5 and groups[i + 1][0] in "01234567/":
|
|
461
464
|
grp = groups[i + 1]
|
|
462
465
|
sr, rara, sasa = grp[0], grp[1:3], grp[3:5]
|
|
463
466
|
results.append(
|
|
@@ -495,6 +498,121 @@ def clean_message(message: str) -> str:
|
|
|
495
498
|
return message.replace("=", "").strip()
|
|
496
499
|
|
|
497
500
|
|
|
501
|
+
PART_NAMES = ("TTAA", "TTBB", "TTCC", "TTDD")
|
|
502
|
+
|
|
503
|
+
KT_PER_MS = 3600.0 / 1852.0
|
|
504
|
+
|
|
505
|
+
# Groups that cannot be a TTTDD group: "not observed" markers, and section markers whose digits
|
|
506
|
+
# give an impossible temperature or an unused depression code. The other markers are valid TTTDD
|
|
507
|
+
# groups (21212 = 21.2 C, 31313 = -31.3 C, 51515 = -51.5 C, ...), read as data where the position
|
|
508
|
+
# in the message says so; temperatures of -66 and -77 C likewise start like the 66/77 indicators.
|
|
509
|
+
_NOT_TEMP = frozenset(["88999", "77999", "66999", "41414", "52525", "54545", "55555", "56565", "58585"])
|
|
510
|
+
|
|
511
|
+
# Regional (51515-59595) and national (61616-69696) sections come last in a part: no level data
|
|
512
|
+
# follows them. 55555 and 66666 are not in the list: in parts B and D 55555 is a significant level
|
|
513
|
+
# (55, 555 hPa), and in parts A and C 66666 is a maximum wind at 666 hPa.
|
|
514
|
+
_END_SECTIONS = frozenset(
|
|
515
|
+
["51515", "52525", "53535", "54545", "56565", "57575", "58585", "59595"]
|
|
516
|
+
+ ["61616", "62626", "63636", "64646", "65656", "67676", "68686", "69696"]
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _is_temp_group(g: Optional[str]) -> bool:
|
|
521
|
+
"""A TTTDD group: five characters that can be a temperature and depression."""
|
|
522
|
+
return g is not None and len(g) == 5 and g not in _NOT_TEMP
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _is_wind_group(g: Optional[str]) -> bool:
|
|
526
|
+
"""A ddfff group: missing (with "/") or a valid direction (00-36, 99); 31313 is the section."""
|
|
527
|
+
return g is not None and len(g) == 5 and g != "31313" and ("/" in g or decode_wind(g)[1] is not None)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def speed_unit(date_group: str) -> str:
|
|
531
|
+
"""YY of YYGGId / YYGGa4: the day plus 50 when wind speeds are in knots, the day for m/s."""
|
|
532
|
+
return "m/s" if 1 <= int(date_group[:2]) <= 31 else "kt"
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _to_knots(speed: Optional[float], unit: str) -> Optional[float]:
|
|
536
|
+
if speed is None or unit == "kt":
|
|
537
|
+
return speed
|
|
538
|
+
return round(speed * KT_PER_MS, 1)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _wind_text(wd: float, ws: float, unit: str) -> str:
|
|
542
|
+
"""A wind as reported, in the message's unit."""
|
|
543
|
+
return f"VRB/{int(ws)}{unit}" if np.isnan(wd) else f"{int(wd)}/{int(ws)}{unit}"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _skip_to_header(groups: List[str], i: int, headers: Tuple[str, ...]) -> int:
|
|
547
|
+
"""Index of the next part header (or the end) after a regional or national section."""
|
|
548
|
+
i += 1
|
|
549
|
+
while i < len(groups) and groups[i] not in headers:
|
|
550
|
+
i += 1
|
|
551
|
+
return i
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def wind_top_pressure(id_char: str, part_c: bool) -> Optional[float]:
|
|
555
|
+
"""
|
|
556
|
+
Id (code table 1734): pressure of the last standard level that carries a wind group, in
|
|
557
|
+
hundreds of hPa in Part A (0 = 1000, 9 = 925, 8 = 850) and tens of hPa in Part C. Levels
|
|
558
|
+
above it have no wind group (Id 2 covers 250 and 200 hPa, Id 1 covers 150 and 100 hPa).
|
|
559
|
+
"/" (no wind at any standard level) gives infinity; anything else gives None (unknown).
|
|
560
|
+
"""
|
|
561
|
+
if id_char == "/":
|
|
562
|
+
return math.inf
|
|
563
|
+
if len(id_char) != 1 or id_char not in "0123456789":
|
|
564
|
+
return None
|
|
565
|
+
d = int(id_char)
|
|
566
|
+
if part_c:
|
|
567
|
+
return 100.0 if d == 0 else d * 10.0
|
|
568
|
+
return {0: 1000.0, 9: 925.0, 8: 850.0}.get(d, d * 100.0)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def split_bulletin(text: Optional[str]) -> Dict[str, str]:
|
|
572
|
+
"""
|
|
573
|
+
Splits a bulletin holding several parts into {"TTAA": "TTAA ...", ...}, each part running up
|
|
574
|
+
to the next part's name. A part found twice keeps the later text.
|
|
575
|
+
"""
|
|
576
|
+
if not text:
|
|
577
|
+
return {}
|
|
578
|
+
matches = list(re.finditer(r"\b(TTAA|TTBB|TTCC|TTDD)\b", text))
|
|
579
|
+
parts: Dict[str, str] = {}
|
|
580
|
+
for k, m in enumerate(matches):
|
|
581
|
+
end = matches[k + 1].start() if k + 1 < len(matches) else len(text)
|
|
582
|
+
parts[m.group(1)] = text[m.start() : end].strip()
|
|
583
|
+
return parts
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def route_parts(
|
|
587
|
+
ttaa: Optional[str] = None,
|
|
588
|
+
ttbb: Optional[str] = None,
|
|
589
|
+
ttcc: Optional[str] = None,
|
|
590
|
+
ttdd: Optional[str] = None,
|
|
591
|
+
) -> Dict[str, str]:
|
|
592
|
+
"""
|
|
593
|
+
The text of each part. A message holding several parts (a whole bulletin) is split: its
|
|
594
|
+
part with the argument's name is used there and the others fill the parts not given on
|
|
595
|
+
their own. A message without any part name is used as it is.
|
|
596
|
+
"""
|
|
597
|
+
routed: Dict[str, str] = {}
|
|
598
|
+
found: Dict[str, str] = {}
|
|
599
|
+
for name, msg in zip(PART_NAMES, (ttaa, ttbb, ttcc, ttdd)):
|
|
600
|
+
if not msg or not isinstance(msg, str):
|
|
601
|
+
continue
|
|
602
|
+
split = split_bulletin(msg)
|
|
603
|
+
if not split:
|
|
604
|
+
routed[name] = msg
|
|
605
|
+
continue
|
|
606
|
+
if name in split:
|
|
607
|
+
routed[name] = split[name]
|
|
608
|
+
for other, body in split.items():
|
|
609
|
+
if other != name:
|
|
610
|
+
found.setdefault(other, body)
|
|
611
|
+
for name, body in found.items():
|
|
612
|
+
routed.setdefault(name, body)
|
|
613
|
+
return routed
|
|
614
|
+
|
|
615
|
+
|
|
498
616
|
def parse_ttaa_ttcc(
|
|
499
617
|
message: str, cloud_tables: Optional[Dict[str, Any]] = None
|
|
500
618
|
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
@@ -537,99 +655,117 @@ def parse_ttaa_ttcc(
|
|
|
537
655
|
|
|
538
656
|
is_ttcc = "TTCC" in message
|
|
539
657
|
standard_levels = standard_levels_ttcc if is_ttcc else standard_levels_ttaa
|
|
658
|
+
# Pressure of the last standard level with a wind group (Id), None without a header
|
|
659
|
+
wind_top: Optional[float] = None
|
|
660
|
+
unit = "kt"
|
|
661
|
+
|
|
662
|
+
def group(k: int) -> Optional[str]:
|
|
663
|
+
return groups[k] if k < len(groups) else None
|
|
664
|
+
|
|
665
|
+
def special_pressure(code: str) -> Optional[str]:
|
|
666
|
+
"""PPP of an 88/77/66 group: whole hPa in Part A, tenths of hPa in Part C."""
|
|
667
|
+
try:
|
|
668
|
+
p_raw = int(code)
|
|
669
|
+
except ValueError:
|
|
670
|
+
return None
|
|
671
|
+
return f"{round(p_raw / 10.0, 1)}" if is_ttcc else f"{p_raw}"
|
|
540
672
|
|
|
541
673
|
i = 0
|
|
542
674
|
while i < len(groups):
|
|
543
675
|
g = groups[i]
|
|
544
676
|
|
|
545
|
-
# Header
|
|
546
|
-
if
|
|
677
|
+
# Header (TTAA/TTCC + YYGGId + IIiii)
|
|
678
|
+
if g in ("TTAA", "TTCC"):
|
|
679
|
+
is_ttcc = g == "TTCC"
|
|
680
|
+
standard_levels = standard_levels_ttcc if is_ttcc else standard_levels_ttaa
|
|
681
|
+
wind_top = None
|
|
547
682
|
i += 1
|
|
548
|
-
if i < len(groups) and re.match(r"
|
|
683
|
+
if i < len(groups) and re.match(r"^[0-9]{4}[0-9/]$", groups[i]):
|
|
684
|
+
wind_top = wind_top_pressure(groups[i][4], is_ttcc)
|
|
685
|
+
unit = speed_unit(groups[i])
|
|
549
686
|
i += 1
|
|
550
|
-
if i < len(groups) and re.match(r"
|
|
687
|
+
if i < len(groups) and re.match(r"^[0-9]{5}$", groups[i]):
|
|
551
688
|
i += 1
|
|
552
689
|
continue
|
|
553
690
|
|
|
554
|
-
# Tropopause (88PtPtPt)
|
|
691
|
+
# Tropopause (88PtPtPt TtTtTaDtDt dtdtftftft)
|
|
555
692
|
if g.startswith("88") and len(g) == 5:
|
|
556
693
|
if g == "88999":
|
|
557
694
|
i += 1
|
|
558
695
|
continue
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
if
|
|
696
|
+
pressure = special_pressure(g[2:])
|
|
697
|
+
i += 1
|
|
698
|
+
if pressure is None:
|
|
699
|
+
continue
|
|
700
|
+
special_data.append(
|
|
701
|
+
{"Symbol": "PtPtPt", "Subject": "Tropopause", "Description": "Pressure", "Value": f"{pressure}hPa"}
|
|
702
|
+
)
|
|
703
|
+
t_grp = group(i)
|
|
704
|
+
if _is_temp_group(t_grp):
|
|
705
|
+
t = decode_temperature(t_grp[:3], tables=cloud_tables)
|
|
706
|
+
d = decode_dewpoint_depression(t_grp[3:], tables=cloud_tables)
|
|
707
|
+
if t is not None:
|
|
708
|
+
special_data.append(
|
|
709
|
+
{"Symbol": "TtTtTt", "Subject": "Tropopause", "Description": "Temperature", "Value": f"{t}C"}
|
|
710
|
+
)
|
|
711
|
+
if t is not None and d is not None:
|
|
712
|
+
dw = calculate_dewpoint(t, d)
|
|
713
|
+
special_data.append(
|
|
714
|
+
{"Symbol": "DtDt", "Subject": "Tropopause", "Description": "Dewpoint", "Value": f"{dw}C"}
|
|
715
|
+
)
|
|
716
|
+
i += 1
|
|
717
|
+
w_grp = group(i)
|
|
718
|
+
if _is_wind_group(w_grp):
|
|
582
719
|
wd, ws = decode_wind(w_grp)
|
|
583
720
|
if ws is not None:
|
|
584
|
-
val_str = f"VRB/{int(ws)}kt" if np.isnan(wd) else f"{int(wd)}/{int(ws)}kt"
|
|
585
721
|
special_data.append(
|
|
586
|
-
{
|
|
722
|
+
{
|
|
723
|
+
"Symbol": "dtdtftft",
|
|
724
|
+
"Subject": "Tropopause",
|
|
725
|
+
"Description": "Wind",
|
|
726
|
+
"Value": _wind_text(wd, ws, unit),
|
|
727
|
+
}
|
|
587
728
|
)
|
|
729
|
+
i += 1
|
|
730
|
+
continue
|
|
588
731
|
|
|
589
|
-
|
|
590
|
-
continue
|
|
591
|
-
except (ValueError, IndexError):
|
|
592
|
-
i += 1
|
|
593
|
-
continue
|
|
594
|
-
|
|
595
|
-
# Maximum Wind Level (77PmPmPm or 66PmPmPm)
|
|
732
|
+
# Maximum Wind Level (77PmPmPm or 66PmPmPm, dmdmfmfmfm, optional 4vbvbvava)
|
|
596
733
|
if (g.startswith("77") or g.startswith("66")) and len(g) == 5:
|
|
597
734
|
if g in ["77999", "66999"]:
|
|
598
735
|
i += 1
|
|
599
736
|
continue
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
737
|
+
pressure = special_pressure(g[2:])
|
|
738
|
+
i += 1
|
|
739
|
+
if pressure is None:
|
|
740
|
+
continue
|
|
741
|
+
special_data.append(
|
|
742
|
+
{"Symbol": "PmPmPm", "Subject": "Max Wind", "Description": "Pressure", "Value": f"{pressure}hPa"}
|
|
743
|
+
)
|
|
744
|
+
wind_grp = group(i)
|
|
745
|
+
if _is_wind_group(wind_grp):
|
|
746
|
+
wd, ws = decode_wind(wind_grp)
|
|
747
|
+
if ws is not None:
|
|
748
|
+
special_data.append(
|
|
749
|
+
{
|
|
750
|
+
"Symbol": "dmdmfmfmfm",
|
|
751
|
+
"Subject": "Max Wind",
|
|
752
|
+
"Description": "Wind",
|
|
753
|
+
"Value": _wind_text(wd, ws, unit),
|
|
754
|
+
}
|
|
755
|
+
)
|
|
756
|
+
i += 1
|
|
757
|
+
# Vertical wind shear group: 4vbvbvava (41414 too: the cloud section is in part B only)
|
|
758
|
+
shear_grp = group(i)
|
|
759
|
+
if shear_grp is not None and len(shear_grp) == 5 and shear_grp.startswith("4"):
|
|
619
760
|
vb, va = shear_grp[1:3], shear_grp[3:5]
|
|
620
761
|
special_data.append(
|
|
621
|
-
{"Symbol": "vbvb", "Subject": "Max Wind", "Description": "Shear Below", "Value": f"{vb}
|
|
762
|
+
{"Symbol": "vbvb", "Subject": "Max Wind", "Description": "Shear Below", "Value": f"{vb}{unit}"}
|
|
622
763
|
)
|
|
623
764
|
special_data.append(
|
|
624
|
-
{"Symbol": "vava", "Subject": "Max Wind", "Description": "Shear Above", "Value": f"{va}
|
|
765
|
+
{"Symbol": "vava", "Subject": "Max Wind", "Description": "Shear Above", "Value": f"{va}{unit}"}
|
|
625
766
|
)
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
i = i_next
|
|
629
|
-
continue
|
|
630
|
-
except (ValueError, IndexError):
|
|
631
|
-
i += 1
|
|
632
|
-
continue
|
|
767
|
+
i += 1
|
|
768
|
+
continue
|
|
633
769
|
|
|
634
770
|
# Regional / Instrumentation group 31313
|
|
635
771
|
if g == "31313":
|
|
@@ -648,12 +784,15 @@ def parse_ttaa_ttcc(
|
|
|
648
784
|
i += 1
|
|
649
785
|
continue
|
|
650
786
|
|
|
651
|
-
# Regional
|
|
652
|
-
if g
|
|
787
|
+
# Regional and national sections end the level data
|
|
788
|
+
if g in _END_SECTIONS or g == "55555":
|
|
789
|
+
i = _skip_to_header(groups, i, ("TTAA", "TTCC"))
|
|
790
|
+
continue
|
|
791
|
+
if g == "21212":
|
|
653
792
|
i += 1
|
|
654
793
|
continue
|
|
655
794
|
|
|
656
|
-
# Mandatory Standard Isobaric Levels (PPhhh)
|
|
795
|
+
# Mandatory Standard Isobaric Levels (PPhhh TTTDD, then dddff up to the level Id names)
|
|
657
796
|
if len(g) == 5 and g[:2] in standard_levels:
|
|
658
797
|
pp = g[:2]
|
|
659
798
|
pressure, height = None, None
|
|
@@ -672,30 +811,30 @@ def parse_ttaa_ttcc(
|
|
|
672
811
|
pass
|
|
673
812
|
|
|
674
813
|
if pressure is not None:
|
|
675
|
-
t_group = groups[i + 1] if i + 1 < len(groups) else None
|
|
676
|
-
w_group = groups[i + 2] if i + 2 < len(groups) else None
|
|
677
814
|
dp: Dict[str, Any] = {"Pressure": float(pressure), "Source": "Standard"}
|
|
678
815
|
if height is not None:
|
|
679
816
|
dp["Height"] = height
|
|
817
|
+
# The surface always has a wind group (///// when missing)
|
|
818
|
+
has_wind = pp == "99" or wind_top is None or pressure >= wind_top
|
|
680
819
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
if
|
|
820
|
+
k = i + 1
|
|
821
|
+
t_group = group(k)
|
|
822
|
+
if _is_temp_group(t_group):
|
|
684
823
|
t = decode_temperature(t_group[:3], tables=cloud_tables)
|
|
685
824
|
d = decode_dewpoint_depression(t_group[3:], tables=cloud_tables)
|
|
686
825
|
dp["Temp"] = t
|
|
687
826
|
dp["DewPoint"] = calculate_dewpoint(t, d)
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
827
|
+
k += 1
|
|
828
|
+
w_group = group(k)
|
|
829
|
+
if has_wind and _is_wind_group(w_group):
|
|
830
|
+
wd, ws = decode_wind(w_group)
|
|
831
|
+
dp["WindDir"] = wd
|
|
832
|
+
dp["WindSpeed"] = _to_knots(ws, unit)
|
|
833
|
+
k += 1
|
|
695
834
|
|
|
696
|
-
if
|
|
835
|
+
if k > i + 1:
|
|
697
836
|
levels_data.append(dp)
|
|
698
|
-
i
|
|
837
|
+
i = k
|
|
699
838
|
continue
|
|
700
839
|
|
|
701
840
|
i += 1
|
|
@@ -717,17 +856,20 @@ def parse_ttbb_ttdd(
|
|
|
717
856
|
mode = "TEMP"
|
|
718
857
|
is_ttdd = "TTDD" in message
|
|
719
858
|
last_p: Optional[float] = None
|
|
859
|
+
unit = "kt"
|
|
720
860
|
|
|
721
861
|
i = 0
|
|
722
862
|
while i < len(groups):
|
|
723
863
|
g = groups[i]
|
|
724
864
|
|
|
725
|
-
# Header skip (TTBB/TTDD +
|
|
865
|
+
# Header skip (TTBB/TTDD + YYGGa4 + IIiii; a4 may be "/")
|
|
726
866
|
if re.match(r"^(TTBB|TTDD)$", g):
|
|
727
867
|
is_ttdd = (g == "TTDD")
|
|
868
|
+
mode = "TEMP"
|
|
728
869
|
last_p = None
|
|
729
870
|
i += 1
|
|
730
|
-
if i < len(groups) and re.match(r"
|
|
871
|
+
if i < len(groups) and re.match(r"^[0-9]{4}[0-9/]$", groups[i]):
|
|
872
|
+
unit = speed_unit(groups[i])
|
|
731
873
|
i += 1
|
|
732
874
|
if i < len(groups) and re.match(r"^\d{5}$", groups[i]):
|
|
733
875
|
i += 1
|
|
@@ -756,8 +898,9 @@ def parse_ttbb_ttdd(
|
|
|
756
898
|
i += 1
|
|
757
899
|
continue
|
|
758
900
|
|
|
759
|
-
|
|
760
|
-
|
|
901
|
+
# Regional and national sections end the level data
|
|
902
|
+
if g in _END_SECTIONS:
|
|
903
|
+
i = _skip_to_header(groups, i, ("TTBB", "TTDD"))
|
|
761
904
|
continue
|
|
762
905
|
|
|
763
906
|
# Significant Level Group: nnPPP
|
|
@@ -812,7 +955,7 @@ def parse_ttbb_ttdd(
|
|
|
812
955
|
{
|
|
813
956
|
"Pressure": float(pressure),
|
|
814
957
|
"WindDir": wd,
|
|
815
|
-
"WindSpeed": ws,
|
|
958
|
+
"WindSpeed": _to_knots(ws, unit),
|
|
816
959
|
"Source": "SigWind",
|
|
817
960
|
}
|
|
818
961
|
)
|
|
@@ -833,23 +976,18 @@ def merge_data(df_list: List[pd.DataFrame]) -> pd.DataFrame:
|
|
|
833
976
|
full_df = pd.concat(df_list, ignore_index=True)
|
|
834
977
|
full_df = full_df[full_df["Pressure"].notna() & (full_df["Pressure"] > 0)].copy()
|
|
835
978
|
|
|
836
|
-
#
|
|
837
|
-
|
|
979
|
+
# Pressure in tenths of hPa, as Parts C and D report it (whole-hPa rounding merged distinct
|
|
980
|
+
# levels); rounding for display is left to the caller
|
|
981
|
+
full_df["Pressure"] = full_df["Pressure"].astype(float).round(1)
|
|
838
982
|
|
|
839
|
-
# Combine records by pressure, giving precedence to non-null values
|
|
983
|
+
# Combine records by pressure, giving precedence to non-null values. A column no part reports
|
|
984
|
+
# (heights in a lone TTBB, winds without a wind section) is left empty.
|
|
985
|
+
columns = ["Height", "Temp", "DewPoint", "WindDir", "WindSpeed", "Source"]
|
|
840
986
|
grouped = (
|
|
841
987
|
full_df.groupby("Pressure")
|
|
842
|
-
.agg(
|
|
843
|
-
{
|
|
844
|
-
"Height": "first",
|
|
845
|
-
"Temp": "first",
|
|
846
|
-
"DewPoint": "first",
|
|
847
|
-
"WindDir": "first",
|
|
848
|
-
"WindSpeed": "first",
|
|
849
|
-
"Source": "first",
|
|
850
|
-
}
|
|
851
|
-
)
|
|
988
|
+
.agg({c: "first" for c in columns if c in full_df.columns})
|
|
852
989
|
.reset_index()
|
|
990
|
+
.reindex(columns=["Pressure"] + columns)
|
|
853
991
|
)
|
|
854
992
|
|
|
855
993
|
if "Height" in grouped.columns:
|
|
@@ -869,45 +1007,55 @@ def decode_full(
|
|
|
869
1007
|
) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
|
870
1008
|
"""
|
|
871
1009
|
Decodes full FM 35 radiosonde sounding components (TTAA, TTBB, TTCC, TTDD).
|
|
872
|
-
|
|
1010
|
+
Any argument may hold a whole bulletin (see route_parts).
|
|
1011
|
+
Returns (df_main, df_special); df_special's Part column names the part each entry came from.
|
|
873
1012
|
"""
|
|
874
1013
|
cloud_tables = load_wmo_tables()
|
|
1014
|
+
parts = route_parts(ttaa_msg, ttbb_msg, ttcc_msg, ttdd_msg)
|
|
875
1015
|
|
|
876
1016
|
data_frames: List[pd.DataFrame] = []
|
|
877
1017
|
special_frames: List[pd.DataFrame] = []
|
|
878
1018
|
|
|
879
|
-
for
|
|
880
|
-
(
|
|
881
|
-
(
|
|
882
|
-
(
|
|
883
|
-
(
|
|
1019
|
+
for name, parser in [
|
|
1020
|
+
("TTAA", parse_ttaa_ttcc),
|
|
1021
|
+
("TTCC", parse_ttaa_ttcc),
|
|
1022
|
+
("TTBB", parse_ttbb_ttdd),
|
|
1023
|
+
("TTDD", parse_ttbb_ttdd),
|
|
884
1024
|
]:
|
|
885
|
-
|
|
1025
|
+
msg = parts.get(name)
|
|
1026
|
+
if msg:
|
|
886
1027
|
lvls, spcls = parser(msg, cloud_tables=cloud_tables)
|
|
887
1028
|
if lvls:
|
|
888
1029
|
data_frames.append(pd.DataFrame(lvls))
|
|
889
1030
|
if spcls:
|
|
890
|
-
special_frames.append(pd.DataFrame(spcls))
|
|
1031
|
+
special_frames.append(pd.DataFrame(spcls).assign(Part=name))
|
|
891
1032
|
|
|
892
1033
|
df_main = merge_data(data_frames)
|
|
1034
|
+
if not df_main.empty and df_main["Temp"].notna().sum() < 2:
|
|
1035
|
+
raise ValueError("TEMP message without temperatures above the surface")
|
|
893
1036
|
df_main = interpolate_data(df_main)
|
|
894
1037
|
df_main = calculate_geopotential(df_main)
|
|
895
1038
|
|
|
896
1039
|
df_special = pd.concat(special_frames, ignore_index=True) if special_frames else pd.DataFrame()
|
|
897
1040
|
|
|
898
1041
|
if not df_special.empty:
|
|
899
|
-
|
|
900
|
-
|
|
1042
|
+
key = ["Symbol", "Subject", "Description", "Value"]
|
|
1043
|
+
if set(key).issubset(df_special.columns):
|
|
1044
|
+
df_special = df_special[key + ["Part"]]
|
|
901
1045
|
|
|
902
|
-
|
|
1046
|
+
# The 31313 and cloud sections repeat in every part: keep the first copy of each entry.
|
|
1047
|
+
# Tropopause and maximum wind entries are all kept, since each part reports its own.
|
|
1048
|
+
repeated = df_special.duplicated(subset=key) & df_special["Subject"].isin(["Solar/Inst", "Cloud"])
|
|
1049
|
+
df_special = df_special[~repeated]
|
|
903
1050
|
|
|
904
1051
|
# Sort Cloud group by standard order: h, Nh, CL, CM, CH
|
|
905
1052
|
mask = df_special["Subject"] == "Cloud"
|
|
906
1053
|
if mask.any():
|
|
907
1054
|
order = {"h": 0, "Nh": 1, "CL": 2, "CM": 3, "CH": 4}
|
|
908
|
-
clouds = df_special[mask].sort_values(by="Symbol", key=lambda col: col.map(order))
|
|
1055
|
+
clouds = df_special[mask].sort_values(by="Symbol", key=lambda col: col.map(order), kind="stable")
|
|
909
1056
|
others = df_special[~mask]
|
|
910
|
-
df_special = pd.concat([clouds, others]
|
|
1057
|
+
df_special = pd.concat([clouds, others])
|
|
1058
|
+
df_special = df_special.reset_index(drop=True)
|
|
911
1059
|
|
|
912
1060
|
return df_main, df_special
|
|
913
1061
|
|
|
@@ -930,7 +1078,8 @@ def decode(
|
|
|
930
1078
|
Returns:
|
|
931
1079
|
tuple[pd.DataFrame, pd.DataFrame]:
|
|
932
1080
|
- df_main: Vertical profile with Pressure, Height, Temp, DewPoint, WindDir, WindSpeed.
|
|
933
|
-
- df_special: Tropopause, Maximum Wind, Cloud layers, and Radiosonde/Solar metadata
|
|
1081
|
+
- df_special: Tropopause, Maximum Wind, Cloud layers, and Radiosonde/Solar metadata,
|
|
1082
|
+
with the part (TTAA, TTBB, TTCC, TTDD) each entry came from in the Part column.
|
|
934
1083
|
"""
|
|
935
1084
|
# Defensive type validation
|
|
936
1085
|
for name, val in [("ttaa", ttaa), ("ttbb", ttbb), ("ttcc", ttcc), ("ttdd", ttdd)]:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|