From 1c64f16aefd5b43cc4beace3c7488b7b78621bf6 Mon Sep 17 00:00:00 2001 From: Dave Dietrick Date: Thu, 18 May 2023 18:21:00 -0400 Subject: [PATCH] Initial checkin --- .gitignore | 5 + README.md | 28 +++++ app/__init__.py | 37 +++++++ app/extensions.py | 5 + app/models/__init__.py | 0 app/models/battery_change.py | 16 +++ app/models/shift.py | 19 ++++ app/models/vehicle.py | 23 ++++ app/routes/__init__.py | 0 app/routes/auto.py | 145 ++++++++++++++++++++++++ app/routes/shift.py | 97 ++++++++++++++++ app/routes/vehicle.py | 54 +++++++++ requirements.txt | 27 +++++ test/__init__.py | 24 ++++ test/test_auto.py | 27 +++++ test/test_auto_example.png | Bin 0 -> 23496 bytes test/test_auto_results.py | 31 ++++++ test/test_shift.py | 207 +++++++++++++++++++++++++++++++++++ test/test_vehicle.py | 125 +++++++++++++++++++++ test/utils.py | 12 ++ test/vehicle_data.py | 138 +++++++++++++++++++++++ 21 files changed, 1020 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/extensions.py create mode 100644 app/models/__init__.py create mode 100644 app/models/battery_change.py create mode 100644 app/models/shift.py create mode 100644 app/models/vehicle.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/auto.py create mode 100644 app/routes/shift.py create mode 100644 app/routes/vehicle.py create mode 100644 requirements.txt create mode 100644 test/__init__.py create mode 100644 test/test_auto.py create mode 100644 test/test_auto_example.png create mode 100644 test/test_auto_results.py create mode 100644 test/test_shift.py create mode 100644 test/test_vehicle.py create mode 100644 test/utils.py create mode 100644 test/vehicle_data.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7322804 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +**pycache** +.vscode +.pytest_cache +*.sqlite3 +venv \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a577b61 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +### Install + +pip install -r requirements.txt + +### reset db + +FLASK_APP=app.py FLASK_ENV=development flask reset-db + +### create vehicles + +FLASK_APP=app.py FLASK_ENV=development flask create-vehicles + +### run + +FLASK_APP=app.py FLASK_ENV=development flask run + +### get list of vehicles + +http://127.0.0.1:5000/vehicles + +### Tests + +pytest + +### Notes for running with python 3.11 +- Updated py to 1.11.0 +- Updated pytest to 6.2.5 +- Moved `yield client` into `with app.app_context()` block in `test/__init__.py` \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..ee1d90e --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,37 @@ +from flask import Flask +from test.vehicle_data import vehicle_data + +from .extensions import db, ma +from .models.vehicle import Vehicle + +def create_app(): + app = Flask(__name__) + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite3' + + db.init_app(app) + ma.init_app(app) + + @app.cli.command("reset-db") + def reset_db(): + db.drop_all() + db.create_all() + + + @app.cli.command("create-vehicles") + def create_vehicles(): + for vehicle in vehicle_data: + db.session.add(Vehicle(**vehicle)) + db.session.commit() + + from .routes.vehicle import vehicle_routes + app.register_blueprint(vehicle_routes) + + from .routes.shift import shift_routes + app.register_blueprint(shift_routes) + + from .routes.auto import auto_routes + app.register_blueprint(auto_routes) + + return app + diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..18dc171 --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,5 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_marshmallow import Marshmallow + +db = SQLAlchemy() +ma = Marshmallow() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/battery_change.py b/app/models/battery_change.py new file mode 100644 index 0000000..02ad151 --- /dev/null +++ b/app/models/battery_change.py @@ -0,0 +1,16 @@ +from app.extensions import db, ma + +class BatteryChange(db.Model): + order = db.Column(db.Integer) + shift_id = db.Column(db.Integer, db.ForeignKey('shift.id'), primary_key=True, nullable=False) + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), primary_key=True, nullable=False) + completed = db.Column(db.Boolean, nullable=False, default=False) + vehicle = db.relationship('Vehicle', backref='change_to_vehicle') + +class BatteryChangeSchema(ma.Schema): + vehicle = ma.Nested('VehicleSchema', only=['license_plate', 'battery_level', + 'in_use', 'model', 'location_lat', + 'location_long', 'id'], many=False) + class Meta: + model=BatteryChange + fields = ('shift_id', 'vehicle_id', 'completed', 'vehicle') \ No newline at end of file diff --git a/app/models/shift.py b/app/models/shift.py new file mode 100644 index 0000000..44e5e04 --- /dev/null +++ b/app/models/shift.py @@ -0,0 +1,19 @@ +from app.extensions import db, ma +from flask_marshmallow import fields + +class Shift(db.Model): + id = db.Column(db.Integer, primary_key=True) + battery_changes = db.relationship('BatteryChange', + backref='battery_changes', + order_by='BatteryChange.order') + +class ShiftSchema(ma.Schema): + battery_changes = ma.Nested('BatteryChangeSchema', many=True) + all_completed = fields.fields.Method('get_all_completed') + class Meta: + model=Shift + include_fk=True + fields = ("id", "battery_changes", "all_completed") + + def get_all_completed(self, obj): + return all(change.completed == True for change in obj.battery_changes) \ No newline at end of file diff --git a/app/models/vehicle.py b/app/models/vehicle.py new file mode 100644 index 0000000..cf4fcc8 --- /dev/null +++ b/app/models/vehicle.py @@ -0,0 +1,23 @@ +from app.extensions import db, ma + +class Vehicle(db.Model): + __tablename__ = 'vehicle' + id = db.Column(db.Integer, primary_key=True) + license_plate = db.Column(db.String(80), unique=True, nullable=False) + battery_level = db.Column(db.Float, nullable=False) + in_use = db.Column(db.Boolean, nullable=False) + model = db.Column(db.String(10), nullable=False) + location_lat = db.Column(db.Float, nullable=False) + location_long = db.Column(db.Float, nullable=False) + battery_change_shifts = db.relationship('BatteryChange', backref='battery_change_shifts') + shifts = db.relationship('Shift', secondary="battery_change", backref='vehicles_to_shifts') + +class VehicleSchema(ma.Schema): + battery_change_shifts = ma.Nested('BatteryChangeSchema', many=True) + shifts = ma.Nested('ShiftSchema', only=['id'], many=True) + class Meta: + # Fields to expose + model = Vehicle + fields = ("id", "license_plate", "battery_level", "model", + "in_use", "location_lat", "location_long", 'battery_change_shifts', + "shifts") \ No newline at end of file diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/auto.py b/app/routes/auto.py new file mode 100644 index 0000000..f6c5db0 --- /dev/null +++ b/app/routes/auto.py @@ -0,0 +1,145 @@ +from flask import Blueprint +from itertools import permutations +from app.extensions import db +from app.models.vehicle import Vehicle +from app.models.shift import Shift, ShiftSchema +from app.models.battery_change import BatteryChange +from math import sqrt, pow + +auto_routes = Blueprint('auto_routes', __name__) + +@auto_routes.route('/auto//') +def generate_auto_shift(lat, long): + lat = float(lat) + long = float(long) + closest_vehicles = get_closest_vehicles(lat, long, 20) + + solution = kruskals(closest_vehicles) + + order = get_final_order(solution, closest_vehicles) + + new_shift = Shift() + for i, ind in enumerate(order): + id = closest_vehicles[ind].id + new_change = BatteryChange(**{ + "order": i, + "shift_id": new_shift.id, + "vehicle_id": id + }) + new_shift.battery_changes.append(new_change) + db.session.add(new_shift) + db.session.commit() + + shift_schema = ShiftSchema(many=False) + + return shift_schema.dumps(new_shift) + +def get_closest_vehicles(lat, long, num): + vehicles = Vehicle.query.all() + distances = [] + for vehicle in vehicles: + distance = sqrt(pow(abs(lat) - abs(vehicle.location_lat), 2) + pow(abs(long) - abs(vehicle.location_long), 2)) + distances.append((vehicle, distance)) + + distances = sorted(distances, key=lambda x: x[1])[:num] + return [x[0] for x in distances] + +def get_distance_between_vehicles(left, right): + # Assumes all lats/longs of cars to be consistently positive or negatively. + # Would most likely include data point for city/region and only query cars in that area + return sqrt(pow(abs(left.location_lat) - abs(right.location_lat), 2) + pow(abs(left.location_long) - abs(right.location_long), 2)) + +def get_sorted_edges(vehicles): + ''' + Returns list of tuples representing edges (, , ) + where node is index of vehicle in sorted vehicles by distance from starting point. + The return list is sorted by edge distance. + ''' + if not vehicles or len(vehicles) == 1: return [] + + edge_list = [] + for source_ind, vehicle in enumerate(vehicles): + for other_ind, other_vehicle in enumerate(vehicles): + # So we don't double count edges + if source_ind < other_ind: + distance = get_distance_between_vehicles(vehicle, other_vehicle) + edge_list.append((source_ind, other_ind, distance)) + + return sorted(edge_list, key = lambda edge: edge[2]) + +def kruskals(vehicles): + ''' + Gets the optimal edges to reduce the distance travelled between nodes. + Returns list of lists where each index represents a vehicle in the closest + vehicle list (indexes match) and each sublist represents the edges + to other nodes + ''' + edges = get_sorted_edges(vehicles) + + parents = [i for i in range(len(vehicles))] + ranks = [0 for _ in range(len(vehicles))] + solution = [[] for _ in range(len(vehicles))] + + def find(vertex, parents): + if vertex != parents[vertex]: + parents[vertex] = find(parents[vertex], parents) + + return parents[vertex] + + def union(root1, root2, parents, ranks): + if ranks[root1] < ranks[root2]: + parents[root1] = root2 + elif ranks[root1] > ranks[root2]: + parents[root2] = root1 + else: + parents[root2] = root1 + ranks[root1] += 1 + + for edge in edges: + root1 = find(edge[0], parents) + root2 = find(edge[1], parents) + if root1 != root2: + solution[edge[0]].append((edge[1], edge[2])) + solution[edge[1]].append((edge[0], edge[2])) + union(root1, root2, parents, ranks) + + return solution + +def get_final_order(solution, closest_vehicles): + ''' + Uses the solution from kruskals algorithm to determine the final route. + ''' + + # Find first vertex with only one edge, this will be out starting point + # i.e. Closest leaf node + ind = 0 + for i in range(len(solution)): + if len(solution[i]) == 1: + ind = i + break + + order = [ind] + # Keep a stack to go back to previous nodes if we hit a dead end + stack = [] + while len(order) != len(closest_vehicles): + while len(solution[ind]) != 0: + # Iterate through edges of current node, removing once travelled + edge = solution[ind].pop(0) + + # New node, add to order and check that node's edges + if edge[0] not in order: + if solution[ind]: + stack.append(ind) + ind = edge[0] + order.append(ind) + break + # Hit the end of edges for current node, go back + if len(solution[ind]) == 0 and stack: + ind = stack.pop() + + # If we're out of edges in the current node and nowhere to go back to, + # we must have visited all nodes, return out + elif len(solution[ind]) == 0 and not stack: + break + + return order \ No newline at end of file diff --git a/app/routes/shift.py b/app/routes/shift.py new file mode 100644 index 0000000..ccd103a --- /dev/null +++ b/app/routes/shift.py @@ -0,0 +1,97 @@ +from flask import Blueprint, request, render_template_string +from app.extensions import db +from app.models.shift import Shift, ShiftSchema +from app.models.battery_change import BatteryChange, BatteryChangeSchema + +shift_routes = Blueprint('shift_routes', __name__) + +@shift_routes.route('/shifts') +def list_shifts(): + shift_schema = ShiftSchema(many=True) + shifts = Shift.query.all() + return shift_schema.dumps(shifts) + +@shift_routes.route('/shifts/') +def get_shift(id): + shift_schema = ShiftSchema(many=False) + shift = Shift.query.get_or_404(id) + return shift_schema.dumps(shift) + +@shift_routes.route('/shifts', methods=['POST']) +def create_shift(): + new_shift = Shift() + + if request.form.get('vehicles'): + vehicle_ids = [int(i) for i in request.form.get('vehicles').split(',')] + for id in vehicle_ids: + new_change = BatteryChange(**{ + "shift_id": new_shift.id, + "vehicle_id": id + }) + new_shift.battery_changes.append(new_change) + db.session.add(new_shift) + db.session.commit() + shift_schema = ShiftSchema(many=False) + return shift_schema.dumps(new_shift) + +@shift_routes.route('/shifts/', methods=['DELETE']) +def delete_shift(id): + shift = Shift.query.get_or_404(id) + for change in shift.battery_changes: + db.session.delete(change) + db.session.commit() + db.session.delete(shift) + db.session.commit() + return id + +@shift_routes.route('/shifts//vehicles/', methods=['GET']) +def get_vehicle_in_shift(shift_id, vehicle_id): + shift = Shift.query.get_or_404(shift_id) + for change in shift.battery_changes: + if change.vehicle_id == int(vehicle_id): + change_schema = BatteryChangeSchema(many=False) + return change_schema.dumps(change) + return 'No vehicle found with id %s in shift %s' % (vehicle_id, shift_id), 404 + +@shift_routes.route('/shifts//vehicles/', methods=['POST']) +def add_vehicle_to_shift(shift_id, vehicle_id): + shift_schema = ShiftSchema(many=False) + shift = Shift.query.get_or_404(shift_id) + shift.battery_changes.append(BatteryChange**{ + 'shift_id': shift.id, + 'vehicle_id': vehicle_id + }) + db.session.commit() + return shift_schema.dumps(shift) + +@shift_routes.route('/shifts//vehicles//completed', methods=['POST']) +def complete_vehicle_in_shift(shift_id, vehicle_id): + shift_schema = ShiftSchema(many=False) + shift = Shift.query.get_or_404(shift_id) + for change in shift.battery_changes: + if int(change.vehicle_id) == int(vehicle_id): + change.completed = True + db.session.commit() + return shift_schema.dumps(shift) + + return 'Vehicle not found', 404 + +@shift_routes.route('/shifts//vehicles/', methods=['DELETE']) +def remove_vehicle_from_shift(shift_id, vehicle_id): + shift_schema = ShiftSchema(many=False) + shift = Shift.query.get_or_404(shift_id) + delete_me = [] + keep_me = [] + for change in shift.battery_changes: + if int(change.vehicle_id) == int(vehicle_id): + delete_me.append(change) + else: + keep_me.append(change) + + if not delete_me: return 'No vehicles found with id %s' % vehicle_id, 404 + + shift.battery_changes = keep_me + for change in delete_me: + db.session.delete(change) + db.session.commit() + return shift_schema.dumps(shift) \ No newline at end of file diff --git a/app/routes/vehicle.py b/app/routes/vehicle.py new file mode 100644 index 0000000..23012da --- /dev/null +++ b/app/routes/vehicle.py @@ -0,0 +1,54 @@ +from flask import Blueprint, request +from app.extensions import db +from app.models.vehicle import Vehicle, VehicleSchema + +vehicle_routes = Blueprint('vehicle_routes', __name__) + +@vehicle_routes.route('/vehicles') +def list_vehicles(): + vehicles_schema = VehicleSchema(many=True) + vehicles = Vehicle.query.all() + return vehicles_schema.dumps(vehicles) + +@vehicle_routes.route('/vehicles/') +def get_vehicle(id): + vehicles_schema = VehicleSchema(many=False) + vehicle = Vehicle.query.get_or_404(id) + return vehicles_schema.dumps(vehicle) + +@vehicle_routes.route('/vehicles', methods=['POST']) +def create_vehicle(): + new_vehicle = Vehicle(**{ + "license_plate": request.form.get('license_plate'), + "battery_level": request.form.get('battery_level', 100), + "in_use": bool(request.form.get('in_use', True)), + "model": request.form.get('model'), + "location_lat": request.form.get('location_lat'), + "location_long": request.form.get('location_long'), + }) + db.session.add(new_vehicle) + db.session.commit() + vehicles_schema = VehicleSchema(many=False) + + return vehicles_schema.dumps(new_vehicle) + +@vehicle_routes.route('/vehicles/', methods=['POST']) +def update_vehicle(id): + vehicles_schema = VehicleSchema(many=False) + vehicle = Vehicle.query.get_or_404(id) + vehicle.license_plate = request.form.get('license_plate', vehicle.license_plate) + vehicle.battery_level = request.form.get('battery_level', vehicle.battery_level) + vehicle.in_use = request.form.get('in_use', vehicle.in_use) + vehicle.model = request.form.get('model', vehicle.model) + vehicle.location_lat = request.form.get('location_lat', vehicle.location_lat) + vehicle.location_long = request.form.get('location_long', vehicle.location_long) + + db.session.commit() + return vehicles_schema.dumps(vehicle) + +@vehicle_routes.route('/vehicles/', methods=['DELETE']) +def delete_vehicle(id): + vehicle = Vehicle.query.get_or_404(id) + db.session.delete(vehicle) + db.session.commit() + return id \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e487b28 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,27 @@ +atomicwrites==1.3.0 +attrs==19.2.0 +certifi==2019.6.16 +Click==7.0 +Flask==1.1.1 +flask-marshmallow==0.10.1 +Flask-SQLAlchemy==2.4.1 +importlib-metadata==0.23 +itsdangerous==1.1.0 +Jinja2==2.10.1 +MarkupSafe==1.1.1 +marshmallow==3.2.1 +marshmallow-sqlalchemy==0.19.0 +more-itertools==7.2.0 +packaging==19.2 +pipenv==2018.11.26 +pluggy==0.13.0 +py==1.11.0 +pyparsing==2.4.2 +pytest==6.2.5 +six==1.12.0 +SQLAlchemy==1.3.9 +virtualenv==16.7.5 +virtualenv-clone==0.5.3 +wcwidth==0.1.7 +Werkzeug==0.16.0 +zipp==0.6.0 diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..361786a --- /dev/null +++ b/test/__init__.py @@ -0,0 +1,24 @@ +import os +import tempfile + +import pytest + +from app import create_app, db, Vehicle +from flask import json + +@pytest.fixture +def client(): + app = create_app() + + db_fd, app.config['DATABASE'] = tempfile.mkstemp() + app.config['TESTING'] = True + client = app.test_client() + + with app.app_context(): + db.drop_all() + db.create_all() + + yield client + + os.close(db_fd) + os.unlink(app.config['DATABASE']) \ No newline at end of file diff --git a/test/test_auto.py b/test/test_auto.py new file mode 100644 index 0000000..909fb3c --- /dev/null +++ b/test/test_auto.py @@ -0,0 +1,27 @@ +from flask import json +from app.extensions import db +from app.models.shift import Shift +from app.models.battery_change import BatteryChange +from test import client +from test.utils import create_vehicles + +def test_auto_shift_generation(client): + create_vehicles(8) + + rv = client.get('/auto/40.68136179/-73.996421') + data = json.loads(rv.data) + + print(data) + + ids = [change['vehicle']['id'] for change in data['battery_changes']] + assert ids == [2, 1, 8, 6, 7, 5, 4, 3] + +def test_auto_shift_generation_all(client): + create_vehicles() + + rv = client.get('/auto/40.68136179/-73.996421') + data = json.loads(rv.data) + + ids = [change['vehicle']['id'] for change in data['battery_changes']] + print(ids) + assert ids == [2, 1, 8, 6, 7, 5, 15, 4, 10, 11, 12, 9, 14, 13, 3] \ No newline at end of file diff --git a/test/test_auto_example.png b/test/test_auto_example.png new file mode 100644 index 0000000000000000000000000000000000000000..1893289eb561949c040010ddb415f51df861284b GIT binary patch literal 23496 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