diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/__pycache__/__init__.cpython-311.pyc b/app/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..f04567e Binary files /dev/null and b/app/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/__pycache__/main.cpython-311.pyc b/app/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000..dd96903 Binary files /dev/null and b/app/__pycache__/main.cpython-311.pyc differ diff --git a/app/database/__init__.py b/app/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database/__pycache__/__init__.cpython-311.pyc b/app/database/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..5ce7911 Binary files /dev/null and b/app/database/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/database/__pycache__/elevators.cpython-311.pyc b/app/database/__pycache__/elevators.cpython-311.pyc new file mode 100644 index 0000000..a43753b Binary files /dev/null and b/app/database/__pycache__/elevators.cpython-311.pyc differ diff --git a/app/database/elevators.py b/app/database/elevators.py new file mode 100644 index 0000000..e029f86 --- /dev/null +++ b/app/database/elevators.py @@ -0,0 +1,14 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase + +DATABASE_URL = "sqlite:///./elevators.db" + +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False}, +) + +SessionLocal = sessionmaker(bind=engine, expire_on_commit=False) + +class Base(DeclarativeBase): + pass \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..0d469c4 --- /dev/null +++ b/app/main.py @@ -0,0 +1,26 @@ +import logging +from fastapi import FastAPI +from dotenv import load_dotenv + +from app.database.elevators import Base, engine +from app.routers.elevators import router as main_router + +load_dotenv() + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="Prediction_Engine") + + +@app.on_event("startup") +def on_startup(): + """ + Function executed once when the FastAPI app starts. + It ensures that all database tables defined in the SQLAlchemy models are created. + """ + Base.metadata.create_all(bind=engine) + logger.info("SQLite schema ready") + + +app.include_router(main_router) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/__pycache__/__init__.cpython-311.pyc b/app/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..baf9608 Binary files /dev/null and b/app/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/models/__pycache__/elevators.cpython-311.pyc b/app/models/__pycache__/elevators.cpython-311.pyc new file mode 100644 index 0000000..76976e0 Binary files /dev/null and b/app/models/__pycache__/elevators.cpython-311.pyc differ diff --git a/app/models/elevators.py b/app/models/elevators.py new file mode 100644 index 0000000..da92bef --- /dev/null +++ b/app/models/elevators.py @@ -0,0 +1,18 @@ +from datetime import datetime +from sqlalchemy import Column, Integer, DateTime, Boolean +from app.database.elevators import Base + +class Demand(Base): + __tablename__ = "demands" + + id = Column(Integer, primary_key=True, index=True) + floor = Column(Integer, nullable=False) + timestamp = Column(DateTime, default=datetime.utcnow) # ← OK aqui + + +class ElevatorState(Base): + __tablename__ = "elevator_states" + id = Column(Integer, primary_key=True, index=True) + timestamp = Column(DateTime, default=datetime.utcnow, index=True) + floor = Column(Integer, nullable=False) + vacant = Column(Boolean, nullable=False) \ No newline at end of file diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/__pycache__/__init__.cpython-311.pyc b/app/routers/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..c3ebeb6 Binary files /dev/null and b/app/routers/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/routers/__pycache__/elevators.cpython-311.pyc b/app/routers/__pycache__/elevators.cpython-311.pyc new file mode 100644 index 0000000..577120e Binary files /dev/null and b/app/routers/__pycache__/elevators.cpython-311.pyc differ diff --git a/app/routers/elevators.py b/app/routers/elevators.py new file mode 100644 index 0000000..58583a8 --- /dev/null +++ b/app/routers/elevators.py @@ -0,0 +1,88 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.models.elevators import Demand, ElevatorState +from app.schemas.elevators import DemandOut, DemandCreate, StateOut, StateCreate +from app.database.elevators import SessionLocal + +router = APIRouter(prefix="/api", tags=["elevators"]) + + +def get_db(): + db = SessionLocal() + try: + yield db + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() + + +# POST /demands +@router.post("/demands", response_model=DemandOut) +def create_demand(demand: DemandCreate, db: Session = Depends(get_db)): + """ + Registers a new elevator demand when a user calls the elevator from a specific floor. + """ + rec = Demand(floor=demand.floor) + db.add(rec) + db.flush() + db.refresh(rec) + return rec + + +# POST /state +@router.post("/state", response_model=StateOut) +def create_state(state: StateCreate, db: Session = Depends(get_db)): + """ + Records the elevator’s current state, including its current floor and whether it is vacant. + """ + rec = ElevatorState(floor=state.floor, vacant=state.vacant) + db.add(rec) + db.flush() + db.refresh(rec) + return rec + + +# GET /analytics +@router.get("/analytics") +def analyze_positioning(db: Session = Depends(get_db), limit: int = 50, threshold: int = 3): + """ + Analyzes elevator positioning efficiency by comparing resting positions with upcoming demands. + """ + states = ( + db.query(ElevatorState) + .order_by(ElevatorState.timestamp.asc()) + .limit(limit) + .all() + ) + + if not states: + raise HTTPException(status_code=404, detail="No elevator states found.") + + distances = [] + + for state in states: + demand = ( + db.query(Demand) + .filter(Demand.timestamp > state.timestamp) + .order_by(Demand.timestamp.asc()) + .first() + ) + if demand: + distance = abs(demand.floor - state.floor) + distances.append(distance) + + if not distances: + raise HTTPException(status_code=404, detail="Not enough state → demand pairs found.") + + avg_distance = sum(distances) / len(distances) + + return { + "average_distance": round(avg_distance, 2), + "threshold": threshold, + "mispositioned": avg_distance > threshold, + "message": "Elevator is frequently poorly positioned." if avg_distance > threshold else "Positioning is within acceptable range." + } diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/__pycache__/__init__.cpython-311.pyc b/app/schemas/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..e04b732 Binary files /dev/null and b/app/schemas/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/schemas/__pycache__/elevators.cpython-311.pyc b/app/schemas/__pycache__/elevators.cpython-311.pyc new file mode 100644 index 0000000..86dd1a5 Binary files /dev/null and b/app/schemas/__pycache__/elevators.cpython-311.pyc differ diff --git a/app/schemas/elevators.py b/app/schemas/elevators.py new file mode 100644 index 0000000..9ee91ab --- /dev/null +++ b/app/schemas/elevators.py @@ -0,0 +1,27 @@ +from datetime import datetime +from pydantic import BaseModel, Field + + +class DemandCreate(BaseModel): + floor: int = Field(..., ge=0, description="Andar onde o usuário chamou o elevador") + +class DemandOut(DemandCreate): + id: int + timestamp: datetime + + model_config = { + "from_attributes": True # substitui orm_mode no Pydantic v2 + } + + +class StateCreate(BaseModel): + floor: int + vacant: bool + +class StateOut(StateCreate): + id: int + timestamp: datetime + + model_config = { + "from_attributes": True + } \ No newline at end of file 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/elevators.db b/elevators.db new file mode 100644 index 0000000..57fdc69 Binary files /dev/null and b/elevators.db differ diff --git a/explanation.md b/explanation.md new file mode 100644 index 0000000..b5d4218 --- /dev/null +++ b/explanation.md @@ -0,0 +1,53 @@ +### Problem Understanding +The goal was to build a minimal backend system to record elevator demands and resting states, so that historical data can be used later by an ML model to predict the optimal resting floor. + +### Design Decisions +1. Database Modeling +I created two tables: + +Demand: stores the floor where the elevator was called and the timestamp. + +ElevatorState: stores the elevator’s floor, timestamp, and whether it's vacant. + +This separation allows: + +Compare demand vs. elevator location. + +Simulate a real scenario with frequent calls and elevator resting positions. + +Alternative: I considered using a single table, but separating concerns made analysis easier and aligned with normalized schema practices. + +2. API Endpoints +POST /api/demands: register a new demand. + +POST /api/state: register the elevator’s current floor and availability. + +GET /api/analytics: analyze the average distance between each recorded elevator state and the subsequent demand. This helps evaluate whether the elevator is frequently poorly positioned when idle. + +This gives ML practitioners useful data such as: + +Time series of elevator calls. + +Reactions or delays from elevator states. + +Or initiate a model retraining process to improve floor prediction accuracy based on updated behavior patterns. + +3. Business Rules Added +In GET /analytics, we check if the elevator is often resting too far from the next demand, using a configurable threshold. + +This acts as an early signal of poor optimization. + +4. Testing +Covered endpoints with pytest and TestClient. + +Verified status codes and response content. + +Ensures system stability before plugging in any ML logic. + +### Tech Stack +FastAPI + SQLite. + +SQLAlchemy for ORM. + +Pytest for testing. + diff --git a/readme.md b/readme.md index ea5e444..c125282 100644 --- a/readme.md +++ b/readme.md @@ -1,5 +1,26 @@ # Dev Test +## RUN APP +Instruction to run application + +### Create the virtual environment +python -m venv test_dev + +### Activate the virtual environment +source test_dev/bin/activate (Linux) + +### Install packages +pip install -r requirements.txt + +### Start the API server +uvicorn app.main:app --reload + +### Run tests +pytest tests/test_endpoints.py + +### Explanation +The file explanation.md has the explanation about the solution. + ## Elevators When an elevator is empty and not moving this is known as it's resting floor. The ideal resting floor to be positioned on depends on the likely next floor that the elevator will be called from. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3019d43 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.115.14 +uvicorn==0.35.0 +SQLAlchemy==2.0.41 +pydantic==2.11.7 +python-dotenv==1.1.1 +pytest==8.4.1 +httpx==0.28.1 \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-311.pyc b/tests/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..7bcaa6d Binary files /dev/null and b/tests/__pycache__/__init__.cpython-311.pyc differ diff --git a/tests/__pycache__/endpoints.cpython-311-pytest-7.4.0.pyc b/tests/__pycache__/endpoints.cpython-311-pytest-7.4.0.pyc new file mode 100644 index 0000000..efa9d55 Binary files /dev/null and b/tests/__pycache__/endpoints.cpython-311-pytest-7.4.0.pyc differ diff --git a/tests/__pycache__/test_endpoints.cpython-311-pytest-7.4.0.pyc b/tests/__pycache__/test_endpoints.cpython-311-pytest-7.4.0.pyc new file mode 100644 index 0000000..1a47791 Binary files /dev/null and b/tests/__pycache__/test_endpoints.cpython-311-pytest-7.4.0.pyc differ diff --git a/tests/__pycache__/test_endpoints.cpython-311-pytest-8.4.1.pyc b/tests/__pycache__/test_endpoints.cpython-311-pytest-8.4.1.pyc new file mode 100644 index 0000000..6acf88c Binary files /dev/null and b/tests/__pycache__/test_endpoints.cpython-311-pytest-8.4.1.pyc differ diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py new file mode 100644 index 0000000..564bfbb --- /dev/null +++ b/tests/test_endpoints.py @@ -0,0 +1,40 @@ +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + + +def test_create_demand(): + resp = client.post("/api/demands", json={"floor": 3}) + assert resp.status_code == 200 + payload = resp.json() + assert payload["floor"] == 3 + assert "id" in payload + assert "timestamp" in payload + + +def test_create_state(): + resp = client.post("/api/state", json={"floor": 2, "vacant": True}) + assert resp.status_code == 200 + payload = resp.json() + assert payload["floor"] == 2 + assert payload["vacant"] is True + assert "timestamp" in payload + + +def test_analyze_positioning(): + # Criar um state antigo + client.post("/api/state", json={"floor": 1, "vacant": True}) + + # Criar uma demanda posterior + client.post("/api/demands", json={"floor": 5}) + + # Chamar a rota de análise + resp = client.get("/api/analytics", params={"limit": 10, "threshold": 2}) + assert resp.status_code == 200 + data = resp.json() + + assert "average_distance" in data + assert "mispositioned" in data + assert isinstance(data["average_distance"], float) + assert isinstance(data["mispositioned"], bool) \ No newline at end of file