Finalize project

This commit is contained in:
2021-06-18 15:29:43 +02:00
parent cf7bb3b853
commit 22e06ba6a3
129 changed files with 3 additions and 434 deletions
@@ -0,0 +1,7 @@
FROM python:3.8-slim
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
RUN cd deps; ls; pip install dse-shared-libs-1.tar.gz;
ENTRYPOINT [ "python" ]
CMD [ "i_feed.py" ]
@@ -0,0 +1,99 @@
import pickle
import threading
import time
import os
from datetime import datetime
from circuitbreaker import circuit
# Default switching time in seconds
from dse_shared_libs.traffic_light_color import TrafficLightColor
from dse_shared_libs.message_broker_wrapper import MBWrapper
from dse_shared_libs.traffic_light_state import TrafficLightState
from pika.exceptions import AMQPConnectionError
SWITCHING_TIME = 15
# Scale speed of switching by factor x
SCALING = int(os.environ.get('DSE2021_SCALING', 1))
class TrafficLight:
# id of traffic light
tlid: str
# time between switches in seconds
switching_time: int
# timestamp of last switch
last_switch: datetime
# currently lit color
current_color: TrafficLightColor
# marks if traffic light started
running: bool = False
# stores starting color
_starting_color: TrafficLightColor
# stores runner thread
_t: threading.Thread
# outgoing traffic light state MBWrapper
_tl_mb: MBWrapper
def __init__(self,
tlid: str,
switching_time: int = SWITCHING_TIME,
starting_color: TrafficLightColor = TrafficLightColor.RED):
"""
Init a new Traffic Light client. This will already initialize its message queue.
Start it with self.start().
:param tlid: ID of the traffic light
:param switching_time: time in seconds to switch between states (TrafficLightColor.RED and .GREEN)
:param starting_color: TrafficLightColor to start with
"""
self.tlid = tlid
self.switching_time = switching_time
self._starting_color = starting_color
self._tl_mb = MBWrapper(exchange_name='traffic_light_state_{}'.format(self.tlid))
self._tl_mb.setup_sender()
def start(self):
"""
Starts the traffic light by spawning a new thread..
"""
self.running = True
self._t = threading.Thread(target=self._switcher)
self._t.start()
def stop(self):
"""
The running thread will stop its execution at the next evaluation of while self.running.
"""
self.running = False
@circuit(failure_threshold=10, expected_exception=AMQPConnectionError)
def send_status_update(self):
"""
Sends a status update containing an updated TrafficLightState object to the orchestrator.
This update contains the tlid, current_color and timestamp of the last switch.
"""
print(self.tlid, self.current_color, self.last_switch)
self._tl_mb.send(pickle.dumps(
TrafficLightState(tlid=self.tlid, color=self.current_color, last_switch=self.last_switch)))
def _switcher(self):
"""
Toggles the traffic lights state every self.switching_time / SCALING seconds.
When it switches, it notifies the orchestrator with self.send_status_update()
"""
num_colors = len(TrafficLightColor)
# set current color to the one before starting color, because switch immediately happens in loop
# therefore it sleeps on the end of the loop and first status is immediately sent to msg broker
self.current_color = TrafficLightColor((self._starting_color.value - 1) % num_colors)
while self.running:
self.current_color = TrafficLightColor((self.current_color.value + 1) % num_colors)
self.last_switch = datetime.utcnow()
self.send_status_update()
time.sleep(self.switching_time / SCALING)
if __name__ == '__main__':
...
@@ -0,0 +1,277 @@
import pickle
import threading
import time
import os
from datetime import datetime
from typing import Union
import geopy
import geopy.distance
from circuitbreaker import circuit
from dse_shared_libs.daf import DAF
from dse_shared_libs.message_broker_wrapper import MBWrapper
# Lat, Long
from dse_shared_libs.target_velocity import TargetVelocity
from pika.exceptions import AMQPConnectionError
STARTING_POINT = geopy.Point(47.89053, 16.20703)
# Driving direction in degrees: 0=N, 90=E, 180=S, 270=W
BEARING = 2
# Scale speed of vehicles by factor x
SCALING = int(os.environ.get('DSE2021_SCALING', 1))
# in km/h
STARTING_VELOCITY = 130 * SCALING
# Interval between status updates in seconds (is not scaled)
UPDATE_INTERVAL = 1
# Specify if NCE should happen
NCE = int(os.environ.get('DSE2021_NCE', 1))
# At x km the NCE shall happen
NCE_KM = 2.4
# Time in seconds to recover from NCE (will be scaled)
TIME_TO_RECOVER = 15
# Resets vehicle at km x
RESET_KM = 4.3
class Vehicle:
# vehicle identification number
vin: str
# velocity in km/h
velocity: float
# stores the timestamp most recently used to calculate driving distance
last_update: Union[datetime, None]
# marks if vehicle is currently driving, a new one isn't, has to be started via self.start_driving()
driving: bool = False
# stores last velocity before NCE, is used to recover after NCE
_last_velocity: float
# stores the so-far driven kilometers
_driven_kms: int
# stores the starting point for resetting the vehicle
_starting_point: geopy.Point
# stores the gps_location, shall be accessed by gps_location, where the actual value is calculated and returned
_gps_location: geopy.Point
# stores if nce possible
_nce_possible = True
# stores if nce already happened, it only happens (up to) once
_nce_happened = False
# stores if we are still recovering from NCE
_recovering = False
# continuous driving thread
_t: threading.Thread
# NCE recovery thread
_rt: threading.Thread
# outgoing daf info MBWrapper
_daf_mb: MBWrapper
# incoming target velocity MBWrapper
_velocity_mb: MBWrapper
def __init__(self,
vin: str,
starting_point: geopy.Point = STARTING_POINT,
starting_velocity: float = STARTING_VELOCITY):
"""
Initialize a new vehicle client. Already initializes the corresponding sending and receiving message queue.
A vehicle sends every UPDATE_INTERVAL seconds its current DAF to the orchestrator. It receives as response the
new TargetVelocity to achieve a possible green wave. After a fixed amount of kilometers, the NCE event is
happening. The car then stands still for TIME_TO_RECOVER seconds. Afterwards, it starts driving with the last
known velocity. While it is recovering, it ignores the target velocity responses from the orchestrator.
A vehicle drives a full route between start and end. After reaching the end, it restores the internal state and
starts again from the beginning.
:param vin: Vehicle Identification Number
:param starting_point: point on globe to start at
:param starting_velocity: velocity to start driving with
"""
self.vin = vin
self._starting_point = starting_point
self._gps_location = starting_point
self.velocity = starting_velocity
self._daf_mb = MBWrapper(exchange_name='vehicle_daf_{}'.format(vin))
self._daf_mb.setup_sender()
self._velocity_mb = MBWrapper(exchange_name='vehicle_velocity_{}'.format(self.vin), callback=self.new_velocity)
self._velocity_mb.setup_receiver()
@property
def nce(self):
"""
On accessing this property, it is calculated if the NCE shall happen. NCE only happens up to once per route.
If the NCE happens, the car stops driving (velocity = 0) for TIME_TO_RECOVER seconds. In this time,
responses from the orchestrator are ignored. After recovery, the vehicle starts driving again with the last
known velocity.
:return: True if NCE invoked, otherwise False
"""
d = geopy.distance.distance(kilometers=0.4)
tl2_loc = geopy.Point(47.90937, 16.20814)
# Calculate point 400m south of traffic light 2
nce_point = d.destination(point=tl2_loc, bearing=182)
if NCE == 1 and self._nce_possible and not self._nce_happened:
if self._gps_location.latitude >= nce_point.latitude:
self._nce_happened = True
self._last_velocity = self.velocity
self.velocity = 0
def _sleep_and_recover():
recover_in = TIME_TO_RECOVER / SCALING
self._recovering = True
print('\nNCE !!! Recovering in {} (scaled) seconds.\n'.format(recover_in))
time.sleep(recover_in)
print('\nRecovered.\n')
self._recovering = False
self.velocity = self._last_velocity
self._rt = threading.Thread(target=_sleep_and_recover)
self._rt.start()
return True
return False
@nce.setter
def nce(self, val):
self._nce_happened = val
@property
def daf(self):
"""
Return the DAF object of the vehicle. The properties are always evaluated on calling. That guarantees accurate
values.
:return: current "Datenaufzeichnung für automatisiertes Fahren" (DAF) object
"""
# ATTENTION: ORDER MANDATORY (except for static vin)
return DAF(vehicle_identification_number=self.vin,
# first deduce nce - is calculated, sets velocity to 0 if NCE
near_crash_event=self.nce,
# then get current location based on last location and current speed
gps_location=self.gps_location,
# finally get last_update, is updated by gps_location deduction
timestamp=self.last_update,
# is set to 0 if nce
velocity=self.velocity,
)
@property
def gps_location(self):
"""
An accurate current position of the vehicle at the moment the property is called is retunred.
The gps location is derived from the last position this property was called at and the
time the vehicle was driving since then. Therefore, it is not necessary to call this function exactly at a given
time span, because it calculates the driven kms relative to the calling time.
"""
# Define starting point.
start = self._gps_location
# Get old and updated timestamps
old_timestamp = self.last_update
updated_timestamp = datetime.utcnow()
self.last_update = updated_timestamp
# get driving time between timestamps (in seconds)
driving_time = (updated_timestamp - old_timestamp).total_seconds()
# reached distance in kilometers: convert km/h to km/s and multiply by driving time
kilometers = self.velocity / 3600 * driving_time
# Define a general distance object, initialized with a distance of k km.
d = geopy.distance.distance(kilometers=kilometers)
# Use the `destination` method with a bearing of BEARING degrees
# in order to move from point `start` to kilometers km bearing.
self._gps_location = d.destination(point=start, bearing=BEARING)
# update driven kilometers
self._driven_kms += kilometers
return self._gps_location
@property
def driven_kms(self):
"""
:returns: a string representation of the driven kms
"""
return '{}km'.format(round(self._driven_kms, 2))
def start_driving(self):
"""
Starts a thread responsible for driving. This thread continuously updates internal values and
informs the message broker about the current state (DAF) of the vehicle.
"""
print('{} starts driving ... SCALING: x{}\n\n'.format(self.vin, SCALING))
self.last_update = datetime.utcnow()
self._driven_kms = 0
self._t = threading.Thread(target=self.drive)
self._t.start()
def drive(self):
"""
Drive until not self.driving == update status every UPDATE_INTERVAL seconds.
"""
self.driving = True
while self.driving:
self.check_reset()
self.send_status_update()
time.sleep(UPDATE_INTERVAL)
def stop_driving(self):
"""
Set self.driving to stop driving thread gracefully.
"""
self.driving = False
self._gps_location = self._starting_point
self._driven_kms = 0
def check_reset(self):
"""
Checks if end of route is reached and resets vehicle to starting conditions.
It also resets on malicious coordinates.
Afterwards, the vehicle is still driving, but again from start.
"""
if self._driven_kms >= RESET_KM:
self._reset()
if self._gps_location.latitude < self._starting_point.latitude or \
self._gps_location.longitude < self._starting_point.longitude:
self._reset()
@circuit(failure_threshold=10, expected_exception=AMQPConnectionError)
def send_status_update(self):
"""
Sends the current DAF to the orchestrator. The orchestrator will then respond asynchronously on the response
queue.
"""
print(self.driven_kms, '\t', self.daf)
self._daf_mb.send(pickle.dumps(self.daf))
def new_velocity(self, response: bytes):
"""
Will be invoked if new target velocity message received from orchestrator.
:param response: pickled TargetVelocity object
"""
response: TargetVelocity = pickle.loads(response)
velocity = response.target_velocity
print('Received new velocity {}'.format(velocity))
if not self._recovering:
self.velocity = velocity
else:
print('We are still recovering ... ignoring new target velocity.')
def _reset(self):
print('\n\nEnd of route reached ... resetting and restarting vehicle')
self._gps_location = self._starting_point
self._driven_kms = 0
self.last_update = datetime.utcnow()
self.nce = False
self.velocity = STARTING_VELOCITY
if __name__ == "__main__":
...
+55
View File
@@ -0,0 +1,55 @@
import sys
import time
import geopy.distance
import requests
from dse_shared_libs import daf, traffic_light_state, traffic_light_color, target_velocity
from devices.traffic_light import TrafficLight
from devices.vehicle import Vehicle
sys.modules['daf'] = daf
sys.modules['traffic_light_state'] = traffic_light_state
sys.modules['traffic_light_color'] = traffic_light_color
sys.modules['target_velocity'] = target_velocity
"""
USED AS ENTRYPOINT FOR DOCKERFILE
Start all vehicles / traffic lights here
"""
if __name__ == '__main__':
ENTITY_IDENT_URL = 'http://entityident:5002/api/v1/resources/'
available = False
while not available:
try:
response = requests.get(ENTITY_IDENT_URL + 'cars')
cars = response.json()
response = requests.get(ENTITY_IDENT_URL + 'traffic_lights')
traffic_lights = response.json()
except requests.exceptions.ConnectionError as e:
print("Is the entity_ident_server running and reachable?")
continue
available = True
for traffic_light in traffic_lights['cursor']:
print(traffic_light)
TrafficLight(tlid=traffic_light['id'],
switching_time=traffic_light['switchingTime']).start()
time.sleep(0.5)
for index, car in enumerate(cars['cursor']):
print(car)
# Get location of first TL
tl_1_loc = geopy.Point(47.89584, 16.20719)
# Calculate starting point by going south of TL_location the amount specified in cars.json
d = geopy.distance.distance(kilometers=car['startingDistance'] / float(1000))
starting_point = d.destination(point=tl_1_loc, bearing=182)
starting_velocity = car['startingVelocity']
print('StartingPoint is {} and startingVelocity is {}'.format(starting_point, starting_velocity))
Vehicle(vin=car['vin'], starting_point=starting_point, starting_velocity=starting_velocity).start_driving()
# Sleep for amount of seconds defined in second car before second car starts
if len(cars['cursor']) > 1 and index < len(cars['cursor']) - 1:
time.sleep(cars['cursor'][index + 1]['startingTime'])
@@ -0,0 +1,3 @@
circuitbreaker # fault tolerance
geopy
requests
@@ -0,0 +1,113 @@
import datetime
import pickle
import time
import unittest
from unittest.mock import patch
import geopy
from dse_shared_libs.daf import DAF
from dse_shared_libs.mock.datetime import MyDate
from dse_shared_libs.target_velocity import TargetVelocity
from dse_shared_libs.traffic_light_color import TrafficLightColor
from devices.traffic_light import TrafficLight
from devices.vehicle import Vehicle
from dse_shared_libs.mock.rabbit_mq_mocks import MyBlockingConnection
class TestVehicle(unittest.TestCase):
def setUp(self) -> None:
with patch('pika.BlockingConnection', MyBlockingConnection):
self.vin = 'my_vin'
self.starting_velocity = 130
self.starting_point = geopy.Point(0, 0, 0)
self.timestamp = datetime.datetime.utcnow()
self.v = Vehicle(
vin=self.vin,
starting_point=self.starting_point,
starting_velocity=self.starting_velocity
)
self.v.last_update = self.timestamp
self.v._driven_kms = 0
def tearDown(self) -> None:
time.sleep(0.1)
def test_nce_prop(self):
with patch('time.sleep', return_value=None):
# initially false
self.assertEqual(self.v.nce, False)
# get past nce km
self.v._gps_location.latitude = 48
# now nce should fire
self.assertEqual(self.v.nce, True)
# second call is false again
self.assertEqual(self.v.nce, False)
def test_daf_prop(self):
with patch('devices.vehicle.datetime', MyDate):
MyDate.set_timestamp(self.timestamp)
# test if daf object created properly
daf = DAF(vehicle_identification_number=self.vin,
gps_location=self.starting_point,
velocity=self.starting_velocity,
timestamp=self.timestamp,
near_crash_event=False)
self.assertEqual(self.v.daf, daf)
def test_current_location_prop(self):
with patch('devices.vehicle.datetime', MyDate):
MyDate.set_timestamp(self.timestamp)
# initial position should be starting point
self.assertEqual(self.v.gps_location, self.starting_point)
# lets say last time was about an hour ago
self.v.last_update = self.timestamp - datetime.timedelta(hours=1)
# we drive "almost" directly north
loc = self.v.gps_location
self.assertEqual(round(loc.latitude, 2), 1.17)
self.assertEqual(round(loc.longitude, 2), 0.04)
def test_driven_kms_prop(self):
# initial driven kms are 0
self.assertEqual(self.v.driven_kms, '0km')
# alter driven kms
self.v._driven_kms = 44.24552
# will be rounded to 2 digits
self.assertEqual(self.v.driven_kms, '44.25km')
def test_new_velocity(self):
# starting velocity is set to 130
self.assertEqual(self.v.velocity, 130)
# we get a new velocity, lets say 55 ...
tv = TargetVelocity(vin='my_vin', target_velocity=55, timestamp=self.timestamp)
self.v.new_velocity(pickle.dumps(tv))
# then the vehicle should comply with that
self.assertEqual(self.v.velocity, 55)
class TestTrafficLight(unittest.TestCase):
def setUp(self) -> None:
with patch('pika.BlockingConnection', MyBlockingConnection):
self.tl = TrafficLight(tlid='my_tl',
switching_time=1,
starting_color=TrafficLightColor.RED)
def tearDown(self) -> None:
self.tl.stop()
def test_switching(self):
self.assertFalse(hasattr(self.tl, 'current_color'))
self.tl.start()
self.assertEqual(self.tl.current_color, TrafficLightColor.RED)
time.sleep(1.2)
self.assertEqual(self.tl.current_color, TrafficLightColor.GREEN)
time.sleep(1)
self.assertEqual(self.tl.current_color, TrafficLightColor.RED)