From 35a77069ebe7094038a192f9675ddb685686b041 Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 12:03:53 -0400 Subject: [PATCH 01/30] elevator descrete event simulation --- ElevatorSimulation.ipynb | 391 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 ElevatorSimulation.ipynb diff --git a/ElevatorSimulation.ipynb b/ElevatorSimulation.ipynb new file mode 100644 index 0000000..8925396 --- /dev/null +++ b/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" + ] + } + ] + } + ] +} \ No newline at end of file From 9b9b0712c03b033b26c05d70ad091d25900fe85c Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 12:32:34 -0400 Subject: [PATCH 02/30] add simulation metadata model --- models.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 models.py diff --git a/models.py b/models.py new file mode 100644 index 0000000..544c63b --- /dev/null +++ b/models.py @@ -0,0 +1,33 @@ +from sqlalchemy import Column, Integer, Float, ForeignKey +from sqlalchemy.orm import declarative_base, relationship + +Base = declarative_base() + +class SimulationMetadata(Base): + __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 + 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): + __tablename__ = "elevator_requests" + + id = Column(Integer, primary_key=True, index=True) + + # N-1 relationship with simulation + simulation_id = Column(Integer, ForeignKey("simulations.id"), nullable=False) + simulation = relationship("SimulationMetadata", back_populates="requests") From 2de8c51831452639f844b16a33b04505e563a0fd Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 13:00:40 -0400 Subject: [PATCH 03/30] add elevator request model --- models.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/models.py b/models.py index 544c63b..6573b54 100644 --- a/models.py +++ b/models.py @@ -1,9 +1,14 @@ from sqlalchemy import Column, Integer, Float, ForeignKey +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) @@ -11,23 +16,44 @@ class SimulationMetadata(Base): # 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 + expo_lambda = Column(Float, nullable=False) # req/sec duration = Column(Integer, nullable=False) # seconds base_floor = Column(Integer, nullable=True) - base_floor_weight = Column(Float, nullable=True) # chance multiplier + 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 + 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(Float, nullable=False) + + # Calculated indicators + floor_demand_histogram = Column(ARRAY(Integer), nullable=False) # e.g., [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") From 89c5548d933f7716d458d7b5753a2e5f3e207d78 Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 13:05:47 -0400 Subject: [PATCH 04/30] changed timestamp to datetime --- models.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/models.py b/models.py index 6573b54..f39f251 100644 --- a/models.py +++ b/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, Float, ForeignKey +from sqlalchemy import Column, Integer, Float, ForeignKey, DateTime from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.orm import declarative_base, relationship @@ -17,6 +17,7 @@ class SimulationMetadata(Base): 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 @@ -42,10 +43,10 @@ class ElevatorRequest(Base): current_floor = Column(Integer, nullable=False) last_floor = Column(Integer, nullable=False) time_idle = Column(Float, nullable=False) - timestamp = Column(Float, nullable=False) + timestamp = Column(DateTime, nullable=False) # Calculated indicators - floor_demand_histogram = Column(ARRAY(Integer), nullable=False) # e.g., [1, 2, 0, 3, 1] + 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) From ab0d4d0ad219e9f5a29f5c7dddf73fa9b98b3a68 Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 13:20:11 -0400 Subject: [PATCH 05/30] added endpoints for requests and simulation --- routes.py | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 routes.py diff --git a/routes.py b/routes.py new file mode 100644 index 0000000..9c058ea --- /dev/null +++ b/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 database 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 simulations available + """ + 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() From 84aaee38a2add1c2fd23c5fb7336095c104c3289 Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 13:25:36 -0400 Subject: [PATCH 06/30] add schemas for request and simulation --- schemas.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 schemas.py diff --git a/schemas.py b/schemas.py new file mode 100644 index 0000000..9dcdeb1 --- /dev/null +++ b/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 From 5849c54af9e470d9cb78f2fd12063d4ddc24f94a Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 13:31:00 -0400 Subject: [PATCH 07/30] move into app dir --- schemas.py => app/schemas.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename schemas.py => app/schemas.py (100%) diff --git a/schemas.py b/app/schemas.py similarity index 100% rename from schemas.py rename to app/schemas.py From 3fc599cb3070a05e6e43581361df5f409514126a Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 13:31:23 -0400 Subject: [PATCH 08/30] move into app dir --- routes.py => app/routes.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename routes.py => app/routes.py (100%) diff --git a/routes.py b/app/routes.py similarity index 100% rename from routes.py rename to app/routes.py From 88ac35f94eeffb68fab34492055d2cca9f50391c Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 13:42:04 -0400 Subject: [PATCH 09/30] move into app dir --- models.py => app/models.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename models.py => app/models.py (100%) diff --git a/models.py b/app/models.py similarity index 100% rename from models.py rename to app/models.py From adff816bd8a54ec5a3904fd70009b2f26621cbc2 Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 13:43:20 -0400 Subject: [PATCH 10/30] add db.py --- app/db.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 app/db.py 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() From a300e0467f8ef02b79cc29b913d827a525beb5ff Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 13:46:32 -0400 Subject: [PATCH 11/30] add main.py to run app --- app/main.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 app/main.py 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) From 8839434e03104b48ff7f8214df5a397c0852fe9c Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 14:07:50 -0400 Subject: [PATCH 12/30] add docker compose --- docker-compose.yml | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a923e77 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +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: From e40b2a03390081bfd55913458def499f10b24434 Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 14:09:46 -0400 Subject: [PATCH 13/30] comment file --- docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index a923e77..c811408 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,6 @@ +# yaml file to orquestrate the services of the system: +# DB - API - Simulation + version: "3.9" services: From 6c4e2513043034779e220b90a20434b7b0d3cf05 Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 14:10:48 -0400 Subject: [PATCH 14/30] add api dockerfile --- Dockerfile.api | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 Dockerfile.api 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 From 93f5f3de94120cda008561246edffb3e3c01a7a1 Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 14:11:45 -0400 Subject: [PATCH 15/30] add app reqs --- requirements.api.txt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 requirements.api.txt diff --git a/requirements.api.txt b/requirements.api.txt new file mode 100644 index 0000000..5d0711c --- /dev/null +++ b/requirements.api.txt @@ -0,0 +1,6 @@ +fastapi +uvicorn +sqlalchemy +psycopg2-binary +pydantic +python-dotenv From c2da940f97e3a46a7f905f4148657f7040386b07 Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 14:13:14 -0400 Subject: [PATCH 16/30] add dockerfile for simulation --- Dockerfile.sim | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 Dockerfile.sim 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 ./ From 7d3e4ad3bed6bacd6a8defd6ce128d6ee7726625 Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 14:14:04 -0400 Subject: [PATCH 17/30] add reqs for sim --- requirements.sim.txt | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 requirements.sim.txt 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 From a0aee1a966d304ef6650c1afd8aa5de006cf5f1f Mon Sep 17 00:00:00 2001 From: "Jan Siegel M." Date: Fri, 4 Jul 2025 16:11:50 -0400 Subject: [PATCH 18/30] simulation dir --- ElevatorSimulation.ipynb => simulation/ElevatorSimulation.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename ElevatorSimulation.ipynb => simulation/ElevatorSimulation.ipynb (99%) diff --git a/ElevatorSimulation.ipynb b/simulation/ElevatorSimulation.ipynb similarity index 99% rename from ElevatorSimulation.ipynb rename to simulation/ElevatorSimulation.ipynb index 8925396..9793741 100644 --- a/ElevatorSimulation.ipynb +++ b/simulation/ElevatorSimulation.ipynb @@ -388,4 +388,4 @@ ] } ] -} \ No newline at end of file +} From 45557a827a0566c27d6eeaa66135654d48af1a7e Mon Sep 17 00:00:00 2001 From: Jano Date: Fri, 4 Jul 2025 16:34:50 -0400 Subject: [PATCH 19/30] separated notebook into files --- simulation/demand_generator.py | 66 +++++++++++++++++++++++ simulation/elevator.py | 95 ++++++++++++++++++++++++++++++++++ simulation/params.py | 10 ++++ simulation/simulation.py | 69 ++++++++++++++++++++++++ 4 files changed, 240 insertions(+) create mode 100644 simulation/demand_generator.py create mode 100644 simulation/elevator.py create mode 100644 simulation/params.py create mode 100644 simulation/simulation.py diff --git a/simulation/demand_generator.py b/simulation/demand_generator.py new file mode 100644 index 0000000..c45110b --- /dev/null +++ b/simulation/demand_generator.py @@ -0,0 +1,66 @@ +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() + + # 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..e35ffa3 --- /dev/null +++ b/simulation/elevator.py @@ -0,0 +1,95 @@ +from collections import deque +import simpy + +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): + """ + Elevator agent, takes requests and moves across floors. + + 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 + """ + 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.current_floor = base_floor + self.task_queue = deque() + self.moving = False + + # 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 + + # 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() + 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)) + + yield self.env.timeout(DEFAULT_CHECK_TIME) \ No newline at end of file diff --git a/simulation/params.py b/simulation/params.py new file mode 100644 index 0000000..d16df21 --- /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 = 5 # how many times base floor is more likely to be requested +DEFAULT_CHECK_TIME = 0.1 # 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..fb64826 --- /dev/null +++ b/simulation/simulation.py @@ -0,0 +1,69 @@ +import simpy +from datetime import datetime, timedelta + +from elevator import Elevator +from demand_generator import DemandGenerator + +from params import ( + SIMULATION_DURATION, + FLOORS, DEFAULT_SPEED, + DEFAULT_LAMBDA, DEFAULT_BASE_FLOOR +) + +class Simulation: + def __init__( + self, + sim_time: float, + floors: tuple[int], + speed_floors_per_sec: float, + lambda_: float, + base_floor: int, + start_datetime: datetime + ): + """ + 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 + + # 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 + ) + + 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) + +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() + ) + print("Simulation started at:", sim.start_datetime) + sim.run() + print("Simulation ended at:", sim.start_datetime + timedelta(seconds=sim.sim_time)) \ No newline at end of file From 849085180beadfe791773bba2a5a3857dd5b1645 Mon Sep 17 00:00:00 2001 From: Jano Date: Fri, 4 Jul 2025 17:46:08 -0400 Subject: [PATCH 20/30] fix indentation --- app/routes.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/app/routes.py b/app/routes.py index 9c058ea..6d30203 100644 --- a/app/routes.py +++ b/app/routes.py @@ -4,7 +4,7 @@ from models import SimulationMetadata, ElevatorRequest from schemas import SimulationCreate, SimulationOut, ElevatorRequestCreate, ElevatorRequestOut -from database import get_db +from db import get_db router = APIRouter() @@ -15,19 +15,19 @@ 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 + 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 simulations available + Read all available simulations """ - return db.query(SimulationMetadata).all() + return db.query(SimulationMetadata).all() @router.get("/simulation/{id}", response_model=SimulationOut) @@ -35,10 +35,10 @@ 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 + 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 --- @@ -48,11 +48,11 @@ def create_elevator_request(req_data: ElevatorRequestCreate, db: Session = Depen """ Creates a single request """ - req = ElevatorRequest(**req_data.dict()) - db.add(req) - db.commit() - db.refresh(req) - return req + 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]) @@ -60,4 +60,4 @@ 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() + return db.query(ElevatorRequest).filter(ElevatorRequest.simulation_id == sim_id).all() From 8b3bf7c60ac345b457f44687c8c5d68febe8fb82 Mon Sep 17 00:00:00 2001 From: Jano Date: Fri, 4 Jul 2025 17:46:45 -0400 Subject: [PATCH 21/30] add snapshot logic --- simulation/demand_generator.py | 7 ++++ simulation/elevator.py | 61 ++++++++++++++++++++++++++++++++-- simulation/params.py | 2 +- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/simulation/demand_generator.py b/simulation/demand_generator.py index c45110b..f5b7553 100644 --- a/simulation/demand_generator.py +++ b/simulation/demand_generator.py @@ -60,6 +60,13 @@ def run(self): # Generate a random request origin, destination = self.generate_origin_destination() + # 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) diff --git a/simulation/elevator.py b/simulation/elevator.py index e35ffa3..6d567de 100644 --- a/simulation/elevator.py +++ b/simulation/elevator.py @@ -1,5 +1,8 @@ +from datetime import timedelta from collections import deque +import requests import simpy +import os from params import DEFAULT_WAIT_TIME, DEFAULT_CHECK_TIME @@ -7,7 +10,7 @@ class Elevator: def __init__(self, env: simpy.Environment, floors: tuple[int], speed_floors_per_sec: float, base_floor: int): """ - Elevator agent, takes requests and moves across floors. + Elevator agent, takes requests and moves across floors and stores data of interest. Args: env: SimPy environment @@ -20,6 +23,7 @@ def __init__(self, env: simpy.Environment, floors: tuple[int], speed_floors_per_ self.speed = speed_floors_per_sec self.base_floor = base_floor if base_floor in self.floors else None + self.last_snapshot = None # stores data of interest self.current_floor = base_floor self.task_queue = deque() self.moving = False @@ -92,4 +96,57 @@ def run(self): print(f"[{self.env.now:.1f}] Elevator vacant, going to floor {next_floor}") yield self.env.process(self.move_to(next_floor)) - yield self.env.timeout(DEFAULT_CHECK_TIME) \ No newline at end of file + # Here the elevator is vacant waiting for next requested floor, + # save a snapshot + + self.save_snapshot(self.current_floor, None, None) + + yield self.env.timeout(DEFAULT_CHECK_TIME) + + def save_snapshot(self, current_floor: int, last_floor: int, time_idle: float): + """ + Captures elevator state when idle and relevant features. + Stores the data with expected backend format, but in memory to add label later. + """ + # timestamp = self.start_datetime + timedelta(seconds=self.env.now) + + # # Features to compute + # histogram = self.get_floor_demand_histogram() + # entropy = self.compute_entropy(histogram) + # hot_floor = self.get_hot_floor_last_30s() + # mean_floor = self.compute_mean_floor(histogram) + # center_of_mass = self.compute_center_of_mass_distance(current_floor, histogram) + + # # Create dict as expected by backend + # self.last_snapshot = { + # "simulation_id": self.simulation_id, + # "current_floor": current_floor, + # "last_floor": last_floor, + # "time_idle": time_idle, + # "timestamp": timestamp.isoformat(), + # "floor_demand_histogram": histogram, + # "hot_floor_last_30s": hot_floor, + # "requests_entropy": entropy, + # "mean_requested_floor": mean_floor, + # "distance_to_center_of_mass": center_of_mass, + # "next_floor_requested": None + # } + self.last_snapshot = {"current_floor": current_floor} + print("snapshot created", self.last_snapshot) + + def post_snapshot(self): + """ + Stores the completed snapshot in the backend database. + """ + 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"[{self.env.now:.1f}] Snapshot posted: {self.last_snapshot}") + + diff --git a/simulation/params.py b/simulation/params.py index d16df21..5410027 100644 --- a/simulation/params.py +++ b/simulation/params.py @@ -7,4 +7,4 @@ SIMULATION_DURATION = 100 # in seconds DEFAULT_BASE_FLOOR = 1 # starting floor, "street level" BASE_FLOOR_WEIGHT = 5 # how many times base floor is more likely to be requested -DEFAULT_CHECK_TIME = 0.1 # every how many seconds the elevator checks for new tasks \ No newline at end of file +DEFAULT_CHECK_TIME = 0.5 # every how many seconds the elevator checks for new tasks \ No newline at end of file From cc3b750ad094e93c7b17a0914fd974cf00d99c94 Mon Sep 17 00:00:00 2001 From: Jano Date: Fri, 4 Jul 2025 17:48:07 -0400 Subject: [PATCH 22/30] add seed and metadata post --- simulation/simulation.py | 51 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/simulation/simulation.py b/simulation/simulation.py index fb64826..2858f64 100644 --- a/simulation/simulation.py +++ b/simulation/simulation.py @@ -1,5 +1,8 @@ -import simpy from datetime import datetime, timedelta +import simpy +import random +import os +import requests from elevator import Elevator from demand_generator import DemandGenerator @@ -7,7 +10,10 @@ from params import ( SIMULATION_DURATION, FLOORS, DEFAULT_SPEED, - DEFAULT_LAMBDA, DEFAULT_BASE_FLOOR + DEFAULT_LAMBDA, + DEFAULT_BASE_FLOOR, + DEFAULT_WAIT_TIME, + BASE_FLOOR_WEIGHT, ) class Simulation: @@ -18,7 +24,8 @@ def __init__( speed_floors_per_sec: float, lambda_: float, base_floor: int, - start_datetime: datetime + start_datetime: datetime, + seed: int ): """ Main simulation controller. @@ -33,6 +40,11 @@ def __init__( 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( @@ -55,6 +67,35 @@ def run(self): """ 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, @@ -62,8 +103,10 @@ def run(self): speed_floors_per_sec=DEFAULT_SPEED, lambda_=DEFAULT_LAMBDA, base_floor=DEFAULT_BASE_FLOOR, - start_datetime=datetime.now() + start_datetime=datetime.now(), + seed=31 ) print("Simulation started at:", sim.start_datetime) + sim.post_metadata() # save metadata before starting sim.run() print("Simulation ended at:", sim.start_datetime + timedelta(seconds=sim.sim_time)) \ No newline at end of file From bcaf51054c00d57e5aca01b3c93b79f61f7c19c5 Mon Sep 17 00:00:00 2001 From: Jano Date: Fri, 4 Jul 2025 18:03:43 -0400 Subject: [PATCH 23/30] last foor logic --- simulation/elevator.py | 59 +++++++++++++++++++++------------------- simulation/simulation.py | 2 +- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/simulation/elevator.py b/simulation/elevator.py index 6d567de..50bfa41 100644 --- a/simulation/elevator.py +++ b/simulation/elevator.py @@ -27,6 +27,7 @@ def __init__(self, env: simpy.Environment, floors: tuple[int], speed_floors_per_ self.current_floor = base_floor self.task_queue = deque() self.moving = False + self.last_floor = None # Start the elevator process self.process = env.process(self.run()) @@ -55,6 +56,7 @@ def move_to(self, target_floor: int): 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 @@ -96,42 +98,43 @@ def run(self): print(f"[{self.env.now:.1f}] Elevator vacant, going to floor {next_floor}") yield self.env.process(self.move_to(next_floor)) - # Here the elevator is vacant waiting for next requested floor, - # save a snapshot + # 3. Use prediction from a model + # WIP - self.save_snapshot(self.current_floor, None, None) + # 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 save_snapshot(self, current_floor: int, last_floor: int, time_idle: float): + 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.start_datetime + timedelta(seconds=self.env.now) - - # # Features to compute - # histogram = self.get_floor_demand_histogram() - # entropy = self.compute_entropy(histogram) - # hot_floor = self.get_hot_floor_last_30s() - # mean_floor = self.compute_mean_floor(histogram) - # center_of_mass = self.compute_center_of_mass_distance(current_floor, histogram) - - # # Create dict as expected by backend - # self.last_snapshot = { - # "simulation_id": self.simulation_id, - # "current_floor": current_floor, - # "last_floor": last_floor, - # "time_idle": time_idle, - # "timestamp": timestamp.isoformat(), - # "floor_demand_histogram": histogram, - # "hot_floor_last_30s": hot_floor, - # "requests_entropy": entropy, - # "mean_requested_floor": mean_floor, - # "distance_to_center_of_mass": center_of_mass, - # "next_floor_requested": None - # } - self.last_snapshot = {"current_floor": current_floor} + #timestamp = self.start_datetime + timedelta(seconds=self.env.now) + + # Features to compute + #histogram = self.get_floor_demand_histogram() + #entropy = self.compute_entropy(histogram) + #hot_floor = self.get_hot_floor_last_30s() + #mean_floor = self.compute_mean_floor(histogram) + #center_of_mass = self.compute_center_of_mass_distance(self.current_floor, histogram) + + # Create dict as expected by backend + self.last_snapshot = { + #"simulation_id": self.simulation_id, + "current_floor": self.current_floor, + "last_floor": self.last_floor, + #"time_idle": time_idle, + #"timestamp": timestamp.isoformat(), + #"floor_demand_histogram": histogram, + #"hot_floor_last_30s": hot_floor, + #"requests_entropy": entropy, + #"mean_requested_floor": mean_floor, + #"distance_to_center_of_mass": center_of_mass, + "next_floor_requested": None + } print("snapshot created", self.last_snapshot) def post_snapshot(self): diff --git a/simulation/simulation.py b/simulation/simulation.py index 2858f64..2111ec3 100644 --- a/simulation/simulation.py +++ b/simulation/simulation.py @@ -107,6 +107,6 @@ def post_metadata(self): seed=31 ) print("Simulation started at:", sim.start_datetime) - sim.post_metadata() # save metadata before starting + #sim.post_metadata() # save metadata before starting sim.run() print("Simulation ended at:", sim.start_datetime + timedelta(seconds=sim.sim_time)) \ No newline at end of file From 12dc367dcaef64fb238572264ad499971f0d6474 Mon Sep 17 00:00:00 2001 From: Jano Date: Fri, 4 Jul 2025 18:57:46 -0400 Subject: [PATCH 24/30] calculate features --- simulation/demand_generator.py | 1 + simulation/elevator.py | 104 ++++++++++++++++++++++++++++----- simulation/params.py | 2 +- simulation/simulation.py | 3 +- 4 files changed, 92 insertions(+), 18 deletions(-) diff --git a/simulation/demand_generator.py b/simulation/demand_generator.py index f5b7553..8a177da 100644 --- a/simulation/demand_generator.py +++ b/simulation/demand_generator.py @@ -59,6 +59,7 @@ def run(self): # 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: diff --git a/simulation/elevator.py b/simulation/elevator.py index 50bfa41..0282952 100644 --- a/simulation/elevator.py +++ b/simulation/elevator.py @@ -2,13 +2,14 @@ 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): + 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. @@ -22,12 +23,18 @@ def __init__(self, env: simpy.Environment, floors: tuple[int], speed_floors_per_ 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.current_floor = base_floor 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()) @@ -76,6 +83,7 @@ def run(self): # 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 @@ -101,38 +109,88 @@ def run(self): # 3. Use 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 # or a default floor + + 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.start_datetime + timedelta(seconds=self.env.now) + timestamp = self.simulation.start_datetime + timedelta(seconds=self.env.now) # Features to compute - #histogram = self.get_floor_demand_histogram() - #entropy = self.compute_entropy(histogram) - #hot_floor = self.get_hot_floor_last_30s() - #mean_floor = self.compute_mean_floor(histogram) - #center_of_mass = self.compute_center_of_mass_distance(self.current_floor, histogram) + 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_id, + "simulation_id": self.simulation.simulation_id, "current_floor": self.current_floor, "last_floor": self.last_floor, - #"time_idle": time_idle, - #"timestamp": timestamp.isoformat(), - #"floor_demand_histogram": histogram, - #"hot_floor_last_30s": hot_floor, - #"requests_entropy": entropy, - #"mean_requested_floor": mean_floor, - #"distance_to_center_of_mass": center_of_mass, + "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 } print("snapshot created", self.last_snapshot) @@ -140,6 +198,20 @@ def save_snapshot(self): 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!") diff --git a/simulation/params.py b/simulation/params.py index 5410027..d6ca5c6 100644 --- a/simulation/params.py +++ b/simulation/params.py @@ -6,5 +6,5 @@ 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 = 5 # how many times base floor is more likely to be requested +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 index 2111ec3..a4024fa 100644 --- a/simulation/simulation.py +++ b/simulation/simulation.py @@ -51,7 +51,8 @@ def __init__( env=self.env, floors=floors, speed_floors_per_sec=speed_floors_per_sec, - base_floor=base_floor + base_floor=base_floor, + simulation=self ) self.demand_generator = DemandGenerator( From a85802b737e32c27621140e53c712ab71f7f043b Mon Sep 17 00:00:00 2001 From: Jano Date: Fri, 4 Jul 2025 19:01:53 -0400 Subject: [PATCH 25/30] cleaned code --- simulation/elevator.py | 18 +++++++++--------- simulation/simulation.py | 6 +++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/simulation/elevator.py b/simulation/elevator.py index 0282952..4e63b9a 100644 --- a/simulation/elevator.py +++ b/simulation/elevator.py @@ -18,6 +18,7 @@ def __init__(self, env: simpy.Environment, floors: tuple[int], speed_floors_per_ 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 @@ -106,7 +107,7 @@ def run(self): print(f"[{self.env.now:.1f}] Elevator vacant, going to floor {next_floor}") yield self.env.process(self.move_to(next_floor)) - # 3. Use prediction from a model + # 3. Use next floor prediction from a model # WIP if self.idle_start_time is None: @@ -125,7 +126,7 @@ def compute_mean_floor(self, histogram: dict): """ total = sum(histogram.values()) if total == 0: - return None # or a default floor + return None weighted_sum = sum(floor * count for floor, count in histogram.items()) return weighted_sum / total @@ -193,7 +194,6 @@ def save_snapshot(self): "distance_to_center_of_mass": center_of_mass_distance, "next_floor_requested": None } - print("snapshot created", self.last_snapshot) def post_snapshot(self): """ @@ -216,12 +216,12 @@ def post_snapshot(self): 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) + 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"[{self.env.now:.1f}] Snapshot posted: {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/simulation.py b/simulation/simulation.py index a4024fa..6b225a3 100644 --- a/simulation/simulation.py +++ b/simulation/simulation.py @@ -107,7 +107,7 @@ def post_metadata(self): start_datetime=datetime.now(), seed=31 ) - print("Simulation started at:", sim.start_datetime) - #sim.post_metadata() # save metadata before starting + print("[SYS] Simulation started at:", sim.start_datetime) + sim.post_metadata() # save metadata before starting sim.run() - print("Simulation ended at:", sim.start_datetime + timedelta(seconds=sim.sim_time)) \ No newline at end of file + print("[SYS] Simulation ended at:", sim.start_datetime + timedelta(seconds=sim.sim_time)) \ No newline at end of file From 43b89e8ca5c616e6b49711444f03fd2ac268c4d3 Mon Sep 17 00:00:00 2001 From: Jano Date: Fri, 4 Jul 2025 19:32:53 -0400 Subject: [PATCH 26/30] clean old files --- chatgpt/app_tests.py | 10 -------- chatgpt/db.sql | 12 --------- chatgpt/main.py | 43 --------------------------------- chatgpt/requirements.txt | 4 --- readme.md => readme-original.md | 0 5 files changed, 69 deletions(-) delete mode 100644 chatgpt/app_tests.py delete mode 100644 chatgpt/db.sql delete mode 100644 chatgpt/main.py delete mode 100644 chatgpt/requirements.txt rename readme.md => readme-original.md (100%) 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/readme.md b/readme-original.md similarity index 100% rename from readme.md rename to readme-original.md From 6dc965d42b93f7e24c337b506c060d6ef4e73e2a Mon Sep 17 00:00:00 2001 From: Jano Date: Fri, 4 Jul 2025 19:33:20 -0400 Subject: [PATCH 27/30] add tests --- requirements.api.txt | 2 ++ tests/test_api.py | 66 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_db.py | 13 +++++++++ 3 files changed, 81 insertions(+) create mode 100644 tests/test_api.py create mode 100644 tests/test_db.py diff --git a/requirements.api.txt b/requirements.api.txt index 5d0711c..18019a6 100644 --- a/requirements.api.txt +++ b/requirements.api.txt @@ -4,3 +4,5 @@ sqlalchemy psycopg2-binary pydantic python-dotenv +pytest +httpx \ 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 From 64798d46df8c145b47c8078152d70e9f7bacc5c8 Mon Sep 17 00:00:00 2001 From: Jano Date: Fri, 4 Jul 2025 19:33:32 -0400 Subject: [PATCH 28/30] added readme --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..cde7a5c --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# 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 but 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. +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. From c1cdec2815a85e3692db81c7a322776346d4fa35 Mon Sep 17 00:00:00 2001 From: Jano Date: Fri, 4 Jul 2025 19:36:07 -0400 Subject: [PATCH 29/30] readme typo --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index cde7a5c..25cddcf 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,13 @@ The design considers 3 main components: 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 but 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. +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. From 494d9f806329a8d51e75c18e1a4c7b0e4dc4b608 Mon Sep 17 00:00:00 2001 From: Jano Date: Fri, 4 Jul 2025 19:36:54 -0400 Subject: [PATCH 30/30] edit readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 25cddcf..7d4c005 100644 --- a/README.md +++ b/README.md @@ -25,5 +25,5 @@ We store a snapshot of the state of the simulation when a relevant demand was cr 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. +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.