fm35-decoder 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of fm35-decoder might be problematic. Click here for more details.
- fm35_decoder-0.1.1.dist-info/METADATA +96 -0
- fm35_decoder-0.1.1.dist-info/RECORD +16 -0
- fm35_decoder-0.1.1.dist-info/WHEEL +4 -0
- fm35_decoder-0.1.1.dist-info/licenses/LICENSE +21 -0
- fm35_decoder_luciodaou/__init__.py +3 -0
- fm35_decoder_luciodaou/decoder.py +1178 -0
- fm35_decoder_luciodaou/table_codes/CH_0509.csv +12 -0
- fm35_decoder_luciodaou/table_codes/CL_0513.csv +12 -0
- fm35_decoder_luciodaou/table_codes/CM_0515.csv +12 -0
- fm35_decoder_luciodaou/table_codes/D_0777.csv +102 -0
- fm35_decoder_luciodaou/table_codes/Nh_2700.csv +12 -0
- fm35_decoder_luciodaou/table_codes/Sr_3849.csv +7 -0
- fm35_decoder_luciodaou/table_codes/T_3931.csv +11 -0
- fm35_decoder_luciodaou/table_codes/h_1600.csv +12 -0
- fm35_decoder_luciodaou/table_codes/rara_3685.csv +158 -0
- fm35_decoder_luciodaou/table_codes/sasa_3872.csv +11 -0
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import os
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import numpy as np
|
|
5
|
+
from importlib import resources
|
|
6
|
+
|
|
7
|
+
# --- Decoding Helper Functions ---
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def decode_temperature(ttt_str, tables=None):
|
|
11
|
+
"""
|
|
12
|
+
Decodes the TTT part (TaTaTa).
|
|
13
|
+
Uses WMO Code Table 3931 for tenths and sign.
|
|
14
|
+
Returns temperature in Celsius.
|
|
15
|
+
"""
|
|
16
|
+
if not ttt_str or len(ttt_str) != 3:
|
|
17
|
+
return None
|
|
18
|
+
try:
|
|
19
|
+
tt = int(ttt_str[:2])
|
|
20
|
+
ta_code = ttt_str[2] # Code for tenths/sign (as string)
|
|
21
|
+
|
|
22
|
+
if not tables or "T_3931" not in tables:
|
|
23
|
+
# Fallback to old logic if table not loaded
|
|
24
|
+
ta = int(ta_code)
|
|
25
|
+
temp_abs = tt + (ta / 10.0)
|
|
26
|
+
return -temp_abs if ta % 2 != 0 else temp_abs
|
|
27
|
+
|
|
28
|
+
t_table = tables.get("T_3931", {})
|
|
29
|
+
entry = t_table.get(ta_code)
|
|
30
|
+
|
|
31
|
+
if entry:
|
|
32
|
+
sign = entry.get("Sign", "+")
|
|
33
|
+
tenths = float(entry.get("TenthsValue", 0.0))
|
|
34
|
+
|
|
35
|
+
# TenthsValue in CSV is signed (e.g., -0.2), or use sign column
|
|
36
|
+
# Logic: Temp = Sign * (TT + Tenths)
|
|
37
|
+
|
|
38
|
+
val = float(tt) + abs(tenths)
|
|
39
|
+
return -val if sign == "-" else val
|
|
40
|
+
|
|
41
|
+
return None
|
|
42
|
+
except ValueError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def decode_dewpoint_depression(dd_str, tables=None):
|
|
47
|
+
"""
|
|
48
|
+
Decodes the DD part (Dew Point Depression).
|
|
49
|
+
Uses WMO Code Table 0777.
|
|
50
|
+
Returns depression in Celsius.
|
|
51
|
+
"""
|
|
52
|
+
if not dd_str or len(dd_str) != 2:
|
|
53
|
+
return None
|
|
54
|
+
try:
|
|
55
|
+
if dd_str == "//":
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
if tables and "D_0777" in tables:
|
|
59
|
+
d_table = tables.get("D_0777", {})
|
|
60
|
+
entry = d_table.get(dd_str)
|
|
61
|
+
if entry:
|
|
62
|
+
val = entry.get("Value")
|
|
63
|
+
if pd.isna(val) or val == "":
|
|
64
|
+
return None
|
|
65
|
+
return float(val)
|
|
66
|
+
|
|
67
|
+
# Fallback
|
|
68
|
+
dd = int(dd_str)
|
|
69
|
+
if dd <= 50:
|
|
70
|
+
return dd / 10.0
|
|
71
|
+
elif 56 <= dd <= 99:
|
|
72
|
+
return float(dd - 50)
|
|
73
|
+
return None
|
|
74
|
+
except ValueError:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def decode_wind(dff_str):
|
|
79
|
+
"""
|
|
80
|
+
Decodes wind group in WMO format (ddfff).
|
|
81
|
+
dd: Direction in tens of degrees.
|
|
82
|
+
fff: Speed + (units digit of direction) * 100.
|
|
83
|
+
|
|
84
|
+
Rule:
|
|
85
|
+
1. Extract fff.
|
|
86
|
+
2. If fff >= 500:
|
|
87
|
+
Direction ends in 5.
|
|
88
|
+
Speed = fff - 500.
|
|
89
|
+
3. If fff < 500:
|
|
90
|
+
Direction ends in 0.
|
|
91
|
+
Speed = fff.
|
|
92
|
+
|
|
93
|
+
Direction = dd * 10 + (5|0).
|
|
94
|
+
"""
|
|
95
|
+
if not dff_str or len(dff_str) != 5:
|
|
96
|
+
return None, None
|
|
97
|
+
try:
|
|
98
|
+
if "/////" in dff_str:
|
|
99
|
+
return None, None
|
|
100
|
+
|
|
101
|
+
dd = int(dff_str[:2])
|
|
102
|
+
fff = int(dff_str[2:])
|
|
103
|
+
|
|
104
|
+
# Direction Range Validation: 00-36 or 99 (Variable)
|
|
105
|
+
if not (0 <= dd <= 36 or dd == 99):
|
|
106
|
+
return None, None
|
|
107
|
+
|
|
108
|
+
direction_unit = 0
|
|
109
|
+
speed = fff
|
|
110
|
+
|
|
111
|
+
if fff >= 500:
|
|
112
|
+
direction_unit = 5
|
|
113
|
+
speed = fff - 500
|
|
114
|
+
|
|
115
|
+
direction = dd * 10 + direction_unit
|
|
116
|
+
|
|
117
|
+
# Determine strict WMO speed unit? Usually knots for upper air.
|
|
118
|
+
return direction, speed
|
|
119
|
+
except ValueError:
|
|
120
|
+
return None, None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def calculate_dewpoint(temp, depression):
|
|
124
|
+
if temp is not None and depression is not None:
|
|
125
|
+
return round(temp - depression, 1)
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_wmo_tables(base_path=None):
|
|
130
|
+
codes = {}
|
|
131
|
+
tables = {
|
|
132
|
+
"Nh": "Nh_2700.csv",
|
|
133
|
+
"CL": "CL_0513.csv",
|
|
134
|
+
"h": "h_1600.csv",
|
|
135
|
+
"CM": "CM_0515.csv",
|
|
136
|
+
"CH": "CH_0509.csv",
|
|
137
|
+
"Sr": "Sr_3849.csv",
|
|
138
|
+
"rara": "rara_3685.csv",
|
|
139
|
+
"sasa": "sasa_3872.csv",
|
|
140
|
+
"T_3931": "T_3931.csv",
|
|
141
|
+
"D_0777": "D_0777.csv",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
# standard way to access package data
|
|
146
|
+
with resources.path("fm35_decoder.table_codes", "") as table_dir:
|
|
147
|
+
for key, filename in tables.items():
|
|
148
|
+
path = os.path.join(table_dir, filename)
|
|
149
|
+
if os.path.exists(path):
|
|
150
|
+
try:
|
|
151
|
+
df_code = pd.read_csv(path, dtype=str)
|
|
152
|
+
if "Code" in df_code.columns:
|
|
153
|
+
if key in ["T_3931", "D_0777"]:
|
|
154
|
+
codes[key] = df_code.set_index("Code").to_dict(
|
|
155
|
+
orient="index"
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
codes[key] = df_code.set_index("Code")[
|
|
159
|
+
"Description"
|
|
160
|
+
].to_dict()
|
|
161
|
+
except Exception:
|
|
162
|
+
codes[key] = {}
|
|
163
|
+
else:
|
|
164
|
+
codes[key] = {}
|
|
165
|
+
except Exception:
|
|
166
|
+
# Fallback for local development if not installed as a package
|
|
167
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
168
|
+
table_dir = os.path.join(current_dir, "table_codes")
|
|
169
|
+
if not os.path.exists(table_dir):
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
for key, filename in tables.items():
|
|
173
|
+
path = os.path.join(table_dir, filename)
|
|
174
|
+
if os.path.exists(path):
|
|
175
|
+
try:
|
|
176
|
+
df_code = pd.read_csv(path, dtype=str)
|
|
177
|
+
if "Code" in df_code.columns:
|
|
178
|
+
if key in ["T_3931", "D_0777"]:
|
|
179
|
+
codes[key] = df_code.set_index("Code").to_dict(
|
|
180
|
+
orient="index"
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
codes[key] = df_code.set_index("Code")[
|
|
184
|
+
"Description"
|
|
185
|
+
].to_dict()
|
|
186
|
+
except Exception:
|
|
187
|
+
codes[key] = {}
|
|
188
|
+
else:
|
|
189
|
+
codes[key] = {}
|
|
190
|
+
return codes
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def interpolate_data(df):
|
|
194
|
+
"""
|
|
195
|
+
Interpolates missing Temperature, DewPoint, and Wind data vertically.
|
|
196
|
+
- Temperature/DewPoint: Linear interpolation in Log-Pressure.
|
|
197
|
+
- Wind: Vector interpolation (U/V components) in Log-Pressure.
|
|
198
|
+
"""
|
|
199
|
+
if df.empty or "Pressure" not in df.columns:
|
|
200
|
+
return df
|
|
201
|
+
|
|
202
|
+
# Work on a copy to avoid side effects
|
|
203
|
+
df = df.copy()
|
|
204
|
+
|
|
205
|
+
# Ensure sorted by Pressure Descending (Surface -> Top)
|
|
206
|
+
# But for interpolation, monotonic increasing or decreasing index is needed.
|
|
207
|
+
# Log(P) is monotonic.
|
|
208
|
+
df = df.sort_values("Pressure", ascending=False).reset_index(drop=True)
|
|
209
|
+
|
|
210
|
+
# Calculate Log-Pressure (Natural Log)
|
|
211
|
+
# Avoid log(0)
|
|
212
|
+
df["log_p"] = np.log(df["Pressure"].replace(0, np.nan))
|
|
213
|
+
|
|
214
|
+
# set index to log_p for interpolation
|
|
215
|
+
# Note: log_p is descending (Surface=high P, Top=low P).
|
|
216
|
+
# interpolate() works on index.
|
|
217
|
+
df = df.set_index("log_p")
|
|
218
|
+
|
|
219
|
+
# --- Temperature Interpolation ---
|
|
220
|
+
# Linear interpolation in Log-P space
|
|
221
|
+
cols_to_interp = ["Temp", "DewPoint"]
|
|
222
|
+
for col in cols_to_interp:
|
|
223
|
+
if col in df.columns:
|
|
224
|
+
# Ensure numeric to avoid TypeError: Series cannot interpolate with object dtype
|
|
225
|
+
df[col] = pd.to_numeric(df[col], errors="coerce")
|
|
226
|
+
# Mask for original NaNs to round only them (optional, but cleaner)
|
|
227
|
+
mask = df[col].isna()
|
|
228
|
+
df[col] = df[col].interpolate(method="index")
|
|
229
|
+
df.loc[mask, col] = df.loc[mask, col].round(1)
|
|
230
|
+
|
|
231
|
+
# --- Wind Interpolation (Vector) ---
|
|
232
|
+
if "WindSpeed" in df.columns and "WindDir" in df.columns:
|
|
233
|
+
# Ensure numeric
|
|
234
|
+
df["WindSpeed"] = pd.to_numeric(df["WindSpeed"], errors="coerce")
|
|
235
|
+
df["WindDir"] = pd.to_numeric(df["WindDir"], errors="coerce")
|
|
236
|
+
|
|
237
|
+
# Convert to U (Zonal) and V (Meridional)
|
|
238
|
+
# meteo dir: 0/360=North, 90=East.
|
|
239
|
+
# math dir: 0=East, 90=North.
|
|
240
|
+
# u = -speed * sin(dir)
|
|
241
|
+
# v = -speed * cos(dir)
|
|
242
|
+
rads = np.radians(df["WindDir"])
|
|
243
|
+
df["u"] = -df["WindSpeed"] * np.sin(rads)
|
|
244
|
+
df["v"] = -df["WindSpeed"] * np.cos(rads)
|
|
245
|
+
|
|
246
|
+
# Interpolate U and V
|
|
247
|
+
df["u"] = df["u"].interpolate(method="index")
|
|
248
|
+
df["v"] = df["v"].interpolate(method="index")
|
|
249
|
+
|
|
250
|
+
# Reconstruct Speed and Direction
|
|
251
|
+
# Speed = sqrt(u^2 + v^2)
|
|
252
|
+
speed = np.sqrt(df["u"] ** 2 + df["v"] ** 2)
|
|
253
|
+
|
|
254
|
+
# Dir = atan2(u, v)? No.
|
|
255
|
+
# atan2(y, x) -> atan2(v, u)?
|
|
256
|
+
# To convert back to meteo:
|
|
257
|
+
# dir = (270 - degrees(atan2(v, u))) % 360
|
|
258
|
+
degrees = np.degrees(np.arctan2(df["v"], df["u"]))
|
|
259
|
+
direction = (270 - degrees) % 360
|
|
260
|
+
|
|
261
|
+
# Fill missing values in original columns
|
|
262
|
+
mask = df["WindSpeed"].isna() | df["WindDir"].isna()
|
|
263
|
+
|
|
264
|
+
# Assign interpolated values
|
|
265
|
+
df.loc[mask, "WindSpeed"] = speed[mask].round(1)
|
|
266
|
+
df.loc[mask, "WindDir"] = direction[mask].round(0)
|
|
267
|
+
|
|
268
|
+
# Clean up temporary columns
|
|
269
|
+
df = df.drop(columns=["u", "v"])
|
|
270
|
+
|
|
271
|
+
# Reset index and restore original sorting
|
|
272
|
+
df = df.reset_index(drop=False) # log_p becomes column
|
|
273
|
+
df = df.drop(columns=["log_p"])
|
|
274
|
+
|
|
275
|
+
return df
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def calculate_geopotential(df):
|
|
279
|
+
"""
|
|
280
|
+
Calculates missing Geopotential Height values using the Hypsometric Equation.
|
|
281
|
+
Formula: Z2 = Z1 + (R * T_avg / g) * ln(P1 / P2)
|
|
282
|
+
"""
|
|
283
|
+
if df.empty or "Pressure" not in df.columns or "Temp" not in df.columns:
|
|
284
|
+
return df
|
|
285
|
+
|
|
286
|
+
# Constants
|
|
287
|
+
R = 287.05 # Specific gas constant for dry air (J/(kg·K))
|
|
288
|
+
g = 9.80665 # Gravity (m/s^2)
|
|
289
|
+
|
|
290
|
+
# Ensure sorted by pressure descending (Surface -> Top)
|
|
291
|
+
df = df.sort_values("Pressure", ascending=False).reset_index(drop=True)
|
|
292
|
+
|
|
293
|
+
# Work with a copy to avoid SettingWithCopy warnings and temporary columns
|
|
294
|
+
# Ensure Temp is numeric
|
|
295
|
+
temp_numeric = pd.to_numeric(df["Temp"], errors="coerce")
|
|
296
|
+
df["Temp_K"] = temp_numeric + 273.15
|
|
297
|
+
|
|
298
|
+
# Interpolate missing temperatures for calculation (linear in log-P would be better, but linear is ok)
|
|
299
|
+
if df["Temp_K"].isnull().any():
|
|
300
|
+
df["Temp_K"] = df["Temp_K"].interpolate(method="linear", limit_direction="both")
|
|
301
|
+
|
|
302
|
+
# 1. Forward Pass (Surface -> Top): Fill NaNs using valid level below
|
|
303
|
+
# We iterate manually because each step depends on the potentially calculated prev step
|
|
304
|
+
for i in range(1, len(df)):
|
|
305
|
+
if pd.isna(df.at[i, "Height"]) and not pd.isna(df.at[i - 1, "Height"]):
|
|
306
|
+
try:
|
|
307
|
+
p1 = df.at[i - 1, "Pressure"] # Lower altitude (higher P)
|
|
308
|
+
z1 = df.at[i - 1, "Height"]
|
|
309
|
+
t1 = df.at[i - 1, "Temp_K"]
|
|
310
|
+
|
|
311
|
+
p2 = df.at[i, "Pressure"] # Higher altitude (lower P)
|
|
312
|
+
t2 = df.at[i, "Temp_K"]
|
|
313
|
+
|
|
314
|
+
if p2 <= 0:
|
|
315
|
+
continue # Avoid log domain error
|
|
316
|
+
|
|
317
|
+
avg_t = (t1 + t2) / 2
|
|
318
|
+
# Hypsometric: Z2 - Z1 = (R * T / g) * ln(P1/P2)
|
|
319
|
+
dz = (R * avg_t / g) * np.log(p1 / p2)
|
|
320
|
+
|
|
321
|
+
df.at[i, "Height"] = round(z1 + dz)
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
|
|
325
|
+
# 2. Backward Pass (Top -> Surface): Fill NaNs at bottom using valid level above
|
|
326
|
+
# Useful if surface height is missing but 1000hPa or 925hPa is known
|
|
327
|
+
first_valid = df["Height"].first_valid_index()
|
|
328
|
+
if first_valid is not None and first_valid > 0:
|
|
329
|
+
for i in range(first_valid - 1, -1, -1):
|
|
330
|
+
if pd.isna(df.at[i, "Height"]):
|
|
331
|
+
try:
|
|
332
|
+
# p1 is "Upper" level (Lower Pressure, index i+1)
|
|
333
|
+
p1 = df.at[i + 1, "Pressure"]
|
|
334
|
+
z1 = df.at[i + 1, "Height"]
|
|
335
|
+
t1 = df.at[i + 1, "Temp_K"]
|
|
336
|
+
|
|
337
|
+
# p2 is "Target" level (Higher Pressure, index i)
|
|
338
|
+
p2 = df.at[i, "Pressure"]
|
|
339
|
+
t2 = df.at[i, "Temp_K"]
|
|
340
|
+
|
|
341
|
+
if p1 <= 0:
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
avg_t = (t1 + t2) / 2
|
|
345
|
+
# Z_target = Z_upper - DZ
|
|
346
|
+
# DZ = (R * T / g) * ln(P_target / P_upper)
|
|
347
|
+
dz = (R * avg_t / g) * np.log(p2 / p1)
|
|
348
|
+
|
|
349
|
+
df.at[i, "Height"] = round(z1 - dz)
|
|
350
|
+
except Exception:
|
|
351
|
+
pass
|
|
352
|
+
|
|
353
|
+
# 3. WMO Extrapolation Rule 35.2.2.4 (Upward to standard levels)
|
|
354
|
+
# Check if we can extrapolate to the next standard level above the top
|
|
355
|
+
standard_levels = [
|
|
356
|
+
1000,
|
|
357
|
+
925,
|
|
358
|
+
850,
|
|
359
|
+
700,
|
|
360
|
+
500,
|
|
361
|
+
400,
|
|
362
|
+
300,
|
|
363
|
+
250,
|
|
364
|
+
200,
|
|
365
|
+
150,
|
|
366
|
+
100,
|
|
367
|
+
70,
|
|
368
|
+
50,
|
|
369
|
+
30,
|
|
370
|
+
20,
|
|
371
|
+
10,
|
|
372
|
+
]
|
|
373
|
+
|
|
374
|
+
# Get current top of sounding (lowest pressure with valid Data)
|
|
375
|
+
# We rely on 'Temp_K' being present (interpolated) and 'Height' being calculated
|
|
376
|
+
# Find the row with the lowest pressure that has valid Temp and Height
|
|
377
|
+
valid_df = df.dropna(subset=["Height", "Temp_K"])
|
|
378
|
+
if not valid_df.empty:
|
|
379
|
+
top_row = valid_df.iloc[-1] # Sorted descending by pressure, so last is top
|
|
380
|
+
p_min = top_row["Pressure"]
|
|
381
|
+
t_min = top_row["Temp_K"]
|
|
382
|
+
z_min = top_row["Height"]
|
|
383
|
+
|
|
384
|
+
# Identify target standard levels that are slightly above p_min
|
|
385
|
+
# Filter standard levels < p_min
|
|
386
|
+
targets = [sl for sl in standard_levels if sl < p_min]
|
|
387
|
+
|
|
388
|
+
new_rows = []
|
|
389
|
+
|
|
390
|
+
for p_target in targets:
|
|
391
|
+
delta_p = p_min - p_target
|
|
392
|
+
|
|
393
|
+
# Criterion a: Delta P <= 25 hPa AND Delta P <= 0.25 * P_target
|
|
394
|
+
if delta_p <= 25 and delta_p <= 0.25 * p_target:
|
|
395
|
+
try:
|
|
396
|
+
# Criterion b: Use points at P_min and P_base (P_min + Delta_P)
|
|
397
|
+
p_base = p_min + delta_p
|
|
398
|
+
|
|
399
|
+
# Interpolate Temp at p_base from existing profile
|
|
400
|
+
# Linear interpolation in log(P) for Temperature
|
|
401
|
+
# We need the full profile arrays
|
|
402
|
+
all_p = df["Pressure"].values
|
|
403
|
+
all_t = df["Temp_K"].values
|
|
404
|
+
|
|
405
|
+
# Sort by P ascending for np.interp (Standard atmosphere decreases P with height,
|
|
406
|
+
# but interpolation expects increasing x usually, or handled correctly)
|
|
407
|
+
# Let's use log(P)
|
|
408
|
+
log_p = np.log(all_p)
|
|
409
|
+
log_p_base = np.log(p_base)
|
|
410
|
+
|
|
411
|
+
# Ensure x is increasing for interp
|
|
412
|
+
# all_p is currently Descending (1000 -> 100)
|
|
413
|
+
# log_p is Descending (6.9 -> 4.6)
|
|
414
|
+
# Flip for interpolation
|
|
415
|
+
t_base = np.interp(log_p_base, log_p[::-1], all_t[::-1])
|
|
416
|
+
|
|
417
|
+
# Extrapolate T to P_target
|
|
418
|
+
# Slope m = (T_min - T_base) / (ln(P_min) - ln(P_base))
|
|
419
|
+
# T_target = T_min + m * (ln(P_target) - ln(P_min))
|
|
420
|
+
log_p_min = np.log(p_min)
|
|
421
|
+
log_p_target = np.log(p_target)
|
|
422
|
+
|
|
423
|
+
# Prevent division by zero if delta_p is tiny (though criteria imply it exists)
|
|
424
|
+
if abs(log_p_min - log_p_base) < 1e-6:
|
|
425
|
+
t_target = t_min
|
|
426
|
+
else:
|
|
427
|
+
slope = (t_min - t_base) / (log_p_min - log_p_base)
|
|
428
|
+
t_target = t_min + slope * (log_p_target - log_p_min)
|
|
429
|
+
|
|
430
|
+
avg_t = (t_min + t_target) / 2
|
|
431
|
+
|
|
432
|
+
# Hypsometric: Z_target = Z_min + (R * T_avg / g) * ln(P_min / P_target)
|
|
433
|
+
dz = (R * avg_t / g) * np.log(p_min / p_target)
|
|
434
|
+
z_target = z_min + dz
|
|
435
|
+
|
|
436
|
+
new_rows.append(
|
|
437
|
+
{
|
|
438
|
+
"Pressure": int(p_target),
|
|
439
|
+
"Height": round(z_target),
|
|
440
|
+
"Temp": round(t_target - 273.15, 1),
|
|
441
|
+
"DewPoint": None, # Cannot reliably extrapolate moisture
|
|
442
|
+
"Source": "Extrapolated",
|
|
443
|
+
}
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
# Update p_min/z_min for next step?
|
|
447
|
+
# Rule says "extrapolate a sounding", implies from the *actual* sounding top.
|
|
448
|
+
# So we don't daisy chain. We stop after one or handle independent targets?
|
|
449
|
+
# "provided the extrapolation does not extend through a pressure interval exceeding 25 hPa"
|
|
450
|
+
# This implies valid only for the immediate vicinity of the sounding top.
|
|
451
|
+
# Usually only one standard level fits this tight window.
|
|
452
|
+
|
|
453
|
+
except Exception:
|
|
454
|
+
pass
|
|
455
|
+
|
|
456
|
+
if new_rows:
|
|
457
|
+
# Append new extrapolated rows
|
|
458
|
+
df_ext = pd.DataFrame(new_rows)
|
|
459
|
+
df = pd.concat([df, df_ext], ignore_index=True)
|
|
460
|
+
df = df.sort_values("Pressure", ascending=False).reset_index(drop=True)
|
|
461
|
+
|
|
462
|
+
# Drop temp column
|
|
463
|
+
df.drop(columns=["Temp_K"], inplace=True)
|
|
464
|
+
|
|
465
|
+
return df
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def decode_cloud_group(group, tables):
|
|
469
|
+
"""
|
|
470
|
+
Decodes 5-digit cloud group Nh CL h CM CH.
|
|
471
|
+
Returns: list of dicts [{'Symbol':..., 'Subject': 'Cloud', 'Description':..., 'Value':...}]
|
|
472
|
+
"""
|
|
473
|
+
if not tables or len(group) != 5:
|
|
474
|
+
return [
|
|
475
|
+
{
|
|
476
|
+
"Symbol": "41414",
|
|
477
|
+
"Subject": "Cloud",
|
|
478
|
+
"Description": "Cloud Group",
|
|
479
|
+
"Value": group,
|
|
480
|
+
}
|
|
481
|
+
]
|
|
482
|
+
|
|
483
|
+
nh, cl, h, cm, ch = group[0], group[1], group[2], group[3], group[4]
|
|
484
|
+
results = []
|
|
485
|
+
|
|
486
|
+
val_nh = tables.get("Nh", {}).get(nh, nh)
|
|
487
|
+
results.append(
|
|
488
|
+
{"Symbol": "Nh", "Subject": "Cloud", "Description": "Amount", "Value": val_nh}
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
val_cl = tables.get("CL", {}).get(cl, cl)
|
|
492
|
+
results.append(
|
|
493
|
+
{"Symbol": "CL", "Subject": "Cloud", "Description": "Low Type", "Value": val_cl}
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
val_h = tables.get("h", {}).get(h, h)
|
|
497
|
+
results.append(
|
|
498
|
+
{
|
|
499
|
+
"Symbol": "h",
|
|
500
|
+
"Subject": "Cloud",
|
|
501
|
+
"Description": "Base Height",
|
|
502
|
+
"Value": val_h,
|
|
503
|
+
}
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
val_cm = tables.get("CM", {}).get(cm, cm)
|
|
507
|
+
results.append(
|
|
508
|
+
{"Symbol": "CM", "Subject": "Cloud", "Description": "Mid Type", "Value": val_cm}
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
val_ch = tables.get("CH", {}).get(ch, ch)
|
|
512
|
+
results.append(
|
|
513
|
+
{
|
|
514
|
+
"Symbol": "CH",
|
|
515
|
+
"Subject": "Cloud",
|
|
516
|
+
"Description": "High Type",
|
|
517
|
+
"Value": val_ch,
|
|
518
|
+
}
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
return results
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def decode_31313_group(groups, start_index, tables):
|
|
525
|
+
"""
|
|
526
|
+
Decodes 31313 group sequence.
|
|
527
|
+
Returns: (list_of_dicts, new_index)
|
|
528
|
+
"""
|
|
529
|
+
i = start_index
|
|
530
|
+
results = []
|
|
531
|
+
|
|
532
|
+
# Check next group: sr rara sasa (5 digits)
|
|
533
|
+
if i + 1 < len(groups) and len(groups[i + 1]) == 5:
|
|
534
|
+
grp = groups[i + 1]
|
|
535
|
+
sr = grp[0]
|
|
536
|
+
rara = grp[1:3]
|
|
537
|
+
sasa = grp[3:5]
|
|
538
|
+
|
|
539
|
+
results.append(
|
|
540
|
+
{
|
|
541
|
+
"Symbol": "sr",
|
|
542
|
+
"Subject": "Solar/Inst",
|
|
543
|
+
"Description": "Solar Corr",
|
|
544
|
+
"Value": tables.get("Sr", {}).get(sr, sr),
|
|
545
|
+
}
|
|
546
|
+
)
|
|
547
|
+
results.append(
|
|
548
|
+
{
|
|
549
|
+
"Symbol": "rara",
|
|
550
|
+
"Subject": "Solar/Inst",
|
|
551
|
+
"Description": "Sonde Type",
|
|
552
|
+
"Value": tables.get("rara", {}).get(rara, rara),
|
|
553
|
+
}
|
|
554
|
+
)
|
|
555
|
+
results.append(
|
|
556
|
+
{
|
|
557
|
+
"Symbol": "sasa",
|
|
558
|
+
"Subject": "Solar/Inst",
|
|
559
|
+
"Description": "Tracking",
|
|
560
|
+
"Value": tables.get("sasa", {}).get(sasa, sasa),
|
|
561
|
+
}
|
|
562
|
+
)
|
|
563
|
+
i += 1
|
|
564
|
+
|
|
565
|
+
# Check next group: 8GGgg (Time)
|
|
566
|
+
if i + 1 < len(groups) and groups[i + 1].startswith("8"):
|
|
567
|
+
grp = groups[i + 1]
|
|
568
|
+
if len(grp) == 5:
|
|
569
|
+
gg_h = grp[1:3]
|
|
570
|
+
gg_m = grp[3:5]
|
|
571
|
+
results.append(
|
|
572
|
+
{
|
|
573
|
+
"Symbol": "8GGgg",
|
|
574
|
+
"Subject": "Solar/Inst",
|
|
575
|
+
"Description": "Time",
|
|
576
|
+
"Value": f"{gg_h}:{gg_m}",
|
|
577
|
+
}
|
|
578
|
+
)
|
|
579
|
+
i += 1
|
|
580
|
+
|
|
581
|
+
# Check optional 9xxxx group
|
|
582
|
+
if (
|
|
583
|
+
i + 1 < len(groups)
|
|
584
|
+
and groups[i + 1].startswith("9")
|
|
585
|
+
and len(groups[i + 1]) == 5
|
|
586
|
+
):
|
|
587
|
+
i += 1
|
|
588
|
+
|
|
589
|
+
return results, i + 1
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def decode_height(pressure, h_str):
|
|
593
|
+
"""
|
|
594
|
+
Decodes geopotential height based on pressure level (hPa) and reported string.
|
|
595
|
+
Rules:
|
|
596
|
+
- P < 500 hPa: h_str is tens of standard geopotential meters.
|
|
597
|
+
- P >= 500 hPa: h_str is whole standard geopotential meters.
|
|
598
|
+
- Ambiguity/Truncation: Add thousands (for P>=500) or ten-thousands (for P<500)
|
|
599
|
+
to match 'Standard Atmosphere' approximation.
|
|
600
|
+
- Negative heights (P=1000 etc): If h_str > 500 for low levels, might be 500+|h|.
|
|
601
|
+
"""
|
|
602
|
+
if not h_str or len(h_str) != 3:
|
|
603
|
+
return None
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
val_raw = int(h_str)
|
|
607
|
+
except ValueError:
|
|
608
|
+
return None
|
|
609
|
+
|
|
610
|
+
# Approximate Standard Atmosphere Heights (m)
|
|
611
|
+
std_h = {
|
|
612
|
+
1000: 111,
|
|
613
|
+
925: 762,
|
|
614
|
+
850: 1457,
|
|
615
|
+
700: 3012,
|
|
616
|
+
500: 5574,
|
|
617
|
+
400: 7185,
|
|
618
|
+
300: 9164,
|
|
619
|
+
250: 10363,
|
|
620
|
+
200: 11784,
|
|
621
|
+
150: 13608,
|
|
622
|
+
100: 16180,
|
|
623
|
+
70: 18440,
|
|
624
|
+
50: 20580,
|
|
625
|
+
30: 23850,
|
|
626
|
+
20: 26500,
|
|
627
|
+
10: 31050,
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
# 1. Determine Scale
|
|
631
|
+
if pressure <= 500:
|
|
632
|
+
val = val_raw * 10
|
|
633
|
+
step = 10000
|
|
634
|
+
else:
|
|
635
|
+
val = val_raw
|
|
636
|
+
step = 1000 # Usually truncated at 3 digits -> mod 1000 or mod 10000?
|
|
637
|
+
# For 850hPa (1500m), reported 5?? (3 digits).
|
|
638
|
+
# If 1560 reported as 560 -> mod 1000.
|
|
639
|
+
|
|
640
|
+
target = std_h.get(pressure, 0)
|
|
641
|
+
|
|
642
|
+
# Heuristics
|
|
643
|
+
candidates = []
|
|
644
|
+
|
|
645
|
+
# Case A: Positive adders
|
|
646
|
+
# Try adding k * step (0 to 5)
|
|
647
|
+
for k in range(6):
|
|
648
|
+
cand = val + k * step
|
|
649
|
+
candidates.append(cand)
|
|
650
|
+
|
|
651
|
+
# Case B: Negative (Only for P >= 500, specifically 1000)
|
|
652
|
+
# Rule: 500 + abs(h). E.g. -100m -> abs(100)=100 -> 600 reported.
|
|
653
|
+
# So if reported > 500, could be -ve.
|
|
654
|
+
if pressure >= 500:
|
|
655
|
+
if val_raw >= 500:
|
|
656
|
+
# Interpreted as 500 + |h|
|
|
657
|
+
abs_h = val_raw - 500
|
|
658
|
+
cand = -abs_h
|
|
659
|
+
candidates.append(cand)
|
|
660
|
+
|
|
661
|
+
# Find candidate closest to target
|
|
662
|
+
best_h = min(candidates, key=lambda x: abs(x - target))
|
|
663
|
+
|
|
664
|
+
return best_h
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
# --- Parsing Logic ---
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def clean_message(message):
|
|
671
|
+
message = message.replace("=", "")
|
|
672
|
+
return re.sub(r"\s+", " ", message).strip()
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def parse_ttaa_ttcc(message, cloud_tables=None):
|
|
676
|
+
levels_data = []
|
|
677
|
+
special_data = []
|
|
678
|
+
|
|
679
|
+
clean_msg = clean_message(message)
|
|
680
|
+
groups = clean_msg.split(" ")
|
|
681
|
+
|
|
682
|
+
standard_levels_ttaa = {
|
|
683
|
+
"99": "Surface",
|
|
684
|
+
"00": 1000,
|
|
685
|
+
"92": 925,
|
|
686
|
+
"85": 850,
|
|
687
|
+
"70": 700,
|
|
688
|
+
"50": 500,
|
|
689
|
+
"40": 400,
|
|
690
|
+
"30": 300,
|
|
691
|
+
"25": 250,
|
|
692
|
+
"20": 200,
|
|
693
|
+
"15": 150,
|
|
694
|
+
"10": 100,
|
|
695
|
+
}
|
|
696
|
+
standard_levels_ttcc = {
|
|
697
|
+
"70": 70,
|
|
698
|
+
"50": 50,
|
|
699
|
+
"30": 30,
|
|
700
|
+
"20": 20,
|
|
701
|
+
"10": 10,
|
|
702
|
+
"07": 7,
|
|
703
|
+
"05": 5,
|
|
704
|
+
"03": 3,
|
|
705
|
+
"02": 2,
|
|
706
|
+
"01": 1,
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
is_ttcc = "TTCC" in message
|
|
710
|
+
standard_levels = standard_levels_ttcc if is_ttcc else standard_levels_ttaa
|
|
711
|
+
|
|
712
|
+
i = 0
|
|
713
|
+
while i < len(groups):
|
|
714
|
+
g = groups[i]
|
|
715
|
+
|
|
716
|
+
if re.match(r"^(TTAA|TTCC|TTBB|TTDD)$", g):
|
|
717
|
+
i += 1
|
|
718
|
+
if i < len(groups) and re.match(r"^\d{5}$", groups[i]):
|
|
719
|
+
i += 1
|
|
720
|
+
if i < len(groups) and re.match(r"^\d{5}$", groups[i]):
|
|
721
|
+
i += 1
|
|
722
|
+
continue
|
|
723
|
+
|
|
724
|
+
# Tropopause
|
|
725
|
+
if g.startswith("88"):
|
|
726
|
+
if g == "88999":
|
|
727
|
+
i += 1
|
|
728
|
+
continue
|
|
729
|
+
try:
|
|
730
|
+
p_code = g[2:]
|
|
731
|
+
pressure = int(p_code) if len(p_code) == 3 else None
|
|
732
|
+
|
|
733
|
+
temp_grp = groups[i + 1] if i + 1 < len(groups) else None
|
|
734
|
+
wind_grp = groups[i + 2] if i + 2 < len(groups) else None
|
|
735
|
+
|
|
736
|
+
t, d, wd, ws = None, None, None, None
|
|
737
|
+
|
|
738
|
+
special_data.append(
|
|
739
|
+
{
|
|
740
|
+
"Symbol": "PtPtPt",
|
|
741
|
+
"Subject": "Tropopause",
|
|
742
|
+
"Description": "Pressure",
|
|
743
|
+
"Value": f"{pressure}hPa",
|
|
744
|
+
}
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
if temp_grp:
|
|
748
|
+
t = decode_temperature(temp_grp[:3], tables=cloud_tables)
|
|
749
|
+
d = decode_dewpoint_depression(temp_grp[3:], tables=cloud_tables)
|
|
750
|
+
if t is not None:
|
|
751
|
+
special_data.append(
|
|
752
|
+
{
|
|
753
|
+
"Symbol": "TtTtTt",
|
|
754
|
+
"Subject": "Tropopause",
|
|
755
|
+
"Description": "Temperature",
|
|
756
|
+
"Value": f"{t}C",
|
|
757
|
+
}
|
|
758
|
+
)
|
|
759
|
+
if t is not None and d is not None:
|
|
760
|
+
dw = calculate_dewpoint(t, d)
|
|
761
|
+
special_data.append(
|
|
762
|
+
{
|
|
763
|
+
"Symbol": "DtDt",
|
|
764
|
+
"Subject": "Tropopause",
|
|
765
|
+
"Description": "Dewpoint",
|
|
766
|
+
"Value": f"{dw}C",
|
|
767
|
+
}
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
if wind_grp:
|
|
771
|
+
wd, ws = decode_wind(wind_grp)
|
|
772
|
+
if wd is not None:
|
|
773
|
+
special_data.append(
|
|
774
|
+
{
|
|
775
|
+
"Symbol": "dtdtftft",
|
|
776
|
+
"Subject": "Tropopause",
|
|
777
|
+
"Description": "Wind",
|
|
778
|
+
"Value": f"{wd}/{ws}kt",
|
|
779
|
+
}
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
i += 3
|
|
783
|
+
continue
|
|
784
|
+
except Exception:
|
|
785
|
+
i += 1
|
|
786
|
+
continue
|
|
787
|
+
|
|
788
|
+
# Max Wind
|
|
789
|
+
if g.startswith("77") or g.startswith("66"):
|
|
790
|
+
if g in ["77999", "66999"]:
|
|
791
|
+
i += 1
|
|
792
|
+
continue
|
|
793
|
+
try:
|
|
794
|
+
p_code = g[2:]
|
|
795
|
+
pressure = int(p_code)
|
|
796
|
+
wind_grp = groups[i + 1] if i + 1 < len(groups) else None
|
|
797
|
+
wd, ws = None, None
|
|
798
|
+
|
|
799
|
+
if wind_grp and len(wind_grp) == 5:
|
|
800
|
+
try:
|
|
801
|
+
d_deg = int(wind_grp[:2]) * 10
|
|
802
|
+
s_kt = int(wind_grp[2:])
|
|
803
|
+
wd, ws = d_deg, s_kt
|
|
804
|
+
except ValueError:
|
|
805
|
+
pass
|
|
806
|
+
|
|
807
|
+
special_data.append(
|
|
808
|
+
{
|
|
809
|
+
"Symbol": "PmPmPm",
|
|
810
|
+
"Subject": "Max Wind",
|
|
811
|
+
"Description": "Pressure",
|
|
812
|
+
"Value": f"{pressure}hPa",
|
|
813
|
+
}
|
|
814
|
+
)
|
|
815
|
+
if wd is not None:
|
|
816
|
+
special_data.append(
|
|
817
|
+
{
|
|
818
|
+
"Symbol": "dmdmfmfmfm",
|
|
819
|
+
"Subject": "Max Wind",
|
|
820
|
+
"Description": "Wind",
|
|
821
|
+
"Value": f"{wd}/{ws}kt",
|
|
822
|
+
}
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
i_next = i + 2
|
|
826
|
+
if (
|
|
827
|
+
i_next < len(groups)
|
|
828
|
+
and groups[i_next].startswith("4")
|
|
829
|
+
and len(groups[i_next]) == 5
|
|
830
|
+
):
|
|
831
|
+
shear_grp = groups[i_next]
|
|
832
|
+
vb = shear_grp[1:3]
|
|
833
|
+
va = shear_grp[3:5]
|
|
834
|
+
special_data.append(
|
|
835
|
+
{
|
|
836
|
+
"Symbol": "vbvb",
|
|
837
|
+
"Subject": "Max Wind",
|
|
838
|
+
"Description": "Shear Below",
|
|
839
|
+
"Value": f"{vb}kt",
|
|
840
|
+
}
|
|
841
|
+
)
|
|
842
|
+
special_data.append(
|
|
843
|
+
{
|
|
844
|
+
"Symbol": "vava",
|
|
845
|
+
"Subject": "Max Wind",
|
|
846
|
+
"Description": "Shear Above",
|
|
847
|
+
"Value": f"{va}kt",
|
|
848
|
+
}
|
|
849
|
+
)
|
|
850
|
+
i_next += 1
|
|
851
|
+
|
|
852
|
+
i = i_next
|
|
853
|
+
continue
|
|
854
|
+
except Exception:
|
|
855
|
+
i += 1
|
|
856
|
+
continue
|
|
857
|
+
|
|
858
|
+
if g == "31313":
|
|
859
|
+
decoded_list, new_i = decode_31313_group(groups, i, cloud_tables)
|
|
860
|
+
special_data.extend(decoded_list)
|
|
861
|
+
i = new_i
|
|
862
|
+
continue
|
|
863
|
+
|
|
864
|
+
if g == "41414":
|
|
865
|
+
if i + 1 < len(groups):
|
|
866
|
+
decoded_list = decode_cloud_group(groups[i + 1], cloud_tables)
|
|
867
|
+
special_data.extend(decoded_list)
|
|
868
|
+
i += 2
|
|
869
|
+
else:
|
|
870
|
+
i += 1
|
|
871
|
+
continue
|
|
872
|
+
|
|
873
|
+
if g.startswith(
|
|
874
|
+
(
|
|
875
|
+
"51515",
|
|
876
|
+
"52525",
|
|
877
|
+
"53535",
|
|
878
|
+
"54545",
|
|
879
|
+
"55555",
|
|
880
|
+
"56565",
|
|
881
|
+
"57575",
|
|
882
|
+
"58585",
|
|
883
|
+
"59595",
|
|
884
|
+
"21212",
|
|
885
|
+
)
|
|
886
|
+
):
|
|
887
|
+
i += 1
|
|
888
|
+
continue
|
|
889
|
+
|
|
890
|
+
if len(g) == 2 or (len(g) == 5 and g[:2] in standard_levels):
|
|
891
|
+
pp = g[:2]
|
|
892
|
+
pressure, height = None, None
|
|
893
|
+
if pp == "99":
|
|
894
|
+
try:
|
|
895
|
+
pressure = 1000 + int(g[2:]) if int(g[2:]) < 100 else int(g[2:])
|
|
896
|
+
except (ValueError, TypeError):
|
|
897
|
+
pass
|
|
898
|
+
elif pp in standard_levels and len(g) == 5:
|
|
899
|
+
try:
|
|
900
|
+
pressure = standard_levels[pp]
|
|
901
|
+
h_code = g[2:]
|
|
902
|
+
height = decode_height(pressure, h_code)
|
|
903
|
+
except (ValueError, TypeError, Exception):
|
|
904
|
+
pass
|
|
905
|
+
|
|
906
|
+
if pressure is not None:
|
|
907
|
+
t_group = groups[i + 1] if i + 1 < len(groups) else None
|
|
908
|
+
w_group = groups[i + 2] if i + 2 < len(groups) else None
|
|
909
|
+
dp = {"Pressure": float(pressure), "Source": "Standard"}
|
|
910
|
+
if height is not None:
|
|
911
|
+
dp["Height"] = height
|
|
912
|
+
|
|
913
|
+
valid = False
|
|
914
|
+
indicators = ("88", "77", "66", "51515", "31313", "41414")
|
|
915
|
+
if (
|
|
916
|
+
t_group
|
|
917
|
+
and len(t_group) == 5
|
|
918
|
+
and not t_group.startswith(indicators)
|
|
919
|
+
):
|
|
920
|
+
t = decode_temperature(t_group[:3], tables=cloud_tables)
|
|
921
|
+
d = decode_dewpoint_depression(t_group[3:], tables=cloud_tables)
|
|
922
|
+
dp["Temp"] = t
|
|
923
|
+
dp["DewPoint"] = calculate_dewpoint(t, d)
|
|
924
|
+
valid = True
|
|
925
|
+
if (
|
|
926
|
+
w_group
|
|
927
|
+
and len(w_group) == 5
|
|
928
|
+
and not w_group.startswith(indicators)
|
|
929
|
+
):
|
|
930
|
+
wd, ws = decode_wind(w_group)
|
|
931
|
+
dp["WindDir"] = wd
|
|
932
|
+
dp["WindSpeed"] = ws
|
|
933
|
+
valid = True
|
|
934
|
+
|
|
935
|
+
if valid:
|
|
936
|
+
levels_data.append(dp)
|
|
937
|
+
i += 3
|
|
938
|
+
continue
|
|
939
|
+
i += 1
|
|
940
|
+
return levels_data, special_data
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def parse_ttbb_ttdd(message, cloud_tables=None):
|
|
944
|
+
levels_data = []
|
|
945
|
+
special_data = []
|
|
946
|
+
clean_msg = clean_message(message)
|
|
947
|
+
groups = clean_msg.split(" ")
|
|
948
|
+
mode = "TEMP"
|
|
949
|
+
is_ttdd = "TTDD" in message # Simple check, or track via group iteration
|
|
950
|
+
last_nn = None # Sequence tracking for intruder detection
|
|
951
|
+
|
|
952
|
+
i = 0
|
|
953
|
+
while i < len(groups):
|
|
954
|
+
g = groups[i]
|
|
955
|
+
|
|
956
|
+
if re.match(r"^(TTBB|TTDD)$", g):
|
|
957
|
+
if g == "TTDD":
|
|
958
|
+
is_ttdd = True
|
|
959
|
+
elif g == "TTBB":
|
|
960
|
+
is_ttdd = False
|
|
961
|
+
|
|
962
|
+
i += 1
|
|
963
|
+
if i < len(groups) and re.match(r"^\d{5}$", groups[i]):
|
|
964
|
+
i += 1
|
|
965
|
+
if i < len(groups) and re.match(r"^\d{5}$", groups[i]):
|
|
966
|
+
i += 1
|
|
967
|
+
continue
|
|
968
|
+
if g == "21212":
|
|
969
|
+
mode = "WIND"
|
|
970
|
+
last_nn = None # Reset sequence counter for new section
|
|
971
|
+
i += 1
|
|
972
|
+
continue
|
|
973
|
+
|
|
974
|
+
if g == "31313":
|
|
975
|
+
decoded_list, new_i = decode_31313_group(groups, i, cloud_tables)
|
|
976
|
+
special_data.extend(decoded_list)
|
|
977
|
+
i = new_i
|
|
978
|
+
continue
|
|
979
|
+
|
|
980
|
+
if g == "41414":
|
|
981
|
+
if i + 1 < len(groups):
|
|
982
|
+
decoded_list = decode_cloud_group(groups[i + 1], cloud_tables)
|
|
983
|
+
special_data.extend(decoded_list)
|
|
984
|
+
i += 2
|
|
985
|
+
else:
|
|
986
|
+
i += 1
|
|
987
|
+
continue
|
|
988
|
+
|
|
989
|
+
if g == "51515":
|
|
990
|
+
i += 1
|
|
991
|
+
continue
|
|
992
|
+
|
|
993
|
+
if len(g) == 5 and g[:2].isdigit():
|
|
994
|
+
nn = g[:2]
|
|
995
|
+
|
|
996
|
+
# --- Strict Sequence Validation ---
|
|
997
|
+
is_valid_level = False
|
|
998
|
+
|
|
999
|
+
if last_nn is None:
|
|
1000
|
+
# Restrict start to 00 or 11 to filter out header noise
|
|
1001
|
+
try:
|
|
1002
|
+
n_val = int(nn)
|
|
1003
|
+
if n_val == 0 or n_val == 11:
|
|
1004
|
+
is_valid_level = True
|
|
1005
|
+
else:
|
|
1006
|
+
pass
|
|
1007
|
+
except (ValueError, IndexError):
|
|
1008
|
+
pass
|
|
1009
|
+
else:
|
|
1010
|
+
try:
|
|
1011
|
+
ln = int(last_nn)
|
|
1012
|
+
# strict next step
|
|
1013
|
+
if ln == 0:
|
|
1014
|
+
exp = 11
|
|
1015
|
+
elif ln == 99:
|
|
1016
|
+
exp = 11
|
|
1017
|
+
else:
|
|
1018
|
+
exp = ln + 11
|
|
1019
|
+
|
|
1020
|
+
try:
|
|
1021
|
+
n_val = int(nn)
|
|
1022
|
+
if n_val == exp:
|
|
1023
|
+
is_valid_level = True
|
|
1024
|
+
else:
|
|
1025
|
+
pass
|
|
1026
|
+
except (ValueError, IndexError):
|
|
1027
|
+
pass
|
|
1028
|
+
except (ValueError, IndexError):
|
|
1029
|
+
pass
|
|
1030
|
+
|
|
1031
|
+
if not is_valid_level:
|
|
1032
|
+
# Treat as intruder/noise.
|
|
1033
|
+
i += 1
|
|
1034
|
+
continue
|
|
1035
|
+
|
|
1036
|
+
last_nn = nn
|
|
1037
|
+
# ----------------------------------
|
|
1038
|
+
|
|
1039
|
+
try:
|
|
1040
|
+
ppp_part = int(g[2:])
|
|
1041
|
+
pressure = None
|
|
1042
|
+
if nn == "00":
|
|
1043
|
+
pressure = 1000 + ppp_part if ppp_part < 100 else ppp_part
|
|
1044
|
+
else:
|
|
1045
|
+
pressure = float(ppp_part)
|
|
1046
|
+
|
|
1047
|
+
# If TTDD, pressure is in tenths of hPa
|
|
1048
|
+
if is_ttdd:
|
|
1049
|
+
pressure = pressure / 10.0
|
|
1050
|
+
|
|
1051
|
+
if mode == "TEMP":
|
|
1052
|
+
t_group = groups[i + 1] if i + 1 < len(groups) else None
|
|
1053
|
+
if t_group and len(t_group) == 5 and t_group != "21212":
|
|
1054
|
+
t = decode_temperature(t_group[:3], tables=cloud_tables)
|
|
1055
|
+
d = decode_dewpoint_depression(t_group[3:], tables=cloud_tables)
|
|
1056
|
+
levels_data.append(
|
|
1057
|
+
{
|
|
1058
|
+
"Pressure": float(pressure),
|
|
1059
|
+
"Temp": t,
|
|
1060
|
+
"DewPoint": calculate_dewpoint(t, d),
|
|
1061
|
+
"Source": "SigTemp",
|
|
1062
|
+
}
|
|
1063
|
+
)
|
|
1064
|
+
i += 2
|
|
1065
|
+
continue
|
|
1066
|
+
elif mode == "WIND":
|
|
1067
|
+
w_group = groups[i + 1] if i + 1 < len(groups) else None
|
|
1068
|
+
if w_group and len(w_group) == 5:
|
|
1069
|
+
wd, ws = decode_wind(w_group)
|
|
1070
|
+
levels_data.append(
|
|
1071
|
+
{
|
|
1072
|
+
"Pressure": float(pressure),
|
|
1073
|
+
"WindDir": wd,
|
|
1074
|
+
"WindSpeed": ws,
|
|
1075
|
+
"Source": "SigWind",
|
|
1076
|
+
}
|
|
1077
|
+
)
|
|
1078
|
+
i += 2
|
|
1079
|
+
continue
|
|
1080
|
+
except (ValueError, IndexError):
|
|
1081
|
+
pass
|
|
1082
|
+
i += 1
|
|
1083
|
+
return levels_data, special_data
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def merge_data(df_list):
|
|
1087
|
+
if not df_list:
|
|
1088
|
+
return pd.DataFrame()
|
|
1089
|
+
full_df = pd.concat(df_list, ignore_index=True)
|
|
1090
|
+
|
|
1091
|
+
# Round Pressure to integer
|
|
1092
|
+
full_df["Pressure"] = full_df["Pressure"].round(0).astype(int)
|
|
1093
|
+
|
|
1094
|
+
grouped = (
|
|
1095
|
+
full_df.groupby("Pressure")
|
|
1096
|
+
.agg(
|
|
1097
|
+
{
|
|
1098
|
+
"Height": "first",
|
|
1099
|
+
"Temp": "first",
|
|
1100
|
+
"DewPoint": "first",
|
|
1101
|
+
"WindDir": "first",
|
|
1102
|
+
"WindSpeed": "first",
|
|
1103
|
+
"Source": "first",
|
|
1104
|
+
}
|
|
1105
|
+
)
|
|
1106
|
+
.reset_index()
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
# Convert Height to Int64 (nullable int) to handle NaNs gracefully
|
|
1110
|
+
if "Height" in grouped.columns:
|
|
1111
|
+
grouped["Height"] = grouped["Height"].astype(float).round(0).astype("Int64")
|
|
1112
|
+
|
|
1113
|
+
return grouped.sort_values(by="Pressure", ascending=False).reset_index(drop=True)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def decode_full(ttaa_msg, ttbb_msg, ttcc_msg, ttdd_msg):
|
|
1117
|
+
cloud_tables = load_wmo_tables()
|
|
1118
|
+
|
|
1119
|
+
data_frames = []
|
|
1120
|
+
special_frames = []
|
|
1121
|
+
|
|
1122
|
+
for msg, parser in [
|
|
1123
|
+
(ttaa_msg, parse_ttaa_ttcc),
|
|
1124
|
+
(ttcc_msg, parse_ttaa_ttcc),
|
|
1125
|
+
(ttbb_msg, parse_ttbb_ttdd),
|
|
1126
|
+
(ttdd_msg, parse_ttbb_ttdd),
|
|
1127
|
+
]:
|
|
1128
|
+
if msg:
|
|
1129
|
+
lvls, spcls = parser(msg, cloud_tables=cloud_tables)
|
|
1130
|
+
if lvls:
|
|
1131
|
+
data_frames.append(pd.DataFrame(lvls))
|
|
1132
|
+
if spcls:
|
|
1133
|
+
special_frames.append(pd.DataFrame(spcls))
|
|
1134
|
+
|
|
1135
|
+
df_main = merge_data(data_frames)
|
|
1136
|
+
df_main = interpolate_data(df_main)
|
|
1137
|
+
df_main = calculate_geopotential(df_main)
|
|
1138
|
+
df_special = (
|
|
1139
|
+
pd.concat(special_frames, ignore_index=True)
|
|
1140
|
+
if special_frames
|
|
1141
|
+
else pd.DataFrame()
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
# Validating and sorting data
|
|
1145
|
+
if not df_special.empty:
|
|
1146
|
+
# Reorder columns as requested
|
|
1147
|
+
if set(["Symbol", "Subject", "Description", "Value"]).issubset(
|
|
1148
|
+
df_special.columns
|
|
1149
|
+
):
|
|
1150
|
+
df_special = df_special[["Symbol", "Subject", "Description", "Value"]]
|
|
1151
|
+
|
|
1152
|
+
# Drop duplicates based on content
|
|
1153
|
+
df_special = df_special.drop_duplicates()
|
|
1154
|
+
|
|
1155
|
+
# Sort Special Data (Cloud groups specifically)
|
|
1156
|
+
mask = df_special["Subject"] == "Cloud"
|
|
1157
|
+
if mask.any():
|
|
1158
|
+
order = {"h": 0, "Nh": 1, "CL": 2, "CM": 3, "CH": 4}
|
|
1159
|
+
# Only sort the Cloud subject rows
|
|
1160
|
+
clouds = df_special[mask].sort_values(
|
|
1161
|
+
by="Symbol", key=lambda col: col.map(order)
|
|
1162
|
+
)
|
|
1163
|
+
others = df_special[~mask]
|
|
1164
|
+
df_special = pd.concat([clouds, others], ignore_index=True)
|
|
1165
|
+
|
|
1166
|
+
return df_main, df_special
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def decode(ttaa, ttbb, ttcc, ttdd):
|
|
1170
|
+
"""
|
|
1171
|
+
Exposed function for external scripts.
|
|
1172
|
+
Returns (df_main, df_special)
|
|
1173
|
+
"""
|
|
1174
|
+
return decode_full(ttaa, ttbb, ttcc, ttdd)
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
# This file is intended to be used as a module.
|
|
1178
|
+
# Use decode(ttaa, ttbb, ttcc, ttdd) to process meteorological data.
|