openrvdas.logger.utils.convert_fields

Utilities for converting field values to specified types.

  1#!/usr/bin/env python3
  2"""Utilities for converting field values to specified types."""
  3
  4import logging
  5
  6# Map string type names to actual python types/conversion functions.
  7# Recognized types include:
  8#   float, double -> float
  9#   int, short, ushort, uint, long, ubyte, byte, hex_int -> int
 10#   str, char, string, text -> str
 11#   bool, boolean -> bool
 12TYPE_MAP = {
 13    'float': float,
 14    'double': float,
 15    'int': int,
 16    'short': int,
 17    'ushort': int,
 18    'uint': int,
 19    'long': int,
 20    'ubyte': int,
 21    'byte': int,
 22    'str': str,
 23    'char': str,
 24    'string': str,
 25    'text': str,
 26    'bool': bool,
 27    'boolean': bool,
 28    'hex_int': lambda x: int(str(x), 16),  # Handles "1A", "0x1A", etc.
 29}
 30
 31
 32def convert_lat_lon(value, direction, rounding_decimals=5):
 33    """
 34    Convert NMEA style lat/lon (DDMM.MMMM) and direction (N/S/E/W)
 35    to decimal degrees.
 36
 37    Args:
 38        value: NMEA format value (e.g., "4807.038" for 48°07.038')
 39        direction: Cardinal direction ('N', 'S', 'E', 'W')
 40        rounding_decimals: Number of decimal places to round to
 41
 42    Returns:
 43        Decimal degrees as float, or None if conversion fails
 44    """
 45    try:
 46        val = float(value)
 47        # NMEA format is roughly DDMM.MMMM
 48        # Degrees is the integer part of val / 100
 49        degrees = int(val / 100)
 50        minutes = val - (degrees * 100)
 51        decimal = degrees + (minutes / 60)
 52
 53        if direction.upper() in ['S', 'W']:
 54            decimal = -decimal
 55
 56        return round(decimal, rounding_decimals)
 57    except (ValueError, TypeError, AttributeError) as e:
 58        logging.warning(f'Failed to convert lat/lon: value=\'{value}\', '
 59                        f'direction=\'{direction}\' - {e}')
 60        return None
 61
 62
 63def convert_field_value(value, target_type_str, quiet=False):
 64    """
 65    Convert a single field value to the specified type.
 66
 67    Args:
 68        value: The value to convert
 69        target_type_str: String name of target type (e.g., 'float', 'int', 'str')
 70        quiet: If True, suppress warning messages
 71
 72    Returns:
 73        Converted value, or original value if conversion fails
 74    """
 75    converter = TYPE_MAP.get(target_type_str)
 76    if not converter:
 77        if not quiet:
 78            logging.warning(f'Unknown type \'{target_type_str}\' requested')
 79        return value
 80
 81    try:
 82        # Special case to head off ValueError of int("123.0")
 83        if isinstance(value, str) and converter is int:
 84            try:
 85                value = float(value)
 86            except ValueError:
 87                # Not a float string, let int() call below handle/fail it naturally
 88                pass
 89
 90        return converter(value)
 91    except ValueError:
 92        if not quiet:
 93            logging.warning(f'Failed to convert value \'{value}\' '
 94                            f'(type={type(value).__name__}) to \'{target_type_str}\'')
 95        return value
 96
 97
 98def convert_fields(fields, field_specs, lat_lon_specs=None,
 99                   delete_source_fields=False, delete_unconverted_fields=False,
100                   quiet=False):
101    """
102    Convert fields in a dictionary according to specifications.
103
104    This is a shared utility used by ConvertFieldsTransform and can be used
105    directly by parsers for field conversion.
106
107    Args:
108        fields: Dict of field_name -> value (modified in place)
109        field_specs: Dict of field_name -> target_type or {data_type: target_type}
110        lat_lon_specs: Dict of target_field -> (value_field, direction_field)
111                      for NMEA lat/lon conversion
112        delete_source_fields: If True, remove source fields after lat/lon conversion
113        delete_unconverted_fields: If True, remove fields not involved in conversion
114        quiet: If True, suppress warning messages
115
116    Returns:
117        The modified fields dict, or None if no fields remain after processing
118    """
119    if not fields:
120        return None
121
122    # Track which fields were successfully converted or used
123    processed_fields = set()
124
125    # 1. Handle simple Type Conversions
126    if field_specs:
127        for field_name, field_def in field_specs.items():
128            if field_name not in fields:
129                continue
130
131            # Extract target type from dict, or use string directly
132            if isinstance(field_def, dict):
133                target_type_str = field_def.get('data_type')
134            elif isinstance(field_def, str):
135                target_type_str = field_def
136            else:
137                continue
138
139            if not target_type_str:
140                continue
141
142            val = fields[field_name]
143            converter = TYPE_MAP.get(target_type_str)
144            if converter:
145                try:
146                    # Special case to head off ValueError of int("123.0")
147                    if isinstance(val, str) and converter is int:
148                        try:
149                            val = float(val)
150                        except ValueError:
151                            pass
152
153                    fields[field_name] = converter(val)
154                    processed_fields.add(field_name)
155                except ValueError:
156                    if not quiet:
157                        logging.warning(f'Failed to convert field \'{field_name}\': '
158                                        f'value=\'{val}\' (type={type(val).__name__}) '
159                                        f'to target_type=\'{target_type_str}\'')
160            else:
161                if not quiet:
162                    logging.warning(f'Unknown type \'{target_type_str}\' '
163                                    f'requested for field \'{field_name}\'')
164
165    # 2. Handle Lat/Lon Conversions
166    if lat_lon_specs:
167        for target_field, (val_field, dir_field) in lat_lon_specs.items():
168            if val_field not in fields or dir_field not in fields:
169                continue
170
171            val = fields[val_field]
172            direction = fields[dir_field]
173
174            decimal_degrees = convert_lat_lon(val, direction)
175
176            if decimal_degrees is not None:
177                fields[target_field] = decimal_degrees
178                processed_fields.add(target_field)
179
180                # Mark source fields as processed
181                if delete_source_fields:
182                    processed_fields.add(val_field)
183                    processed_fields.add(dir_field)
184
185                    # Delete source fields, but NOT if source == target
186                    if val_field in fields and val_field != target_field:
187                        del fields[val_field]
188                    if dir_field in fields and dir_field != target_field:
189                        del fields[dir_field]
190
191    # 3. Clean up unconverted fields
192    if delete_unconverted_fields:
193        all_fields = list(fields.keys())
194        for f in all_fields:
195            if f not in processed_fields:
196                del fields[f]
197
198    # If no fields remain, return None
199    if not fields:
200        return None
201
202    return fields
TYPE_MAP = {'float': <class 'float'>, 'double': <class 'float'>, 'int': <class 'int'>, 'short': <class 'int'>, 'ushort': <class 'int'>, 'uint': <class 'int'>, 'long': <class 'int'>, 'ubyte': <class 'int'>, 'byte': <class 'int'>, 'str': <class 'str'>, 'char': <class 'str'>, 'string': <class 'str'>, 'text': <class 'str'>, 'bool': <class 'bool'>, 'boolean': <class 'bool'>, 'hex_int': <function <lambda>>}
def convert_lat_lon(value, direction, rounding_decimals=5):
33def convert_lat_lon(value, direction, rounding_decimals=5):
34    """
35    Convert NMEA style lat/lon (DDMM.MMMM) and direction (N/S/E/W)
36    to decimal degrees.
37
38    Args:
39        value: NMEA format value (e.g., "4807.038" for 48°07.038')
40        direction: Cardinal direction ('N', 'S', 'E', 'W')
41        rounding_decimals: Number of decimal places to round to
42
43    Returns:
44        Decimal degrees as float, or None if conversion fails
45    """
46    try:
47        val = float(value)
48        # NMEA format is roughly DDMM.MMMM
49        # Degrees is the integer part of val / 100
50        degrees = int(val / 100)
51        minutes = val - (degrees * 100)
52        decimal = degrees + (minutes / 60)
53
54        if direction.upper() in ['S', 'W']:
55            decimal = -decimal
56
57        return round(decimal, rounding_decimals)
58    except (ValueError, TypeError, AttributeError) as e:
59        logging.warning(f'Failed to convert lat/lon: value=\'{value}\', '
60                        f'direction=\'{direction}\' - {e}')
61        return None

Convert NMEA style lat/lon (DDMM.MMMM) and direction (N/S/E/W) to decimal degrees.

Args: value: NMEA format value (e.g., "4807.038" for 48°07.038') direction: Cardinal direction ('N', 'S', 'E', 'W') rounding_decimals: Number of decimal places to round to

Returns: Decimal degrees as float, or None if conversion fails

def convert_field_value(value, target_type_str, quiet=False):
64def convert_field_value(value, target_type_str, quiet=False):
65    """
66    Convert a single field value to the specified type.
67
68    Args:
69        value: The value to convert
70        target_type_str: String name of target type (e.g., 'float', 'int', 'str')
71        quiet: If True, suppress warning messages
72
73    Returns:
74        Converted value, or original value if conversion fails
75    """
76    converter = TYPE_MAP.get(target_type_str)
77    if not converter:
78        if not quiet:
79            logging.warning(f'Unknown type \'{target_type_str}\' requested')
80        return value
81
82    try:
83        # Special case to head off ValueError of int("123.0")
84        if isinstance(value, str) and converter is int:
85            try:
86                value = float(value)
87            except ValueError:
88                # Not a float string, let int() call below handle/fail it naturally
89                pass
90
91        return converter(value)
92    except ValueError:
93        if not quiet:
94            logging.warning(f'Failed to convert value \'{value}\' '
95                            f'(type={type(value).__name__}) to \'{target_type_str}\'')
96        return value

Convert a single field value to the specified type.

Args: value: The value to convert target_type_str: String name of target type (e.g., 'float', 'int', 'str') quiet: If True, suppress warning messages

Returns: Converted value, or original value if conversion fails

def convert_fields( fields, field_specs, lat_lon_specs=None, delete_source_fields=False, delete_unconverted_fields=False, quiet=False):
 99def convert_fields(fields, field_specs, lat_lon_specs=None,
100                   delete_source_fields=False, delete_unconverted_fields=False,
101                   quiet=False):
102    """
103    Convert fields in a dictionary according to specifications.
104
105    This is a shared utility used by ConvertFieldsTransform and can be used
106    directly by parsers for field conversion.
107
108    Args:
109        fields: Dict of field_name -> value (modified in place)
110        field_specs: Dict of field_name -> target_type or {data_type: target_type}
111        lat_lon_specs: Dict of target_field -> (value_field, direction_field)
112                      for NMEA lat/lon conversion
113        delete_source_fields: If True, remove source fields after lat/lon conversion
114        delete_unconverted_fields: If True, remove fields not involved in conversion
115        quiet: If True, suppress warning messages
116
117    Returns:
118        The modified fields dict, or None if no fields remain after processing
119    """
120    if not fields:
121        return None
122
123    # Track which fields were successfully converted or used
124    processed_fields = set()
125
126    # 1. Handle simple Type Conversions
127    if field_specs:
128        for field_name, field_def in field_specs.items():
129            if field_name not in fields:
130                continue
131
132            # Extract target type from dict, or use string directly
133            if isinstance(field_def, dict):
134                target_type_str = field_def.get('data_type')
135            elif isinstance(field_def, str):
136                target_type_str = field_def
137            else:
138                continue
139
140            if not target_type_str:
141                continue
142
143            val = fields[field_name]
144            converter = TYPE_MAP.get(target_type_str)
145            if converter:
146                try:
147                    # Special case to head off ValueError of int("123.0")
148                    if isinstance(val, str) and converter is int:
149                        try:
150                            val = float(val)
151                        except ValueError:
152                            pass
153
154                    fields[field_name] = converter(val)
155                    processed_fields.add(field_name)
156                except ValueError:
157                    if not quiet:
158                        logging.warning(f'Failed to convert field \'{field_name}\': '
159                                        f'value=\'{val}\' (type={type(val).__name__}) '
160                                        f'to target_type=\'{target_type_str}\'')
161            else:
162                if not quiet:
163                    logging.warning(f'Unknown type \'{target_type_str}\' '
164                                    f'requested for field \'{field_name}\'')
165
166    # 2. Handle Lat/Lon Conversions
167    if lat_lon_specs:
168        for target_field, (val_field, dir_field) in lat_lon_specs.items():
169            if val_field not in fields or dir_field not in fields:
170                continue
171
172            val = fields[val_field]
173            direction = fields[dir_field]
174
175            decimal_degrees = convert_lat_lon(val, direction)
176
177            if decimal_degrees is not None:
178                fields[target_field] = decimal_degrees
179                processed_fields.add(target_field)
180
181                # Mark source fields as processed
182                if delete_source_fields:
183                    processed_fields.add(val_field)
184                    processed_fields.add(dir_field)
185
186                    # Delete source fields, but NOT if source == target
187                    if val_field in fields and val_field != target_field:
188                        del fields[val_field]
189                    if dir_field in fields and dir_field != target_field:
190                        del fields[dir_field]
191
192    # 3. Clean up unconverted fields
193    if delete_unconverted_fields:
194        all_fields = list(fields.keys())
195        for f in all_fields:
196            if f not in processed_fields:
197                del fields[f]
198
199    # If no fields remain, return None
200    if not fields:
201        return None
202
203    return fields

Convert fields in a dictionary according to specifications.

This is a shared utility used by ConvertFieldsTransform and can be used directly by parsers for field conversion.

Args: fields: Dict of field_name -> value (modified in place) field_specs: Dict of field_name -> target_type or {data_type: target_type} lat_lon_specs: Dict of target_field -> (value_field, direction_field) for NMEA lat/lon conversion delete_source_fields: If True, remove source fields after lat/lon conversion delete_unconverted_fields: If True, remove fields not involved in conversion quiet: If True, suppress warning messages

Returns: The modified fields dict, or None if no fields remain after processing