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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fm35_decoder
3
- Version: 1.0
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
- 1000 163 20.4 18.0 10.0 8.0
112
- 938 714 21.2 18.8 10.0 8.0
113
- 925 843 20.0 18.1 75.0 6.0
114
- 882 1249 16.8 16.4 23.0 8.5
115
- 870 1366 19.8 13.8 15.0 10.0
116
- 850 1570 18.6 13.6 360.0 8.0
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
- 30 23800 -58.3 -91.3 85.0 35.0
119
- 20 26480 -55.2 -89.0 90.0 38.0
120
- 10 31020 -44.1 NaN 95.0 42.0
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 906hPa
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
- 1000 163 20.4 18.0 10.0 8.0
94
- 938 714 21.2 18.8 10.0 8.0
95
- 925 843 20.0 18.1 75.0 6.0
96
- 882 1249 16.8 16.4 23.0 8.5
97
- 870 1366 19.8 13.8 15.0 10.0
98
- 850 1570 18.6 13.6 360.0 8.0
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
- 30 23800 -58.3 -91.3 85.0 35.0
101
- 20 26480 -55.2 -89.0 90.0 38.0
102
- 10 31020 -44.1 NaN 95.0 42.0
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 906hPa
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
  ---
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fm35_decoder"
3
- version = "1.0"
3
+ version = "1.1"
4
4
  description = "A Python tool to decode standard FM35 TEMP weather messages from upper-air observations."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1,3 @@
1
+ from .decoder import decode, decode_full, route_parts, split_bulletin
2
+
3
+ __all__ = ["decode", "decode_full", "route_parts", "split_bulletin"]
@@ -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
- df[col] = df[col].interpolate(method="index")
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 skip (TTAA/TTCC + YYGGId + IIiii)
546
- if re.match(r"^(TTAA|TTCC)$", g):
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"^\d{5}$", groups[i]):
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"^\d{5}$", groups[i]):
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
- try:
560
- pressure = int(g[2:])
561
- special_data.append(
562
- {"Symbol": "PtPtPt", "Subject": "Tropopause", "Description": "Pressure", "Value": f"{pressure}hPa"}
563
- )
564
-
565
- t_grp = groups[i + 1] if i + 1 < len(groups) else None
566
- w_grp = groups[i + 2] if i + 2 < len(groups) else None
567
-
568
- if t_grp and len(t_grp) == 5:
569
- t = decode_temperature(t_grp[:3], tables=cloud_tables)
570
- d = decode_dewpoint_depression(t_grp[3:], tables=cloud_tables)
571
- if t is not None:
572
- special_data.append(
573
- {"Symbol": "TtTtTt", "Subject": "Tropopause", "Description": "Temperature", "Value": f"{t}C"}
574
- )
575
- if t is not None and d is not None:
576
- dw = calculate_dewpoint(t, d)
577
- special_data.append(
578
- {"Symbol": "DtDt", "Subject": "Tropopause", "Description": "Dewpoint", "Value": f"{dw}C"}
579
- )
580
-
581
- if w_grp and len(w_grp) == 5:
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
- {"Symbol": "dtdtftft", "Subject": "Tropopause", "Description": "Wind", "Value": val_str}
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
- i += 3
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
- try:
601
- pressure = int(g[2:])
602
- special_data.append(
603
- {"Symbol": "PmPmPm", "Subject": "Max Wind", "Description": "Pressure", "Value": f"{pressure}hPa"}
604
- )
605
-
606
- wind_grp = groups[i + 1] if i + 1 < len(groups) else None
607
- if wind_grp and len(wind_grp) == 5:
608
- wd, ws = decode_wind(wind_grp)
609
- if ws is not None:
610
- val_str = f"VRB/{int(ws)}kt" if np.isnan(wd) else f"{int(wd)}/{int(ws)}kt"
611
- special_data.append(
612
- {"Symbol": "dmdmfmfmfm", "Subject": "Max Wind", "Description": "Wind", "Value": val_str}
613
- )
614
-
615
- i_next = i + 2
616
- # Check for vertical wind shear group: 4vbvbvava
617
- if i_next < len(groups) and groups[i_next].startswith("4") and len(groups[i_next]) == 5:
618
- shear_grp = groups[i_next]
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}kt"}
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}kt"}
765
+ {"Symbol": "vava", "Subject": "Max Wind", "Description": "Shear Above", "Value": f"{va}{unit}"}
625
766
  )
626
- i_next += 1
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 groups to skip
652
- if g.startswith(("51515", "52525", "53535", "54545", "55555", "56565", "57575", "58585", "59595", "21212")):
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
- valid = False
682
- indicators = ("88", "77", "66", "51515", "31313", "41414")
683
- if t_group and len(t_group) == 5 and not t_group.startswith(indicators):
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
- valid = True
689
-
690
- if w_group and len(w_group) == 5 and not w_group.startswith(indicators):
691
- wd, ws = decode_wind(w_group)
692
- dp["WindDir"] = wd
693
- dp["WindSpeed"] = ws
694
- valid = True
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 valid:
835
+ if k > i + 1:
697
836
  levels_data.append(dp)
698
- i += 3
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 + YYGGId + IIiii)
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"^\d{5}$", groups[i]):
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
- if g == "51515":
760
- i += 1
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
- # Round Pressure to integer
837
- full_df["Pressure"] = full_df["Pressure"].round(0).astype(int)
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
- Returns (df_main, df_special).
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 msg, parser in [
880
- (ttaa_msg, parse_ttaa_ttcc),
881
- (ttcc_msg, parse_ttaa_ttcc),
882
- (ttbb_msg, parse_ttbb_ttdd),
883
- (ttdd_msg, parse_ttbb_ttdd),
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
- if msg and isinstance(msg, str):
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
- if {"Symbol", "Subject", "Description", "Value"}.issubset(df_special.columns):
900
- df_special = df_special[["Symbol", "Subject", "Description", "Value"]]
1042
+ key = ["Symbol", "Subject", "Description", "Value"]
1043
+ if set(key).issubset(df_special.columns):
1044
+ df_special = df_special[key + ["Part"]]
901
1045
 
902
- df_special = df_special.drop_duplicates()
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], ignore_index=True)
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)]:
@@ -1,3 +0,0 @@
1
- from .decoder import decode, decode_full
2
-
3
- __all__ = ["decode", "decode_full"]
File without changes
File without changes