diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..a87e254 --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,8 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.api.txt . +RUN pip install --no-cache-dir -r requirements.api.txt + +COPY ./app ./app diff --git a/Dockerfile.sim b/Dockerfile.sim new file mode 100644 index 0000000..bb03490 --- /dev/null +++ b/Dockerfile.sim @@ -0,0 +1,8 @@ +FROM python:3.11-slim + +WORKDIR /sim + +COPY requirements.sim.txt . +RUN pip install --no-cache-dir -r requirements.sim.txt + +COPY ./simulation_runner ./ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d4c005 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Elevator Simulation + +## Overview +This software system is used to create high quality synthetic data and store it to be used in a ML ingestion pipeline. +The design considers 3 main components: +- Simulation +- API +- Database + +### Simulation +A descrete event simulation in simpy is proposed to model the elevator scenario, its a great tool for logistics and phenomena that follows Poisson processes. +This allows us to recreate an environment where the elevator can perform its actions realistically and add all the logic we want. +For this case a simple simulation was created, considering a single elevator in a building with n floors, the requests are taken and executed in FIFO order. +A bit of business logic was added, considering that the first floor is usually at street level and is much busier, a spike in the demand for floor one was added, also, the elevator rests at the first floor when idle. +The generated data is posted to the API at runtime. + +### API +A simple FastAPI was developed, with endpoint to create and read generated data. See routes.py +These allow the simulation to store data in the database, and the future ML pipeline to retrieve this data to train. +Also, tests were added to check the endpoints functionality. + +### Database +A FastAPI data model connected to a PostgreSQL data schema is proposed (see models.py) to store simulation metadata and labeled demand data. +We store a snapshot of the state of the simulation when a relevant demand was created (features) and then add the next requested floor created after that scenario (label). +Also, a simple test was added to check the database connection with the API. + +#### Note +The system was designed in a containerized fashion, to be able to deploy it easily in a production environment (see docker-compose.yml). +The logic was separated as a different service for the simulations and for the app, since the simulation could be very resource heavy and we dont want to overload the backend. diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..1be498f --- /dev/null +++ b/app/db.py @@ -0,0 +1,16 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +import os + +DATABASE_URL = os.getenv("DATABASE_URL") + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..722cf03 --- /dev/null +++ b/app/main.py @@ -0,0 +1,11 @@ +from fastapi import FastAPI +from routes import router + +app = FastAPI( + title="elevator-sim API", + description="Stores simulation metadata and elevator requests for model training", + version="0.1.0" +) + +# Include the endpoints +app.include_router(router) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..f39f251 --- /dev/null +++ b/app/models.py @@ -0,0 +1,60 @@ +from sqlalchemy import Column, Integer, Float, ForeignKey, DateTime +from sqlalchemy.dialects.postgresql import ARRAY +from sqlalchemy.orm import declarative_base, relationship + +Base = declarative_base() + +class SimulationMetadata(Base): + """ + Stores data describing the simulation, each simulation has many requets. + Parameters can be used to reproduce a simulation or provide extra features. + """ + __tablename__ = "simulations" + + id = Column(Integer, primary_key=True, index=True) + + # Simulation parameters + wait_time = Column(Float, nullable=False) # seconds + elevator_speed = Column(Float, nullable=False) # floors/sec + expo_lambda = Column(Float, nullable=False) # req/sec + start_datetime = Column(DateTime, nullable=False) # Timestamp + duration = Column(Integer, nullable=False) # seconds + base_floor = Column(Integer, nullable=True) + base_floor_weight = Column(Float, nullable=True) # chance multiplier + floor_min = Column(Integer, nullable=False) + floor_max = Column(Integer, nullable=False) + random_seed = Column(Integer, nullable=False) # for reproducibility + + # 1-N relationship with requests + requests = relationship("ElevatorRequest", back_populates="simulation") + + +class ElevatorRequest(Base): + """ + Snapshot of the simulation when the elevator was idle waiting for next requested floor. + Used as features to train a model, next_floor_requested can be sused as label. + Each record belongs to a single simulation. + """ + __tablename__ = "elevator_requests" + + id = Column(Integer, primary_key=True, index=True) + + # State features + current_floor = Column(Integer, nullable=False) + last_floor = Column(Integer, nullable=False) + time_idle = Column(Float, nullable=False) + timestamp = Column(DateTime, nullable=False) + + # Calculated indicators + floor_demand_histogram = Column(ARRAY(Integer), nullable=False) # eg: [1, 2, 0, 3, 1] + hot_floor_last_30s = Column(Integer, nullable=True) + requests_entropy = Column(Float, nullable=True) + mean_requested_floor = Column(Float, nullable=True) + distance_to_center_of_mass = Column(Float, nullable=True) + + # Label + next_floor_requested = Column(Integer, nullable=True) + + # N-1 relationship with simulation + simulation_id = Column(Integer, ForeignKey("simulations.id"), nullable=False) + simulation = relationship("SimulationMetadata", back_populates="requests") diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..6d30203 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List + +from models import SimulationMetadata, ElevatorRequest +from schemas import SimulationCreate, SimulationOut, ElevatorRequestCreate, ElevatorRequestOut +from db import get_db + +router = APIRouter() + +# Simulation endpoints --- + +@router.post("/simulation", response_model=SimulationOut) +def create_simulation(sim_data: SimulationCreate, db: Session = Depends(get_db)): + """ + Create a single simulation object + """ + sim = SimulationMetadata(**sim_data.dict()) + db.add(sim) + db.commit() + db.refresh(sim) + return sim + + +@router.get("/simulations", response_model=List[SimulationOut]) +def get_simulations(db: Session = Depends(get_db)): + """ + Read all available simulations + """ + return db.query(SimulationMetadata).all() + + +@router.get("/simulation/{id}", response_model=SimulationOut) +def get_simulation(id: int, db: Session = Depends(get_db)): + """ + Read a specific simulation + """ + sim = db.query(SimulationMetadata).filter(SimulationMetadata.id == id).first() + if not sim: + raise HTTPException(status_code=404, detail="Simulation not found") + return sim + + +# Requests endpoints --- + +@router.post("/elevator_request", response_model=ElevatorRequestOut) +def create_elevator_request(req_data: ElevatorRequestCreate, db: Session = Depends(get_db)): + """ + Creates a single request + """ + req = ElevatorRequest(**req_data.dict()) + db.add(req) + db.commit() + db.refresh(req) + return req + + +@router.get("/elevator_request/{sim_id}", response_model=List[ElevatorRequestOut]) +def get_requests_for_simulation(sim_id: int, db: Session = Depends(get_db)): + """ + Read all requests that correspond to a specific simulation + """ + return db.query(ElevatorRequest).filter(ElevatorRequest.simulation_id == sim_id).all() diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..9dcdeb1 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,51 @@ +from datetime import datetime +from typing import List, Optional +from pydantic import BaseModel + + +# Simulation schema --- + +class SimulationBase(BaseModel): + wait_time: float + elevator_speed: float + expo_lambda: float + start_datetime: datetime + duration: int + base_floor: Optional[int] = None + base_floor_weight: Optional[float] = None + floor_min: int + floor_max: int + random_seed: int + +class SimulationCreate(SimulationBase): + pass + +class SimulationOut(SimulationBase): + id: int + class Config: + orm_mode = True + + +# Request schema --- + +class ElevatorRequestBase(BaseModel): + current_floor: int + last_floor: int + time_idle: float + timestamp: datetime + floor_demand_histogram: List[int] + hot_floor_last_30s: Optional[int] = None + requests_entropy: Optional[float] = None + mean_requested_floor: Optional[float] = None + distance_to_center_of_mass: Optional[float] = None + next_floor_requested: Optional[int] = None + +class ElevatorRequestCreate(ElevatorRequestBase): + simulation_id: int + +class ElevatorRequestOut(ElevatorRequestBase): + id: int + simulation_id: int + + class Config: + orm_mode = True diff --git a/chatgpt/app_tests.py b/chatgpt/app_tests.py deleted file mode 100644 index 258a8a6..0000000 --- a/chatgpt/app_tests.py +++ /dev/null @@ -1,10 +0,0 @@ -def test_create_demand(client): - response = client.post('/demand', json={'floor': 3}) - assert response.status_code == 201 - assert response.get_json() == {'message': 'Demand created'} - - -def test_create_state(client): - response = client.post('/state', json={'floor': 5, 'vacant': True}) - assert response.status_code == 201 - assert response.get_json() == {'message': 'State created'} diff --git a/chatgpt/db.sql b/chatgpt/db.sql deleted file mode 100644 index 1555ffe..0000000 --- a/chatgpt/db.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE elevator_demands ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - floor INTEGER -); - -CREATE TABLE elevator_states ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - floor INTEGER, - vacant BOOLEAN -); diff --git a/chatgpt/main.py b/chatgpt/main.py deleted file mode 100644 index 7f97d98..0000000 --- a/chatgpt/main.py +++ /dev/null @@ -1,43 +0,0 @@ -from flask import Flask, request, jsonify -from flask_sqlalchemy import SQLAlchemy -from datetime import datetime - -app = Flask(__name__) -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///elevator.db' -db = SQLAlchemy(app) - - -class ElevatorDemand(db.Model): - id = db.Column(db.Integer, primary_key=True) - timestamp = db.Column(db.DateTime, default=datetime.utcnow) - floor = db.Column(db.Integer, nullable=False) - - -class ElevatorState(db.Model): - id = db.Column(db.Integer, primary_key=True) - timestamp = db.Column(db.DateTime, default=datetime.utcnow) - floor = db.Column(db.Integer, nullable=False) - vacant = db.Column(db.Boolean, nullable=False) - - -@app.route('/demand', methods=['POST']) -def create_demand(): - data = request.get_json() - new_demand = ElevatorDemand(floor=data['floor']) - db.session.add(new_demand) - db.session.commit() - return jsonify({'message': 'Demand created'}), 201 - - -@app.route('/state', methods=['POST']) -def create_state(): - data = request.get_json() - new_state = ElevatorState(floor=data['floor'], vacant=data['vacant']) - db.session.add(new_state) - db.session.commit() - return jsonify({'message': 'State created'}), 201 - - -if __name__ == '__main__': - db.create_all() - app.run(debug=True) diff --git a/chatgpt/requirements.txt b/chatgpt/requirements.txt deleted file mode 100644 index 14d1bb0..0000000 --- a/chatgpt/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -Flask==2.0.2 -Flask-SQLAlchemy==2.5.1 -pytest==6.2.5 -pytest-flask==1.2.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c811408 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +# yaml file to orquestrate the services of the system: +# DB - API - Simulation + +version: "3.9" + +services: + + db: + image: postgres:14 + container_name: elevator_db + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: elevator_sim + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + api: + build: + context: . + dockerfile: Dockerfile.api + container_name: elevator_api + restart: always + depends_on: + - db + environment: + DATABASE_URL: postgres://postgres:postgres@db:5432/elevator_sim + ports: + - "8000:8000" + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + volumes: + - .:/app + + simulator: + build: + context: . + dockerfile: Dockerfile.sim + container_name: elevator_simulator + restart: "no" # does not run continously + depends_on: + - api + environment: + API_BASE_URL: http://api:8000 + command: ["python", "runner.py"] + volumes: + - ./simulation_runner:/sim + +volumes: + postgres_data: diff --git a/readme.md b/readme-original.md similarity index 100% rename from readme.md rename to readme-original.md diff --git a/requirements.api.txt b/requirements.api.txt new file mode 100644 index 0000000..18019a6 --- /dev/null +++ b/requirements.api.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn +sqlalchemy +psycopg2-binary +pydantic +python-dotenv +pytest +httpx \ No newline at end of file diff --git a/requirements.sim.txt b/requirements.sim.txt new file mode 100644 index 0000000..aa8bc36 --- /dev/null +++ b/requirements.sim.txt @@ -0,0 +1,4 @@ +simpy +requests +numpy +python-dotenv diff --git a/simulation/ElevatorSimulation.ipynb b/simulation/ElevatorSimulation.ipynb new file mode 100644 index 0000000..9793741 --- /dev/null +++ b/simulation/ElevatorSimulation.ipynb @@ -0,0 +1,391 @@ +{ + "nbformat": 4, + "nbformat_minor": 0, + "metadata": { + "colab": { + "provenance": [], + "authorship_tag": "ABX9TyMjGq9I7tD4Rw7cYUW/ON4+", + "include_colab_link": true + }, + "kernelspec": { + "name": "python3", + "display_name": "Python 3" + }, + "language_info": { + "name": "python" + } + }, + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "id": "view-in-github", + "colab_type": "text" + }, + "source": [ + "\"Open" + ] + }, + { + "cell_type": "markdown", + "source": [ + "This is a simplified simulation of an elevator transporting people between floors in a building\n", + "\n", + "**To Do:**\n", + "- multiple elevators\n", + "- add capacity (multiple people per elevator)\n", + "- add routing optimization (pickup in between)\n", + "- More realistic demand\n", + " - Rush hours\n", + " - More frequency for base floor (done)" + ], + "metadata": { + "id": "f6dYiwORohTf" + } + }, + { + "cell_type": "code", + "source": [ + "!pip install simpy --quiet\n", + "import simpy" + ], + "metadata": { + "id": "r8iK40V4j4xJ" + }, + "execution_count": 57, + "outputs": [] + }, + { + "cell_type": "code", + "execution_count": 58, + "metadata": { + "id": "TFnlphknjzTY" + }, + "outputs": [], + "source": [ + "# elevator.py\n", + "\n", + "from collections import deque\n", + "from typing import Tuple\n", + "\n", + "# Parameters\n", + "DEFAULT_WAIT_TIME = 1.0 # in seconds\n", + "DEFAULT_SPEED = 1.0 # floors per sec\n", + "DEFAULT_LAMBDA = 0.1 # average of requests per second, for poisson proces\n", + "FLOORS = tuple(range(1, 6)) # floors 1 to 5\n", + "SIMULATION_DURATION = 100 # in seconds\n", + "DEFAULT_BASE_FLOOR = 1 # starting floor, \"street level\"\n", + "BASE_FLOOR_WEIGHT = 5 # how many times base floor is more likely to be requested\n", + "\n", + "\n", + "class Elevator:\n", + " def __init__(self, env: simpy.Environment, floors: tuple[int], speed_floors_per_sec: float, base_floor: int):\n", + " \"\"\"\n", + " Elevator agent, takes requests and moves across floors.\n", + "\n", + " Args:\n", + " env: SimPy environment\n", + " floors: Valid floor numbers (1, 2, 3, ..., 10)\n", + " speed_floors_per_sec: Constant speed of elevator in floors per second\n", + " base_floor: floor at street level, starting point\n", + " \"\"\"\n", + " self.env = env\n", + " self.floors = floors\n", + " self.speed = speed_floors_per_sec\n", + " self.base_floor = base_floor if base_floor in self.floors else None\n", + "\n", + " self.current_floor = base_floor\n", + " self.task_queue = deque()\n", + " self.moving = False\n", + "\n", + " # Start the elevator process\n", + " self.process = env.process(self.run())\n", + "\n", + " def add_task(self, target_floor: int):\n", + " \"\"\"\n", + " Enqueue a request to move to a specific floor.\n", + " \"\"\"\n", + " if target_floor not in self.floors:\n", + " raise ValueError(f\"Invalid floor: {target_floor}\")\n", + " self.task_queue.append(target_floor)\n", + "\n", + " def hold(self, duration: float):\n", + " \"\"\"\n", + " Elevator remains idle at current floor for a set duration.\n", + " \"\"\"\n", + " yield self.env.timeout(duration)\n", + "\n", + " def move_to(self, target_floor: int):\n", + " \"\"\"\n", + " Simulates elevator travel from current floor to target_floor.\n", + " Uses constant speed to compute duration.\n", + " \"\"\"\n", + " # Calculate travel time\n", + " floor_diff = abs(self.current_floor - target_floor)\n", + " if floor_diff == 0:\n", + " return # Already at floor\n", + " travel_time = floor_diff / self.speed\n", + "\n", + " # Move event\n", + " self.moving = True\n", + " print(f\"[{self.env.now:.1f}] Elevator starting move from {self.current_floor} to {target_floor}\")\n", + " yield self.env.timeout(travel_time)\n", + "\n", + " self.current_floor = target_floor\n", + " self.moving = False\n", + " print(f\"[{self.env.now:.1f}] Elevator arrived at floor {self.current_floor}\")\n", + "\n", + " def run(self):\n", + " \"\"\"\n", + " Elevator main loop: process queued tasks in FIFO order.\n", + " \"\"\"\n", + " while True:\n", + " if self.task_queue:\n", + "\n", + " # Get next task\n", + " next_floor = self.task_queue.popleft()\n", + " print(f\"[{self.env.now:.1f}] Elevator processing request to floor {next_floor}\")\n", + "\n", + " # Move if necesary\n", + " if next_floor == self.current_floor and not self.moving:\n", + " print(f\"[{self.env.now:.1f}] Elevator is already at floor {next_floor}\")\n", + " else:\n", + " yield self.env.process(self.move_to(next_floor))\n", + " yield self.env.process(self.hold(DEFAULT_WAIT_TIME)) # hold briefly after arrival\n", + " else:\n", + "\n", + " # No tasks, execute resting policy:\n", + "\n", + " # 1. Stay at current floor\n", + " # yield self.env.timeout(0.5)\n", + "\n", + " # 2. Go to base floor\n", + " next_floor = self.base_floor\n", + "\n", + " if self.current_floor != next_floor:\n", + " print(f\"[{self.env.now:.1f}] Elevator vacant, going to floor {next_floor}\")\n", + " yield self.env.process(self.move_to(next_floor))\n", + "\n", + " yield self.env.timeout(0.1)\n", + "\n", + "\n" + ] + }, + { + "cell_type": "code", + "source": [ + "# demandGenerator.py\n", + "\n", + "import simpy\n", + "import random\n", + "\n", + "class DemandGenerator:\n", + " def __init__(self, env: simpy.Environment, floors: tuple[int], elevator, lambda_: float):\n", + " \"\"\"\n", + " Generates elevator demand at random intervals.\n", + "\n", + " Args:\n", + " env: SimPy environment\n", + " floors: Valid floor numbers\n", + " elevator: Reference to the Elevator instance\n", + " lambda_: Mean arrival interval (Exponential distribution)\n", + " \"\"\"\n", + " self.env = env\n", + " self.floors = floors\n", + " self.elevator = elevator\n", + " self.lambda_ = lambda_\n", + "\n", + " # Start the generator process\n", + " self.process = env.process(self.run())\n", + "\n", + " def generate_interarrival_time(self) -> float:\n", + " \"\"\"\n", + " Samples the next interarrival time from an exponential distribution.\n", + " \"\"\"\n", + " return random.expovariate(self.lambda_)\n", + "\n", + " def generate_origin_destination(self) -> Tuple[int, int]:\n", + " \"\"\"\n", + " Selects origin and destination floors from a distribution.\n", + " Ensures origin != destination.\n", + " \"\"\"\n", + " origin = self.weighted_floor_choice()\n", + " destination = self.weighted_floor_choice(exclude=origin)\n", + " return origin, destination\n", + "\n", + " def weighted_floor_choice(self, exclude: int = None) -> int:\n", + " \"\"\"\n", + " Selects a floor with higher probability for base floor.\n", + " Optionally excludes a specific floor.\n", + " This mimics a uniform distribution with a peak.\n", + " \"\"\"\n", + " valid_floors = [floor for floor in self.floors if floor != exclude]\n", + " weights = [BASE_FLOOR_WEIGHT if floor == self.elevator.base_floor else 1 for floor in valid_floors]\n", + " return random.choices(valid_floors, weights=weights, k=1)[0]\n", + "\n", + " def run(self):\n", + " \"\"\"\n", + " Generates demand at stochastic intervals and sends tasks to the elevator.\n", + " \"\"\"\n", + " while True:\n", + " # Wait until next demand\n", + " interarrival_time = self.generate_interarrival_time()\n", + " yield self.env.timeout(interarrival_time)\n", + "\n", + " # Generate a random request\n", + " origin, destination = self.generate_origin_destination()\n", + "\n", + " # Elevator gets a task to go to origin then to destination\n", + " print(f\"[{self.env.now:.1f}] Request: from {origin} to {destination}\")\n", + " self.elevator.add_task(origin)\n", + " self.elevator.add_task(destination)\n" + ], + "metadata": { + "id": "fcS4vqwzkB0x" + }, + "execution_count": 59, + "outputs": [] + }, + { + "cell_type": "code", + "source": [ + "# simulation.py\n", + "\n", + "#from elevator import Elevator\n", + "#from demand_generator import DemandGenerator\n", + "\n", + "class Simulation:\n", + " def __init__(\n", + " self,\n", + " sim_time: float,\n", + " floors: tuple[int],\n", + " speed_floors_per_sec: float,\n", + " lambda_: float,\n", + " base_floor: int\n", + " ):\n", + " \"\"\"\n", + " Main simulation controller.\n", + "\n", + " Args:\n", + " sim_time: Total duration of the simulation (in seconds)\n", + " floors: Valid floor numbers (e.g., (1, 2, ..., 10))\n", + " speed_floors_per_sec: Elevator travel speed\n", + " lambda_: Average time between user requests (Poisson process)\n", + " base_floor: Starting floor\n", + " \"\"\"\n", + " self.sim_time = sim_time\n", + " self.env = simpy.Environment()\n", + "\n", + " # Initialize elevator and demand generator\n", + " self.elevator = Elevator(\n", + " env=self.env,\n", + " floors=floors,\n", + " speed_floors_per_sec=speed_floors_per_sec,\n", + " base_floor=base_floor\n", + " )\n", + "\n", + " self.demand_generator = DemandGenerator(\n", + " env=self.env,\n", + " floors=floors,\n", + " elevator=self.elevator,\n", + " lambda_=lambda_\n", + " )\n", + "\n", + " def run(self):\n", + " \"\"\"\n", + " Runs the simulation.\n", + " \"\"\"\n", + " self.env.run(until=self.sim_time)\n" + ], + "metadata": { + "id": "MT1xLhj8kIWo" + }, + "execution_count": 60, + "outputs": [] + }, + { + "cell_type": "code", + "source": [ + "# run\n", + "\n", + "if __name__ == \"__main__\":\n", + " sim = Simulation(\n", + " sim_time=SIMULATION_DURATION,\n", + " floors=FLOORS,\n", + " speed_floors_per_sec=DEFAULT_SPEED,\n", + " lambda_=DEFAULT_LAMBDA,\n", + " base_floor=DEFAULT_BASE_FLOOR\n", + " )\n", + " sim.run()\n" + ], + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "G_0OKjunkdnY", + "outputId": "abe569d3-5a55-4da6-d206-62f306dc9ebd" + }, + "execution_count": 61, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "[5.2] Request: from 5 to 3\n", + "[5.2] Elevator processing request to floor 5\n", + "[5.2] Elevator starting move from 1 to 5\n", + "[9.2] Elevator arrived at floor 5\n", + "[10.2] Elevator processing request to floor 3\n", + "[10.2] Elevator starting move from 5 to 3\n", + "[12.2] Elevator arrived at floor 3\n", + "[13.2] Elevator vacant, going to floor 1\n", + "[13.2] Elevator starting move from 3 to 1\n", + "[13.7] Request: from 3 to 5\n", + "[15.2] Elevator arrived at floor 1\n", + "[15.3] Elevator processing request to floor 3\n", + "[15.3] Elevator starting move from 1 to 3\n", + "[17.3] Elevator arrived at floor 3\n", + "[18.3] Elevator processing request to floor 5\n", + "[18.3] Elevator starting move from 3 to 5\n", + "[20.3] Elevator arrived at floor 5\n", + "[21.3] Elevator vacant, going to floor 1\n", + "[21.3] Elevator starting move from 5 to 1\n", + "[22.6] Request: from 1 to 3\n", + "[24.6] Request: from 1 to 4\n", + "[25.3] Elevator arrived at floor 1\n", + "[25.4] Elevator processing request to floor 1\n", + "[25.4] Elevator is already at floor 1\n", + "[25.4] Elevator processing request to floor 3\n", + "[25.4] Elevator starting move from 1 to 3\n", + "[27.4] Elevator arrived at floor 3\n", + "[28.4] Elevator processing request to floor 1\n", + "[28.4] Elevator starting move from 3 to 1\n", + "[30.4] Elevator arrived at floor 1\n", + "[31.4] Elevator processing request to floor 4\n", + "[31.4] Elevator starting move from 1 to 4\n", + "[34.4] Elevator arrived at floor 4\n", + "[35.4] Elevator vacant, going to floor 1\n", + "[35.4] Elevator starting move from 4 to 1\n", + "[38.4] Elevator arrived at floor 1\n", + "[62.0] Request: from 5 to 1\n", + "[62.1] Elevator processing request to floor 5\n", + "[62.1] Elevator starting move from 1 to 5\n", + "[66.1] Elevator arrived at floor 5\n", + "[67.1] Elevator processing request to floor 1\n", + "[67.1] Elevator starting move from 5 to 1\n", + "[71.1] Elevator arrived at floor 1\n", + "[92.7] Request: from 1 to 2\n", + "[92.7] Elevator processing request to floor 1\n", + "[92.7] Elevator is already at floor 1\n", + "[92.7] Elevator processing request to floor 2\n", + "[92.7] Elevator starting move from 1 to 2\n", + "[93.7] Elevator arrived at floor 2\n", + "[94.7] Elevator vacant, going to floor 1\n", + "[94.7] Elevator starting move from 2 to 1\n", + "[95.7] Elevator arrived at floor 1\n" + ] + } + ] + } + ] +} diff --git a/simulation/demand_generator.py b/simulation/demand_generator.py new file mode 100644 index 0000000..8a177da --- /dev/null +++ b/simulation/demand_generator.py @@ -0,0 +1,74 @@ +from typing import Tuple +import simpy +import random + +from params import BASE_FLOOR_WEIGHT + +class DemandGenerator: + def __init__(self, env: simpy.Environment, floors: tuple[int], elevator, lambda_: float): + """ + Generates elevator demand at random intervals. + + Args: + env: SimPy environment + floors: Valid floor numbers + elevator: Reference to the Elevator instance + lambda_: Mean arrival interval (Exponential distribution) + """ + self.env = env + self.floors = floors + self.elevator = elevator + self.lambda_ = lambda_ + + # Start the generator process + self.process = env.process(self.run()) + + def generate_interarrival_time(self) -> float: + """ + Samples the next interarrival time from an exponential distribution. + """ + return random.expovariate(self.lambda_) + + def generate_origin_destination(self) -> Tuple[int, int]: + """ + Selects origin and destination floors from a distribution. + Ensures origin != destination. + """ + origin = self.weighted_floor_choice() + destination = self.weighted_floor_choice(exclude=origin) + return origin, destination + + def weighted_floor_choice(self, exclude: int = None) -> int: + """ + Selects a floor with higher probability for base floor. + Optionally excludes a specific floor. + This mimics a uniform distribution with a peak. + """ + valid_floors = [floor for floor in self.floors if floor != exclude] + weights = [BASE_FLOOR_WEIGHT if floor == self.elevator.base_floor else 1 for floor in valid_floors] + return random.choices(valid_floors, weights=weights, k=1)[0] + + def run(self): + """ + Generates demand at stochastic intervals and sends tasks to the elevator. + """ + while True: + # Wait until next demand + interarrival_time = self.generate_interarrival_time() + yield self.env.timeout(interarrival_time) + + # Generate a random request + origin, destination = self.generate_origin_destination() + self.elevator.request_histogram[origin] += 1 + + # We have label for the snapshot (next request), update, store and clean + if self.elevator.last_snapshot: + self.elevator.last_snapshot["next_floor_requested"] = origin + self.elevator.post_snapshot() + self.elevator.last_snapshot = None + + + # Elevator gets a task to go to origin then to destination + print(f"[{self.env.now:.1f}] Request: from {origin} to {destination}") + self.elevator.add_task(origin) + self.elevator.add_task(destination) \ No newline at end of file diff --git a/simulation/elevator.py b/simulation/elevator.py new file mode 100644 index 0000000..4e63b9a --- /dev/null +++ b/simulation/elevator.py @@ -0,0 +1,227 @@ +from datetime import timedelta +from collections import deque +import requests +import simpy +import math +import os + +from params import DEFAULT_WAIT_TIME, DEFAULT_CHECK_TIME + + +class Elevator: + def __init__(self, env: simpy.Environment, floors: tuple[int], speed_floors_per_sec: float, base_floor: int, simulation): + """ + Elevator agent, takes requests and moves across floors and stores data of interest. + + Args: + env: SimPy environment + floors: Valid floor numbers (1, 2, 3, ..., 10) + speed_floors_per_sec: Constant speed of elevator in floors per second + base_floor: floor at street level, starting point + simulation: parent simulation object + """ + self.env = env + self.floors = floors + self.speed = speed_floors_per_sec + self.base_floor = base_floor if base_floor in self.floors else None + self.simulation = simulation + + # Data structures + self.last_snapshot = None # stores data of interest + self.task_queue = deque() + self.moving = False + + # Stats + self.current_floor = base_floor + self.last_floor = None + self.idle_start_time = None + self.request_histogram = {f: 0 for f in self.floors} + + # Start the elevator process + self.process = env.process(self.run()) + + def add_task(self, target_floor: int): + """ + Enqueue a request to move to a specific floor. + """ + if target_floor not in self.floors: + raise ValueError(f"Invalid floor: {target_floor}") + self.task_queue.append(target_floor) + + def hold(self, duration: float): + """ + Elevator remains idle at current floor for a set duration. + """ + yield self.env.timeout(duration) + + def move_to(self, target_floor: int): + """ + Simulates elevator travel from current floor to target_floor. + Uses constant speed to compute duration. + """ + # Calculate travel time + floor_diff = abs(self.current_floor - target_floor) + if floor_diff == 0: + return # Already at floor + travel_time = floor_diff / self.speed + self.last_floor = self.current_floor + + # Move event + self.moving = True + print(f"[{self.env.now:.1f}] Elevator starting move from {self.current_floor} to {target_floor}") + yield self.env.timeout(travel_time) + + self.current_floor = target_floor + self.moving = False + print(f"[{self.env.now:.1f}] Elevator arrived at floor {self.current_floor}") + + def run(self): + """ + Elevator main loop: process queued tasks in FIFO order. + """ + while True: + if self.task_queue: + + # Get next task + next_floor = self.task_queue.popleft() + self.idle_start_time = None + print(f"[{self.env.now:.1f}] Elevator processing request to floor {next_floor}") + + # Move if necesary + if next_floor == self.current_floor and not self.moving: + print(f"[{self.env.now:.1f}] Elevator is already at floor {next_floor}") + else: + yield self.env.process(self.move_to(next_floor)) + yield self.env.process(self.hold(DEFAULT_WAIT_TIME)) # hold briefly after arrival + else: + + # No tasks, execute resting policy: + + # 1. Stay at current floor + # yield self.env.timeout(DEFAULT_CHECK_TIME) + + # 2. Go to base floor + next_floor = self.base_floor + + if self.current_floor != next_floor: + print(f"[{self.env.now:.1f}] Elevator vacant, going to floor {next_floor}") + yield self.env.process(self.move_to(next_floor)) + + # 3. Use next floor prediction from a model + # WIP + + if self.idle_start_time is None: + self.idle_start_time = self.env.now + + # Here the elevator is vacant waiting for next requested floor, + # so save a snapshot + self.save_snapshot() + + yield self.env.timeout(DEFAULT_CHECK_TIME) + + def compute_mean_floor(self, histogram: dict): + """ + Calculates the weighted mean floor based on cumulative demand histogram. + Gives a notion of the location of a hot spot. + """ + total = sum(histogram.values()) + if total == 0: + return None + + weighted_sum = sum(floor * count for floor, count in histogram.items()) + return weighted_sum / total + + + def compute_center_of_mass_distance(self, current_floor: int): + """ + Computes the absolute distance between the current floor and demand center of mass. + Gives a notion of the distance to a hot spot. + """ + mean = self.compute_mean_floor(self.request_histogram) + if mean is None: + return None + + return abs(current_floor - mean) + + def compute_entropy(self, histogram: dict): + """ + Calculates the entropy of the cumulative floor demand histogram. + Captures how predictable or chaotic the demand has been: + low entropy, requests come from few floors; + high entropy, requests evenly spread out. + Gives a notion of the distribution we are dealing with and its predictability. + + Note: 0 ≤ entropy ≤ log2(floors) + """ + total = sum(histogram.values()) + if total == 0: + return None + + entropy = 0.0 + for count in histogram.values(): + if count > 0: + p = count / total + entropy -= p * math.log2(p) + + return round(entropy, 3) + + + def save_snapshot(self): + """ + Captures elevator state when idle and relevant features. + Stores the data with expected backend format, but in memory to add label later. + """ + timestamp = self.simulation.start_datetime + timedelta(seconds=self.env.now) + + # Features to compute + histogram = [self.request_histogram[f] for f in self.floors] + entropy = self.compute_entropy(self.request_histogram) + #hot_floor = self.get_hot_floor_last_30s() # TODO + mean_floor = self.compute_mean_floor(self.request_histogram) + center_of_mass_distance = self.compute_center_of_mass_distance(self.current_floor) + + # Create dict as expected by backend + self.last_snapshot = { + "simulation_id": self.simulation.simulation_id, + "current_floor": self.current_floor, + "last_floor": self.last_floor, + "time_idle": round(self.env.now - self.idle_start_time, 3), + "timestamp": timestamp.isoformat(), + "floor_demand_histogram": histogram, + #"hot_floor_last_30s": hot_floor, # TODO + "requests_entropy": entropy, + "mean_requested_floor": mean_floor, + "distance_to_center_of_mass": center_of_mass_distance, + "next_floor_requested": None + } + + def post_snapshot(self): + """ + Stores the completed snapshot in the backend database. + + Example snapshot: + { + 'simulation_id': 13, + 'current_floor': 1, + 'last_floor': 2, + 'time_idle': 52.0, + 'timestamp': '2025-07-04T18:55:18.39', + 'floor_demand_histogram': [2, 1, 1, 1, 0], + 'requests_entropy': 1.922, + 'mean_requested_floor': 2.2, + 'distance_to_center_of_mass': 1.20, + 'next_floor_requested': 3 + } + """ + if not self.last_snapshot: + raise ValueError("No snapshot to store!") + + api_url = os.getenv("API_BASE_URL", "http://localhost:8000") + endpoint = f"{api_url}/elevator_request" + response = requests.post(endpoint, json=self.last_snapshot) + + if response.status_code != 200: + raise Exception(f"Failed to post elevator request: {response.status_code} {response.text}") + print(f"[SYS] Snapshot posted! {self.last_snapshot}") + + diff --git a/simulation/params.py b/simulation/params.py new file mode 100644 index 0000000..d6ca5c6 --- /dev/null +++ b/simulation/params.py @@ -0,0 +1,10 @@ +# Simulation constant parameters + +DEFAULT_WAIT_TIME = 1.0 # in seconds +DEFAULT_SPEED = 1.0 # floors per sec +DEFAULT_LAMBDA = 0.1 # average of requests per second, for poisson proces +FLOORS = tuple(range(1, 6)) # floors 1 to 5 +SIMULATION_DURATION = 100 # in seconds +DEFAULT_BASE_FLOOR = 1 # starting floor, "street level" +BASE_FLOOR_WEIGHT = 3 # how many times base floor is more likely to be requested +DEFAULT_CHECK_TIME = 0.5 # every how many seconds the elevator checks for new tasks \ No newline at end of file diff --git a/simulation/simulation.py b/simulation/simulation.py new file mode 100644 index 0000000..6b225a3 --- /dev/null +++ b/simulation/simulation.py @@ -0,0 +1,113 @@ +from datetime import datetime, timedelta +import simpy +import random +import os +import requests + +from elevator import Elevator +from demand_generator import DemandGenerator + +from params import ( + SIMULATION_DURATION, + FLOORS, DEFAULT_SPEED, + DEFAULT_LAMBDA, + DEFAULT_BASE_FLOOR, + DEFAULT_WAIT_TIME, + BASE_FLOOR_WEIGHT, +) + +class Simulation: + def __init__( + self, + sim_time: float, + floors: tuple[int], + speed_floors_per_sec: float, + lambda_: float, + base_floor: int, + start_datetime: datetime, + seed: int + ): + """ + Main simulation controller. + + Args: + sim_time: Total duration of the simulation (in seconds) + floors: Valid floor numbers (e.g., (1, 2, ..., 10)) + speed_floors_per_sec: Elevator travel speed + lambda_: Average time between user requests (Poisson process) + base_floor: Starting floor + """ + self.sim_time = sim_time + self.env = simpy.Environment() + self.start_datetime = start_datetime + self.simulation_id = None # is set by backend + + # Set seed + self.seed = seed + random.seed(seed) + + # Initialize elevator and demand generator + self.elevator = Elevator( + env=self.env, + floors=floors, + speed_floors_per_sec=speed_floors_per_sec, + base_floor=base_floor, + simulation=self + ) + + self.demand_generator = DemandGenerator( + env=self.env, + floors=floors, + elevator=self.elevator, + lambda_=lambda_ + ) + + def run(self): + """ + Runs the simulation. + """ + self.env.run(until=self.sim_time) + + def post_metadata(self): + """ + Sends simulation metadata to the FastAPI backend. + Returns the simulation ID assigned by the API. + """ + api_url = os.getenv("API_BASE_URL", "http://localhost:8000") + endpoint = f"{api_url}/simulation" + + payload = { + "wait_time": DEFAULT_WAIT_TIME, + "elevator_speed": self.elevator.speed, + "expo_lambda": self.demand_generator.lambda_, + "start_datetime": self.start_datetime.isoformat(), + "duration": int(self.sim_time), + "base_floor": self.elevator.base_floor, + "base_floor_weight": BASE_FLOOR_WEIGHT, + "floor_min": min(self.elevator.floors), + "floor_max": max(self.elevator.floors), + "random_seed": self.seed, + } + + response = requests.post(endpoint, json=payload) + if response.status_code != 200: + raise Exception(f"Failed to post simulation metadata: {response.status_code} {response.text}") + + sim_data = response.json() + print(f"Simulation metadata saved with ID: {sim_data['id']}") + self.simulation_id = sim_data["id"] + +if __name__ == "__main__": + sim = Simulation( + sim_time=SIMULATION_DURATION, + floors=FLOORS, + speed_floors_per_sec=DEFAULT_SPEED, + lambda_=DEFAULT_LAMBDA, + base_floor=DEFAULT_BASE_FLOOR, + start_datetime=datetime.now(), + seed=31 + ) + print("[SYS] Simulation started at:", sim.start_datetime) + sim.post_metadata() # save metadata before starting + sim.run() + print("[SYS] Simulation ended at:", sim.start_datetime + timedelta(seconds=sim.sim_time)) \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..5985444 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,66 @@ +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + +def test_post_simulation(): + """ + Test that the /simulation endpoint correctly creates a simulation record. + Checks for status 200 and presence of required fields in the response. + """ + response = client.post("/simulation", json={ + "wait_time": 1.0, + "elevator_speed": 1.0, + "expo_lambda": 0.1, + "start_datetime": "2025-06-29T00:00:00", + "duration": 100, + "base_floor": 1, + "base_floor_weight": 5, + "floor_min": 1, + "floor_max": 5, + "random_seed": 42 + }) + assert response.status_code == 200 + data = response.json() + assert "id" in data + assert data["elevator_speed"] == 1.0 + global simulation_id + simulation_id = data["id"] # used in subsequent tests + + +def test_post_elevator_request(): + """ + Test that the /elevator_request endpoint correctly stores a request snapshot. + Uses the simulation ID from the previous test. Verifies basic structure. + """ + payload = { + "simulation_id": simulation_id, + "current_floor": 3, + "last_floor": 2, + "time_idle": 5.0, + "timestamp": "2025-06-29T00:01:00", + "floor_demand_histogram": [1, 2, 3, 0, 0], + "hot_floor_last_30s": 2, + "requests_entropy": 1.5, + "mean_requested_floor": 2.5, + "distance_to_center_of_mass": 0.5, + "next_floor_requested": 1 + } + response = client.post("/elevator_request", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["current_floor"] == 3 + global request_id + request_id = data["id"] + + +def test_get_requests_by_simulation(): + """ + Test that the /elevator_request/{sim_id} endpoint returns a list of requests. + Verifies the previously posted request is included in the list. + """ + response = client.get(f"/elevator_request/{simulation_id}") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert any(req["id"] == request_id for req in data) diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..dc286ee --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,13 @@ +from app.db import SessionLocal + +def test_database_connection(): + """ + Verifies that a connection to the PostgreSQL database can be established. + Executes a trivial SELECT statement to confirm connectivity. + """ + try: + db = SessionLocal() + result = db.execute("SELECT 1").scalar() + assert result == 1 + finally: + db.close() \ No newline at end of file