Skip to content

Sensor classes

An overview of the sensor classes:

Sensor superclass

Sensor module.

The superclass for the sensor classes. This module provides the higher level Sensor and SensorGroup classes. The Sensor class is a single sensor, the SensorGroup is a dictionary of Sensors. The SensorGroup class is created to deal with the properties over all sensors together.

Sensor dataclass

Defines the properties and methods of the sensor class.

Attributes:

Name Type Description
label str

String label for sensor

time DatetimeArray

Array containing time values associated with concentration reading

location Coordinate

Coordinate object specifying the observation locations

concentration ndarray

Array containing concentration values associated with time reading

source_on ndarray

Array of size nof_observations containing boolean values indicating whether a source is on or off for each observation, i.e. we are assuming the sensor can/can't see a source

Source code in src/pyelq/sensor/sensor.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
@dataclass
class Sensor:
    """Defines the properties and methods of the sensor class.

    Attributes:
        label (str, optional): String label for sensor
        time (pandas.arrays.DatetimeArray, optional): Array containing time values associated with concentration
            reading
        location (Coordinate, optional): Coordinate object specifying the observation locations
        concentration (np.ndarray, optional): Array containing concentration values associated with time reading
        source_on (np.ndarray, optional): Array of size nof_observations containing boolean values indicating
            whether a source is on or off for each observation, i.e. we are assuming the sensor can/can't see a source

    """

    label: str = field(init=False)
    time: DatetimeArray = field(init=False, default=None)
    location: Coordinate = field(init=False)
    concentration: np.ndarray = field(default_factory=lambda: np.array([]))
    source_on: np.ndarray = field(init=False, default=None)

    @property
    def nof_observations(self) -> int:
        """Int: Number of observations contained in concentration array."""
        return self.concentration.size

    def plot_sensor_location(self, fig: go.Figure, color=None) -> go.Figure:
        """Plotting the sensor location.

        Args:
            fig (go.Figure): Plotly figure object to add the trace to
            color (`optional`): When specified, the color to be used

        Returns:
            fig (go.Figure): Plotly figure object with sensor location trace added to it

        """
        lla_object = self.location.to_lla()

        marker_dict = {"size": 10, "opacity": 0.8}
        if color is not None:
            marker_dict["color"] = color

        fig.add_trace(
            go.Scattermap(
                mode="markers+lines",
                lat=np.array(lla_object.latitude),
                lon=np.array(lla_object.longitude),
                marker=marker_dict,
                line={"width": 3},
                name=self.label,
            )
        )
        return fig

    def plot_timeseries(self, fig: go.Figure, color=None, mode: str = "markers") -> go.Figure:
        """Timeseries plot of the sensor concentration observations.

        Args:
            fig (go.Figure): Plotly figure object to add the trace to
            color (`optional`): When specified, the color to be used
            mode (str, optional): Mode used for plotting, i.e. markers, lines or markers+lines

        Returns:
            fig (go.Figure): Plotly figure object with sensor concentration timeseries trace added to it

        """
        marker_dict = {"size": 5, "opacity": 1}
        if color is not None:
            marker_dict["color"] = color

        fig.add_trace(
            go.Scatter(
                x=self.time,
                y=self.concentration.flatten(),
                mode=mode,
                marker=marker_dict,
                name=self.label,
                legendgroup=self.label,
            )
        )

        return fig

    def subset_sensor(self, section_index: int) -> "Sensor":
        """Subset the sensor based on the provided section index.

        The method is designed to return a new `Sensor` object containing only the observations corresponding to a
        specified section index. Sections are defined by unique values in the `source_on` attribute. For a case where
        the source is turned on and off multiple times, (0 values in `source_on` indicate off periods and positive
        integers indicate different on periods). For example, if section_index=1, a new Sensor will be returned
        containing only observations where self.source_on == 1.

        If sensor.location.shape[0] and sensor.time.shape[0] align we assume the location values are dependent on time and
        therefore need to be filtered accordingly and otherwise we keep the original location.

        This functionality is useful for situations where data is collected in multiple sections, e.g. repeated on/off
        releases where we want to work with one section at a time or later stitch multiple per-section segments
        together.

        Args:
            section_index (int): Integer indicating which observations to keep.

        Returns:
            new_sensor (Sensor): A new Sensor object containing only the specified observations.

        """
        if self.source_on is None:
            return deepcopy(self)

        section_indices = (self.source_on == section_index).flatten()
        new_sensor = deepcopy(self)
        new_sensor.time = self.time[section_indices]
        new_sensor.concentration = self.concentration[section_indices]
        location_object = new_sensor.location.to_array()
        if location_object.shape[0] == self.time.shape[0]:
            location_object = location_object[section_indices, :]
            new_sensor.location = new_sensor.location.from_array(location_object)

        new_sensor.source_on = self.source_on[section_indices]
        return new_sensor

nof_observations property

plot_sensor_location(fig, color=None)

Plotting the sensor location.

Parameters:

Name Type Description Default
fig Figure

Plotly figure object to add the trace to

required
color `optional`

When specified, the color to be used

None

Returns:

Name Type Description
fig Figure

Plotly figure object with sensor location trace added to it

Source code in src/pyelq/sensor/sensor.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def plot_sensor_location(self, fig: go.Figure, color=None) -> go.Figure:
    """Plotting the sensor location.

    Args:
        fig (go.Figure): Plotly figure object to add the trace to
        color (`optional`): When specified, the color to be used

    Returns:
        fig (go.Figure): Plotly figure object with sensor location trace added to it

    """
    lla_object = self.location.to_lla()

    marker_dict = {"size": 10, "opacity": 0.8}
    if color is not None:
        marker_dict["color"] = color

    fig.add_trace(
        go.Scattermap(
            mode="markers+lines",
            lat=np.array(lla_object.latitude),
            lon=np.array(lla_object.longitude),
            marker=marker_dict,
            line={"width": 3},
            name=self.label,
        )
    )
    return fig

plot_timeseries(fig, color=None, mode='markers')

Timeseries plot of the sensor concentration observations.

Parameters:

Name Type Description Default
fig Figure

Plotly figure object to add the trace to

required
color `optional`

When specified, the color to be used

None
mode str

Mode used for plotting, i.e. markers, lines or markers+lines

'markers'

Returns:

Name Type Description
fig Figure

Plotly figure object with sensor concentration timeseries trace added to it

Source code in src/pyelq/sensor/sensor.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def plot_timeseries(self, fig: go.Figure, color=None, mode: str = "markers") -> go.Figure:
    """Timeseries plot of the sensor concentration observations.

    Args:
        fig (go.Figure): Plotly figure object to add the trace to
        color (`optional`): When specified, the color to be used
        mode (str, optional): Mode used for plotting, i.e. markers, lines or markers+lines

    Returns:
        fig (go.Figure): Plotly figure object with sensor concentration timeseries trace added to it

    """
    marker_dict = {"size": 5, "opacity": 1}
    if color is not None:
        marker_dict["color"] = color

    fig.add_trace(
        go.Scatter(
            x=self.time,
            y=self.concentration.flatten(),
            mode=mode,
            marker=marker_dict,
            name=self.label,
            legendgroup=self.label,
        )
    )

    return fig

subset_sensor(section_index)

Subset the sensor based on the provided section index.

The method is designed to return a new Sensor object containing only the observations corresponding to a specified section index. Sections are defined by unique values in the source_on attribute. For a case where the source is turned on and off multiple times, (0 values in source_on indicate off periods and positive integers indicate different on periods). For example, if section_index=1, a new Sensor will be returned containing only observations where self.source_on == 1.

If sensor.location.shape[0] and sensor.time.shape[0] align we assume the location values are dependent on time and therefore need to be filtered accordingly and otherwise we keep the original location.

This functionality is useful for situations where data is collected in multiple sections, e.g. repeated on/off releases where we want to work with one section at a time or later stitch multiple per-section segments together.

Parameters:

Name Type Description Default
section_index int

Integer indicating which observations to keep.

required

Returns:

Name Type Description
new_sensor Sensor

A new Sensor object containing only the specified observations.

Source code in src/pyelq/sensor/sensor.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def subset_sensor(self, section_index: int) -> "Sensor":
    """Subset the sensor based on the provided section index.

    The method is designed to return a new `Sensor` object containing only the observations corresponding to a
    specified section index. Sections are defined by unique values in the `source_on` attribute. For a case where
    the source is turned on and off multiple times, (0 values in `source_on` indicate off periods and positive
    integers indicate different on periods). For example, if section_index=1, a new Sensor will be returned
    containing only observations where self.source_on == 1.

    If sensor.location.shape[0] and sensor.time.shape[0] align we assume the location values are dependent on time and
    therefore need to be filtered accordingly and otherwise we keep the original location.

    This functionality is useful for situations where data is collected in multiple sections, e.g. repeated on/off
    releases where we want to work with one section at a time or later stitch multiple per-section segments
    together.

    Args:
        section_index (int): Integer indicating which observations to keep.

    Returns:
        new_sensor (Sensor): A new Sensor object containing only the specified observations.

    """
    if self.source_on is None:
        return deepcopy(self)

    section_indices = (self.source_on == section_index).flatten()
    new_sensor = deepcopy(self)
    new_sensor.time = self.time[section_indices]
    new_sensor.concentration = self.concentration[section_indices]
    location_object = new_sensor.location.to_array()
    if location_object.shape[0] == self.time.shape[0]:
        location_object = location_object[section_indices, :]
        new_sensor.location = new_sensor.location.from_array(location_object)

    new_sensor.source_on = self.source_on[section_indices]
    return new_sensor

SensorGroup dataclass

Bases: dict

A dictionary containing multiple Sensors.

This class is used when we want to combine a collection of sensors and be able to store/access overall properties.

Attributes:

Name Type Description
color_map list

Default colormap to use for plotting

Source code in src/pyelq/sensor/sensor.py
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
@dataclass
class SensorGroup(dict):
    """A dictionary containing multiple Sensors.

    This class is used when we want to combine a collection of sensors and be able to store/access overall properties.

    Attributes:
        color_map (list, optional): Default colormap to use for plotting

    """

    color_map: list = field(default_factory=list, init=False)

    def __post_init__(self):
        self.color_map = px.colors.qualitative.Pastel

    @property
    def nof_observations(self) -> int:
        """Int: The total number of observations across all the sensors."""
        return int(np.sum([sensor.nof_observations for sensor in self.values()], axis=None))

    @property
    def concentration(self) -> np.ndarray:
        """np.ndarray: Column vector of concentration values across all sensors, unwrapped per sensor."""
        return np.concatenate([sensor.concentration.flatten() for sensor in self.values()], axis=0)

    @property
    def time(self) -> pd.arrays.DatetimeArray:
        """DatetimeArray: Column vector of time values across all sensors."""
        return pd.array(np.concatenate([sensor.time for sensor in self.values()]), dtype="datetime64[ns]")

    @property
    def location(self) -> Coordinate:
        """Coordinate: Coordinate object containing observation locations from all sensors in the group."""
        location_object = deepcopy(next(iter(self.values())).location)
        if isinstance(location_object, ENU):
            attr_list = ["east", "north", "up"]
        elif isinstance(location_object, LLA):
            attr_list = ["latitude", "longitude", "altitude"]
        elif isinstance(location_object, ECEF):
            attr_list = ["x", "y", "z"]
        else:
            raise TypeError(
                f"Location object should be either ENU, LLA or ECEF, while currently it is{type(location_object)}"
            )
        for attr in attr_list:
            setattr(
                location_object,
                attr,
                np.concatenate([np.array(getattr(sensor.location, attr), ndmin=1) for sensor in self.values()], axis=0),
            )
        return location_object

    @property
    def sensor_index(self) -> np.ndarray:
        """np.ndarray: Column vector of integer indices linking concentration observation to a particular sensor."""
        return np.concatenate(
            [np.ones(sensor.nof_observations, dtype=int) * i for i, sensor in enumerate(self.values())]
        )

    @property
    def source_on(self) -> np.ndarray:
        """Column vector of integers indicating whether sources are expected to be on, unwrapped over sensors.

        Different integer values represent different sections where the source is on.

        Assumes source is on when None is specified for a specific sensor.

        Returns:
            np.ndarray: Source on attribute, unwrapped over sensors.

        """
        overall_idx = np.array([])
        for curr_key in self.keys():
            if self[curr_key].source_on is None:
                temp_idx = np.ones(self[curr_key].nof_observations).astype(int)
            else:
                temp_idx = self[curr_key].source_on.flatten()

            overall_idx = np.concatenate([overall_idx, temp_idx])
        return overall_idx.astype(int)

    @property
    def nof_sensors(self) -> int:
        """Int: Number of sensors contained in the SensorGroup."""
        return len(self)

    def add_sensor(self, sensor: Sensor):
        """Add a sensor to the SensorGroup."""
        self[sensor.label] = sensor

    def plot_sensor_location(self, fig: go.Figure, color_map: list = None) -> go.Figure:
        """Plotting of the locations of all sensors in the SensorGroup.

        Args:
            fig (go.Figure): Plotly figure object to add the trace to
            color_map (list, optional): When specified, the colormap to be used, plotting will cycle through
                the colors

        Returns:
            fig (go.Figure): Plotly figure object with sensor location traces added to it

        """
        if color_map is None:
            color_map = self.color_map

        for i, sensor in enumerate(self.values()):
            color_idx = i % len(color_map)
            fig = sensor.plot_sensor_location(fig, color=color_map[color_idx])

        return fig

    def plot_timeseries(self, fig: go.Figure, color_map: list = None, mode: str = "markers") -> go.Figure:
        """Plotting of the concentration timeseries of all sensors in the SensorGroup.

        Args:
            fig (go.Figure): Plotly figure object to add the trace to
            color_map (list, optional): When specified, the colormap to be used, plotting will cycle through
                the colors
            mode (str, optional): Mode used for plotting, i.e. markers, lines or markers+lines

        Returns:
            fig (go.Figure): Plotly figure object with sensor concentration time series traces added to it

        """
        if color_map is None:
            color_map = self.color_map

        for i, sensor in enumerate(self.values()):
            color_idx = i % len(color_map)
            fig = sensor.plot_timeseries(fig, color=color_map[color_idx], mode=mode)

        return fig

    def subset_sensor(self, section_index: int) -> "SensorGroup":
        """Subset the sensor based on the provided section index.

        The method is designed to return a new `SensorGroup` object containing only the observations corresponding to a
        specified section index. Sections are defined by unique values in the `sensor.source_on` attribute. For a case
        where the source is turned on and off multiple times, (0 values in `sensor.source_on` indicate off periods and
        positive integers indicate different on periods). For example, if section_index=1, a new SensorGroup will be
        returned containing only observations where sensor.source_on == 1.
        This functionality is useful for situations where data is collected in multiple sections, e.g. repeated on/off
        releases where we want to work with one section at a time or later stitch multiple per-section segments
        together.

        Args:
            section_index (int): An integer indicating which observations to keep.

        Returns:
            SensorGroup: A new SensorGroup object containing only the specified observations

        """
        subset_sensor = SensorGroup()
        for _, sensor in enumerate(self.values()):
            subset_sensor_i = sensor.subset_sensor(section_index)
            subset_sensor.add_sensor(subset_sensor_i)
        return subset_sensor

nof_observations property

concentration property

np.ndarray: Column vector of concentration values across all sensors, unwrapped per sensor.

time property

location property

sensor_index property

np.ndarray: Column vector of integer indices linking concentration observation to a particular sensor.

source_on property

Column vector of integers indicating whether sources are expected to be on, unwrapped over sensors.

Different integer values represent different sections where the source is on.

Assumes source is on when None is specified for a specific sensor.

Returns:

Type Description
ndarray

np.ndarray: Source on attribute, unwrapped over sensors.

nof_sensors property

add_sensor(sensor)

Add a sensor to the SensorGroup.

Source code in src/pyelq/sensor/sensor.py
236
237
238
def add_sensor(self, sensor: Sensor):
    """Add a sensor to the SensorGroup."""
    self[sensor.label] = sensor

plot_sensor_location(fig, color_map=None)

Plotting of the locations of all sensors in the SensorGroup.

Parameters:

Name Type Description Default
fig Figure

Plotly figure object to add the trace to

required
color_map list

When specified, the colormap to be used, plotting will cycle through the colors

None

Returns:

Name Type Description
fig Figure

Plotly figure object with sensor location traces added to it

Source code in src/pyelq/sensor/sensor.py
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
def plot_sensor_location(self, fig: go.Figure, color_map: list = None) -> go.Figure:
    """Plotting of the locations of all sensors in the SensorGroup.

    Args:
        fig (go.Figure): Plotly figure object to add the trace to
        color_map (list, optional): When specified, the colormap to be used, plotting will cycle through
            the colors

    Returns:
        fig (go.Figure): Plotly figure object with sensor location traces added to it

    """
    if color_map is None:
        color_map = self.color_map

    for i, sensor in enumerate(self.values()):
        color_idx = i % len(color_map)
        fig = sensor.plot_sensor_location(fig, color=color_map[color_idx])

    return fig

plot_timeseries(fig, color_map=None, mode='markers')

Plotting of the concentration timeseries of all sensors in the SensorGroup.

Parameters:

Name Type Description Default
fig Figure

Plotly figure object to add the trace to

required
color_map list

When specified, the colormap to be used, plotting will cycle through the colors

None
mode str

Mode used for plotting, i.e. markers, lines or markers+lines

'markers'

Returns:

Name Type Description
fig Figure

Plotly figure object with sensor concentration time series traces added to it

Source code in src/pyelq/sensor/sensor.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
def plot_timeseries(self, fig: go.Figure, color_map: list = None, mode: str = "markers") -> go.Figure:
    """Plotting of the concentration timeseries of all sensors in the SensorGroup.

    Args:
        fig (go.Figure): Plotly figure object to add the trace to
        color_map (list, optional): When specified, the colormap to be used, plotting will cycle through
            the colors
        mode (str, optional): Mode used for plotting, i.e. markers, lines or markers+lines

    Returns:
        fig (go.Figure): Plotly figure object with sensor concentration time series traces added to it

    """
    if color_map is None:
        color_map = self.color_map

    for i, sensor in enumerate(self.values()):
        color_idx = i % len(color_map)
        fig = sensor.plot_timeseries(fig, color=color_map[color_idx], mode=mode)

    return fig

subset_sensor(section_index)

Subset the sensor based on the provided section index.

The method is designed to return a new SensorGroup object containing only the observations corresponding to a specified section index. Sections are defined by unique values in the sensor.source_on attribute. For a case where the source is turned on and off multiple times, (0 values in sensor.source_on indicate off periods and positive integers indicate different on periods). For example, if section_index=1, a new SensorGroup will be returned containing only observations where sensor.source_on == 1. This functionality is useful for situations where data is collected in multiple sections, e.g. repeated on/off releases where we want to work with one section at a time or later stitch multiple per-section segments together.

Parameters:

Name Type Description Default
section_index int

An integer indicating which observations to keep.

required

Returns:

Name Type Description
SensorGroup SensorGroup

A new SensorGroup object containing only the specified observations

Source code in src/pyelq/sensor/sensor.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
def subset_sensor(self, section_index: int) -> "SensorGroup":
    """Subset the sensor based on the provided section index.

    The method is designed to return a new `SensorGroup` object containing only the observations corresponding to a
    specified section index. Sections are defined by unique values in the `sensor.source_on` attribute. For a case
    where the source is turned on and off multiple times, (0 values in `sensor.source_on` indicate off periods and
    positive integers indicate different on periods). For example, if section_index=1, a new SensorGroup will be
    returned containing only observations where sensor.source_on == 1.
    This functionality is useful for situations where data is collected in multiple sections, e.g. repeated on/off
    releases where we want to work with one section at a time or later stitch multiple per-section segments
    together.

    Args:
        section_index (int): An integer indicating which observations to keep.

    Returns:
        SensorGroup: A new SensorGroup object containing only the specified observations

    """
    subset_sensor = SensorGroup()
    for _, sensor in enumerate(self.values()):
        subset_sensor_i = sensor.subset_sensor(section_index)
        subset_sensor.add_sensor(subset_sensor_i)
    return subset_sensor