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.

@@ -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.