openrvdas.logger.transforms.geofence_transform

Read lat/lon from passed records and compare to a geofence loaded at initialization time. Emit pre-defined messages if lat/lon transition between inside and outside of fence.

EEZ files in GML format can be downloaded from https://marineregions.org/eezsearch.php

Sample logger that switches modes when entering/exiting EEZ:

# Read parsed DASRecords from UDP
readers:
  class: UDPReader
  kwargs:
    port: 6224
# Look for lat/lon values in the DASRecords and emit appropriate commands
# when entering/leaving EEZ. Note that EEZ files in GML format can be
# downloaded from https://marineregions.org/eezsearch.php.
transforms:
  - class: GeofenceTransform
    module: logger.transforms.geofence_transform
    kwargs:
      latitude_field_name: s330Latitude,
      longitude_field_name: s330Longitude
      boundary_file_name: /tmp/eez.gml
      leaving_boundary_message: set_active_mode write+influx
      entering_boundary_message: set_active_mode no_write+influx
# Send the messages that we get from geofence to the LoggerManager
writers:
  - class: LoggerManagerWriter
    module: logger.writers.logger_manager_writer
    kwargs:
      database: django
      allowed_prefixes:
        - 'set_active_mode '
        - 'sleep '

Some questions:

  • Should messages be emitted when first record is received? That is, when transform first fires up, should it send the "entering_boundary_message" if the first record it receives indicates it's inside? DECISION: Yes.

NOTE: optional parameter distance_from_boundary is in degrees. Computing the appropriate value in km/nm is nontrivial and requires figuring out the right UTM projection for each location and recomputing it for each point and switching when lat/lon moved to a new UTM projection area, possibly resulting in discontinuities. Simpler and less error-prone to just require degrees.

  1#!/usr/bin/env python3
  2"""Read lat/lon from passed records and compare to a geofence loaded at initialization
  3time. Emit pre-defined messages if lat/lon transition between inside and outside of
  4fence.
  5
  6EEZ files in GML format can be downloaded from https://marineregions.org/eezsearch.php
  7
  8Sample logger that switches modes when entering/exiting EEZ:
  9```
 10# Read parsed DASRecords from UDP
 11readers:
 12  class: UDPReader
 13  kwargs:
 14    port: 6224
 15# Look for lat/lon values in the DASRecords and emit appropriate commands
 16# when entering/leaving EEZ. Note that EEZ files in GML format can be
 17# downloaded from https://marineregions.org/eezsearch.php.
 18transforms:
 19  - class: GeofenceTransform
 20    module: logger.transforms.geofence_transform
 21    kwargs:
 22      latitude_field_name: s330Latitude,
 23      longitude_field_name: s330Longitude
 24      boundary_file_name: /tmp/eez.gml
 25      leaving_boundary_message: set_active_mode write+influx
 26      entering_boundary_message: set_active_mode no_write+influx
 27# Send the messages that we get from geofence to the LoggerManager
 28writers:
 29  - class: LoggerManagerWriter
 30    module: logger.writers.logger_manager_writer
 31    kwargs:
 32      database: django
 33      allowed_prefixes:
 34        - 'set_active_mode '
 35        - 'sleep '
 36```
 37Some questions:
 38- Should messages be emitted when first record is received? That is, when transform
 39  first fires up, should it send the "entering_boundary_message" if the first record
 40  it receives indicates it's inside? DECISION: Yes.
 41
 42NOTE: optional parameter distance_from_boundary is in degrees. Computing the appropriate
 43value in km/nm is nontrivial and requires figuring out the right UTM projection for each
 44location and recomputing it for each point and switching when lat/lon moved to a new UTM
 45projection area, possibly resulting in discontinuities. Simpler and less error-prone
 46to just require degrees.
 47"""
 48import logging
 49import os
 50import time
 51
 52from typing import Union
 53from logger.utils.das_record import DASRecord  # noqa: E402
 54from logger.transforms.transform import Transform  # noqa: E402
 55
 56# Load the transform-specific packages we need
 57import_errors = False
 58try:
 59    import geopandas as gpd
 60except ImportError:
 61    import_errors = True
 62try:
 63    from shapely.geometry import Point
 64except ImportError:
 65    import_errors = True
 66
 67import_pandas_errors = False
 68try:
 69    import pandas as pd
 70except ImportError:
 71    import_pandas_errors = True
 72
 73
 74################################################################################
 75class GeofenceTransform(Transform):
 76    """Class that reads lat/lon from passed records and compare to a geofence loaded at
 77    initialization time. Emit pre-defined messages if lat/lon transition between inside
 78    and outside of fence.
 79    """
 80    def __init__(self,
 81                 latitude_field_name,
 82                 longitude_field_name,
 83                 boundary_file_name=None,
 84                 boundary_dir_name=None,
 85                 distance_from_boundary_in_degrees=0,
 86                 leaving_boundary_message=None,
 87                 entering_boundary_message=None,
 88                 seconds_between_checks=0,
 89                 **kwargs):
 90        """
 91        latitude_field_name
 92        longitude_field_name
 93                Field names to read for lat/lon values.# what fields to listen
 94                to for lat/lon values. Format is assumed to be decimal, with
 95                negative values representing south latitude and west longitude.
 96
 97        boundary_file_name
 98                Path to file from which to load GML boundary definition..
 99
100        boundary_dir_name
101                Path to directory from which to load multiple GML boundary definitions.
102
103        distance_from_boundary_in_degrees
104                Optional distance from boundary to place the fence, in degrees.
105                Negative means inside the boundary.
106
107        leaving_boundary_message
108                Optional message to emit when boundary is crossed, outbound
109        entering_boundary_message,
110                Optional message to emit when boundary is crossed, inbound
111
112        seconds_between_checks
113                Optional number of seconds to wait between doing checks,
114                computation overhead
115        """
116        # Only throw this error if user tries to actually use this code
117        if import_errors:
118            raise ImportError('GeofenceTransform requires installation of geopandas and '
119                              'shapely packages. Please run "pip install geopandas shapely" '
120                              'and retry.')
121
122        super().__init__(**kwargs)  # processes 'quiet' and type hints
123
124        self.latitude_field_name = latitude_field_name
125        self.longitude_field_name = longitude_field_name
126        self.leaving_boundary_message = leaving_boundary_message
127        self.entering_boundary_message = entering_boundary_message
128        self.seconds_between_checks = seconds_between_checks
129
130        # Once we start receiving data, this will either be True or False
131        self.last_position_inside = None
132
133        self.last_check = 0  # timestamp: the last time we checked
134
135        if (boundary_file_name and boundary_dir_name
136                or not (boundary_file_name or boundary_dir_name)):
137            raise ValueError('Please specify a boundary_file_name OR a boundary_dir_name '
138                             'containing multiple gml files.')
139
140        eez_data = None
141
142        if boundary_file_name:
143            # Load the EEZ data from the GML file
144            eez_data = gpd.read_file(boundary_file_name)
145
146        if boundary_dir_name:
147            if import_pandas_errors:
148                raise ImportError('GeofenceTransform using "boundary_dir_name" requires '
149                                  'installation of the pandas packages. Please run "pip '
150                                  'install pandas" and retry.')
151
152            # List all GML files in the directory
153            gml_files = [os.path.join(boundary_dir_name, file)
154                         for file in os.listdir(boundary_dir_name)
155                         if file.endswith('.gml')]
156
157            # Initialize an empty list to store GeoDataFrames
158            dfs = []
159
160            # Read each GML file into a GeoDataFrame and store it in the list
161            for gml in gml_files:
162                dfs.append(gpd.read_file(gml))
163
164            # Combine all GeoDataFrames into a single GeoDataFrame
165            combined_gdf = gpd.GeoDataFrame(pd.concat(dfs, ignore_index=True), crs=dfs[0].crs)
166
167            # Combine all polygons into a single polygon
168            # Handle deprecation of unary_union in newer geopandas/shapely versions
169            try:
170                union_polygon = combined_gdf.union_all()
171            except AttributeError:
172                # Fallback for older versions
173                union_polygon = combined_gdf.unary_union
174
175            # Convert the union polygon into a GeoDataFrame
176            eez_data = gpd.GeoDataFrame(geometry=[union_polygon])
177
178        # Buffer the country's EEZ by distance in degrees
179        self.buffered_eez = eez_data.buffer(distance_from_boundary_in_degrees)
180
181    ############################
182    def _get_lat_lon(self, record: Union[DASRecord, dict]):
183        """If the DASRecord or dict contains a lat/lon pair, return it as a tuple,
184        otherwise return (None, None)."""
185
186        if type(record) is dict:
187            # Is it a simple dict with lat/lon defined at the top level?
188            lat = record.get(self.latitude_field_name, None)
189            lon = record.get(self.longitude_field_name, None)
190            if lat is not None and lon is not None:
191                return (lat, lon)
192
193            # Is it a dict with a 'fields' subdict?
194            if record.get('fields') is not None:
195                lat = record['fields'].get(self.latitude_field_name, None)
196                lon = record['fields'].get(self.longitude_field_name, None)
197                if lat is not None and lon is not None:
198                    return (lat, lon)
199
200            # No lat/lon pairs we can find in this dict
201            return (None, None)
202
203        # Maybe they've passed us a DASRecord
204        if type(record) is DASRecord:
205            lat = record.fields.get(self.latitude_field_name, None)
206            lon = record.fields.get(self.longitude_field_name, None)
207            if lat is not None and lon is not None:
208                return (lat, lon)
209            else:
210                return (None, None)
211
212    # Define a function to check if a point is within N nautical miles of the geofenced area
213    def _is_inside_boundary(self, lat, lon):
214        # Note that Point() takes longitude as first arg, not latitude
215        point = Point(lon, lat)
216        return self.buffered_eez.contains(point).any()
217
218    ############################
219    def transform(self, record: Union[DASRecord, dict]):
220        """Look for the named lat/lon fields in the passed dict. If the previous
221        lat/lon pair was on one side of the geofence and this lat/lon pair is on
222        the other, return the appropriate string defined in either
223        leaving_boundary_message or entering_boundary_message. Otherwise, return
224        None.
225
226        record
227                A DASRecord, dict of {field_name: field_value} pairs, or a list of
228                DASRecords/dicts in which to look for the specified latitude_field_name
229                and longitude_field_name.
230        """
231        # See if it's something we can process, and if not, try digesting
232        if not self.can_process_record(record):  # BaseModule
233            return self.digest_record(record)  # BaseModule
234
235        # If we've checked too recently, skip check. Note that because this decision
236        # is made for computational efficiency rather than data efficiency, it is made
237        # based on system time, not the timestamp of the record.
238        now = time.time()
239        time_since_last = now - self.last_check
240        if self.seconds_between_checks and time_since_last < self.seconds_between_checks:
241            logging.debug(f'Only {time_since_last} seconds since last GeofenceTransform check; '
242                          f'less than the {self.seconds_between_checks} required.')
243            return None
244
245        # Does this record have a lat/lon?
246        (lat, lon) = self._get_lat_lon(record)
247        if lat is None or lon is None:
248            return None
249
250        # We have a lat and lon, so we're going ahead and checking
251        self.last_check = now
252
253        is_inside = self._is_inside_boundary(lat, lon)
254        if is_inside == self.last_position_inside:
255            return None
256
257        self.last_position_inside = is_inside
258        if is_inside:
259            logging.info('GeofenceTransform entered boundary')
260            logging.info(f'Issuing message: {self.entering_boundary_message}')
261            return self.entering_boundary_message
262        else:
263            logging.info('GeofenceTransform exited boundary')
264            logging.info(f'Issuing message: {self.leaving_boundary_message}')
265            return self.leaving_boundary_message
import_errors = True
import_pandas_errors = True
class GeofenceTransform(logger.transforms.transform.Transform):
 76class GeofenceTransform(Transform):
 77    """Class that reads lat/lon from passed records and compare to a geofence loaded at
 78    initialization time. Emit pre-defined messages if lat/lon transition between inside
 79    and outside of fence.
 80    """
 81    def __init__(self,
 82                 latitude_field_name,
 83                 longitude_field_name,
 84                 boundary_file_name=None,
 85                 boundary_dir_name=None,
 86                 distance_from_boundary_in_degrees=0,
 87                 leaving_boundary_message=None,
 88                 entering_boundary_message=None,
 89                 seconds_between_checks=0,
 90                 **kwargs):
 91        """
 92        latitude_field_name
 93        longitude_field_name
 94                Field names to read for lat/lon values.# what fields to listen
 95                to for lat/lon values. Format is assumed to be decimal, with
 96                negative values representing south latitude and west longitude.
 97
 98        boundary_file_name
 99                Path to file from which to load GML boundary definition..
100
101        boundary_dir_name
102                Path to directory from which to load multiple GML boundary definitions.
103
104        distance_from_boundary_in_degrees
105                Optional distance from boundary to place the fence, in degrees.
106                Negative means inside the boundary.
107
108        leaving_boundary_message
109                Optional message to emit when boundary is crossed, outbound
110        entering_boundary_message,
111                Optional message to emit when boundary is crossed, inbound
112
113        seconds_between_checks
114                Optional number of seconds to wait between doing checks,
115                computation overhead
116        """
117        # Only throw this error if user tries to actually use this code
118        if import_errors:
119            raise ImportError('GeofenceTransform requires installation of geopandas and '
120                              'shapely packages. Please run "pip install geopandas shapely" '
121                              'and retry.')
122
123        super().__init__(**kwargs)  # processes 'quiet' and type hints
124
125        self.latitude_field_name = latitude_field_name
126        self.longitude_field_name = longitude_field_name
127        self.leaving_boundary_message = leaving_boundary_message
128        self.entering_boundary_message = entering_boundary_message
129        self.seconds_between_checks = seconds_between_checks
130
131        # Once we start receiving data, this will either be True or False
132        self.last_position_inside = None
133
134        self.last_check = 0  # timestamp: the last time we checked
135
136        if (boundary_file_name and boundary_dir_name
137                or not (boundary_file_name or boundary_dir_name)):
138            raise ValueError('Please specify a boundary_file_name OR a boundary_dir_name '
139                             'containing multiple gml files.')
140
141        eez_data = None
142
143        if boundary_file_name:
144            # Load the EEZ data from the GML file
145            eez_data = gpd.read_file(boundary_file_name)
146
147        if boundary_dir_name:
148            if import_pandas_errors:
149                raise ImportError('GeofenceTransform using "boundary_dir_name" requires '
150                                  'installation of the pandas packages. Please run "pip '
151                                  'install pandas" and retry.')
152
153            # List all GML files in the directory
154            gml_files = [os.path.join(boundary_dir_name, file)
155                         for file in os.listdir(boundary_dir_name)
156                         if file.endswith('.gml')]
157
158            # Initialize an empty list to store GeoDataFrames
159            dfs = []
160
161            # Read each GML file into a GeoDataFrame and store it in the list
162            for gml in gml_files:
163                dfs.append(gpd.read_file(gml))
164
165            # Combine all GeoDataFrames into a single GeoDataFrame
166            combined_gdf = gpd.GeoDataFrame(pd.concat(dfs, ignore_index=True), crs=dfs[0].crs)
167
168            # Combine all polygons into a single polygon
169            # Handle deprecation of unary_union in newer geopandas/shapely versions
170            try:
171                union_polygon = combined_gdf.union_all()
172            except AttributeError:
173                # Fallback for older versions
174                union_polygon = combined_gdf.unary_union
175
176            # Convert the union polygon into a GeoDataFrame
177            eez_data = gpd.GeoDataFrame(geometry=[union_polygon])
178
179        # Buffer the country's EEZ by distance in degrees
180        self.buffered_eez = eez_data.buffer(distance_from_boundary_in_degrees)
181
182    ############################
183    def _get_lat_lon(self, record: Union[DASRecord, dict]):
184        """If the DASRecord or dict contains a lat/lon pair, return it as a tuple,
185        otherwise return (None, None)."""
186
187        if type(record) is dict:
188            # Is it a simple dict with lat/lon defined at the top level?
189            lat = record.get(self.latitude_field_name, None)
190            lon = record.get(self.longitude_field_name, None)
191            if lat is not None and lon is not None:
192                return (lat, lon)
193
194            # Is it a dict with a 'fields' subdict?
195            if record.get('fields') is not None:
196                lat = record['fields'].get(self.latitude_field_name, None)
197                lon = record['fields'].get(self.longitude_field_name, None)
198                if lat is not None and lon is not None:
199                    return (lat, lon)
200
201            # No lat/lon pairs we can find in this dict
202            return (None, None)
203
204        # Maybe they've passed us a DASRecord
205        if type(record) is DASRecord:
206            lat = record.fields.get(self.latitude_field_name, None)
207            lon = record.fields.get(self.longitude_field_name, None)
208            if lat is not None and lon is not None:
209                return (lat, lon)
210            else:
211                return (None, None)
212
213    # Define a function to check if a point is within N nautical miles of the geofenced area
214    def _is_inside_boundary(self, lat, lon):
215        # Note that Point() takes longitude as first arg, not latitude
216        point = Point(lon, lat)
217        return self.buffered_eez.contains(point).any()
218
219    ############################
220    def transform(self, record: Union[DASRecord, dict]):
221        """Look for the named lat/lon fields in the passed dict. If the previous
222        lat/lon pair was on one side of the geofence and this lat/lon pair is on
223        the other, return the appropriate string defined in either
224        leaving_boundary_message or entering_boundary_message. Otherwise, return
225        None.
226
227        record
228                A DASRecord, dict of {field_name: field_value} pairs, or a list of
229                DASRecords/dicts in which to look for the specified latitude_field_name
230                and longitude_field_name.
231        """
232        # See if it's something we can process, and if not, try digesting
233        if not self.can_process_record(record):  # BaseModule
234            return self.digest_record(record)  # BaseModule
235
236        # If we've checked too recently, skip check. Note that because this decision
237        # is made for computational efficiency rather than data efficiency, it is made
238        # based on system time, not the timestamp of the record.
239        now = time.time()
240        time_since_last = now - self.last_check
241        if self.seconds_between_checks and time_since_last < self.seconds_between_checks:
242            logging.debug(f'Only {time_since_last} seconds since last GeofenceTransform check; '
243                          f'less than the {self.seconds_between_checks} required.')
244            return None
245
246        # Does this record have a lat/lon?
247        (lat, lon) = self._get_lat_lon(record)
248        if lat is None or lon is None:
249            return None
250
251        # We have a lat and lon, so we're going ahead and checking
252        self.last_check = now
253
254        is_inside = self._is_inside_boundary(lat, lon)
255        if is_inside == self.last_position_inside:
256            return None
257
258        self.last_position_inside = is_inside
259        if is_inside:
260            logging.info('GeofenceTransform entered boundary')
261            logging.info(f'Issuing message: {self.entering_boundary_message}')
262            return self.entering_boundary_message
263        else:
264            logging.info('GeofenceTransform exited boundary')
265            logging.info(f'Issuing message: {self.leaving_boundary_message}')
266            return self.leaving_boundary_message

Class that reads lat/lon from passed records and compare to a geofence loaded at initialization time. Emit pre-defined messages if lat/lon transition between inside and outside of fence.

GeofenceTransform( latitude_field_name, longitude_field_name, boundary_file_name=None, boundary_dir_name=None, distance_from_boundary_in_degrees=0, leaving_boundary_message=None, entering_boundary_message=None, seconds_between_checks=0, **kwargs)
 81    def __init__(self,
 82                 latitude_field_name,
 83                 longitude_field_name,
 84                 boundary_file_name=None,
 85                 boundary_dir_name=None,
 86                 distance_from_boundary_in_degrees=0,
 87                 leaving_boundary_message=None,
 88                 entering_boundary_message=None,
 89                 seconds_between_checks=0,
 90                 **kwargs):
 91        """
 92        latitude_field_name
 93        longitude_field_name
 94                Field names to read for lat/lon values.# what fields to listen
 95                to for lat/lon values. Format is assumed to be decimal, with
 96                negative values representing south latitude and west longitude.
 97
 98        boundary_file_name
 99                Path to file from which to load GML boundary definition..
100
101        boundary_dir_name
102                Path to directory from which to load multiple GML boundary definitions.
103
104        distance_from_boundary_in_degrees
105                Optional distance from boundary to place the fence, in degrees.
106                Negative means inside the boundary.
107
108        leaving_boundary_message
109                Optional message to emit when boundary is crossed, outbound
110        entering_boundary_message,
111                Optional message to emit when boundary is crossed, inbound
112
113        seconds_between_checks
114                Optional number of seconds to wait between doing checks,
115                computation overhead
116        """
117        # Only throw this error if user tries to actually use this code
118        if import_errors:
119            raise ImportError('GeofenceTransform requires installation of geopandas and '
120                              'shapely packages. Please run "pip install geopandas shapely" '
121                              'and retry.')
122
123        super().__init__(**kwargs)  # processes 'quiet' and type hints
124
125        self.latitude_field_name = latitude_field_name
126        self.longitude_field_name = longitude_field_name
127        self.leaving_boundary_message = leaving_boundary_message
128        self.entering_boundary_message = entering_boundary_message
129        self.seconds_between_checks = seconds_between_checks
130
131        # Once we start receiving data, this will either be True or False
132        self.last_position_inside = None
133
134        self.last_check = 0  # timestamp: the last time we checked
135
136        if (boundary_file_name and boundary_dir_name
137                or not (boundary_file_name or boundary_dir_name)):
138            raise ValueError('Please specify a boundary_file_name OR a boundary_dir_name '
139                             'containing multiple gml files.')
140
141        eez_data = None
142
143        if boundary_file_name:
144            # Load the EEZ data from the GML file
145            eez_data = gpd.read_file(boundary_file_name)
146
147        if boundary_dir_name:
148            if import_pandas_errors:
149                raise ImportError('GeofenceTransform using "boundary_dir_name" requires '
150                                  'installation of the pandas packages. Please run "pip '
151                                  'install pandas" and retry.')
152
153            # List all GML files in the directory
154            gml_files = [os.path.join(boundary_dir_name, file)
155                         for file in os.listdir(boundary_dir_name)
156                         if file.endswith('.gml')]
157
158            # Initialize an empty list to store GeoDataFrames
159            dfs = []
160
161            # Read each GML file into a GeoDataFrame and store it in the list
162            for gml in gml_files:
163                dfs.append(gpd.read_file(gml))
164
165            # Combine all GeoDataFrames into a single GeoDataFrame
166            combined_gdf = gpd.GeoDataFrame(pd.concat(dfs, ignore_index=True), crs=dfs[0].crs)
167
168            # Combine all polygons into a single polygon
169            # Handle deprecation of unary_union in newer geopandas/shapely versions
170            try:
171                union_polygon = combined_gdf.union_all()
172            except AttributeError:
173                # Fallback for older versions
174                union_polygon = combined_gdf.unary_union
175
176            # Convert the union polygon into a GeoDataFrame
177            eez_data = gpd.GeoDataFrame(geometry=[union_polygon])
178
179        # Buffer the country's EEZ by distance in degrees
180        self.buffered_eez = eez_data.buffer(distance_from_boundary_in_degrees)

latitude_field_name longitude_field_name Field names to read for lat/lon values.# what fields to listen to for lat/lon values. Format is assumed to be decimal, with negative values representing south latitude and west longitude.

boundary_file_name Path to file from which to load GML boundary definition..

boundary_dir_name Path to directory from which to load multiple GML boundary definitions.

distance_from_boundary_in_degrees Optional distance from boundary to place the fence, in degrees. Negative means inside the boundary.

leaving_boundary_message Optional message to emit when boundary is crossed, outbound entering_boundary_message, Optional message to emit when boundary is crossed, inbound

seconds_between_checks Optional number of seconds to wait between doing checks, computation overhead

latitude_field_name
longitude_field_name
leaving_boundary_message
entering_boundary_message
seconds_between_checks
last_position_inside
last_check
buffered_eez
def transform(self, record: Union[logger.utils.das_record.DASRecord, dict]):
220    def transform(self, record: Union[DASRecord, dict]):
221        """Look for the named lat/lon fields in the passed dict. If the previous
222        lat/lon pair was on one side of the geofence and this lat/lon pair is on
223        the other, return the appropriate string defined in either
224        leaving_boundary_message or entering_boundary_message. Otherwise, return
225        None.
226
227        record
228                A DASRecord, dict of {field_name: field_value} pairs, or a list of
229                DASRecords/dicts in which to look for the specified latitude_field_name
230                and longitude_field_name.
231        """
232        # See if it's something we can process, and if not, try digesting
233        if not self.can_process_record(record):  # BaseModule
234            return self.digest_record(record)  # BaseModule
235
236        # If we've checked too recently, skip check. Note that because this decision
237        # is made for computational efficiency rather than data efficiency, it is made
238        # based on system time, not the timestamp of the record.
239        now = time.time()
240        time_since_last = now - self.last_check
241        if self.seconds_between_checks and time_since_last < self.seconds_between_checks:
242            logging.debug(f'Only {time_since_last} seconds since last GeofenceTransform check; '
243                          f'less than the {self.seconds_between_checks} required.')
244            return None
245
246        # Does this record have a lat/lon?
247        (lat, lon) = self._get_lat_lon(record)
248        if lat is None or lon is None:
249            return None
250
251        # We have a lat and lon, so we're going ahead and checking
252        self.last_check = now
253
254        is_inside = self._is_inside_boundary(lat, lon)
255        if is_inside == self.last_position_inside:
256            return None
257
258        self.last_position_inside = is_inside
259        if is_inside:
260            logging.info('GeofenceTransform entered boundary')
261            logging.info(f'Issuing message: {self.entering_boundary_message}')
262            return self.entering_boundary_message
263        else:
264            logging.info('GeofenceTransform exited boundary')
265            logging.info(f'Issuing message: {self.leaving_boundary_message}')
266            return self.leaving_boundary_message

Look for the named lat/lon fields in the passed dict. If the previous lat/lon pair was on one side of the geofence and this lat/lon pair is on the other, return the appropriate string defined in either leaving_boundary_message or entering_boundary_message. Otherwise, return None.

record A DASRecord, dict of {field_name: field_value} pairs, or a list of DASRecords/dicts in which to look for the specified latitude_field_name and longitude_field_name.