diff --git a/dump/mapping/bufr_marine_insitu_obs_builder.py b/dump/mapping/bufr_marine_insitu_obs_builder.py old mode 100644 new mode 100755 index b1196f8..825d4a9 --- a/dump/mapping/bufr_marine_insitu_obs_builder.py +++ b/dump/mapping/bufr_marine_insitu_obs_builder.py @@ -66,35 +66,101 @@ def get_station_basin(self, lat, lon): dlon = self.longitudes[1] - self.longitudes[0] # the data may be a masked array - ocean_basin = [] + ocean_basin = ma.array([0]*lat.size, mask=lat.mask, dtype=np.int32) for i in range(n): if not ma.is_masked(lat[i]): i1 = round((lat[i] - lat0) / dlat) i2 = round((lon[i] - lon0) / dlon) - ocean_basin.append(self.basin_array[i1][i2]) - return np.array(ocean_basin, dtype=np.int32) + ocean_basin[i] = self.basin_array[i1][i2] + return ocean_basin + + +def clean_lat_lon(lat, lon): + """ + return a mask for valid pairs of latitude and longitude. + + Parameters: + lat : array-like or None + Latitude values, expected in [-90, 90]. + lon : array-like or None + Longitude values, all in [-180, 180] or all in [0, 360]. + + Returns: + mask : numpy.ndarray + Boolean mask (same shape as inputs), True for valid lat/lon pairs. + Use to filter other arrays (e.g., other_data[mask]). + """ + # Handle undefined or None inputs + if lat is None or lon is None: + return np.array([], dtype=bool) + + # Convert inputs to NumPy arrays, preserving masked arrays + try: + lat = np.asarray(lat, dtype=float) + lon = np.asarray(lon, dtype=float) + except (ValueError, TypeError): + return np.zeros_like(lat, dtype=bool) + + # Ensure arrays have the same shape + # probably needs to throw an exception + if lat.shape != lon.shape: + return np.zeros_like(lat, dtype=bool) + + # Initialize mask (True for valid, False for invalid) + mask = np.ones(lat.shape, dtype=bool) + + # Validate latitude: must be in [-90, 90] + mask &= (lat >= -90) & (lat <= 90) & ~np.isnan(lat) + + # Validate longitude: all in [-180, 180] or all in [0, 360] + valid_lons = lon[mask] # Longitudes where lat is valid + if len(valid_lons) == 0: + return mask + + # Check longitude range consistency + in_neg180_180 = (valid_lons >= -180) & (valid_lons <= 180) + in_0_360 = (valid_lons >= 0) & (valid_lons <= 360) + + # Determine if all valid longitudes are in one range + all_neg180_180 = np.all(in_neg180_180) + all_0_360 = np.all(in_0_360) + + # If neither range is fully consistent, use dominant range + # if the fraction of the "other" range is too large, + # probably needs to throw an error + if not (all_neg180_180 or all_0_360): + if np.sum(in_neg180_180) >= np.sum(in_0_360): + mask &= (lon >= -180) & (lon <= 180) + else: + mask &= (lon >= 0) & (lon <= 360) + else: + if all_neg180_180: + mask &= (lon >= -180) & (lon <= 180) + else: + mask &= (lon >= 0) & (lon <= 360) + + # Ensure no NaN in longitude + mask &= ~np.isnan(lon) + return mask class MarineInsituObsBuilder(ObsBuilder): def __init__(self, mapping_path, log_name=os.path.basename(__file__), config=None): - # print(f'===============================>>>><<<<') - # print(f'config = {config}') - # print(f'===============================>>>><<<<') self.ocean_basin_file = config['ocean_basin'] if 'ocean_basin' in config else None - # print(f'self.ocean_basin_file = {self.ocean_basin_file}') - # print(f'config = {config}') - super().__init__(mapping_path, log_name=log_name, config=config) def make_obs(self, comm, input_path): container = super().make_obs(comm, input_path) + lat = container.get('latitude') + lon = container.get('longitude') + lat_lon_mask = clean_lat_lon(lat, lon) + container.apply_mask(lat_lon_mask) + if self.ocean_basin_file and os.path.exists(self.ocean_basin_file): self._add_ocean_basin(container, self.ocean_basin_file) else: self.log.warning(f"No ocean basin file provided, or can not be found") - - # print("MMMMMMMMMMMMMMMMMM") return container def _make_description(self): @@ -109,6 +175,7 @@ def _make_description(self): return description + def _add_preqc_var(self, container, name): v = container.get(name) paths = container.get_paths(name) @@ -119,11 +186,7 @@ def _add_preqc_var(self, container, name): def _add_error_var(self, container, name, error): v = container.get(name) paths = container.get_paths(name) - # print(">>>>>>>>>>>>>>>>>>>>>>") - # print(f"_add_error_var {name}") error_var_name = f"ObsError{name}" - # print(f"_add_error_var {error_var_name}") - # print(">>>>>>>>>>>>>>>>>>>>>>") error_var = np.full_like(v, error) container.add(error_var_name, error_var, paths) @@ -143,4 +206,3 @@ def _add_ocean_basin(self, container, nc_file_path): ocean = OceanBasin(nc_file_path) v = ocean.get_station_basin(lat, lon) container.add("oceanBasin", v, paths) - diff --git a/dump/mapping/bufr_marine_insitu_profile_argo.py b/dump/mapping/bufr_marine_insitu_profile_argo.py old mode 100644 new mode 100755 index 7f04c45..0033ae9 --- a/dump/mapping/bufr_marine_insitu_profile_argo.py +++ b/dump/mapping/bufr_marine_insitu_profile_argo.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import os import numpy as np diff --git a/dump/mapping/bufr_marine_insitu_profile_bathy.py b/dump/mapping/bufr_marine_insitu_profile_bathy.py old mode 100644 new mode 100755 index fa0b9ba..449f9d3 --- a/dump/mapping/bufr_marine_insitu_profile_bathy.py +++ b/dump/mapping/bufr_marine_insitu_profile_bathy.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import os import numpy as np diff --git a/dump/mapping/bufr_marine_insitu_profile_glider.py b/dump/mapping/bufr_marine_insitu_profile_glider.py old mode 100644 new mode 100755 index 7188947..76121dd --- a/dump/mapping/bufr_marine_insitu_profile_glider.py +++ b/dump/mapping/bufr_marine_insitu_profile_glider.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import os import numpy as np diff --git a/dump/mapping/bufr_marine_insitu_profile_tesac.py b/dump/mapping/bufr_marine_insitu_profile_tesac.py old mode 100644 new mode 100755 index 0f34bbc..064dad9 --- a/dump/mapping/bufr_marine_insitu_profile_tesac.py +++ b/dump/mapping/bufr_marine_insitu_profile_tesac.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import os import numpy as np diff --git a/dump/mapping/bufr_marine_insitu_profile_tropical.py b/dump/mapping/bufr_marine_insitu_profile_tropical.py old mode 100644 new mode 100755 index 6d9aba4..e9befb4 --- a/dump/mapping/bufr_marine_insitu_profile_tropical.py +++ b/dump/mapping/bufr_marine_insitu_profile_tropical.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import os import numpy as np diff --git a/dump/mapping/bufr_marine_insitu_profile_xbtctd.py b/dump/mapping/bufr_marine_insitu_profile_xbtctd.py old mode 100644 new mode 100755 index dfa1ff6..c511321 --- a/dump/mapping/bufr_marine_insitu_profile_xbtctd.py +++ b/dump/mapping/bufr_marine_insitu_profile_xbtctd.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import os import numpy as np diff --git a/dump/mapping/bufr_marine_insitu_surface_altkob.py b/dump/mapping/bufr_marine_insitu_surface_altkob.py old mode 100644 new mode 100755 index c7d792b..c506d3d --- a/dump/mapping/bufr_marine_insitu_surface_altkob.py +++ b/dump/mapping/bufr_marine_insitu_surface_altkob.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import os import numpy as np diff --git a/dump/mapping/bufr_marine_insitu_surface_cstgd.py b/dump/mapping/bufr_marine_insitu_surface_cstgd.py old mode 100644 new mode 100755 index 514ac33..48a1593 --- a/dump/mapping/bufr_marine_insitu_surface_cstgd.py +++ b/dump/mapping/bufr_marine_insitu_surface_cstgd.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import os import numpy as np diff --git a/dump/mapping/bufr_marine_insitu_surface_dbuoyb_drifter.py b/dump/mapping/bufr_marine_insitu_surface_dbuoyb_drifter.py new file mode 100755 index 0000000..89e81fc --- /dev/null +++ b/dump/mapping/bufr_marine_insitu_surface_dbuoyb_drifter.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 + +import os +import numpy as np + +import bufr +from bufr.obs_builder import add_main_functions +from bufr_marine_insitu_obs_builder import MarineInsituObsBuilder, map_path + +MAPPING_PATH = map_path('bufr_marine_insitu_surface_dbuoyb_drifter.yaml') + + +''' +buoy types for drifters: +------------------------ +00 Unspecified drifting buoy +01 Standard Lagrangian drifter (Global Drifter Programme) +02 Standard FGGE type drifting buoy (non-Lagrangian meteorological drifting buoy) +03 Wind measuring FGGE type drifting buoy (non-Lagrangian meteorological drifting buoy) +04 Ice drifter +05 SVPG Standard Lagrangian drifter with GPS (BUFR) +06 SVP-HR drifter with high-resolution temperature or thermistor string (BUFR) +10 ALACE (Autonomous Lagrangian Circulation Explorer) +11 MARVOR (MARine VORtical profiler) +12 RAFOS (Ranging and Fixing of Sound) +13 PROVOR (Profiling float with Argos) +14 SOLO (Swimbladder-Operated Lagrangian Oscillating) +15 APEX (Autonomous Profiling Explorer) +''' + +drifter_buoy_types = [0, 1, 2, 3, 4, 5, 6, 10, 11, 12, 13, 14, 15] + + +class MarineInsituSurfaceDrifterObsBuilder(MarineInsituObsBuilder): + def __init__(self, config=None): + super().__init__(MAPPING_PATH, config=config, log_name=os.path.basename(__file__)) + + def make_obs(self, comm, input_path): + # Get container from mapping file first + container = super().make_obs(comm, input_path) + + temp = container.get("seaSurfaceTemperature") + temp_mask = (temp > -10.0) & (temp < 50.0) + + buoy_type = container.get("buoyType") + rpid = container.get("stationID") + + # rpid = stationID: string array (e.g., 'A8xxx') + # buoy_type: int array (e.g., 1, 2, 3), etc. + drifter_mask = np.isin(buoy_type, drifter_buoy_types, assume_unique=True) + + # Optional: Add RPID check for drifter patterns (e.g., starts with 'A8') + rpid_drifter_mask = np.array([isinstance(r, str) and r.startswith('A8') for r in rpid.filled('')]) + drifter_mask = drifter_mask | rpid_drifter_mask + + # Handle masked (missing) BUYT values + # If BUYT is masked, assume not a drifter unless RPID suggests otherwise + drifter_mask = np.where(buoy_type.mask, rpid_drifter_mask, drifter_mask) + + # print("BBBBBBBBBBBBBBBBBBB") + # print(buoy_mask) + # print(temp_mask.size) + # print(buoy_type.size) + # print(buoy_mask.size) + # print(np.count_nonzero(buoy_mask)) + # print(np.count_nonzero(temp_mask)) + # print("BBBBBBBBBBBBBBBBBBB") + # container.apply_mask(buoy_mask & temp_mask) + + container.apply_mask(drifter_mask & temp_mask) + + self._add_preqc_var(container, "seaSurfaceTemperature") + self._add_error_var(container, "seaSurfaceTemperature", error=0.24) + + return container + +add_main_functions(MarineInsituSurfaceDrifterObsBuilder) diff --git a/dump/mapping/bufr_marine_insitu_surface_dbuoyb_drifter.yaml b/dump/mapping/bufr_marine_insitu_surface_dbuoyb_drifter.yaml new file mode 100644 index 0000000..37b3eca --- /dev/null +++ b/dump/mapping/bufr_marine_insitu_surface_dbuoyb_drifter.yaml @@ -0,0 +1,111 @@ +bufr: + variables: + dateTime: + datetime: + year: "*/YEAR" + month: "*/MNTH" + day: "*/DAYS" + hour: "*/HOUR" + minute: "*/MINU" + + receiptTime: + datetime: + year: "[*/RCYR, */RCPTIM{1}/RCYR]" + month: "[*/RCMO, */RCPTIM{1}/RCMO]" + day: "[*/RCDY, */RCPTIM{1}/RCDY]" + hour: "[*/RCHR, */RCPTIM{1}/RCHR]" + minute: "[*/RCMI, */RCPTIM{1}/RCMI]" + + stationID: + query: "*/RPID" + + buoyType: + query: "*/BUYT" + + latitude: + query: "*/CLATH" + + longitude: + query: "*/CLONH" + + seaSurfaceTemperature: + query: "*/BBYSSTS/SST0" + transforms: + - offset: -273.15 # convert from Kelvin to Celsius + + +encoder: + globals: + - name: "source" + type: string + value: "NCEP data tank" + + - name: "description" + type: string + value: "6-hrly in situ drifters surface" + + - name: "platformLongDescription" + type: string + value: "Drifters surface obs from dbuoyb: temperature" + + - name: "data_providerOrigin" + type: string + value: "U.S. NOAA" + + - name: "Converter" + type: string + value: "BUFR to IODA Converter" + + - name: "_ioda_layout_version" + type: int + value: 0 + + - name: "_ioda_layout" + type: string + value: "ObsGroup" + + variables: + - name: "MetaData/dateTime" + source: variables/dateTime + longName: "dateTime" + units: "seconds since 1970-01-01T00:00:00Z" + + - name: "MetaData/rcptdateTime" + source: variables/receiptTime + longName: "receipt Datetime" + units: "seconds since 1970-01-01T00:00:00Z" + + - name: "MetaData/stationID" + source: variables/stationID + longName: "Station Identification" + units: "" + + - name: "MetaData/BuoyType" + source: variables/buoyType + longName: "Buoy Type" + units: "" + + - name: "MetaData/latitude" + source: variables/latitude + longName: "Latitude" + units: "degrees_north" + + - name: "MetaData/longitude" + source: variables/longitude + longName: "Longitude" + units: "degrees_east" + + - name: "ObsValue/seaSurfaceTemperature" + source: variables/seaSurfaceTemperature + longName: "Sea Surface Temperature" + units: "degC" + + - name: "ObsError/seaSurfaceTemperature" + source: variables/ObsErrorseaSurfaceTemperature + longName: "Sea Surface Temperature Error" + units: "degC" + + - name: "PreQC/seaSurfaceTemperature" + source: variables/PreQCseaSurfaceTemperature + longName: "PreQC Sea Surface temperature" + units: "" diff --git a/dump/mapping/bufr_marine_insitu_surface_drifter.py b/dump/mapping/bufr_marine_insitu_surface_drifter.py old mode 100644 new mode 100755 index 85ae2a5..d1beb46 --- a/dump/mapping/bufr_marine_insitu_surface_drifter.py +++ b/dump/mapping/bufr_marine_insitu_surface_drifter.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import os import numpy as np @@ -36,7 +38,7 @@ def make_obs(self, comm, input_path): print(np.count_nonzero(buoy_mask)) print(np.count_nonzero(temp_mask)) print("BBBBBBBBBBBBBBBBBBB") - # container.apply_mask(buoy_mask & temp_mask) + container.apply_mask(buoy_mask & temp_mask) self._add_preqc_var(container, "seaSurfaceTemperature") self._add_preqc_var(container, "salinity") diff --git a/dump/mapping/bufr_marine_insitu_surface_lcman.py b/dump/mapping/bufr_marine_insitu_surface_lcman.py old mode 100644 new mode 100755 index c61289e..0cb6788 --- a/dump/mapping/bufr_marine_insitu_surface_lcman.py +++ b/dump/mapping/bufr_marine_insitu_surface_lcman.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import os import numpy as np diff --git a/dump/mapping/bufr_marine_insitu_surface_shipsu.py b/dump/mapping/bufr_marine_insitu_surface_shipsu.py old mode 100644 new mode 100755 index 248e4e2..c44f70f --- a/dump/mapping/bufr_marine_insitu_surface_shipsu.py +++ b/dump/mapping/bufr_marine_insitu_surface_shipsu.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import os import numpy as np diff --git a/dump/mapping/bufr_marine_insitu_surface_trkob.py b/dump/mapping/bufr_marine_insitu_surface_trkob.py old mode 100644 new mode 100755 index 2bb9e18..ec8f53f --- a/dump/mapping/bufr_marine_insitu_surface_trkob.py +++ b/dump/mapping/bufr_marine_insitu_surface_trkob.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import os import numpy as np diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..c4bd6b2 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,18 @@ +import pytest + +# Custom pytest option for cleanup +def pytest_addoption(parser): + parser.addoption( + "--cleanup", + action="store_true", + default=False, + help="Clean up downloaded data and test results after tests", + ) + + parser.addoption( + "--test-config-file", + action="store", + default="spoc-tests.yaml", + help="Path to the YAML file containing test suites (default: spoc-tests.yaml.yaml)" + ) + diff --git a/test/marine/b2i_config.py b/test/marine/b2i_config.py new file mode 100644 index 0000000..3f8d6e3 --- /dev/null +++ b/test/marine/b2i_config.py @@ -0,0 +1,193 @@ +import json +import yaml +import os +import sys +from collections import OrderedDict + + +def bufr_filename(cycle_datetime, cycle_type, hh, data_format): + return f"{cycle_datetime}-{cycle_type}.t{hh}z.{data_format}.tm00.bufr_d" + +def ioda_filename(cycle_type, hh, descriptor, cycle_datetime): + return f"{cycle_type}.t{hh}z.insitu_{descriptor}.{cycle_datetime}.nc4" + +# Custom YAML dumper to preserve dictionary order +class OrderedDumper(yaml.SafeDumper): + pass + +def _dict_representer(dumper, data): + return dumper.represent_mapping( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, + data.items() + ) + +class Bufr2iodaConfig: + def __init__(self): + OrderedDumper.add_representer(OrderedDict, _dict_representer) + + def read_config_file(self, config_file): + _, file_extension = os.path.splitext(config_file) + if file_extension == ".json": + with open(config_file, "r") as file: + config = json.load(file) + self.set(config) + elif file_extension == ".yaml": + with open(config_file, "r") as file: + config = yaml.safe_load(file) + self.set(config) + else: + print("Fatal error: Unknown file extension = ", file_extension) + sys.exit(1) + + def set(self, config): + # Get parameters from configuration + self.data_format = config["data_format"] + self.source = config["source"] + self.data_type = config["data_type"] + self.data_description = config["data_description"] + self.data_provider = config["data_provider"] + self.cycle_type = config["cycle_type"] + self.cycle_datetime = config["cycle_datetime"] + self.dump_dir = config["dump_directory"] + self.ioda_dir = config["ioda_directory"] + self.ocean_basin = config["ocean_basin"] + + self.yyyymmdd = self.cycle_datetime[0:8] + self.hh = self.cycle_datetime[8:10] + + # General Information + self.converter = 'BUFR to IODA Converter' + + def create_config_file(self, data_format, subsets, + data_type, data_description, + cycle_type, cycle_datetime, + dump_dir, ioda_dir, + ocean_basin, output_path): + """ + Create a YAML config file for BUFR to IODA conversion with keys in specified order. + + Args: + data_format (str): Data format (e.g., 'bathy'). + subsets (str): BUFR subsets (e.g., 'BATHY'). + data_type (str): Data type (e.g., 'bathy'). + cycle_type (str): Cycle type (e.g., 'gdas'). + cycle_datetime (str): Cycle date-time (e.g., '2021063006'). + dump_dir (str): Path to BUFR input directory. + ioda_dir (str): Path to IODA output directory. + ocean_basin (str): Path to ocean basin file. + output_path (str): Path for output YAML file. + + Raises: + ValueError: If string inputs are empty or invalid. + FileNotFoundError: If paths are invalid. + OSError: If writing output file fails. + """ + # Validate string inputs + for param, value in [ + ('data_format', data_format), ('subsets', subsets), + ('data_type', data_type), ('cycle_type', cycle_type), + ('cycle_datetime', cycle_datetime), + ('data_description', data_description) + ]: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{param} must be a non-empty string") + + # Validate paths + if not os.path.isdir(dump_dir): + raise FileNotFoundError(f"BUFR directory not found: {dump_dir}") + if not os.path.isdir(ioda_dir): + raise FileNotFoundError(f"IODA directory not found: {ioda_dir}") + if not os.path.isfile(ocean_basin): + raise FileNotFoundError(f"Ocean basin file not found: {ocean_basin}") + + # Create config with OrderedDict to preserve key order + config = OrderedDict([ + ('data_format', data_format), + ('subsets', subsets), + ('source', 'NCEP data tank'), + ('data_type', data_type), + ('cycle_type', cycle_type), + ('cycle_datetime', cycle_datetime), + ('dump_directory', dump_dir), + ('ioda_directory', ioda_dir), + ('ocean_basin', ocean_basin), + ('data_description', data_description), + ('data_provider', 'U.S. NOAA') + ]) + + # Ensure output directory exists + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + # Write YAML file with 2-space indentation, preserving order + with open(output_path, 'w') as f: + yaml.dump(config, f, Dumper=OrderedDumper, default_flow_style=False, indent=2) + + def replace_config_placeholders(config_path, bufr_dir, ioda_dir, ocean_basin_file, output_path): + """ + Replace placeholders in a YAML config file and save to output_path. + + Args: + config_path (str): Path to input YAML config file. + bufr_dir (str): Path to BUFR input directory (replaces __BUFRINPUTDIR__). + ioda_dir (str): Path to IODA output directory (replaces __IODAOUTPUTDIR__). + ocean_basin_file (str): Path to ocean basin file (replaces __OCEANBASIN__). + output_path (str): Path for output YAML file. + + Raises: + FileNotFoundError: If config_path or directories are invalid. + yaml.YAMLError: If YAML parsing fails. + OSError: If writing output file fails. + """ + # Validate input paths + if not os.path.isfile(config_path): + raise FileNotFoundError(f"Config file not found: {config_path}") + if not os.path.isdir(bufr_dir): + raise FileNotFoundError(f"BUFR directory not found: {bufr_dir}") + if not os.path.isdir(ioda_dir): + raise FileNotFoundError(f"IODA directory not found: {ioda_dir}") + if not os.path.isfile(ocean_basin_file): + raise FileNotFoundError(f"Ocean basin file not found: {ocean_basin_file}") + + # Read YAML config + with open(config_path, 'r') as f: + config_text = f.read() + + # Replace placeholders + config_text = config_text.replace('__BUFRINPUTDIR__', bufr_dir) + config_text = config_text.replace('__IODAOUTPUTDIR__', ioda_dir) + config_text = config_text.replace('__OCEANBASIN__', ocean_basin_file) + + # Ensure output directory exists + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + # Write new YAML file + with open(output_path, 'w') as f: + f.write(config_text) + + +if __name__ == "__main__": + + # test the above function: + config_path = "/work/noaa/da/edwardg/spoc/test/testconfig/bufr2ioda_insitu_profile_bathy_2021063006.yaml.in" + bufr_dir = "/work/noaa/da/edwardg/" + ioda_dir = "/work/noaa/da/edwardg/" + ocean_basin_file = "/work/noaa/da/edwardg/spoc/test/jocean_basin.txt" + output_path = "/work/noaa/da/edwardg/spoc/test/testconfig/jbufr2ioda_insitu_profile_bathy_2021063006.yaml" + + replace_config_placeholders(config_path, bufr_dir, ioda_dir, ocean_basin_file, output_path) + + # test the other function + try: + create_config_file( + data_format="bathy", + subsets="BATHY", + data_type="bathy", + cycle_type="gdas", + cycle_datetime="2021063006", + dump_dir="/work/noaa/da/edwardg", + ioda_dir="/work/noaa/da/edwardg", + ocean_basin="/work/noaa/da/edwardg/spoc/test/jocean_basin.txt", + output_path="/work/noaa/da/edwardg/spoc/test/testconfig/jjbufr2ioda_insitu_profile_bathy_2021063006.yaml" + ) + except Exception as e: + print(f"Error: {e}") diff --git a/test/marine/b2i_tests.py b/test/marine/b2i_tests.py new file mode 100644 index 0000000..9eadc9e --- /dev/null +++ b/test/marine/b2i_tests.py @@ -0,0 +1,101 @@ +import os +import json +from dataclasses import dataclass + + +OCEAN_BASIN_FILE = "/work/noaa/global/glopara/fix/gdas/soca/20240802/common/RECCAP2_region_masks_all_v20221025.nc" + +cycle_type = "gdas" +cycle_datetime = '2019010700' +cycle = "00" + + +marine_profile_instruments = [ + "argo", + "bathy", + "glider", + "tesac", + "tropical", + "xbtctd" +] +marine_surface_instruments = [ + "altkob", + "cstgd", + "drifter", + "dbuoyb_drifter", + "lcman", + "shipsu", + "trkob" +] + +all_instruments = marine_profile_instruments + marine_surface_instruments + + +b2i_test_names = {} +for instrument in all_instruments: + b2i_test_names[instrument] = "b2i_test_" + instrument + + +b2i_converters = {} +for instrument in marine_profile_instruments: + b2i_converters[instrument] = "bufr_marine_insitu_profile_" + instrument + ".py" +for instrument in marine_surface_instruments: + b2i_converters[instrument] = "bufr_marine_insitu_surface_" + instrument + ".py" + +# print(json.dumps(b2i_converters, indent=4)) + + + +b2i_config_filenames = {} +for instrument in marine_profile_instruments: + b2i_config_filenames[instrument] = "bufr2ioda_insitu_profile_" + instrument + "_" + cycle_datetime + ".yaml" +for instrument in marine_surface_instruments: + b2i_config_filenames[instrument] = "bufr2ioda_insitu_surface_" + instrument + "_" + cycle_datetime + ".yaml" + +# print(json.dumps(b2i_config_filenames, indent=4)) + + +@dataclass +class ConfigTestData: + data_format: str + subsets: str + data_type: str + data_description: str + + # converter_filename: str + # bufr_filename: str + # ioda_filename: str + # config_filename: str + + +# data_format, subsets, data_type +CONFIG_TEST_DATA = [ + ConfigTestData("subpfl", "SUBPFL", "argo", + '6-hrly in-situ ARGO profiles from subpfl: temperature and salinity'), + ConfigTestData("bathy", "BATHY", "bathy", + '6-hrly in-situ profiles from BATHYthermal temperature'), + ConfigTestData("subpfl", "SUBPFL", "glider", + '6-hrly in-situ Glider profiles from subpfl: temperature and salinity'), + ConfigTestData("tesac", "TESAC", "tesac", + '6-hrly in-situ profiles from TESAC: temperature and salinity'), + ConfigTestData("dbuoy", "DBUOY", "tropical", + '6-hrly in-situ tropical mooring profiles from dbuoy: temperature and salinity'), + ConfigTestData("xbtctd", "XBTCTD", "xbtctd", + '6-hrly in-situ profiles from XBT/CTD: temperature and salinity'), + ConfigTestData("altkob", "ALTKOB", "altkob", + '6-hrly in-situ surface obs from altkob: temperature and salinity'), + ConfigTestData("cstgd", "CSTGD", "cstgd", + "6-hrly in-situ sea surface temperature obs from cstgd"), + # ConfigTestData("dbuoy", "DBUOY", "drifter", + # "6-hrly in-situ Lagrangian drifter drogue profiles from dbuy: temperature"), + # ConfigTestData("dbuoyb", "DBUOYB", "drifter", + # "6-hrly in-situ Lagrangian drifter drogue profiles from dbuoyb: temperature"), + ConfigTestData("dbuoyb", "DBUOYB", "dbuoyb_drifter", + "6-hrly in-situ Lagrangian drifter drogue profiles from dbuoyb: temperature"), + ConfigTestData("lcman", "LCMAN", "lcman", + '6-hrly in-situ surface temperature obs from LCMAN'), + ConfigTestData("shipsu", "SHIPSU", "shipsu", + "6-hrly in-situ temperature obs from shipsu"), + ConfigTestData("trkob", "TRACKOB", "trkob", + '6-hrly in-situ surface obs from TRACKOB: temperature and salinity') +] diff --git a/test/marine/marine_config.py b/test/marine/marine_config.py new file mode 100644 index 0000000..344e8e8 --- /dev/null +++ b/test/marine/marine_config.py @@ -0,0 +1,87 @@ +import os +import yaml +from b2i_config import * +from b2i_tests import * + + +# utility to create yaml configuration files +# for bufr to marine ioda converters + +def create_test_config_files(b2i_config, test_dir): + testconfig_dir = os.path.join(test_dir, "testconfig") + testdata_dir = os.path.join(test_dir, "testdata") + testresult_dir = os.path.join(test_dir, "testresults") + + for test in CONFIG_TEST_DATA: + i = test.data_type + testconfig_path = os.path.join(testconfig_dir, b2i_config_filenames[i]) + b2i_config.create_config_file( + test.data_format, + test.subsets, + test.data_type, + test.data_description, + cycle_type, + cycle_datetime, + testdata_dir, + testresult_dir, + OCEAN_BASIN_FILE, + testconfig_path + ) + print(f'Created yaml file: {testconfig_path}') + +def surface_or_profile_descriptor(test_data_type): + if test_data_type in marine_profile_instruments: + descriptor = "profile_" + test_data_type + elif test_data_type in marine_surface_instruments: + descriptor = "surface_" + test_data_type + else: + descriptor = None + print(f"Error: unknown data_type {test.data_type}") + return descriptor + +def generate_test_case(test): + i = test.data_type + descriptor = surface_or_profile_descriptor(i) + + return { + "name": b2i_test_names[i], + "converter": b2i_converters[i], + "input": bufr_filename(cycle_datetime, cycle_type, cycle, test.data_format), + "reference": ioda_filename(cycle_type, cycle, descriptor, cycle_datetime), + "config": b2i_config_filenames[i] + } + +def generate_marine_test_config_file(config_filename, converter_dir, test_dir): + test_cases = [] + + for test in CONFIG_TEST_DATA: + test_cases.append(generate_test_case(test)) + + # Define the test suite structure + test_suite = { + "name": "marine", + "converter_dir": converter_dir, + "test_data_dir": test_dir, + "tests": test_cases + } + + # Define the YAML structure + yaml_data = { + "test_suites": [test_suite] + } + + # Write to YAML file + with open(config_filename, "w") as yaml_file: + yaml.dump(yaml_data, yaml_file, default_flow_style=False, sort_keys=False) + + +if __name__ == "__main__": + + b2i_config = Bufr2iodaConfig() + + test_dir = "/work/noaa/da/edwardg/spoc/test/marine_data" + create_test_config_files(b2i_config, test_dir) + + converter_dir = "/work/noaa/da/edwardg/spoc/dump/mapping/" + config_filename = 'marine_tests.yaml' + generate_marine_test_config_file(config_filename, converter_dir, test_dir) diff --git a/test/marine/run_converter.py b/test/marine/run_converter.py new file mode 100644 index 0000000..a7c7d52 --- /dev/null +++ b/test/marine/run_converter.py @@ -0,0 +1,37 @@ +import os +import subprocess +from b2i_config import * +from b2i_tests import * +from marine_config import generate_test_case + + +def run_converter(test_case, converter_dir, input_dir, output_dir, config_dir): + converter = os.path.join(converter_dir, test_case["converter"]) + input_path = os.path.join(input_dir, test_case["input"]) + output_path = os.path.join(output_dir, test_case["reference"]) + config_path = os.path.join(config_dir, test_case["config"]) + + cmd = [converter, "--input", input_path, "--output", output_path, "--config", config_path] + print(f"Running command: {' '.join(cmd)}", flush=True) + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + print(f'Success!') + return True + except subprocess.CalledProcessError as e: + print(f'Error running {cmd}') + return False + + +if __name__ == "__main__": + + converter_dir = "/work/noaa/da/edwardg/spoc/dump/mapping/" + input_dir = "/work/noaa/da/edwardg/spoc/test/marine_data/testdata" + output_dir = "." + config_dir = "/work/noaa/da/edwardg/spoc/test/marine_data/testconfig" + + # test_case = generate_test_case(CONFIG_TEST_DATA[0]) + # run_converter(test_case, converter_dir, input_dir, output_dir, config_dir) + + for test in CONFIG_TEST_DATA: + test_case = generate_test_case(test) + run_converter(test_case, converter_dir, input_dir, output_dir, config_dir) diff --git a/test/run_compare.py b/test/run_compare.py index 684f731..630256a 100644 --- a/test/run_compare.py +++ b/test/run_compare.py @@ -1,56 +1,31 @@ import os import subprocess -def run_compare(result_file, expected_file): + +# Check if nccmp is available +def check_nccmp(): + try: + subprocess.run(["nccmp", "--version"], capture_output=True, check=True) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + +def run_nccmp(result_file, reference_file): if not os.path.isfile(result_file): raise FileNotFoundError(f"Result file not found: {result_file}") - if not os.path.isfile(expected_file): - raise FileNotFoundError(f"Expected file not found: {expected_file}") - - # Check if nccmp is available - def check_nccmp(): - try: - subprocess.run(["nccmp", "--version"], capture_output=True, check=True) - return True - except (subprocess.CalledProcessError, FileNotFoundError): - return False + if not os.path.isfile(reference_file): + raise FileNotFoundError(f"Expected file not found: {reference_file}") - # Try to find nccmp if not check_nccmp(): - # Attempt to load nccmp module - try: - # Run 'module load nccmp/1.9.0.1' in a shell - subprocess.run( - "module load nccmp/1.9.0.1", - shell=True, - executable="/bin/bash", # Ensure bash is used - capture_output=True, - text=True, - check=True - ) - print("Successfully loaded nccmp/1.9.0.1 module.") - except subprocess.CalledProcessError as e: - raise FileNotFoundError( - f"Failed to load nccmp/1.9.0.1 module: {e.stderr}" - ) from None - except FileNotFoundError: - raise FileNotFoundError( - "nccmp not found and module command not available." - ) from None - - # Verify nccmp is now available - if not check_nccmp(): - raise FileNotFoundError( - "nccmp still not found after attempting to load module nccmp/1.9.0.1." - ) + raise FileNotFoundError("nccmp not found") # Run nccmp with -d (data comparison) and -f (force, no user prompt) # Use -t for tolerance if needed (e.g., -t 1e-5 for floating-point) - cmd = ["nccmp", "-d", "-m", "-g", "-f", "-S", result_file, expected_file] - print(f'Testing: {cmd}') + cmd = ["nccmp", "-d", "-m", "-g", "-f", "-S", str(result_file), str(reference_file)] + print(f"Testing command: {' '.join(cmd)}", flush=True) try: result = subprocess.run(cmd, capture_output=True, text=True, check=True) - print(f"nccmp comparison passed: {result_file} matches {expected_file}") + print(f"nccmp comparison passed: {result_file} matches {reference_file}") return True except subprocess.CalledProcessError as e: print(f"nccmp comparison failed: {e.stderr}") diff --git a/test/test_framework.py b/test/test_framework.py index 9a93a6f..35d8223 100644 --- a/test/test_framework.py +++ b/test/test_framework.py @@ -1,120 +1,243 @@ import pytest +import yaml import os -import subprocess import tarfile -import urllib.request +import requests +from pathlib import Path import shutil -import yaml -from run_compare import run_compare -from config import * - -import re - - -''' -B2I_SCRIPT_DIR = "/work/noaa/da/edwardg/spoc/dump/mapping/" -B2I_CONFIG_CLASS_FILE = "bufr_marine_insitu_config.py" -B2I_CONFIG_CLASS_NAME = "Bufr2iodaConfig" -OCEAN_BASIN_FILE = "/work/noaa/global/glopara/fix/gdas/soca/20240802/common/RECCAP2_region_masks_all_v20221025.nc" - -SPOC_URL = "https://ftp.emc.ncep.noaa.gov/static_files/public/spoc" -TARBALL_FILE = "spoc-0.0.0.tgz" -TARBALL_URL = SPOC_URL + "/" + TARBALL_FILE -TARBALL_DIR = "/work/noaa/da/edwardg/spoc/test" -REMOTE_DATA_DIR = os.path.join(TARBALL_DIR, "remote_data") -TESTDATA_DIR = os.path.join(REMOTE_DATA_DIR, "testdata") -TESTOUTPUT_DIR = os.path.join(REMOTE_DATA_DIR, "testoutput") -TESTCONFIG_DIR = os.path.join(REMOTE_DATA_DIR, "testconfig") -TESTRESULT_DIR = os.path.join(TARBALL_DIR, "testresult") -''' - -TESTS = create_tests() - - - - -@pytest.fixture(scope="session", autouse=True) -def setup_test_environment(): - """Download tarball, extract, set up testconfig and testresult directories.""" - # Create tarball directory - os.makedirs(TARBALL_DIR, exist_ok=True) - - # Download tarball - tarball_path = os.path.join(TARBALL_DIR, TARBALL_FILE) - print(f"Downloading tarball from {TARBALL_URL}") - urllib.request.urlretrieve(TARBALL_URL, tarball_path) - - # Extract tarball +import subprocess +from run_compare import run_nccmp + + +def load_test_suites(yaml_file): + # Check if the file exists + if not os.path.exists(yaml_file): + pytest.fail(f"YAML file '{yaml_file}' not found. Please provide a valid file using --test-config-file.") + + # Load the YAML file + try: + with open(yaml_file, 'r') as file: + data = yaml.safe_load(file) + except yaml.YAMLError as e: + pytest.fail(f"Error parsing YAML file '{yaml_file}': {e}") + except Exception as e: + pytest.fail(f"Failed to read YAML file '{yaml_file}': {e}") + + # Validate the YAML structure + if not isinstance(data, dict) or "test_suites" not in data: + pytest.fail(f"Invalid YAML structure in '{yaml_file}'. Expected 'test_suites' key.") + + test_suites = data["test_suites"] + if not test_suites: + pytest.fail(f"No test suites found in '{yaml_file}'.") + + return test_suites + + +def download_and_extract_tarball(suite, downloads_dir): + url = suite["url"] + tarball = suite["tarball"] + tarball_path = Path("test_suites") / tarball + print(f"Downloading {tarball} from {url}") + response = requests.get(f"{url}/{tarball}", stream=True) + if response.status_code != 200: + raise Exception(f"Failed to download {tarball}: HTTP {response.status_code}") + tarball_path.parent.mkdir(exist_ok=True) + with open(tarball_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + print(f"Extracting {tarball} to {downloads_dir}") with tarfile.open(tarball_path, "r:gz") as tar: - tar.extractall(TARBALL_DIR) - print(f"Extracted tarball to {REMOTE_DATA_DIR}") - - # Create testresult directory - if os.path.exists(TESTRESULT_DIR): - print(f"Removing old test results directory:\n {TESTRESULT_DIR}") - shutil.rmtree(TESTRESULT_DIR) - print(f"Creating new test results directory:\n {TESTRESULT_DIR}") - os.makedirs(TESTRESULT_DIR) - - # Check if testconfig exists, create if missing - if not os.path.exists(TESTCONFIG_DIR): - os.makedirs(TESTCONFIG_DIR) - print(f"Created testconfig directory: {TESTCONFIG_DIR}") - - # create_test_yamls() - - # Cleanup tarball - os.remove(tarball_path) - - yield # Run tests - - # Cleanup (optional) - # shutil.rmtree(REMOTE_DATA_DIR) - # shutil.rmtree(TESTRESULT_DIR) - - - -def extract_descriptor(filename): - name = os.path.basename(filename) - pattern = r'^bufr_marine_insitu_(.+)\.py$' - match = re.match(pattern, name) - if match: - return match.group(1) # Return the extracted part (e.g., profile_argo) - return None - - - - - -@pytest.mark.parametrize("test_name,script_name,config_name", TESTS) -def test_converter(test_name, script_name, config_name): - """Run converter script and compare output with expected.""" - descriptor = extract_descriptor(script_name) + tar.extractall(downloads_dir) + tarball_path.unlink() # Remove tarball after extraction + + +def create_subdir_symlink(symlink_path, subdir_path): + # Check if symlink already exists + if os.path.exists(symlink_path): + if os.path.islink(symlink_path) and os.readlink(symlink_path) == subdir_path: + print(f"Symlink already exists at {symlink_path} pointing to {subdir_path}.") + return + else: + print(f"Error: {symlink_path} already exists but is not a symlink to {subdir_path}.") + return + + if not os.path.exists(subdir_path): + print(f"Error: {subdir_path} does not exist.") + + # Create the symlink + try: + os.symlink(subdir_path, symlink_path) + print(f"Successfully created symlink at {symlink_path} pointing to {subdir_path}.") + except OSError as e: + print(f"Error creating symlink: {e}") + + +# Fixture to handle setup and teardown for each test suite +@pytest.fixture(scope="module") +def test_suite_setup(request): + test_suite = request.param + test_suite_dir = Path("test_suites") / test_suite["name"] + + # required subdir structure: + # symlinks: + testdata_dir = test_suite_dir / "testdata" + testoutput_dir = test_suite_dir / "testoutput" + testconfig_dir = test_suite_dir / "testconfig" # optional + # directories: + testresults_dir = test_suite_dir / "testresults" + downloads_dir = test_suite_dir / "downloads" # if tar ball + + # if test_suite_dir exists, and has the right structure, use it. + # otherwise, create it and place in it symlinks to user's data, + # which is either in a given dir or in some dir unpacked from + # a tar ball + + if test_suite_dir.exists(): + # check that it has the required subdirectories + # not checking for config + if not os.path.exists(testdata_dir): + pytest.fail(f"Setup failed: {testdata_dir} does not exist.") + if not os.path.exists(testoutput_dir): + pytest.fail(f"Setup failed: {testoutput_dir} does not exist.") + print(f"Using existing data in {test_suite_dir}") + else: + # create the test_suite_dir and place in it symlinks + # to user data, which is either staged or downloaded + os.makedirs(test_suite_dir, exist_ok=True) + + if "test_data_dir" in test_suite: + user_test_dir = Path(test_suite["test_data_dir"]) + elif "url" in test_suite and "tarball" in test_suite: + os.makedirs(downloads_dir, exist_ok=True) + download_and_extract_tarball(test_suite, downloads_dir) + user_test_dir = downloads_dir + + if user_test_dir.exists(): + print(f"Using data in {user_test_dir}") + else: + pytest.fail(f"Setup failed: {user_test_dir} does not exist.") + + user_input_dir = user_test_dir / "testdata" + user_reference_dir = user_test_dir / "testoutput" + user_config_dir = user_test_dir / "testconfig" + user_results_dir = user_test_dir / "testresults" + + if not os.path.exists(user_input_dir): + pytest.fail(f"Setup failed: {user_input_dir} does not exist.") + create_subdir_symlink(testdata_dir, user_input_dir) + + if not os.path.exists(user_reference_dir): + pytest.fail(f"Setup failed: {user_reference_dir} does not exist.") + create_subdir_symlink(testoutput_dir, user_reference_dir) + + if os.path.exists(user_config_dir): + create_subdir_symlink(testconfig_dir, user_config_dir) + + # the user may optionally provide a directory for test results + if os.path.exists(user_results_dir): + create_subdir_symlink(testresults_dir, user_results_dir) + + os.makedirs(testresults_dir, exist_ok=True) + + yield { + "suite_name": test_suite["name"], + "testdata_dir": testdata_dir, + "testoutput_dir": testoutput_dir, + "testconfig_dir": testconfig_dir, + "testresults_dir": testresults_dir, + "converter_dir": test_suite.get("converter_dir"), + "tests": test_suite["tests"], + } + + # Optional cleanup (controlled by pytest command-line option) + if request.config.getoption("--cleanup"): + print(f"Cleaning up {test_suite_dir}") + shutil.rmtree(test_suite_dir) + + +def pytest_generate_tests(metafunc): + if "test_suite_setup" in metafunc.fixturenames and "test_case" in metafunc.fixturenames: + # Get the YAML file path from the command-line option + yaml_file = metafunc.config.getoption("--test-config-file") + test_suites = load_test_suites(yaml_file) + + # Parameterize tests + params = [] + for suite in test_suites: + if "tests" not in suite: + pytest.fail(f"Test suite '{suite.get('name', 'unknown')}' missing 'tests' key.") + for test in suite["tests"]: + params.append((suite, test)) + + metafunc.parametrize( + ("test_suite_setup", "test_case"), + params, + indirect=["test_suite_setup"], + ids=[f"{suite['name']}_{test['name']}" for suite, test in params], + ) + +# Main test function +def test_converter(test_suite_setup, test_case): + suite_name = test_suite_setup["suite_name"] + converter_dir = test_suite_setup["converter_dir"] + testdata_dir = test_suite_setup["testdata_dir"] + testoutput_dir = test_suite_setup["testoutput_dir"] + testconfig_dir = test_suite_setup["testconfig_dir"] + testresults_dir = test_suite_setup["testresults_dir"] + + test_name = test_case["name"] + converter = test_case["converter"] + config_file = test_case.get("config") + input_file = test_case.get("input") + reference_file = test_case["reference"] + + # Determine converter path + if converter_dir: + converter_path = Path(converter_dir) / converter + else: + converter_path = Path(converter) + + # Check if converter exists + assert converter_path.exists(), f"Converter {converter_path} does not exist" + + # Check if required files exist + if input_file: + input_path = testdata_dir / input_file + assert input_path.exists(), f"Input file {input_path} does not exist" + if config_file: + config_path = testconfig_dir / config_file + assert config_path.exists(), f"Config file {config_path} does not exist" + + # output file name is assumed to be the same as the reference + # file name + output_file = reference_file + output_path = testresults_dir / reference_file + + # Build and run the command + cmd = [str(converter_path)] + if config_file: + cmd.extend(["--config", str(config_path)]) + if input_file and output_file: + cmd.extend(["--input", str(input_path), "--output", str(output_path)]) + else: + # If no input, assume config_file specifies input/output + pytest.fail(f'Input/output or config specification required for {converter_path}') + + print(f"Running command: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True) + assert result.returncode == 0, f"Command failed: {result.stderr}" - # Ensure script and config exist - script_path = os.path.join(B2I_SCRIPT_DIR, script_name) - assert os.path.isfile(script_path), f"Script not found: {script_path}" - config_path = os.path.join(TESTCONFIG_DIR, config_name) - assert os.path.isfile(config_path), f"Config not found: {config_path}" + # Check if output file was created + assert output_path.exists(), f"Output file {output_path} was not created" + # could check reference earlier, but this allows the user to run + # the converter and see that it generates output + reference_path = testoutput_dir / reference_file + assert reference_path.exists(), f"Reference file {reference_path} does not exist" - config = import_bufr2ioda_config(B2I_SCRIPT_DIR, B2I_CONFIG_CLASS_FILE, B2I_CONFIG_CLASS_NAME) - config.read_config_file(config_path) - ioda_filename = config.ioda_filename(descriptor) - print(f"config ioda_filename = {ioda_filename}") + # Compare output with reference + assert run_nccmp(output_path, reference_path), f"Output {output_path} does not match reference {reference_path}" - # Run converter - cmd = ["python3", script_path, "-c", config_path] - print(f"Running test {test_name}: {' '.join(cmd)}") - result = subprocess.run(cmd, capture_output=True, text=True) - assert result.returncode == 0, f"Test {test_name} failed: {result.stderr}" - - result_file = os.path.join(TESTRESULT_DIR, ioda_filename) - expected_file = os.path.join(TESTOUTPUT_DIR, ioda_filename) - - # Compare output - assert os.path.isfile(result_file), f"Result file not found: {result_file}" - assert os.path.isfile(expected_file), f"Expected file not found: {expected_file}" - assert run_compare(result_file, expected_file), ( - f"Test {test_name} failed: {result_file} does not match {expected_file}" - ) - print(f"Test {test_name} passed") +if __name__ == "__main__": + pytest.main(["-v", "--tb=short"])