#import "../utils/common.typ": *

= Software Components <software-components>

The following is an overview of the software components it was necessary to
write to achieve our goals. The codebase is encompassed in different modules
with each being responsible for a distinct task, in particular, we have:

- A scheduler and controller module that is responsible for scheduling the
  execution of the SMOL program and remotely controlling the actuators that we'll
  treat in @smol-scheduler.
- A data collector module that is responsible for the collection of data collected
  by the different sensors that we'll treat in @data-collector-program.
- A module that contains the actuators scripts and is remotely called by the
  scheduler module, we'll treat it in @actuators-script.
- The asset model used to formally describe the greenhouse and that we'll treat in
  @asset-model.
- The SMOL program serves as a proof of concept for the language and will be
  treated in @smol-twinning-program.

#_content(
  [
  == SMOL Scheduler <smol-scheduler>
  
  The SMOL scheduler is the main program that controls the execution of the SMOL
  code and, in doing so, schedules the actuators. It's also responsible for
  starting the data collectors. All of these operations are done remotely via the 
  SSH protocol on a local network.
  
  More precisely, the scheduler is a Java program that interfaces with the #link(<smol-twinning-program>)[SMOL Twinning Program] and
  extracts the data from a knowledge graph generated by SMOL via semantical
  lifting of the program state.
  
  For example, when searching for the plants that need to be watered, the
  scheduler will query the lifted state and save the results in a `ResulSet`
  object. The `ResultSet` object contains the IDs of the plants that need to be
  watered.
  
  ```Java
  var repl = new REPL(settings);
  var query =
    "PREFIX prog: <https://github.com/Edkamb/SemanticObjects/Program#>\n" +
    "SELECT ?plantID " +
    "WHERE { ?plantsToWater prog:Plant_plantId ?plantID}";

  var plantsToWater = repl
    .getInterpreter()
    .query(query);
  ```
  
  where `Plant` is a class defined in the SMOL program that has a `plantId`
  property.
  
  At this point, the ResultSet can be passed to a method `waterControl` that will
  start the actuators responsible for the plants listed.
  
  ```java
  waterControl(plantsToWater);

  private static void waterControl(
    ResultSet plantsToWater
  ) {
    var reader = new GreenhouseModelReader(
      greenhouseAssetModelFile,
      ModelTypeEnum.ASSET_MODEL
    );

    // list of pump pins to activate
    var pumpPins = new ArrayList<Integer>();

    while (plantsToWater.hasNext()) {
      var plantToWater = plantsToWater.next();
      var plantId = plantToWater
        .get("?plantId")
        .asLiteral()
        .toString();

      pumpPins
        .add(reader.getPumpPinForPlant(plantId));
    }
    startWaterActuator(pumpPins);
  }
  ```
  
  `startWaterActuator` will simply iterate over the pins and via SSH send the
  command:
  
  ```bash
  cd /home/pi/greenhouse_actuator && python3 -m actuator water <pin>
  ```
  
  In the SMOL program, the check for the plants that need to be watered is done
  with a simple while loop that compares the current moisture of the plant by
  querying the Influx database for the ideal moisture that is associated with the
  individual in the asset model and deciding based on the difference whether the
  plant should be watered or not. The next step would be to automate the process
  by running a simulation (by designing an FMU) of the greenhouse and using the
  results of the simulation to decide when to water the plants and update the
  simulation accordingly when the predicted values diverge from the real ones.
  
  == Greenhouse Asset Model <asset-model>
  
  The asset model serves as an interface between the physical system - the
  greenhouse - and the digital system - the digital twin -. It contains an
  organized description of the physical system, including its components, their
  properties, and their relationships. To describe the greenhouse we used the
  `OWL` language, a semantic web language that follows the `RDF` specification.
  
  We can see how the data is organized by comparing the graph of the relationships
  between the `Basilicum`, the `Plant`, and the `HealthState` classes.
  
  #figure(
    image("../img/basilicum-plant-healthstate.svg"),
    caption: "Relationships between the Basilicum, Plant, and HealthState classes",
  )
  
  In our asset model, we would formalize the relationships as follows:
  
  ```turtle
  @prefix : <http://www.semanticweb.org/gianl/ontologies/2023/1/sirius-greenhouse#> .
  @prefix ast: <http://www.semanticweb.org/gianl/ontologies/2023/1/sirius-greenhouse#> .
  @prefix owl: <http://www.w3.org/2002/07/owl#> .
  @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
  @prefix xml: <http://www.w3.org/XML/1998/namespace> .
  @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
  @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
  @base <http://www.semanticweb.org/gianl/ontologies/2023/1/sirius-greenhouse> .
  
  <http://www.semanticweb.org/gianl/ontologies/2023/1/sirius-greenhouse> rdf:type owl:Ontology .
  
  ###############################################
  #    Object Properties
  ###############################################
  
  ast:hasHealthState 
      rdf:type owl:ObjectProperty ;
      rdfs:subPropertyOf owl:topObjectProperty ;
      rdfs:domain ast:Plant ;
      rdfs:range ast:HealthState .
  
  ###############################################
  #    Classes
  ###############################################
  
  ast:Basilicum rdf:type owl:Class ;
                rdfs:subClassOf ast:Plant .
  
  ast:Plant rdf:type owl:Class ;
            owl:hasKey ( ast:hasPlantId ) .
  
  ast:HealthState rdf:type owl:Class .
  
  ###############################################
  #    Data Properties
  ###############################################
  
  ast:hasMaxNdvi rdf:type owl:DatatypeProperty ;
                 rdfs:domain ast:HealthState ;
                 rdfs:range xsd:double .
  
  ast:hasMinNdvi rdf:type owl:DatatypeProperty ;
                 rdfs:domain ast:HealthState ;
                 rdfs:range xsd:double .
  
  ast:hasIdealMoisture rdf:type owl:DatatypeProperty ;
                       rdfs:domain ast:Plant ;
                       rdfs:range xsd:double .
  
  ast:hasIdealTemperature 
      rdf:type owl:DatatypeProperty ;
      rdfs:domain ast:Plant ;
      rdfs:range xsd:double .
  
  ast:hasPlantId rdf:type owl:DatatypeProperty ;
                 rdfs:domain ast:Plant ;
                 rdfs:range xsd:string .
  ```
  
  We can see that we define the namespaces at the top of the document to make the
  rest of the definition more readable, the namespaces enable us to use standard
  definitions, for example when we use the `owl:ObjectProperty` class what we are
  using is the 
   
  ```
  http://www.w3.org/2002/07/owl#ObjectProperty
  ```
  class that is defined as
  
  ```turtle
  owl:ObjectProperty a rdfs:Class ;
    rdfs:label "ObjectProperty" ;
    rdfs:comment "The class of object properties." ;
    rdfs:isDefinedBy <http://www.w3.org/2002/07/owl#> ;
    rdfs:subClassOf rdf:Property .
  ```
  
  We have also defined the `ast` namespace that we use to define the classes and
  properties that are specific to our asset model.
  
  == SMOL Twinning program <smol-twinning-program>
  
  The `SMOL` program is run by the host computer and is responsible for the
  creation of the digital twin of the greenhouse.
   
  It achieves this in the following steps:
  + It reads the #link(<asset-model>)[`asset model`] from the `OWL` file
  + It generates `SMOL` objects from the asset model individuals
  + For each asset object it retrieves the sensor data associated with that specific
    asset from the database (i.e. the moisture of a pot)
  + After retrieving the data it performs the semantic lifting of the program state,
    creating a knowledge graph that represents the state of the assets in the
    greenhouse
  
  To interface with the Asset Model we created a SMOL class with methods that
  retrieve the instances of the classes in the asset model. The class is called
  `AssetModel` and is defined as follows:
  
  ```smol
  /**
  * Retrieves data from the asset model and 
  * converts it to SMOL objects
  */
  class AssetModel()
    // get pot instances from the asset model
    List<Pot> getPots()
      List<Pot> pots = construct("
      PREFIX ast: <http://www.semanticweb.org/gianl/ontologies/2023/1/sirius-greenhouse#>
      SELECT ?shelfFloor ?groupPosition ?potPosition
      WHERE {
        ?pot rdf:type ast:Pot ;
          ast:hasShelfFloor ?shelfFloor ;
          ast:hasGroupPosition ?groupPosition ;
          ast:hasPotPosition ?potPosition .
      }");
      return pots;
      // edit query to get new pots
    end
  
    // get shelf instances from the asset model
    List<Shelf> getShelves()
      List<Shelf> shelves = construct("
        PREFIX ast: <http://www.semanticweb.org/gianl/ontologies/2023/1/sirius-greenhouse#>
        SELECT ?shelfFloor
        WHERE {
          ?shelf rdf:type ast:Shelf ;
            ast:hasShelfFloor ?shelfFloor .
        }
    ");
      return shelves;
    end
  
    // get pump instances from the asset model
    List<Pump> getPumps()
      List<Pump> pumps = construct("
        PREFIX ast: <http://www.semanticweb.org/gianl/ontologies/2023/1/sirius-greenhouse#>
        SELECT ?shelfFloor ?groupPosition
        WHERE {
          ?pump rdf:type ast:Pump ;
            ast:hasShelfFloor ?shelfFloor ;
            ast:hasGroupPosition ?groupPosition.
        }
      ");
      return pumps;
    end
  
      // get plant instances from the asset model
    List<Plant> getPlants()
      List<Plant> plants = construct("
        PREFIX ast: <http://www.semanticweb.org/gianl/ontologies/2023/1/sirius-greenhouse#>
        SELECT ?plantId ?idealMoisture
        WHERE {
          ?plant rdf:type ast:Plant ;
            ast:hasPlantId ?plantId ;
            ast:hasIdealMoisture ?idealMoisture .
        }
      ");
      return plants;
    end
  
    // get health state instances from the asset model
    List<HealthState> getHealthStates()
      List<HealthState> healthStates = construct("
        PREFIX ast: <http://www.semanticweb.org/gianl/ontologies/2023/1/sirius-greenhouse#>
        SELECT ?name ?minNdvi ?maxNdvi
        WHERE {
          ?healthState rdf:type ast:HealthState ;
            ast:hasName ?name ;
            ast:hasMinNdvi ?minNdvi ;
            ast:hasMaxNdvi ?maxNdvi .
        }
      ");
      return healthStates;
    end
  
    Unit printAssetModelData()
      ...
    end
  end
  ```
  
  Here we see that the syntax of `SMOL` is very intuitive, with the `end` keyword
  used to delineate code blocks.
  
  We see that each class has a method that retrieves the instances of that class
  from the asset model. The `construct` top-level expression constructs a list of
  new objects from a `SPARQL` query @smol. An example of how the classes are
  defined in `SMOL` is the following:
  
  ```smol
  /**
  * Represents a physical Plant. Should be 
  * retrieved from the asset model via 
  * AssetModel.getPlants() Each plant is 
  * contained in a Pot. The Pot contains
  * the information about which plant 
  * it contains.
  */
  class Plant(String plantId, Double idealMoisture, String healthState)
    Double getNdvi()
      Double healthState = 0.0;
      List<Double> influxReturn = null;
  
      influxReturn = access(
        "from(bucket: \"greenhouse_test\")
          |> range(start: -30d)
          |> filter(fn: (r) => r[\"_measurement\"] == \"ast:plant\")
          |> filter(fn: (r) => r[\"_field\"] == \"ndvi\")
          |> filter(fn: (r) => r[\"plant_id\"] == %1)
          |> keep(columns: [\"_value\"])
          |> last()",
      INFLUXDB("config_local.yml"),
      this.plantId);
  
      healthState = influxReturn.get(0);
      return healthState;
    end
  
    Double getPotMoisture()
      Double moisture = 0.0;
      List<Double> influxReturn = null;
  
      influxReturn = access(
        "from(bucket: \"greenhouse_test\")
          |> range(start: -30d)
          |> filter(fn: (r) => r[\"_measurement\"] == \"ast:pot\")
          |> filter(fn: (r) => r[\"_field\"] == \"moisture\")
          |> filter(fn: (r) => r[\"plant_id\"] == %1)
          |> keep(columns: [\"_value\"])
          |> last()",
      INFLUXDB("config_local.yml"),
      this.plantId);
  
      moisture = influxReturn.get(0);
      return moisture;
    end
  end
  ```
  
  Here we see a second top-level expression, the `access expression`, also called
  `query expression`, used to retrieve a list of literals or lifted objects using
  a query mode `SPARQL` to access the semantically lifted state or `INFLUXDB` to
  access an external InfluxDB database @smol. In our case, we use the `INFLUXDB`
  query mode to retrieve the values of the sensors from the database.
  
  What is loaded into the program state is a digital twin of the physical
  greenhouse, with objects that mirror the physical objects and their properties.
  
  == Data Collector Program <data-collector-program>
  
  To collect data from the sensors connected to the greenhouse we wrote a Python
  program that retrieves the data and uploads them to InfluxDB.
  
  The repository is located at the address
  https://github.com/N-essuno/greenhouse-data-collector.
  
  The project directory structure is organized as follows:
  
  ```
  .
  ├──  .github
  │   └──  workflows
  │       └──  black.yml
  ├──  collector
  │   ├──  __init__.py
  │   ├──  __main__.py
  │   ├──  assets
  │   │   ├──  __init__.py
  │   │   ├──  asset.py
  │   │   ├──  greenhouse_asset.py
  │   │   ├──  measurement_type.py
  │   │   ├──  plant_asset.py
  │   │   ├──  pot_asset.py
  │   │   ├──  pump_asset.py
  │   │   ├──  shelf_asset.py
  │   │   └──  utils.py
  │   ├──  config.ini.example
  │   ├──  config.py
  │   ├──  demo
  │   │   ├──  __init__.py
  │   │   └──  demo_influx.py
  │   ├──  influx
  │   │   ├──  __init__.py
  │   │   └──  influx_controller.py
  │   ├──  sensors
  │   │   ├──  __init__.py
  │   │   ├──  humidity.py
  │   │   ├──  interpreter.py
  │   │   ├──  light_level.py
  │   │   ├──  mcp3008.py
  │   │   ├──  moisture.py
  │   │   ├──  ndvi.py
  │   │   ├──  temperature.py
  │   │   └──  water_level.py
  │   └──  tests
  │       ├──  __init__.py
  │       ├──  config_test.ini
  │       ├──  random_measurements.py
  │       ├──  test_config_eval.py
  │       └──  test_influx_controller.py
  ├──  .gitignore
  ├──  .pre-commit-config.yaml
  ├──  README.md
  ├──  requirements.txt
  └──  scripts
      └──  load_random_data.py
  ```
  The project features a `requirements.txt` file that lists all the dependencies
  needed to run the program.
  
  There is a script `load_random_data.py` that can be used to load random data
  into the database. It can be used to test the program without having to connect
  the sensors to the Raspberry Pi.
  
  The `pre-commit-config.yaml` file is used to configure the pre-commit hooks that
  are run before every commit by leveraging the #link("https://pre-commit.com/")[pre-commit] Python
  framework. The hooks are used to format the code, reorder imports, and
  statically check for errors.
  
  The `.github` folder contains the CI configuration file that is used to check
  that the code is formatted correctly and can be extended to run the tests in the
  future.
   
  In the `test` folder there are some tests that verify the correct functionality
  of the program.
  
  === The Collector Module <collector-module>
  
  The program is structured as a Python module. The main module is the `collector`
  module. It contains the `__main__.py` file which is the entry point of the
  program. It also contains the `config.py` file that is responsible for reading
  the configuration file and making it available to the rest of the program. The
  configuration file is in a `.ini` format, in the main module we include an
  example file that can be used as a template. The configuration file enables the
  user to configure the following parameters:
  
  ```ini
  [influx2]
  url=
  org=
  token=
  
  [pots]
  # moisture_adc_channel refers to the channel in the Analog to Digital Converter
  pot_1={"shelf_floor":"1", "group_position":"left", "pot_position":"left", "moisture_adc_channel":1, "plant_id":"1"}
  pot_2={"shelf_floor":"1", "group_position":"left", "pot_position":"right", "moisture_adc_channel":2, "plant_id":"2"}
  
  [shelves]
  shelf_1={"shelf_floor":"1", "humidity_gpio_pin":4, "temperature_gpio_pin":4}
  
  [plants]
  plant_1={"plant_id":"1"}
  plant_2={"plant_id":"2"}
  
  [sensor_switches]
  use_infrared_sensor=false
  use_light_sensor=false
  
  [moisture_values]
  XP=[2.45, 1.2]
  
  [water_level_values]
  XP=[1.1, 2.0]
  
  [light_level_values]
  XP=[80, 180]
  ```
  
  The numbers in the configuration file are what we used for our sensors after
  calibrating them, `pots`, `shelves`, and `plants` are read as dictionaries that
  represent the asset model of the greenhouse, and `sensor_switches` is used to
  enable or disable the readout of some of the sensors, `moisture_values`,
  `water_level_values` and `light_level_values` are the values used to calibrate
  the sensors, we'll talk more about this later on in @sensors.
  
  The collector module is composed of the following submodules:
  - `assets`
  - `demo`
  - `influx`
  - `sensors`
  - `tests`
  
  === Assets
  
  Used to represent the various assets in our database as classes, having used the
  Python `ABC` module to help us with better class inheritance, we can get an idea
  of how each of them works just by looking at an excerpt of the `Asset` class.
  
  ```python
  import logging
  import sys
  import threading
  import time
  import traceback
  from abc import ABC, abstractmethod
  
  from influxdb_client import Point
  
  from collector.influx.influx_controller import InfluxController
  
  
  class Asset(ABC):
    stop_flag = threading.Event()
    influx_controller = InfluxController()
    sensor_read_interval = 5
  
    def set_sensor_read_interval(
      self, sensor_read_interval: int
    ) -> None:
      """
      Sets the sensor read interval in seconds
      """
      self.sensor_read_interval = sensor_read_interval
  
    @abstractmethod
    def to_point(self) -> Point:
      """
      Convert the asset to a point. Returns a 
      point with the fields and tags of the asset
      """
      pass
  
    @abstractmethod
    def stop_sensor(self):
      pass
  
    def read_sensor_data(self) -> None:
      """
      Read sensor data and write a point to 
      influxdb. The point is created by the 
      to_point() method. Repeat every 
      sensor_read_interval seconds.
      """
      try:
        bucket = self.influx_controller.get_bucket("greenhouse")
  
        while not self.stop_flag.is_set():
          point = self.to_point()
          self.influx_controller.write_point(
            point, bucket
          )
          time.sleep(self.sensor_read_interval)
      except Exception as e:
        self.stop_sensor()
        sys.exit(1)
  
      self.stop_sensor()
      sys.exit(0)
  
    def stop_thread(self):
      self.stop_flag.set()
  
    def reset_stop_flag(self):
      self.stop_flag.clear()
  ```
  
  We can see the way that the `to_point` and `stop_sensor` methods are implemented
  by looking, as an example, at an excerpt of the `ShelfAsset` class.
  
  ```python
  from dataclasses import dataclass...
  
  
  @dataclass
  class ShelfAsset(Asset):
    """
    Class representing The Shelf Asset
  
    Attributes:
      sh_floor (str): floor of the shelf, 1 or 2
      humidity_sensor (Humidity)
      temp_sensor (Temperature)
    """
  
    sh_floor: str
    hum_sensor: Humidity
    temp_sensor: Temperature
  
    _typ = MeasurementType.SHELF.get_measurement_name()
  
    def __post_init__(self):
      if self.sh_floor != "1" and 
         self.sh_floor != "2":
        raise ValueError(
          "sh_floor must be 1 or 2"
        )
  
    def to_point(self) -> Point:
      return (
        Point(self._typ)
        .tag("shelf_floor", self.sh_floor)
        .field(
          "temperature", 
          self.temp_sensor.read()
        )
        .field(
          "humidity", 
          self.hum_sensor.read()
        )
      )
  
    def stop_sensor(self):
      self.temperature_sensor.stop()
  ```
  
  In general with each asset, we initialize some class variables and verify that
  the value they are initialized with is valid (using the `__post_init__` method).
  Here `_typ` turns out to be equivalent to the string `ast:shelf` which is the
  asset type prefixed by the namespace `ast`, required in InfluxDB. The point is
  then decorated with the tags and fields that are specific to the asset.
  
  
  This way of structuring the Asset classes makes it very easy to extend the
  program to support new assets.
  
  === Influx <influx-python>
  
  The `influx` module is a wrapper around the `influxdb_client` library. It
  contains a class `InfluxController` that is responsible for creating the client
  and the bucket and for writing points to the database.
  
  The class is treated as a singleton so that only one instance of it can be
  created. This is done by using the `__new__` method, executed at the object's
  allocation in memory.
  
  ```python
  from typing import Iterable, Optional, Union...
  
  
  class InfluxController:
    """
    Singleton, handles the connection to InfluxDB
    Attributes:
      _instance: the singleton instance
      _client: the InfluxDB client used to interact with the database
    """
  
    _instance = None
    _client = InfluxDBClient
              .from_config_file(CONFIG_PATH)
  
    def __new__(cls):
      """
      Create a new instance of the class if it 
      does not exist, otherwise, return the 
      existing one
      """
      if cls._instance is None:
        cls._instance = super(
          InfluxController, cls
        ).__new__(cls)
  
      return cls._instance
  
    def delete_bucket(
      self, 
      bucket_name: str
    ) -> bool:
      ...
  
    def get_bucket(
      self, bucket_name: str
    ) -> Optional[Bucket]:
      ...
  
    def create_bucket(
      self, 
      bucket_name: str
    ) -> Bucket:
      ...
  
    def write_point(
      self,
      point: Union[Point, Iterable[Point]],
      bucket: Bucket
    ) -> bool:
      ...
  
    def close(self):
      self._client.close()
  ```
  
  === Sensors <sensors>
  
  The `sensors` module contains the classes that represent the sensors connected
  to the Raspberry Pi. Each sensor class has a method `read` that returns the
  value measured, in cases such as the moisture sensor, the value needs to be
  converted to a number that makes sense as a measurement, in this case a
  percentage. When such a conversion is needed, the class feeds the value to an
  interpreter as we can see below.
  
  ```python
  from collector.sensors.interpreter import Interpreter
  from collector.sensors.mcp3008 import MCP3008
  
  
  class Moisture:
    def __init__(
      self, adc: MCP3008, channel: int
    ) -> None:
      """Initializes the Moisture sensor.

      Args:
        adc (MCP3008): the analog to digital converter
        channel (int): the channel of the ADC to which the sensor is connected to
      """
      self.interpret = Interpreter("moisture")
                       .interpret

      self.adc = adc
      self.channel = channel

    def read(self) -> float:
      return self.interpret(
        self.adc.read(self.channel)
      )

    def stop(self):
      self.adc.close()
    ```
  
  The `Interpreter` is a class that utilizes the #link("https://numpy.org/")[NumPy] `interp` function
  that performs one-dimensional linear interpolation of monotonically increasing
  points. Here we can see how the values in the configuration file are used to
  convert the raw values.
  
  Having the function cached as a class variable enables us to reuse it for every
  value that needs to be converted, leading to a noticeable performance
  improvement.
  
  ```python
  import json
  import numpy as np
  
  from collector.config import CONFIG_PATH
  
  try:
    # >3.2
    from configparser import ConfigParser
  except ImportError:
    # python27
    # Refer to the older SafeConfigParser as ConfigParser
    from configparser import SafeConfigParser as ConfigParser
  
  
  class Interpreter:
    """
    Class that interprets raw values from sensors
    to meaningful values. Uses linear inter-
    polation, a variable number of points can 
    be used to define the interpolation function
    """
  
    def __init__(
      self, sensor: str, range: tuple = (0, 100)
    ):
      conf = ConfigParser()
      conf.read(CONFIG_PATH)
  
      self.XP = json.loads(
        conf[sensor + "_values"]["XP"]
      )
      self.FP = np.linspace(
        range[0], range[1], len(self.XP)
      )
  
      # If the first value is greater than the 
      # last one, reverse the arrays
      if self.XP[0] > self.XP[-1]:
        self.XP = self.XP[::-1]
        self.FP = self.FP[::-1]
  
    def interpret(self, value: float) -> float:
        return np.interp(value, self.XP, self.FP)
  ```
  
  Instead of reading the values from the configuration file as standard single
  values, we read them as a #link("https://www.json.org/json-en.html")[JSON], this
  enables us to submit multiple values and calculate the interpolation function
  even for sensors that don't change voltage linearly. 
   
  The NumPy `linspace` function is used to create an array of values that are
  linearly spaced between the first and the last value of the `range` tuple with a
  length equal to the number of values in the `XP` array. #v(600pt) /* FIXME: remove once issue #466 is implemented */The
  arrays `XP` and `FP`, respectively the x-coordinates of the data points and the
  y-coordinates of the data points are reversed if the values are not in
  ascending order, this way it's possible to use the `interp` function even for
  sensors like the moisture sensor that decreases in voltage as the moisture
  increases.
  
  === \_\_main\_\_
  
  The main class serves to start the data collection, it operates in a
  multithreaded fashion, starting a thread for each asset. It also handles the
  stopping of the threads when the program is interrupted.
  
  == Actuators Script <actuators-script>
  
  As a separate project, we have created a Python module that serves to run the
  various scripts for the actuators. Actuators can be pumps (for watering or
  fertilization), can be electronic switches for the lights, can be fans for air
  circulation, etc...
  
  Being separated into a separate module makes it so that the actuators can be run
  remotely from the server running the SMOL scheduler. For instance, to activate
  the pump it's sufficient to call the 
   
  ```python
  GPIO.output(pump_pin, GPIO.HIGH)
  ```
  
  function from the #link("https://pypi.org/project/RPi.GPIO/")[RPi.GPIO] package
  and let the voltage be on `HIGH` for a set amount of time before resetting it to
  `LOW`.
  ],
)