diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..950cf23 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,34 @@ +name: Python Tests + +on: + pull_request: + branches: [ master, macphail_devtest ] + push: + branches: [ master, macphail_devtest ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y python3-dev build-essential + + - name: Install Python dependencies + working-directory: ./app + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + working-directory: ./app + run: python -m pytest app_tests.py -v \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..151609a --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environment +.env +.venv +env/ +venv/ +ENV/ + +# Flask instance folder +instance/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Database +*.sqlite3 +*.db diff --git a/app/app_tests.py b/app/app_tests.py new file mode 100644 index 0000000..c98f12a --- /dev/null +++ b/app/app_tests.py @@ -0,0 +1,555 @@ +import unittest +import json +import tempfile +import os +from datetime import datetime, timedelta +from main import app, db, Building, Elevator, ElevatorCall, ElevatorBusinessRules + +class ElevatorSystemTestCase(unittest.TestCase): + def setUp(self): + """Set up test fixtures before each test method.""" + self.db_fd, app.config['DATABASE'] = tempfile.mkstemp() + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + app.config['DATABASE'] + app.config['TESTING'] = True + app.config['WTF_CSRF_ENABLED'] = False + + self.app = app.test_client() + self.app_context = app.app_context() + self.app_context.push() + + db.create_all() + + # Create test data + self.building = Building( + name="Test Building", + total_floors=5, + building_type="office" + ) + db.session.add(self.building) + db.session.commit() + + self.elevator = Elevator( + building_id=self.building.id, + elevator_number="TEST-01", + current_floor=1 + ) + db.session.add(self.elevator) + db.session.commit() + + def tearDown(self): + """Clean up after each test method.""" + db.session.remove() + db.drop_all() + self.app_context.pop() + os.close(self.db_fd) + os.unlink(app.config['DATABASE']) + + def test_health_check(self): + """Test the health check endpoint.""" + response = self.app.get('/health') + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + self.assertEqual(data['status'], 'healthy') + self.assertIn('timestamp', data) + + def test_create_building(self): + """Test building creation.""" + building_data = { + 'name': 'New Test Building', + 'total_floors': 5, + 'building_type': 'residential' + } + + response = self.app.post('/api/buildings', + data=json.dumps(building_data), + content_type='application/json') + + self.assertEqual(response.status_code, 201) + data = json.loads(response.data) + self.assertEqual(data['name'], 'New Test Building') + self.assertEqual(data['total_floors'], 5) + self.assertEqual(data['building_type'], 'residential') + + def test_create_building_missing_data(self): + """Test building creation with missing required fields.""" + building_data = {'name': 'Incomplete Building'} # Missing total_floors + + response = self.app.post('/api/buildings', + data=json.dumps(building_data), + content_type='application/json') + + self.assertEqual(response.status_code, 400) + data = json.loads(response.data) + self.assertIn('error', data) + + def test_create_elevator(self): + """Test elevator creation.""" + elevator_data = { + 'elevator_number': 'TEST-02', + 'max_capacity': 1200, + 'current_floor': 3 + } + + response = self.app.post(f'/api/buildings/{self.building.id}/elevators', + data=json.dumps(elevator_data), + content_type='application/json') + + self.assertEqual(response.status_code, 201) + data = json.loads(response.data) + self.assertEqual(data['elevator_number'], 'TEST-02') + self.assertEqual(data['current_floor'], 3) + + def test_record_elevator_call(self): + """Test recording an elevator call.""" + call_data = { + 'called_from_floor': 3, + 'destination_floor': 1, + 'estimated_passengers': 2, + 'call_type': 'normal' + } + + response = self.app.post(f'/api/elevators/{self.elevator.id}/call', + data=json.dumps(call_data), + content_type='application/json') + + self.assertEqual(response.status_code, 201) + data = json.loads(response.data) + self.assertIn('call_id', data) + self.assertIn('estimated_response_time', data) + self.assertIn('temporal_features', data) + + # Verify temporal features are populated + temporal = data['temporal_features'] + self.assertIn('day_of_week', temporal) + self.assertIn('hour_of_day', temporal) + self.assertIn('season', temporal) + + def test_record_elevator_call_invalid_floor(self): + """Test recording call with invalid floor.""" + call_data = { + 'called_from_floor': 6, # Building only has 5 floors + 'destination_floor': 1 + } + + response = self.app.post(f'/api/elevators/{self.elevator.id}/call', + data=json.dumps(call_data), + content_type='application/json') + + self.assertEqual(response.status_code, 400) + data = json.loads(response.data) + self.assertIn('Invalid floor number', data['error']) + + + def test_get_elevator_status(self): + """Test getting elevator status.""" + response = self.app.get(f'/api/elevators/{self.elevator.id}/status') + + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + self.assertEqual(data['elevator_id'], self.elevator.id) + self.assertEqual(data['current_floor'], 1) + self.assertIn('maintenance_alert', data) + + def test_get_ml_features(self): + """Test ML features endpoint.""" + # Create some test calls first + call1 = ElevatorCall( + elevator_id=self.elevator.id, + called_from_floor=2, + elevator_position_at_call=1 + ) + call2 = ElevatorCall( + elevator_id=self.elevator.id, + called_from_floor=3, + elevator_position_at_call=2 + ) + db.session.add_all([call1, call2]) + db.session.commit() + + response = self.app.get(f'/api/ml-data/features/{self.building.id}') + + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + self.assertEqual(data['building_id'], self.building.id) + self.assertGreaterEqual(data['total_samples'], 2) + self.assertIn('features', data) + + # Verify feature structure + if data['features']: + feature = data['features'][0] + self.assertIn('target_floor', feature) + self.assertIn('time_features', feature) + self.assertIn('elevator_features', feature) + self.assertIn('contextual_features', feature) + +class BusinessRulesTestCase(unittest.TestCase): + """Test business logic and validation rules.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + self.app_context = app.app_context() + self.app_context.push() + + db.create_all() + + self.building = Building( + name="Business Rules Test Building", + total_floors=5 + ) + db.session.add(self.building) + db.session.commit() + + self.elevator = Elevator( + building_id=self.building.id, + elevator_number="TEST-01", + current_floor=1 + ) + db.session.add(self.elevator) + db.session.commit() + + def tearDown(self): + """Clean up after each test method.""" + db.session.remove() + db.drop_all() + self.app_context.pop() + + def test_validate_floor_range_valid(self): + """Test floor range validation with valid floors.""" + self.assertTrue(ElevatorBusinessRules.validate_floor_range(1, self.building.id)) + self.assertTrue(ElevatorBusinessRules.validate_floor_range(3, self.building.id)) + self.assertTrue(ElevatorBusinessRules.validate_floor_range(5, self.building.id)) + + def test_validate_floor_range_invalid(self): + """Test floor range validation with invalid floors.""" + self.assertFalse(ElevatorBusinessRules.validate_floor_range(0, self.building.id)) + self.assertFalse(ElevatorBusinessRules.validate_floor_range(6, self.building.id)) + self.assertFalse(ElevatorBusinessRules.validate_floor_range(-1, self.building.id)) + + def test_validate_floor_range_nonexistent_building(self): + """Test floor range validation with nonexistent building.""" + self.assertFalse(ElevatorBusinessRules.validate_floor_range(5, 99999)) + + def test_calculate_response_time(self): + """Test response time calculation.""" + call_time = datetime.utcnow() + + # Same floor - minimum time (door operation) + response_time = ElevatorBusinessRules.calculate_response_time(call_time, 5, 5) + self.assertEqual(response_time, 2.0) + + # One floor away + response_time = ElevatorBusinessRules.calculate_response_time(call_time, 5, 4) + self.assertEqual(response_time, 5.0) # 1 floor * 3 seconds + 2 seconds door + + # Multiple floors + response_time = ElevatorBusinessRules.calculate_response_time(call_time, 1, 5) + self.assertEqual(response_time, 14.0) # 4 floors * 3 seconds + 2 seconds door + + def test_maintenance_alert_no_previous_maintenance(self): + """Test maintenance alert for elevator with no maintenance history.""" + elevator = Elevator( + building_id=self.building.id, + elevator_number="MAINT-01", + last_maintenance=None + ) + db.session.add(elevator) + db.session.commit() + + self.assertTrue(ElevatorBusinessRules.should_trigger_maintenance_alert(elevator)) + + def test_maintenance_alert_recent_maintenance(self): + """Test maintenance alert for recently maintained elevator.""" + elevator = Elevator( + building_id=self.building.id, + elevator_number="MAINT-02", + last_maintenance=datetime.utcnow() - timedelta(days=15) + ) + db.session.add(elevator) + db.session.commit() + + self.assertFalse(ElevatorBusinessRules.should_trigger_maintenance_alert(elevator)) + + def test_maintenance_alert_overdue_maintenance(self): + """Test maintenance alert for overdue maintenance.""" + elevator = Elevator( + building_id=self.building.id, + elevator_number="MAINT-03", + last_maintenance=datetime.utcnow() - timedelta(days=35) + ) + db.session.add(elevator) + db.session.commit() + + self.assertTrue(ElevatorBusinessRules.should_trigger_maintenance_alert(elevator)) + + def test_can_elevator_access_floor_valid_range(self): + """Test elevator access within valid floor range.""" + + freight_elevator = Elevator( + building_id=self.building.id, + elevator_number="FREIGHT", + current_floor=1 + ) + db.session.add(freight_elevator) + db.session.commit() + + result = ElevatorBusinessRules.can_elevator_access_floor(freight_elevator.id, 3) + self.assertTrue(result) + + result = ElevatorBusinessRules.can_elevator_access_floor(self.elevator.id, 3) + self.assertTrue(result) + + def test_can_elevator_access_floor_outside_range(self): + """Test elevator access outside building floor range.""" + result = ElevatorBusinessRules.can_elevator_access_floor(self.elevator.id, 0) + self.assertFalse(result) + + result = ElevatorBusinessRules.can_elevator_access_floor(self.elevator.id, 6) + self.assertFalse(result) + + def test_can_elevator_access_floor_freight_restrictions(self): + """Test freight elevator floor restrictions.""" + freight_elevator = Elevator( + building_id=self.building.id, + elevator_number="FREIGHT", + current_floor=1 + ) + db.session.add(freight_elevator) + db.session.commit() + + result = ElevatorBusinessRules.can_elevator_access_floor(freight_elevator.id, 3) + self.assertTrue(result) + + result = ElevatorBusinessRules.can_elevator_access_floor(freight_elevator.id, 6) + self.assertFalse(result) + + def test_can_elevator_access_floor_nonexistent_elevator(self): + """Test with non-existent elevator.""" + result = ElevatorBusinessRules.can_elevator_access_floor(99999, 3) + self.assertFalse(result) + +class ElevatorCallModelTestCase(unittest.TestCase): + """Test the ElevatorCall model and its automatic feature generation.""" + + def setUp(self): + self.app_context = app.app_context() + self.app_context.push() + db.create_all() + + self.building = Building(name="Model Test Building", total_floors=5) + db.session.add(self.building) + db.session.commit() + + self.elevator = Elevator( + building_id=self.building.id, + elevator_number="MODEL-01" + ) + db.session.add(self.elevator) + db.session.commit() + + def tearDown(self): + db.session.remove() + db.drop_all() + self.app_context.pop() + + def test_temporal_features_auto_population(self): + """Test that temporal features are automatically populated.""" + # Create a call with a specific datetime + test_date = datetime(2024, 7, 15, 14, 30) # Monday, July 15, 2024, 2:30 PM + + call = ElevatorCall( + elevator_id=self.elevator.id, + called_from_floor=3, + call_time=test_date + ) + + self.assertEqual(call.day_of_week, 0) # Monday + self.assertEqual(call.hour_of_day, 14) # 2 PM + self.assertEqual(call.week_of_month, 3) # Third week of July + self.assertEqual(call.season, 'summer') + + def test_season_calculation_winter(self): + """Test season calculation for winter months.""" + winter_dates = [ + datetime(2024, 12, 15), # December + datetime(2024, 1, 15), # January + datetime(2024, 2, 15) # February + ] + + for test_date in winter_dates: + call = ElevatorCall( + elevator_id=self.elevator.id, + called_from_floor=1, + call_time=test_date + ) + self.assertEqual(call.season, 'winter', f"Failed for {test_date}") + + def test_season_calculation_all_seasons(self): + """Test season calculation for all seasons.""" + season_tests = [ + (datetime(2024, 3, 15), 'spring'), + (datetime(2024, 6, 15), 'summer'), + (datetime(2024, 9, 15), 'autumn'), + (datetime(2024, 12, 15), 'winter') + ] + + for test_date, expected_season in season_tests: + call = ElevatorCall( + elevator_id=self.elevator.id, + called_from_floor=1, + call_time=test_date + ) + self.assertEqual(call.season, expected_season) + +class MLDataIntegrationTestCase(unittest.TestCase): + """Integration tests for ML data preparation and export.""" + + def setUp(self): + self.app_context = app.app_context() + self.app_context.push() + self.app = app.test_client() + + db.create_all() + + # Create comprehensive test data + self.building = Building( + name="ML Integration Test Building", + total_floors=5, + building_type="mixed" + ) + db.session.add(self.building) + db.session.commit() + + self.elevator = Elevator( + building_id=self.building.id, + elevator_number="ML-01", + current_floor=1 + ) + db.session.add(self.elevator) + db.session.commit() + + # Create diverse call patterns + self._create_test_call_patterns() + + def tearDown(self): + db.session.remove() + db.drop_all() + self.app_context.pop() + + def _create_test_call_patterns(self): + """Create realistic call patterns for testing.""" + import random + + # Morning rush pattern (7-9 AM) + for day in range(5): # Weekdays + for hour in [7, 8]: + for _ in range(random.randint(3, 8)): + call_time = datetime.utcnow().replace(hour=hour, minute=random.randint(0, 59)) + call = ElevatorCall( + elevator_id=self.elevator.id, + called_from_floor=1, # Ground floor calls in morning + destination_floor=random.randint(2, 5), + call_time=call_time, + elevator_position_at_call=random.randint(1, 5), + estimated_passengers=random.randint(1, 4) + ) + db.session.add(call) + + # Lunch pattern (12-1 PM) + for day in range(5): + for hour in [12]: + for _ in range(random.randint(2, 5)): + call_time = datetime.utcnow().replace(hour=hour, minute=random.randint(0, 59)) + call = ElevatorCall( + elevator_id=self.elevator.id, + called_from_floor=random.randint(2, 5), # Various floors going down + destination_floor=1, + call_time=call_time, + elevator_position_at_call=random.randint(1, 5), + estimated_passengers=random.randint(1, 3) + ) + db.session.add(call) + + # Evening pattern (5-7 PM) + for day in range(5): + for hour in [17, 18]: # 5-6 PM + for _ in range(random.randint(4, 9)): + call_time = datetime.utcnow().replace(hour=hour, minute=random.randint(0, 59)) + call = ElevatorCall( + elevator_id=self.elevator.id, + called_from_floor=random.randint(2, 5), # Various floors going down + destination_floor=1, + call_time=call_time, + elevator_position_at_call=random.randint(1, 5), + estimated_passengers=random.randint(1, 3) + ) + db.session.add(call) + + db.session.commit() + + def test_export_ml_features_csv(self): + """Test exporting ML features as CSV""" + response = self.app.get(f'/api/ml-data/features/{self.building.id}?format=csv') + self.assertEqual(response.status_code, 200) + self.assertIn('text/csv', response.content_type) + self.assertIn('Content-Disposition', response.headers) + self.assertIn('target_floor', response.get_data(as_text=True)) + + def test_export_ml_features_json(self): + """Test exporting ML features as JSON""" + response = self.app.get(f'/api/ml-data/features/{self.building.id}?format=json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, 'application/json') + data = json.loads(response.data) + self.assertIn('building_id', data) + self.assertIn('total_samples', data) + self.assertIn('features', data) + if data['total_samples'] > 0: + self.assertIn('target_floor', data['features'][0]) + + def test_export_ml_features_missing_building_id(self): + """Test export with missing building_id""" + response = self.app.get('/api/ml-data/features/') + self.assertEqual(response.status_code, 404) # Changed from 400 to 404 as the route requires building_id + + def test_export_ml_features_nonexistent_building(self): + """Test export with non-existent building_id""" + response = self.app.get('/api/ml-data/features/9999') + self.assertEqual(response.status_code, 404) + + + def test_ml_data_filtering(self): + """Test ML data filtering by date range.""" + # Test with shorter time range + response = self.app.get(f'/api/ml-data/features/{self.building.id}?days_back=7') + + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + + # Should still have structure but possibly fewer samples + self.assertIn('total_samples', data) + self.assertIn('features', data) + +if __name__ == '__main__': + test_suite = unittest.TestSuite() + + test_classes = [ + ElevatorSystemTestCase, + BusinessRulesTestCase, + ElevatorCallModelTestCase, + MLDataIntegrationTestCase + ] + + for test_class in test_classes: + tests = unittest.TestLoader().loadTestsFromTestCase(test_class) + test_suite.addTests(tests) + + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(test_suite) + + # Summary + print(f"\n{'='*50}") + print(f"TESTS RUN: {result.testsRun}") + print(f"FAILURES: {len(result.failures)}") + print(f"ERRORS: {len(result.errors)}") + print(f"SUCCESS RATE: {((result.testsRun - len(result.failures) - len(result.errors)) / result.testsRun * 100):.1f}%") + print(f"{'='*50}") \ No newline at end of file diff --git a/chatgpt/db.sql b/app/db.sql similarity index 100% rename from chatgpt/db.sql rename to app/db.sql diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..47fbbfc --- /dev/null +++ b/app/main.py @@ -0,0 +1,359 @@ +from flask import Flask, request, jsonify, g +from flask_sqlalchemy import SQLAlchemy +from datetime import datetime, timedelta +import sqlite3 +import os +from typing import Dict, List, Optional +import json +import pandas as pd +from io import StringIO + + + +app = Flask(__name__) + +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///elevator_data.db' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['SECRET_KEY'] = 'dev-secret-key' + +db = SQLAlchemy(app) + +class Building(db.Model): + __tablename__ = 'buildings' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + total_floors = db.Column(db.Integer, nullable=False) + building_type = db.Column(db.String(50)) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + elevators = db.relationship('Elevator', backref='building', lazy=True) + +class Elevator(db.Model): + __tablename__ = 'elevators' + + id = db.Column(db.Integer, primary_key=True) + building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=False) + elevator_number = db.Column(db.String(10), nullable=False) + max_capacity = db.Column(db.Integer, default=1000) + current_floor = db.Column(db.Integer, default=1) + current_load = db.Column(db.Integer, default=0) + is_moving = db.Column(db.Boolean, default=False) + maintenance_status = db.Column(db.String(20), default='operational') + last_maintenance = db.Column(db.DateTime) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + calls = db.relationship('ElevatorCall', backref='elevator', lazy=True) + +class ElevatorCall(db.Model): + __tablename__ = 'elevator_calls' + + id = db.Column(db.Integer, primary_key=True) + elevator_id = db.Column(db.Integer, db.ForeignKey('elevators.id'), nullable=False) + called_from_floor = db.Column(db.Integer, nullable=False) + destination_floor = db.Column(db.Integer) + call_time = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + response_time = db.Column(db.Float) + elevator_position_at_call = db.Column(db.Integer) + estimated_passengers = db.Column(db.Integer, default=1) + call_type = db.Column(db.String(20), default='normal') + day_of_week = db.Column(db.Integer) + hour_of_day = db.Column(db.Integer) + week_of_month = db.Column(db.Integer) + season = db.Column(db.String(10)) + weather_condition = db.Column(db.String(20)) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + call_dt = self.call_time or datetime.utcnow() + self.day_of_week = call_dt.weekday() + self.hour_of_day = call_dt.hour + self.week_of_month = (call_dt.day - 1) // 7 + 1 + self.season = self._get_season(call_dt.month) + + def _get_season(self, month: int) -> str: + if month in [12, 1, 2]: + return 'winter' + elif month in [3, 4, 5]: + return 'spring' + elif month in [6, 7, 8]: + return 'summer' + else: + return 'autumn' + +class ElevatorBusinessRules: + @staticmethod + def validate_floor_range(floor: int, building_id: int) -> bool: + """Validate if a floor number is valid for a given building.""" + building = Building.query.get(building_id) + if not building: + return False + return 1 <= floor <= building.total_floors + + @staticmethod + def can_elevator_access_floor(elevator_id: int, floor: int) -> bool: + """Check if an elevator can access a specific floor.""" + elevator = Elevator.query.get(elevator_id) + if not elevator: + return False + + if not ElevatorBusinessRules.validate_floor_range(floor, elevator.building_id): + return False + + if elevator.elevator_number == "FREIGHT" and floor > 5: + return False + + return True + + @staticmethod + def calculate_response_time(call_time: datetime, elevator_position: int, called_floor: int) -> float: + floors_to_travel = abs(elevator_position - called_floor) + return floors_to_travel * 3.0 + 2.0 + + @staticmethod + def should_trigger_maintenance_alert(elevator: Elevator) -> bool: + if not elevator.last_maintenance: + return True + days_since_maintenance = (datetime.utcnow() - elevator.last_maintenance).days + return days_since_maintenance > 30 + +@app.route('/api/buildings', methods=['POST']) +def create_building(): + """Create a new building""" + data = request.get_json() + + if not data or 'name' not in data or 'total_floors' not in data: + return jsonify({'error': 'Missing required fields: name, total_floors'}), 400 + + building = Building( + name=data['name'], + total_floors=data['total_floors'], + building_type=data.get('building_type', 'mixed') + ) + + try: + db.session.add(building) + db.session.commit() + return jsonify({ + 'id': building.id, + 'name': building.name, + 'total_floors': building.total_floors, + 'building_type': building.building_type + }), 201 + except Exception as e: + db.session.rollback() + return jsonify({'error': str(e)}), 500 + +@app.route('/api/buildings//elevators', methods=['POST']) +def create_elevator(building_id): + """Add an elevator to a building""" + data = request.get_json() + + if not data or 'elevator_number' not in data: + return jsonify({'error': 'Missing required field: elevator_number'}), 400 + + elevator = Elevator( + building_id=building_id, + elevator_number=data['elevator_number'], + max_capacity=data.get('max_capacity', 1000), + current_floor=data.get('current_floor', 1) + ) + + try: + db.session.add(elevator) + db.session.commit() + return jsonify({ + 'id': elevator.id, + 'elevator_number': elevator.elevator_number, + 'current_floor': elevator.current_floor, + 'building_id': building_id + }), 201 + except Exception as e: + db.session.rollback() + return jsonify({'error': str(e)}), 500 + +@app.route('/api/elevators//call', methods=['POST']) +def record_elevator_call(elevator_id): + """Record an elevator call """ + elevator = Elevator.query.get_or_404(elevator_id) + data = request.get_json() + + if not data or 'called_from_floor' not in data: + return jsonify({'error': 'Missing required field: called_from_floor'}), 400 + + called_from_floor = data['called_from_floor'] + + if not ElevatorBusinessRules.validate_floor_range(called_from_floor, elevator.building_id): + return jsonify({'error': 'Invalid floor number'}), 400 + + response_time = ElevatorBusinessRules.calculate_response_time( + datetime.utcnow(), elevator.current_floor, called_from_floor + ) + + call = ElevatorCall( + elevator_id=elevator_id, + called_from_floor=called_from_floor, + destination_floor=data.get('destination_floor'), + elevator_position_at_call=elevator.current_floor, + response_time=response_time, + estimated_passengers=data.get('estimated_passengers', 1), + call_type=data.get('call_type', 'normal'), + weather_condition=data.get('weather_condition') + ) + + try: + db.session.add(call) + db.session.commit() + + if data.get('destination_floor'): + elevator.current_floor = data['destination_floor'] + elevator.is_moving = False + db.session.commit() + + return jsonify({ + 'call_id': call.id, + 'estimated_response_time': response_time, + 'call_time': call.call_time.isoformat(), + 'temporal_features': { + 'day_of_week': call.day_of_week, + 'hour_of_day': call.hour_of_day, + 'week_of_month': call.week_of_month, + 'season': call.season + } + }), 201 + except Exception as e: + db.session.rollback() + return jsonify({'error': str(e)}), 500 + +@app.route('/api/elevators//status', methods=['GET']) +def get_elevator_status(elevator_id): + """Get current elevator status""" + elevator = Elevator.query.get_or_404(elevator_id) + + maintenance_alert = ElevatorBusinessRules.should_trigger_maintenance_alert(elevator) + + return jsonify({ + 'elevator_id': elevator.id, + 'elevator_number': elevator.elevator_number, + 'current_floor': elevator.current_floor, + 'current_load': elevator.current_load, + 'is_moving': elevator.is_moving, + 'maintenance_status': elevator.maintenance_status, + 'maintenance_alert': maintenance_alert, + 'building_id': elevator.building_id, + 'max_floors': elevator.building.total_floors + }) + +@app.route('/api/ml-data/features/', methods=['GET']) +def get_ml_features(building_id): + """Get ML features for a building, optionally export as CSV""" + export_format = request.args.get('format', 'json').lower() + building = Building.query.get_or_404(building_id) + + days_back = request.args.get('days_back', 30, type=int) + include_weather = request.args.get('include_weather', False, type=bool) + + cutoff_date = datetime.utcnow() - timedelta(days=days_back) + + calls_query = db.session.query(ElevatorCall).join(Elevator).filter( + Elevator.building_id == building_id, + ElevatorCall.call_time >= cutoff_date + ).all() + + features = [] + for call in calls_query: + feature_row = { + 'target_floor': call.called_from_floor, + 'time_features': { + 'hour_of_day': call.hour_of_day, + 'day_of_week': call.day_of_week, + 'week_of_month': call.week_of_month, + 'season': call.season + }, + 'elevator_features': { + 'elevator_position_at_call': call.elevator_position_at_call, + 'estimated_passengers': call.estimated_passengers, + 'call_type': call.call_type + }, + 'contextual_features': { + 'response_time': call.response_time, + 'building_type': building.building_type, + 'total_floors': building.total_floors + } + } + + if include_weather and call.weather_condition: + feature_row['contextual_features']['weather_condition'] = call.weather_condition + + features.append(feature_row) + + flat_features = [] + for item in features: + flat_item = { + 'target_floor': item['target_floor'], + **item['time_features'], + **{f"elevator_{k}": v for k, v in item['elevator_features'].items()}, + **{f"ctx_{k}": v for k, v in item['contextual_features'].items()} + } + flat_features.append(flat_item) + + + if export_format == 'csv': + df = pd.DataFrame(flat_features) + output = StringIO() + df.to_csv(output, index=False) + output.seek(0) + return output.getvalue(), 200, { + 'Content-Type': 'text/csv', + 'Content-Disposition': f'attachment; filename=elevator_features_building_{building_id}.csv' + } + else: + return jsonify({ + 'building_id': building_id, + 'total_samples': len(features), + 'date_range': { + 'from': cutoff_date.isoformat(), + 'to': datetime.utcnow().isoformat() + }, + 'features': features + }) + +@app.route('/health', methods=['GET']) +def health_check(): + return jsonify({ + 'status': 'healthy', + 'timestamp': datetime.utcnow().isoformat(), + 'database': 'connected' + }) + +_initialized = False + +@app.before_request +def before_request(): + global _initialized + if not _initialized: + db.create_all() + + # Create sample data if none exists + if Building.query.count() == 0: + sample_building = Building( + name="Sample Office Building", + total_floors=10, + building_type="office" + ) + db.session.add(sample_building) + db.session.commit() + + sample_elevator = Elevator( + building_id=sample_building.id, + elevator_number="ELV-01", + current_floor=1 + ) + db.session.add(sample_elevator) + db.session.commit() + + _initialized = True + +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..887f2d5 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,7 @@ +Flask==2.3.3 +Flask-SQLAlchemy==3.0.5 +SQLAlchemy==2.0.21 +Werkzeug==2.3.7 +pytest==6.2.5 +pytest-flask==1.2.0 +pandas==2.1.4 \ 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/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.md index ea5e444..aa4d334 100644 --- a/readme.md +++ b/readme.md @@ -1,58 +1,193 @@ -# Dev Test -## 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. +# MY THOUGHT PROCESS +### Whenever im faced with an AI modeling problem, i often love to revert to the fundamentals of developing models then expand from there, i have found this to be a good way to approach problems in a methodical and structured way, because anything that can be broken down into a process can be measured and anything that can be measured can be improved, this i believe is the very heart of artifiicial intelligence -We can build a prediction engine to predict the likely next floor based on historical demand, if we have the data. +### My methodical steps are as follows: -The goal of this project is to model an elevator and save the data that could later be used to build a prediction engine for which floor is the best resting floor at any time -- When people call an elevator this is considered a demand -- When the elevator is vacant and not moving between floors, the current floor is considered its resting floor -- When the elevator is vacant, it can stay at the current position or move to a different floor -- The prediction model will determine what is the best floor to rest on +### 1. Identify and fully understand the problem +### 2. Identify the prediction target or goal -_The requirement isn't to complete this system but to start building a system that would feed into the training and prediction -of an ML system_ +### 3. Identify the type of available data that we have access to + +### 4. Identify obvious and non-obvious factors that could influence the prediction target + +### 5. Select the appropriate model that would help best solve the problem, understand its limitations and constraints as well as research strategies and techniques to improve performance + +### 6. Prepare the data for training and testing + +### 7. Evaluate the models performance and based on results and decide whether to iterate and improve training data or use a different approach and model entirely and keep iterating and benchmarking, understanding trade offs and benefits between approaches + + + +# Elevator resting floor prediction model solution + +## 1. Problem statement: +### From my understanding were trying to improve the efficiency and service delivery of a buildings elevator by predicting the best floor for the elevator to rest in-between calls. + + +## 2. Prediction target: +### The building consists of multiple floors, and because of this multiple options to consider when predicting the best floor to rest in-between calls, i believe a multiclass predicition target would be the best option for this problem because it not only gives us alternatives but levels of accuracy we can use to adjust service delivery. + +## 3. Available data: +### The data we have available is as follows: + +- ### Time of day +- ### Day of week +- ### Week of month +- ### Maintainance schedule (if any) +- ### Elevator load +- ### Current weather season +- ### Current elevator floor + +## 4. What factors could possibly influence the demand of the elevator (both obvious and non obvious) +- ### Time of day: + ### We might find that there is a higher demand on certain floors in the mornings, lunch or evenings, e.g a working building where people need the elevator closer to ground floors as people come into work and closer to certain floors as people leave work or go out for lunch + +- ### Day of the week + ### We might find that more people come into work on monday mornings as compared to friday, if the building is multipurpose, certain floors might have working people coming in while other days house residents and thus less activity on certain floors +- ### Week of the month + ### We might discover that certain weeks of the month have more people/traffic as compared to other weeks, e.g retreats, field work weeks, etc +- ### Maintainance schedule (if any) + ### This is one of those non obvious factors, we might discover that an elevator that is not regularly maintained is less trusted by users and so they decide maybe to take the stairs, only users that need to travel safer distances might prefer it, e.g a floor above the ground floor, this would also help avoid overfitting and help our model generalize better +- ### Elevator load + ### We might find that when the elevator is heavy, at a particular time, the elevator tends to move to a certain floor with more people in it. + +- ### Current weather season + ### We might find that during winter, christmas or summer, users might not prefer the elevator, or certain floors experience more demand + +- ### Current elevator floor + ### This is another one of those non obvious ones, naturally i wouldnt think a current elevator floor could influence where people would like to go next, but maybe we might discover a pattern of if the elevator is called from the ground floor at a particular time, users are moving to a working floor for example in a multipurpose building + + + +## Model Selection +### Since i now know our problem, prediction target, available data as well as influencing factors towards our prediction target, both obvious and non-obvious, i would research on the best model for the job, which i found to be the CatBoost model, reason being it handles categorical features such as days of week, etc without needing encoding, this to me is the best place to start experimenting and testing an ideal model for the problem at hand. + + +## Prepare data for training and testing and come up with a suitable database model/schema +### My first thought is to design a relational database with tables as follows: + +### Building: +``` +class Building(db.Model): + __tablename__ = 'buildings' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + total_floors = db.Column(db.Integer, nullable=False) + building_type = db.Column(db.String(50)) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + elevators = db.relationship('Elevator', backref='building', lazy=True) + + ``` + + ### This contains metadata about the building as well as a one to many relationship with the Elevator table + + + ### Elevator: + +``` +class Elevator(db.Model): + __tablename__ = 'elevators' + + id = db.Column(db.Integer, primary_key=True) + building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=False) + elevator_number = db.Column(db.String(10), nullable=False) + max_capacity = db.Column(db.Integer, default=1000) + current_floor = db.Column(db.Integer, default=1) + current_load = db.Column(db.Integer, default=0) + is_moving = db.Column(db.Boolean, default=False) + maintenance_status = db.Column(db.String(20), default='operational') + last_maintenance = db.Column(db.DateTime) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + calls = db.relationship('ElevatorCall', backref='elevator', lazy=True) +``` + +### This table contains metadata about the elevator as well as a one to many relationship with the Elevator calls table + +### Elevator Calls +``` +class ElevatorCall(db.Model): + __tablename__ = 'elevator_calls' + + id = db.Column(db.Integer, primary_key=True) + elevator_id = db.Column(db.Integer, db.ForeignKey('elevators.id'), nullable=False) + called_from_floor = db.Column(db.Integer, nullable=False) + destination_floor = db.Column(db.Integer) + call_time = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + response_time = db.Column(db.Float) + elevator_position_at_call = db.Column(db.Integer) + estimated_passengers = db.Column(db.Integer, default=1) + call_type = db.Column(db.String(20), default='normal') + day_of_week = db.Column(db.Integer) + hour_of_day = db.Column(db.Integer) + week_of_month = db.Column(db.Integer) + season = db.Column(db.String(10)) + weather_condition = db.Column(db.String(20)) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + call_dt = self.call_time or datetime.utcnow() + self.day_of_week = call_dt.weekday() + self.hour_of_day = call_dt.hour + self.week_of_month = (call_dt.day - 1) + self.season = self._get_season(call_dt.month) + + def _get_season(self, month: int) -> str: + if month in [12, 1, 2]: + return 'winter' + elif month in [3, 4, 5]: + return 'spring' + elif month in [6, 7, 8]: + return 'summer' + else: + return 'autumn' + +``` + +### I also created an endpoint to export training data in a suitable format for training the model, i added options for csv or json, this makes it a lot easier when working within my Notebook to have the right data. + + +## Evaluate the models performance + +``` +eval_metric=[ + 'MultiClass', + 'Accuracy', + 'TotalF1:average=Weighted', + 'AUC:type=MultiClass', + 'Precision:average=Weighted', + 'Recall:average=Weighted' + ], +``` + +### The CatBoost model already comes with pre-built evaluation metrics +### I would initially start with Accuracy as the base metric because it is simpler to track performance over time with statements such, the model predictions the right floor about 70% of the time, i would combine this with user satisfaction score on service delivery efficiency and quality to get a well rounded view of how the model is performing. + +### This would be my process, i would keep iterating, researching until i find an approach and model that not only satifisfies the prediction target but also works well on new data and can generalize well. + + +## Interesting parts of this project +### Modeling how people behave with elevators is interesting, people can be unpredicatable, and simply make a decision out of feelings, maybe one day they simply dont feel like taking an elevator, for no specific reason or influence from external factors + + +## Challenging parts of this project + +### - Network connectivity is a problem, we might need a failsafe to prevent the elevator from not working at all, + +### - What happens when unusual or sudden events occurs, like surprise inspections from authorities at odd times or evacuations -You will need to talk through your approach, how you modelled the data and why you thought that data was important, provide endpoints to collect the data and -a means to store the data. Testing is important and will be used verify your system -## A note on AI generated code -This project isn't about writing code, AI can and will do that for you. -The next step in this process is to talk through your solution and the decisions you made to come to them. It makes for an awkward and rather boring interview reviewing chatgpt's solution. -If you use a tool to help you write code, that's fine, but we want to see _your_ thought process. -Provided under the chatgpt folder is the response you get back from chat4o. -If your intention isn't to complete the project but to get an AI to spec it for you please, feel free to submit this instead of wasting OpenAI's server resources. -## Problem statement recap -This is a domain modeling problem to build a fit for purpose data storage with a focus on ai data ingestion -- Model the problem into a storage schema (SQL DB schema or whatever you prefer) -- CRUD some data -- Add some flair with a business rule or two -- Have the data in a suitable format to feed to a prediction training algorithm ---- -#### To start -- Fork this repo and begin from there -- For your submission, PR into the main repo. We will review it, a offer any feedback and give you a pass / fail if it passes PR -- Don't spend more than 4 hours on this. Projects that pass PR are paid at the standard hourly rate -#### Marking -- You will be marked on how well your tests cover the code and how useful they would be in a prod system -- You will need to provide storage of some sort. This could be as simple as a sqlite or as complicated as a docker container with a migrations file -- Solutions will be marked against the position you are applying for, a Snr Dev will be expected to have a nearly complete solution and to have thought out the domain and built a schema to fit any issues that could arise -A Jr. dev will be expected to provide a basic design and understand how ML systems like to ingest data -#### Trip-ups from the past -Below is a list of some things from previous submissions that haven't worked out -- Built a prediction engine -- Built a full website with bells and whistles -- Spent more than the time allowed (you won't get bonus points for creating an intricate solution, we want a fit for purpose solution) -- Overcomplicated the system mentally and failed to start