windborne 1.0.9__py3-none-any.whl → 1.1.0__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.
@@ -0,0 +1,456 @@
1
+ import os
2
+ import json
3
+ import csv
4
+ from datetime import datetime, timezone
5
+
6
+
7
+ def format_little_r_value(value, fortran_format, align=None):
8
+ """
9
+ Format a value according to a given Fortran format for use in little_r
10
+ """
11
+ if fortran_format[0] == 'F':
12
+ length, decimal_places = fortran_format[1:].split('.')
13
+ if value is None or value == '':
14
+ return ' ' * int(length)
15
+
16
+ # turn into a string of length characters, with decimal_places decimal places
17
+ return f"{value:>{length}.{decimal_places}f}"[:int(length)]
18
+
19
+ if fortran_format[0] == 'I':
20
+ length = int(fortran_format[1:])
21
+ if value is None or value == '':
22
+ return ' ' * length
23
+
24
+ return f"{value:>{length}d}"[:int(length)]
25
+
26
+ if fortran_format[0] == 'A':
27
+ length = int(fortran_format[1:])
28
+ if value is None:
29
+ return ' ' * length
30
+
31
+ if align == 'right':
32
+ return str(value)[:length].rjust(length, ' ')
33
+
34
+ return str(value)[:length].ljust(length, ' ')
35
+
36
+ if fortran_format[0] == 'L':
37
+ if value and value in ['T', 't', 'True', 'true', '1', True]:
38
+ value = 'T'
39
+ else:
40
+ value = 'F'
41
+
42
+ length = int(fortran_format[1:])
43
+
44
+ return value.rjust(length, ' ')
45
+
46
+ raise ValueError(f"Unknown format: {fortran_format}")
47
+
48
+
49
+ def safe_little_r_float(value, default=-888888.0):
50
+ """
51
+ Convert a value to float. If the value is None, empty, or invalid, return the default.
52
+ """
53
+ try:
54
+ return float(value) if value not in (None, '', 'None') else default
55
+ except (ValueError, TypeError):
56
+ return default
57
+
58
+
59
+ def format_little_r(observations):
60
+ """
61
+ Convert observations to Little_R format.
62
+
63
+ Args:
64
+ observations (list): List of observation dictionaries
65
+
66
+ Returns:
67
+ list: Formatted Little_R records
68
+ """
69
+ little_r_records = []
70
+
71
+ for point in observations:
72
+ # Observation time
73
+ observation_time = datetime.fromtimestamp(point['timestamp'], tz=timezone.utc)
74
+
75
+ # Convert and validate fields
76
+ pressure_hpa = safe_little_r_float(point.get('pressure'))
77
+ pressure_pa = pressure_hpa * 100.0
78
+
79
+ temperature_c = safe_little_r_float(point.get('temperature'))
80
+ temperature_k = temperature_c + 273.15
81
+
82
+ altitude = safe_little_r_float(point.get('altitude'))
83
+ humidity = safe_little_r_float(point.get('humidity'))
84
+ speed_u = safe_little_r_float(point.get('speed_u'))
85
+ speed_v = safe_little_r_float(point.get('speed_v'))
86
+
87
+ # Header formatting
88
+ header = ''.join([
89
+ # Latitude: F20.5
90
+ format_little_r_value(point.get('latitude'), 'F20.5'),
91
+
92
+ # Longitude: F20.5
93
+ format_little_r_value(point.get('longitude'), 'F20.5'),
94
+
95
+ # ID: A40
96
+ format_little_r_value(point.get('id'), 'A40'),
97
+
98
+ # Name: A40
99
+ format_little_r_value(point.get('mission_name'), 'A40'),
100
+
101
+ # Platform (FM‑Code): A40
102
+ format_little_r_value('FM-35 TEMP', 'A40'),
103
+
104
+ # Source: A40
105
+ format_little_r_value('WindBorne', 'A40'),
106
+
107
+ # Elevation: F20.5
108
+ format_little_r_value('', 'F20.5'),
109
+
110
+ # Valid fields: I10
111
+ format_little_r_value(-888888, 'I10'),
112
+
113
+ # Num. errors: I10
114
+ format_little_r_value(0, 'I10'),
115
+
116
+ # Num. warnings: I10
117
+ format_little_r_value(0, 'I10'),
118
+
119
+ # Sequence number: I10
120
+ format_little_r_value(0, 'I10'),
121
+
122
+ # Num. duplicates: I10
123
+ format_little_r_value(0, 'I10'),
124
+
125
+ # Is sounding?: L
126
+ format_little_r_value('T', 'L10'),
127
+
128
+ # Is bogus?: L
129
+ format_little_r_value('F', 'L10'),
130
+
131
+ # Discard?: L
132
+ format_little_r_value('F', 'L10'),
133
+
134
+ # Unix time: I10
135
+ # format_value(point['timestamp'], 'I10'),
136
+ format_little_r_value(-888888, 'I10'),
137
+
138
+ # Julian day: I10
139
+ format_little_r_value(-888888, 'I10'),
140
+
141
+ # Date: A20 YYYYMMDDhhmmss
142
+ format_little_r_value(observation_time.strftime('%Y%m%d%H%M%S'), 'A20', align='right'),
143
+
144
+ # SLP, QC: F13.5, I7
145
+ format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
146
+
147
+ # Ref Pressure, QC: F13.5, I7
148
+ format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
149
+
150
+ # Ground Temp, QC: F13.5, I7
151
+ format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
152
+
153
+ # SST, QC: F13.5, I7
154
+ format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
155
+
156
+ # SFC Pressure, QC: F13.5, I7
157
+ format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
158
+
159
+ # Precip, QC: F13.5, I7
160
+ format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
161
+
162
+ # Daily Max T, QC: F13.5, I7
163
+ format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
164
+
165
+ # Daily Min T, QC: F13.5, I7
166
+ format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
167
+
168
+ # Night Min T, QC: F13.5, I7
169
+ format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
170
+
171
+ # 3hr Pres Change, QC: F13.5, I7
172
+ format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
173
+
174
+ # 24hr Pres Change, QC: F13.5, I7
175
+ format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
176
+
177
+ # Cloud cover, QC: F13.5, I7
178
+ format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
179
+
180
+ # Ceiling, QC: F13.5, I7
181
+ format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
182
+
183
+ # Precipitable water, QC (see note): F13.5, I7
184
+ format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
185
+ ])
186
+
187
+ # Data record formatting
188
+ data_record = ''.join([
189
+ # Pressure (Pa): F13.5
190
+ format_little_r_value(pressure_pa, 'F13.5'),
191
+
192
+ # QC: I7
193
+ format_little_r_value(0, 'I7'),
194
+
195
+ # Height (m): F13.5
196
+ format_little_r_value(altitude, 'F13.5'),
197
+
198
+ # QC: I7
199
+ format_little_r_value(0, 'I7'),
200
+
201
+ # Temperature (K): F13.5
202
+ format_little_r_value(temperature_k, 'F13.5'),
203
+
204
+ # QC: I7
205
+ format_little_r_value(0, 'I7'),
206
+
207
+ # Dew point (K): F13.5
208
+ format_little_r_value(-888888.0, 'F13.5'),
209
+
210
+ # QC: I7
211
+ format_little_r_value(0, 'I7'),
212
+
213
+ # Wind speed (m/s): F13.5
214
+ format_little_r_value(-888888.0, 'F13.5'),
215
+
216
+ # QC: I7
217
+ format_little_r_value(0, 'I7'),
218
+
219
+ # Wind direction (deg): F13.5
220
+ format_little_r_value(-888888.0, 'F13.5'),
221
+
222
+ # QC: I7
223
+ format_little_r_value(0, 'I7'),
224
+
225
+ # Wind U (m/s): F13.5
226
+ format_little_r_value(speed_u, 'F13.5'),
227
+
228
+ # QC: I7
229
+ format_little_r_value(0, 'I7'),
230
+
231
+ # Wind V (m/s): F13.5
232
+ format_little_r_value(speed_v, 'F13.5'),
233
+
234
+ # QC: I7
235
+ format_little_r_value(0, 'I7'),
236
+
237
+ # Relative humidity (%): F13.5
238
+ format_little_r_value(humidity, 'F13.5'),
239
+
240
+ # QC: I7
241
+ format_little_r_value(0, 'I7'),
242
+
243
+ # Thickness (m): F13.5
244
+ format_little_r_value(-888888.0, 'F13.5'),
245
+
246
+ # QC: I7
247
+ format_little_r_value(0, 'I7')
248
+ ])
249
+
250
+ # End record and tail record
251
+ end_record = '-777777.00000 0-777777.00000 0-888888.00000 0-888888.00000 0-888888.00000 0-888888.00000 0-888888.00000 0-888888.00000 0-888888.00000 0-888888.00000 0'
252
+ tail_record = ' 39 0 0'
253
+
254
+ # Combine into a complete record
255
+ complete_record = '\n'.join([header, data_record, end_record, tail_record, ''])
256
+ little_r_records.append(complete_record)
257
+
258
+ return little_r_records
259
+
260
+ def convert_to_netcdf(data, curtime, output_filename):
261
+ """
262
+ Convert data to netCDF format for WMO ISARRA program.
263
+
264
+ The output format is netCDF and the style (variable names, file names, etc.) are described here:
265
+ https://github.com/synoptic/wmo-uasdc/tree/main/raw_uas_to_netCDF
266
+ """
267
+
268
+ # Import necessary libraries
269
+ try:
270
+ import xarray as xr
271
+ except ImportError:
272
+ print("Please install the xarray library to save as netCDF, eg 'python3 -m pip install xarray'.")
273
+ return
274
+
275
+ try:
276
+ import pandas as pd
277
+ except ImportError:
278
+ print("Please install the pandas library to save as netCDF, eg 'python3 -m pip install pandas'.")
279
+ return
280
+
281
+ try:
282
+ import numpy as np
283
+ except ImportError:
284
+ print("Please install the numpy library to save as netCDF, eg 'python3 -m pip install numpy'.")
285
+ return
286
+
287
+ # Mapping of WindBorne names to ISARRA names
288
+ rename_dict = {
289
+ 'latitude': 'lat',
290
+ 'longitude': 'lon',
291
+ 'altitude': 'altitude',
292
+ 'temperature': 'air_temperature',
293
+ 'wind_direction': 'wind_direction',
294
+ 'wind_speed': 'wind_speed',
295
+ 'pressure': 'air_pressure',
296
+ 'humidity_mixing_ratio': 'humidity_mixing_ratio',
297
+ 'index': 'obs',
298
+ }
299
+
300
+ # Convert dictionary to list for DataFrame
301
+ data_list = []
302
+ if isinstance(data, dict):
303
+ # If input is dictionary, convert to list
304
+ for obs_id, obs_data in data.items():
305
+ clean_data = {k: None if v == 'None' else v for k, v in obs_data.items()}
306
+ data_list.append(clean_data)
307
+ else:
308
+ # If input is already a list
309
+ for obs_data in data:
310
+ clean_data = {k: None if v == 'None' else v for k, v in obs_data.items()}
311
+ data_list.append(clean_data)
312
+
313
+ # Put the data in a panda dataframe in order to easily push to xarray then netcdf output
314
+ df = pd.DataFrame(data_list)
315
+
316
+ # Convert numeric columns to float
317
+ numeric_columns = ['latitude', 'longitude', 'altitude', 'pressure', 'temperature',
318
+ 'speed_u', 'speed_v', 'specific_humidity', 'timestamp']
319
+ for col in numeric_columns:
320
+ if col in df.columns:
321
+ df[col] = pd.to_numeric(df[col], errors='coerce')
322
+
323
+ ds = xr.Dataset.from_dataframe(df)
324
+
325
+ # Build the filename and save some variables for use later
326
+ mt = datetime.fromtimestamp(curtime, tz=timezone.utc)
327
+
328
+ # Handle dropsondes
329
+ mission_name = str(df['mission_name'].iloc[0]) if (not df.empty and not pd.isna(df['mission_name'].iloc[0])) else ' '
330
+
331
+ is_multi_mission = False
332
+
333
+ if len(df['mission_name'].unique()) > 1:
334
+ is_multi_mission = True
335
+
336
+ output_file = output_filename
337
+
338
+ # Derived quantities calculated here:
339
+
340
+ # convert from specific humidity to humidity_mixing_ratio
341
+ mg_to_kg = 1000000.
342
+ if not all(x is None for x in ds['specific_humidity'].data):
343
+ ds['humidity_mixing_ratio'] = (ds['specific_humidity'] / mg_to_kg) / (1 - (ds['specific_humidity'] / mg_to_kg))
344
+ else:
345
+ ds['humidity_mixing_ratio'] = ds['specific_humidity']
346
+
347
+ # Wind speed and direction from components
348
+ ds['wind_speed'] = np.sqrt(ds['speed_u']*ds['speed_u'] + ds['speed_v']*ds['speed_v'])
349
+ ds['wind_direction'] = np.mod(180 + (180 / np.pi) * np.arctan2(ds['speed_u'], ds['speed_v']), 360)
350
+
351
+ ds['time'] = ds['timestamp'].astype(float)
352
+ ds = ds.assign_coords(time=("time", ds['time'].data))
353
+
354
+ # Now that calculations are done, remove variables not needed in the netcdf output
355
+ variables_to_drop = ['humidity', 'speed_x', 'speed_y', 'timestamp']
356
+ if 'id' in ds and pd.isna(ds['id']).all():
357
+ variables_to_drop.append('id')
358
+
359
+ existing_vars = [var for var in variables_to_drop if var in ds]
360
+ ds = ds.drop_vars(existing_vars)
361
+
362
+ # Rename the variables
363
+ ds = ds.rename(rename_dict)
364
+
365
+ # Adding attributes to variables in the xarray dataset
366
+ ds['time'].attrs = {
367
+ 'units': 'seconds since 1970-01-01T00:00:00',
368
+ 'long_name': 'Time', '_FillValue': float('nan'),
369
+ 'processing_level': ''
370
+ }
371
+
372
+ ds['lat'].attrs = {
373
+ 'units': 'degrees_north',
374
+ 'long_name': 'Latitude',
375
+ '_FillValue': float('nan'),
376
+ 'processing_level': ''
377
+ }
378
+ ds['lon'].attrs = {
379
+ 'units': 'degrees_east',
380
+ 'long_name': 'Longitude',
381
+ '_FillValue': float('nan'),
382
+ 'processing_level': ''
383
+ }
384
+ ds['altitude'].attrs = {
385
+ 'units': 'meters_above_sea_level',
386
+ 'long_name': 'Altitude',
387
+ '_FillValue': float('nan'),
388
+ 'processing_level': ''
389
+ }
390
+ ds['air_temperature'].attrs = {
391
+ 'units': 'Kelvin',
392
+ 'long_name': 'Air Temperature',
393
+ '_FillValue': float('nan'),
394
+ 'processing_level': ''
395
+ }
396
+ ds['wind_speed'].attrs = {
397
+ 'units': 'm/s',
398
+ 'long_name': 'Wind Speed',
399
+ '_FillValue': float('nan'),
400
+ 'processing_level': ''
401
+ }
402
+ ds['wind_direction'].attrs = {
403
+ 'units': 'degrees',
404
+ 'long_name': 'Wind Direction',
405
+ '_FillValue': float('nan'),
406
+ 'processing_level': ''
407
+ }
408
+ ds['humidity_mixing_ratio'].attrs = {
409
+ 'units': 'kg/kg',
410
+ 'long_name': 'Humidity Mixing Ratio',
411
+ '_FillValue': float('nan'),
412
+ 'processing_level': ''
413
+ }
414
+ ds['air_pressure'].attrs = {
415
+ 'units': 'Pa',
416
+ 'long_name': 'Atmospheric Pressure',
417
+ '_FillValue': float('nan'),
418
+ 'processing_level': ''
419
+ }
420
+ ds['speed_u'].attrs = {
421
+ 'units': 'm/s',
422
+ 'long_name': 'Wind speed in direction of increasing longitude',
423
+ '_FillValue': float('nan'),
424
+ 'processing_level': ''
425
+ }
426
+ ds['speed_v'].attrs = {
427
+ 'units': 'm/s',
428
+ 'long_name': 'Wind speed in direction of increasing latitude',
429
+ '_FillValue': float('nan'),
430
+ 'processing_level': ''
431
+ }
432
+ ds['specific_humidity'].attrs = {
433
+ 'units': 'mg/kg',
434
+ 'long_name': 'Specific Humidity',
435
+ '_FillValue': float('nan'),
436
+ 'processing_level': '',
437
+ 'Conventions': "CF-1.8, WMO-CF-1.0"
438
+ }
439
+ ds['mission_name'].attrs = {
440
+ 'long_name': 'Mission name',
441
+ 'description': 'Which balloon collected the data'
442
+ }
443
+
444
+ # Add Global Attributes synonymous across all UASDC providers
445
+ if not is_multi_mission:
446
+ ds.attrs['wmo__cf_profile'] = "FM 303-2024"
447
+ ds.attrs['featureType'] = "trajectory"
448
+
449
+ # Add Global Attributes unique to Provider
450
+ ds.attrs['platform_name'] = "WindBorne Global Sounding Balloon"
451
+ if not is_multi_mission:
452
+ ds.attrs['flight_id'] = mission_name
453
+
454
+ ds.attrs['site_terrain_elevation_height'] = 'not applicable'
455
+ ds.attrs['processing_level'] = "b1"
456
+ ds.to_netcdf(output_file)