diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..833cc5f --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,3 @@ +_build/ +_static/ +_templates/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e111aee --- /dev/null +++ b/docs/README.md @@ -0,0 +1,61 @@ +# Elevator Saga Documentation + +This directory contains the Sphinx documentation for Elevator Saga. + +## Building the Documentation + +### Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### Build HTML Documentation + +```bash +make html +``` + +The generated HTML documentation will be in `_build/html/`. + +### View Documentation + +Open `_build/html/index.html` in your browser: + +```bash +# On Linux +xdg-open _build/html/index.html + +# On macOS +open _build/html/index.html + +# On Windows +start _build/html/index.html +``` + +### Other Build Formats + +```bash +make latexpdf # Build PDF documentation +make epub # Build EPUB documentation +make clean # Clean build directory +``` + +## Documentation Structure + +- `index.rst` - Main documentation index +- `models.rst` - Data models documentation +- `client.rst` - Client architecture and proxy models +- `communication.rst` - HTTP communication protocol +- `events.rst` - Event system and tick-based simulation +- `api/modules.rst` - Auto-generated API reference + +## Contributing + +When adding new documentation: + +1. Create `.rst` files for new topics +2. Add them to the `toctree` in `index.rst` +3. Follow reStructuredText syntax +4. Build locally to verify formatting +5. Submit PR with documentation changes diff --git a/docs/api/modules.rst b/docs/api/modules.rst new file mode 100644 index 0000000..fcb4524 --- /dev/null +++ b/docs/api/modules.rst @@ -0,0 +1,39 @@ +API Reference +============= + +.. toctree:: + :maxdepth: 4 + +Core Modules +------------ + +.. automodule:: elevator_saga.core.models + :members: + :undoc-members: + :show-inheritance: + +Client Modules +-------------- + +.. automodule:: elevator_saga.client.api_client + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: elevator_saga.client.proxy_models + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: elevator_saga.client.base_controller + :members: + :undoc-members: + :show-inheritance: + +Server Modules +-------------- + +.. automodule:: elevator_saga.server.simulator + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/client.rst b/docs/client.rst new file mode 100644 index 0000000..00edf8a --- /dev/null +++ b/docs/client.rst @@ -0,0 +1,370 @@ +Client Architecture and Proxy Models +==================================== + +The Elevator Saga client provides a powerful abstraction layer that allows you to interact with the simulation using dynamic proxy objects. This architecture provides type-safe, read-only access to simulation state while enabling elevator control commands. + +Overview +-------- + +The client architecture consists of three main components: + +1. **Proxy Models** (``proxy_models.py``): Dynamic proxies that provide transparent access to server state +2. **API Client** (``api_client.py``): HTTP client for communicating with the server +3. **Base Controller** (``base_controller.py``): Abstract base class for implementing control algorithms + +Proxy Models +------------ + +Proxy models in ``elevator_saga/client/proxy_models.py`` provide a clever way to access remote state as if it were local. They inherit from the data models but override attribute access to fetch fresh data from the server. + +How Proxy Models Work +~~~~~~~~~~~~~~~~~~~~~~ + +The proxy pattern implementation uses Python's ``__getattribute__`` magic method to intercept attribute access: + +1. When you access an attribute (e.g., ``elevator.current_floor``), the proxy intercepts the call +2. The proxy fetches the latest state from the server via API client +3. The proxy returns the requested attribute from the fresh state +4. All accesses are **read-only** to maintain consistency + +This design ensures you always work with the most up-to-date simulation state without manual refresh calls. + +ProxyElevator +~~~~~~~~~~~~~ + +Dynamic proxy for ``ElevatorState`` that provides access to elevator properties and control methods: + +.. code-block:: python + + class ProxyElevator(ElevatorState): + """ + Dynamic proxy for elevator state + Provides complete type-safe access and control methods + """ + + def __init__(self, elevator_id: int, api_client: ElevatorAPIClient): + self._elevator_id = elevator_id + self._api_client = api_client + self._init_ok = True + + def go_to_floor(self, floor: int, immediate: bool = False) -> bool: + """Command elevator to go to specified floor""" + return self._api_client.go_to_floor(self._elevator_id, floor, immediate) + +**Accessible Properties** (from ElevatorState): + +- ``id``: Elevator identifier +- ``current_floor``: Current floor number +- ``current_floor_float``: Precise position (e.g., 2.5) +- ``target_floor``: Destination floor +- ``position``: Full Position object +- ``passengers``: List of passenger IDs on board +- ``max_capacity``: Maximum passenger capacity +- ``run_status``: Current ElevatorStatus +- ``target_floor_direction``: Direction to target (UP/DOWN/STOPPED) +- ``last_tick_direction``: Previous movement direction +- ``is_idle``: Whether stopped +- ``is_full``: Whether at capacity +- ``is_running``: Whether in motion +- ``pressed_floors``: Destination floors of current passengers +- ``load_factor``: Current load (0.0 to 1.0) +- ``indicators``: Up/down indicator lights + +**Control Method**: + +- ``go_to_floor(floor, immediate=False)``: Send elevator to floor + + - ``immediate=True``: Change target immediately + - ``immediate=False``: Queue as next target after current destination + +**Example Usage**: + +.. code-block:: python + + # Access elevator state + print(f"Elevator {elevator.id} at floor {elevator.current_floor}") + print(f"Direction: {elevator.target_floor_direction.value}") + print(f"Passengers: {len(elevator.passengers)}/{elevator.max_capacity}") + + # Check status + if elevator.is_idle: + print("Elevator is idle") + elif elevator.is_full: + print("Elevator is full!") + + # Control elevator + if elevator.current_floor == 0: + elevator.go_to_floor(5) # Send to floor 5 + +ProxyFloor +~~~~~~~~~~ + +Dynamic proxy for ``FloorState`` that provides access to floor information: + +.. code-block:: python + + class ProxyFloor(FloorState): + """ + Dynamic proxy for floor state + Provides read-only access to floor information + """ + + def __init__(self, floor_id: int, api_client: ElevatorAPIClient): + self._floor_id = floor_id + self._api_client = api_client + self._init_ok = True + +**Accessible Properties** (from FloorState): + +- ``floor``: Floor number +- ``up_queue``: List of passenger IDs waiting to go up +- ``down_queue``: List of passenger IDs waiting to go down +- ``has_waiting_passengers``: Whether any passengers are waiting +- ``total_waiting``: Total number of waiting passengers + +**Example Usage**: + +.. code-block:: python + + floor = floors[0] + print(f"Floor {floor.floor}") + print(f"Waiting to go up: {len(floor.up_queue)} passengers") + print(f"Waiting to go down: {len(floor.down_queue)} passengers") + + if floor.has_waiting_passengers: + print(f"Total waiting: {floor.total_waiting}") + +ProxyPassenger +~~~~~~~~~~~~~~ + +Dynamic proxy for ``PassengerInfo`` that provides access to passenger information: + +.. code-block:: python + + class ProxyPassenger(PassengerInfo): + """ + Dynamic proxy for passenger information + Provides read-only access to passenger data + """ + + def __init__(self, passenger_id: int, api_client: ElevatorAPIClient): + self._passenger_id = passenger_id + self._api_client = api_client + self._init_ok = True + +**Accessible Properties** (from PassengerInfo): + +- ``id``: Passenger identifier +- ``origin``: Starting floor +- ``destination``: Target floor +- ``arrive_tick``: When passenger appeared +- ``pickup_tick``: When passenger boarded (0 if waiting) +- ``dropoff_tick``: When passenger reached destination (0 if in transit) +- ``elevator_id``: Current elevator ID (None if waiting) +- ``status``: Current PassengerStatus +- ``wait_time``: Ticks waited before boarding +- ``system_time``: Total ticks in system +- ``travel_direction``: UP or DOWN + +**Example Usage**: + +.. code-block:: python + + print(f"Passenger {passenger.id}") + print(f"From floor {passenger.origin} to {passenger.destination}") + print(f"Status: {passenger.status.value}") + + if passenger.status == PassengerStatus.IN_ELEVATOR: + print(f"In elevator {passenger.elevator_id}") + print(f"Waited {passenger.wait_time} ticks") + +Read-Only Protection +~~~~~~~~~~~~~~~~~~~~ + +All proxy models are **read-only**. Attempting to modify attributes will raise an error: + +.. code-block:: python + + elevator.current_floor = 5 # ❌ Raises AttributeError + elevator.passengers.append(123) # ❌ Raises AttributeError + +This ensures that: + +1. Client cannot corrupt server state +2. All state changes go through proper API commands +3. State consistency is maintained + +Implementation Details +~~~~~~~~~~~~~~~~~~~~~~ + +The proxy implementation uses a clever pattern with ``_init_ok`` flag: + +.. code-block:: python + + class ProxyElevator(ElevatorState): + _init_ok = False + + def __init__(self, elevator_id: int, api_client: ElevatorAPIClient): + self._elevator_id = elevator_id + self._api_client = api_client + self._init_ok = True # Enable proxy behavior + + def __getattribute__(self, name: str) -> Any: + # During initialization, use normal attribute access + if not name.startswith("_") and self._init_ok and name not in self.__class__.__dict__: + # Try to find as a method of this class + try: + self_attr = object.__getattribute__(self, name) + if callable(self_attr): + return object.__getattribute__(self, name) + except AttributeError: + pass + # Fetch fresh state and return attribute + elevator_state = self._get_elevator_state() + return elevator_state.__getattribute__(name) + else: + return object.__getattribute__(self, name) + + def __setattr__(self, name: str, value: Any) -> None: + # Allow setting during initialization only + if not self._init_ok: + object.__setattr__(self, name, value) + else: + raise AttributeError(f"Cannot modify read-only attribute '{name}'") + +This design: + +1. Allows normal initialization of internal fields (``_elevator_id``, ``_api_client``) +2. Intercepts access to data attributes after initialization +3. Preserves access to class methods (like ``go_to_floor``) +4. Blocks all attribute modifications after initialization + +Base Controller +--------------- + +The ``ElevatorController`` class in ``base_controller.py`` provides the framework for implementing control algorithms: + +.. code-block:: python + + from elevator_saga.client.base_controller import ElevatorController + from elevator_saga.client.proxy_models import ProxyElevator, ProxyFloor, ProxyPassenger + from typing import List + + class MyController(ElevatorController): + def __init__(self): + super().__init__("http://127.0.0.1:8000", auto_run=True) + + def on_init(self, elevators: List[ProxyElevator], floors: List[ProxyFloor]) -> None: + """Called once at start with all elevators and floors""" + print(f"Initialized with {len(elevators)} elevators") + + def on_passenger_call(self, passenger: ProxyPassenger, floor: ProxyFloor, direction: str) -> None: + """Called when a passenger presses a button""" + print(f"Passenger {passenger.id} at floor {floor.floor} going {direction}") + + def on_elevator_stopped(self, elevator: ProxyElevator, floor: ProxyFloor) -> None: + """Called when elevator stops at a floor""" + print(f"Elevator {elevator.id} stopped at floor {floor.floor}") + # Implement your dispatch logic here + + def on_elevator_idle(self, elevator: ProxyElevator) -> None: + """Called when elevator becomes idle""" + # Send idle elevator somewhere useful + elevator.go_to_floor(0) + +The controller provides these event handlers: + +- ``on_init(elevators, floors)``: Initialization +- ``on_event_execute_start(tick, events, elevators, floors)``: Before processing tick events +- ``on_event_execute_end(tick, events, elevators, floors)``: After processing tick events +- ``on_passenger_call(passenger, floor, direction)``: Button press +- ``on_elevator_stopped(elevator, floor)``: Elevator arrival +- ``on_elevator_idle(elevator)``: Elevator becomes idle +- ``on_passenger_board(elevator, passenger)``: Passenger boards +- ``on_passenger_alight(elevator, passenger, floor)``: Passenger alights +- ``on_elevator_passing_floor(elevator, floor, direction)``: Elevator passes floor +- ``on_elevator_approaching(elevator, floor, direction)``: Elevator about to arrive + +Complete Example +---------------- + +Here's a simple controller that sends idle elevators to the ground floor: + +.. code-block:: python + + #!/usr/bin/env python3 + from typing import List + from elevator_saga.client.base_controller import ElevatorController + from elevator_saga.client.proxy_models import ProxyElevator, ProxyFloor, ProxyPassenger + + class SimpleController(ElevatorController): + def __init__(self): + super().__init__("http://127.0.0.1:8000", auto_run=True) + self.pending_calls = [] + + def on_init(self, elevators: List[ProxyElevator], floors: List[ProxyFloor]) -> None: + print(f"Controlling {len(elevators)} elevators in {len(floors)}-floor building") + + def on_passenger_call(self, passenger: ProxyPassenger, floor: ProxyFloor, direction: str) -> None: + print(f"Call from floor {floor.floor}, direction {direction}") + self.pending_calls.append((floor.floor, direction)) + # Dispatch nearest idle elevator + self._dispatch_to_call(floor.floor) + + def on_elevator_idle(self, elevator: ProxyElevator) -> None: + if self.pending_calls: + floor, direction = self.pending_calls.pop(0) + elevator.go_to_floor(floor) + else: + # No calls, return to ground floor + elevator.go_to_floor(0) + + def on_elevator_stopped(self, elevator: ProxyElevator, floor: ProxyFloor) -> None: + print(f"Elevator {elevator.id} at floor {floor.floor}") + print(f" Passengers on board: {len(elevator.passengers)}") + print(f" Waiting at floor: {floor.total_waiting}") + + def _dispatch_to_call(self, floor: int) -> None: + # Find nearest idle elevator and send it + # (Simplified - real implementation would be more sophisticated) + pass + + if __name__ == "__main__": + controller = SimpleController() + controller.start() + +Benefits of Proxy Architecture +------------------------------- + +1. **Type Safety**: IDE autocomplete and type checking work perfectly +2. **Always Fresh**: No need to manually refresh state +3. **Clean API**: Access remote state as if it were local +4. **Read-Only Safety**: Cannot accidentally corrupt server state +5. **Separation of Concerns**: State management handled by proxies, logic in controller +6. **Testability**: Can mock API client for unit tests + +Performance Considerations +-------------------------- + +Each attribute access triggers an API call. For better performance: + +.. code-block:: python + + # ❌ Inefficient - multiple API calls + if elevator.current_floor < elevator.target_floor: + diff = elevator.target_floor - elevator.current_floor + + # ✅ Better - store references + current = elevator.current_floor + target = elevator.target_floor + if current < target: + diff = target - current + +However, the API client implements **caching within a single tick**, so multiple accesses during event processing are efficient. + +Next Steps +---------- + +- See :doc:`communication` for details on the HTTP API +- See :doc:`events` for understanding the event-driven simulation +- Check ``client_examples/bus_example.py`` for a complete implementation diff --git a/docs/communication.rst b/docs/communication.rst new file mode 100644 index 0000000..20b48e0 --- /dev/null +++ b/docs/communication.rst @@ -0,0 +1,544 @@ +HTTP Communication Architecture +================================ + +Elevator Saga uses a **client-server architecture** with HTTP-based communication. The server manages the simulation state and physics, while clients send commands and query state over HTTP. + +Architecture Overview +--------------------- + +.. code-block:: text + + ┌─────────────────┐ ┌─────────────────┐ + │ Client │ │ Server │ + │ (Controller) │ │ (Simulator) │ + ├─────────────────┤ ├─────────────────┤ + │ base_controller │◄──── HTTP ────────►│ simulator.py │ + │ proxy_models │ │ Flask Routes │ + │ api_client │ │ │ + └─────────────────┘ └─────────────────┘ + │ │ + │ GET /api/state │ + │ POST /api/step │ + │ POST /api/elevators/:id/go_to_floor │ + │ │ + └───────────────────────────────────────┘ + +The separation provides: + +- **Modularity**: Server and client can be developed independently +- **Multiple Clients**: Multiple controllers can compete/cooperate +- **Language Flexibility**: Clients can be written in any language +- **Network Deployment**: Server and client can run on different machines + +Server Side: Simulator +---------------------- + +The server is implemented in ``elevator_saga/server/simulator.py`` using **Flask** as the HTTP framework. + +API Endpoints +~~~~~~~~~~~~~ + +**GET /api/state** + +Returns complete simulation state: + +.. code-block:: python + + @app.route("/api/state", methods=["GET"]) + def get_state() -> Response: + try: + state = simulation.get_state() + return json_response(state) + except Exception as e: + return json_response({"error": str(e)}, 500) + +Response format: + +.. code-block:: json + + { + "tick": 42, + "elevators": [ + { + "id": 0, + "position": {"current_floor": 2, "target_floor": 5, "floor_up_position": 3}, + "passengers": [101, 102], + "max_capacity": 10, + "run_status": "constant_speed", + "..." + } + ], + "floors": [ + {"floor": 0, "up_queue": [103], "down_queue": []}, + "..." + ], + "passengers": { + "101": {"id": 101, "origin": 0, "destination": 5, "..."} + }, + "metrics": { + "done": 50, + "total": 100, + "avg_wait": 15.2, + "p95_wait": 30.0, + "avg_system": 25.5, + "p95_system": 45.0 + } + } + +**POST /api/step** + +Advances simulation by specified number of ticks: + +.. code-block:: python + + @app.route("/api/step", methods=["POST"]) + def step_simulation() -> Response: + try: + data = request.get_json() or {} + ticks = data.get("ticks", 1) + events = simulation.step(ticks) + return json_response({ + "tick": simulation.tick, + "events": events, + }) + except Exception as e: + return json_response({"error": str(e)}, 500) + +Request body: + +.. code-block:: json + + {"ticks": 1} + +Response: + +.. code-block:: json + + { + "tick": 43, + "events": [ + { + "tick": 43, + "type": "stopped_at_floor", + "data": {"elevator": 0, "floor": 5, "reason": "move_reached"} + } + ] + } + +**POST /api/elevators/:id/go_to_floor** + +Commands an elevator to go to a floor: + +.. code-block:: python + + @app.route("/api/elevators//go_to_floor", methods=["POST"]) + def elevator_go_to_floor(elevator_id: int) -> Response: + try: + data = request.get_json() or {} + floor = data["floor"] + immediate = data.get("immediate", False) + simulation.elevator_go_to_floor(elevator_id, floor, immediate) + return json_response({"success": True}) + except Exception as e: + return json_response({"error": str(e)}, 500) + +Request body: + +.. code-block:: json + + {"floor": 5, "immediate": false} + +- ``immediate=false``: Set as next target after current destination +- ``immediate=true``: Change target immediately (cancels current target) + +**POST /api/reset** + +Resets simulation to initial state: + +.. code-block:: python + + @app.route("/api/reset", methods=["POST"]) + def reset_simulation() -> Response: + try: + simulation.reset() + return json_response({"success": True}) + except Exception as e: + return json_response({"error": str(e)}, 500) + +**POST /api/traffic/next** + +Loads next traffic scenario: + +.. code-block:: python + + @app.route("/api/traffic/next", methods=["POST"]) + def next_traffic_round() -> Response: + try: + full_reset = request.get_json().get("full_reset", False) + success = simulation.next_traffic_round(full_reset) + if success: + return json_response({"success": True}) + else: + return json_response({"success": False, "error": "No more scenarios"}, 400) + except Exception as e: + return json_response({"error": str(e)}, 500) + +**GET /api/traffic/info** + +Gets current traffic scenario information: + +.. code-block:: python + + @app.route("/api/traffic/info", methods=["GET"]) + def get_traffic_info() -> Response: + try: + info = simulation.get_traffic_info() + return json_response(info) + except Exception as e: + return json_response({"error": str(e)}, 500) + +Response: + +.. code-block:: json + + { + "current_index": 0, + "total_files": 5, + "max_tick": 1000 + } + +Client Side: API Client +----------------------- + +The client is implemented in ``elevator_saga/client/api_client.py`` using Python's built-in ``urllib`` library. + +ElevatorAPIClient Class +~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + class ElevatorAPIClient: + """Unified elevator API client""" + + def __init__(self, base_url: str): + self.base_url = base_url.rstrip("/") + # Caching fields + self._cached_state: Optional[SimulationState] = None + self._cached_tick: int = -1 + self._tick_processed: bool = False + +State Caching Strategy +~~~~~~~~~~~~~~~~~~~~~~~ + +The API client implements **smart caching** to reduce HTTP requests: + +.. code-block:: python + + def get_state(self, force_reload: bool = False) -> SimulationState: + """Get simulation state with caching""" + # Return cached state if valid + if not force_reload and self._cached_state is not None and not self._tick_processed: + return self._cached_state + + # Fetch fresh state + response_data = self._send_get_request("/api/state") + # ... parse and create SimulationState ... + + # Update cache + self._cached_state = simulation_state + self._cached_tick = simulation_state.tick + self._tick_processed = False # Mark as fresh + + return simulation_state + + def mark_tick_processed(self) -> None: + """Mark current tick as processed, invalidating cache""" + self._tick_processed = True + +**Cache Behavior**: + +1. First ``get_state()`` call in a tick fetches from server +2. Subsequent calls within same tick return cached data +3. After ``step()`` is called, cache is invalidated +4. Next ``get_state()`` fetches fresh data + +This provides: + +- **Performance**: Minimize HTTP requests +- **Consistency**: All operations in a tick see same state +- **Freshness**: New tick always gets new state + +Core API Methods +~~~~~~~~~~~~~~~~ + +**get_state(force_reload=False)** + +Fetches current simulation state: + +.. code-block:: python + + def get_state(self, force_reload: bool = False) -> SimulationState: + if not force_reload and self._cached_state is not None and not self._tick_processed: + return self._cached_state + + response_data = self._send_get_request("/api/state") + + # Parse response into data models + elevators = [ElevatorState.from_dict(e) for e in response_data["elevators"]] + floors = [FloorState.from_dict(f) for f in response_data["floors"]] + # ... handle passengers and metrics ... + + simulation_state = SimulationState( + tick=response_data["tick"], + elevators=elevators, + floors=floors, + passengers=passengers, + metrics=metrics, + events=[] + ) + + # Update cache + self._cached_state = simulation_state + self._cached_tick = simulation_state.tick + self._tick_processed = False + + return simulation_state + +**step(ticks=1)** + +Advances simulation: + +.. code-block:: python + + def step(self, ticks: int = 1) -> StepResponse: + response_data = self._send_post_request("/api/step", {"ticks": ticks}) + + # Parse events + events = [] + for event_data in response_data["events"]: + event_dict = event_data.copy() + if "type" in event_dict: + event_dict["type"] = EventType(event_dict["type"]) + events.append(SimulationEvent.from_dict(event_dict)) + + return StepResponse( + success=True, + tick=response_data["tick"], + events=events + ) + +**go_to_floor(elevator_id, floor, immediate=False)** + +Sends elevator to floor: + +.. code-block:: python + + def go_to_floor(self, elevator_id: int, floor: int, immediate: bool = False) -> bool: + command = GoToFloorCommand( + elevator_id=elevator_id, + floor=floor, + immediate=immediate + ) + + try: + response = self.send_elevator_command(command) + return response + except Exception as e: + debug_log(f"Go to floor failed: {e}") + return False + +HTTP Request Implementation +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The client uses ``urllib.request`` for HTTP communication: + +**GET Request**: + +.. code-block:: python + + def _send_get_request(self, endpoint: str) -> Dict[str, Any]: + url = f"{self.base_url}{endpoint}" + + try: + with urllib.request.urlopen(url, timeout=60) as response: + data = json.loads(response.read().decode("utf-8")) + return data + except urllib.error.URLError as e: + raise RuntimeError(f"GET {url} failed: {e}") + +**POST Request**: + +.. code-block:: python + + def _send_post_request(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]: + url = f"{self.base_url}{endpoint}" + request_body = json.dumps(data).encode("utf-8") + + req = urllib.request.Request( + url, + data=request_body, + headers={"Content-Type": "application/json"} + ) + + try: + with urllib.request.urlopen(req, timeout=600) as response: + response_data = json.loads(response.read().decode("utf-8")) + return response_data + except urllib.error.URLError as e: + raise RuntimeError(f"POST {url} failed: {e}") + +Communication Flow +------------------ + +Typical communication sequence during one tick: + +.. code-block:: text + + Client Server + │ │ + │ 1. GET /api/state │ + ├────────────────────────────────────►│ + │ ◄── SimulationState (cached) │ + │ │ + │ 2. Analyze state, make decisions │ + │ │ + │ 3. POST /api/elevators/0/go_to_floor │ + ├────────────────────────────────────►│ + │ ◄── {"success": true} │ + │ │ + │ 4. GET /api/state (from cache) │ + │ No HTTP request! │ + │ │ + │ 5. POST /api/step │ + ├────────────────────────────────────►│ + │ Server processes tick │ + │ - Moves elevators │ + │ - Boards/alights passengers │ + │ - Generates events │ + │ ◄── {tick: 43, events: [...]} │ + │ │ + │ 6. Process events │ + │ Cache invalidated │ + │ │ + │ 7. GET /api/state (fetches fresh) │ + ├────────────────────────────────────►│ + │ ◄── SimulationState │ + │ │ + └─────────────────────────────────────┘ + +Error Handling +-------------- + +Both client and server implement robust error handling: + +**Server Side**: + +.. code-block:: python + + @app.route("/api/step", methods=["POST"]) + def step_simulation() -> Response: + try: + # ... process request ... + return json_response(result) + except Exception as e: + return json_response({"error": str(e)}, 500) + +**Client Side**: + +.. code-block:: python + + def go_to_floor(self, elevator_id: int, floor: int, immediate: bool = False) -> bool: + try: + response = self.send_elevator_command(command) + return response + except Exception as e: + debug_log(f"Go to floor failed: {e}") + return False + +Thread Safety +------------- + +The simulator uses a lock to ensure thread-safe access: + +.. code-block:: python + + class ElevatorSimulation: + def __init__(self, ...): + self.lock = threading.Lock() + + def step(self, num_ticks: int = 1) -> List[SimulationEvent]: + with self.lock: + # ... process ticks ... + + def get_state(self) -> SimulationStateResponse: + with self.lock: + # ... return state ... + +This allows Flask to handle concurrent requests safely. + +Performance Considerations +-------------------------- + +**Minimize HTTP Calls**: + +.. code-block:: python + + # ❌ Bad - 3 HTTP calls + for i in range(3): + state = api_client.get_state() + print(state.tick) + + # ✅ Good - 1 HTTP call (cached) + state = api_client.get_state() + for i in range(3): + print(state.tick) + +**Batch Commands**: + +.. code-block:: python + + # ❌ Bad - sequential commands + elevator1.go_to_floor(5) + time.sleep(0.1) # Wait for response + elevator2.go_to_floor(3) + + # ✅ Good - issue commands quickly + elevator1.go_to_floor(5) + elevator2.go_to_floor(3) + # All commands received before next tick + +**Cache Awareness**: + +Use ``mark_tick_processed()`` to explicitly invalidate cache if needed, but normally the framework handles this automatically. + +Testing the API +--------------- + +You can test the API directly using curl: + +.. code-block:: bash + + # Get state + curl http://127.0.0.1:8000/api/state + + # Step simulation + curl -X POST http://127.0.0.1:8000/api/step \ + -H "Content-Type: application/json" \ + -d '{"ticks": 1}' + + # Send elevator to floor + curl -X POST http://127.0.0.1:8000/api/elevators/0/go_to_floor \ + -H "Content-Type: application/json" \ + -d '{"floor": 5, "immediate": false}' + + # Reset simulation + curl -X POST http://127.0.0.1:8000/api/reset \ + -H "Content-Type: application/json" \ + -d '{}' + +Next Steps +---------- + +- See :doc:`events` for understanding how events drive the simulation +- See :doc:`client` for using the API through proxy models +- Check the source code for complete implementation details diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..6a938af --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,59 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "Elevator Saga" +copyright = "2025, ZGCA-Forge Team" +author = "ZGCA-Forge Team" +release = "0.1.0" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "sphinx.ext.intersphinx", + "sphinx.ext.todo", + "sphinx_rtd_theme", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] + +# -- Extension configuration ------------------------------------------------- + +# Napoleon settings +napoleon_google_docstring = True +napoleon_numpy_docstring = True +napoleon_include_init_with_doc = True +napoleon_include_private_with_doc = False +napoleon_include_special_with_doc = True +napoleon_use_admonition_for_examples = False +napoleon_use_admonition_for_notes = False +napoleon_use_admonition_for_references = False +napoleon_use_ivar = False +napoleon_use_param = True +napoleon_use_rtype = True +napoleon_preprocess_types = False +napoleon_type_aliases = None +napoleon_attr_annotations = True + +# Intersphinx mapping +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), +} + +# Todo extension +todo_include_todos = True diff --git a/docs/events.rst b/docs/events.rst new file mode 100644 index 0000000..ee144a5 --- /dev/null +++ b/docs/events.rst @@ -0,0 +1,592 @@ +Event-Driven Simulation and Tick-Based Execution +================================================ + +Elevator Saga uses an **event-driven, tick-based** discrete simulation model. The simulation progresses in discrete time steps (ticks), and events are generated to notify the controller about state changes. + +Simulation Overview +------------------- + +.. code-block:: text + + ┌─────────────────────────────────────────────────────────┐ + │ Simulation Loop │ + │ │ + │ Tick N │ + │ 1. Update elevator status (START_UP → CONSTANT_SPEED)│ + │ 2. Process arrivals (new passengers) │ + │ 3. Move elevators (physics simulation) │ + │ 4. Process stops (boarding/alighting) │ + │ 5. Generate events │ + │ │ + │ Events sent to client → Client processes → Commands │ + │ │ + │ Tick N+1 │ + │ (repeat...) │ + └─────────────────────────────────────────────────────────┘ + +Tick-Based Execution +-------------------- + +What is a Tick? +~~~~~~~~~~~~~~~ + +A **tick** is the fundamental unit of simulation time. Each tick represents one discrete time step where: + +1. Physics is updated (elevators move) +2. State changes occur (passengers board/alight) +3. Events are generated +4. Controller receives events and makes decisions + +Think of it like frames in a video game - the simulation updates at discrete intervals. + +Tick Processing Flow +~~~~~~~~~~~~~~~~~~~~ + +In ``simulator.py``, the ``step()`` method processes ticks: + +.. code-block:: python + + def step(self, num_ticks: int = 1) -> List[SimulationEvent]: + """Process one or more simulation ticks""" + with self.lock: + new_events = [] + for _ in range(num_ticks): + self.state.tick += 1 + tick_events = self._process_tick() + new_events.extend(tick_events) + + # Force complete passengers if max duration reached + if self.tick >= self.max_duration_ticks: + completed_count = self.force_complete_remaining_passengers() + + return new_events + +Each ``_process_tick()`` executes the four-phase cycle: + +.. code-block:: python + + def _process_tick(self) -> List[SimulationEvent]: + """Process one simulation tick""" + events_start = len(self.state.events) + + # Phase 1: Update elevator status + self._update_elevator_status() + + # Phase 2: Add new passengers from traffic queue + self._process_arrivals() + + # Phase 3: Move elevators + self._move_elevators() + + # Phase 4: Process elevator stops and passenger boarding/alighting + self._process_elevator_stops() + + # Return events generated this tick + return self.state.events[events_start:] + +Elevator State Machine +----------------------- + +Elevators transition through states each tick: + +.. code-block:: text + + STOPPED ──(target set)──► START_UP ──(1 tick)──► CONSTANT_SPEED + │ + (near target) + ▼ + START_DOWN + │ + (1 tick) + ▼ + (arrived) STOPPED + +State Transitions +~~~~~~~~~~~~~~~~~ + +**Phase 1: Update Elevator Status** (``_update_elevator_status()``): + +.. code-block:: python + + def _update_elevator_status(self) -> None: + """Update elevator operational state""" + for elevator in self.elevators: + # If no direction, check for next target + if elevator.target_floor_direction == Direction.STOPPED: + if elevator.next_target_floor is not None: + self._set_elevator_target_floor(elevator, elevator.next_target_floor) + self._process_passenger_in() + elevator.next_target_floor = None + else: + continue + + # Transition state machine + if elevator.run_status == ElevatorStatus.STOPPED: + # Start acceleration + elevator.run_status = ElevatorStatus.START_UP + elif elevator.run_status == ElevatorStatus.START_UP: + # Switch to constant speed after 1 tick + elevator.run_status = ElevatorStatus.CONSTANT_SPEED + +**Important Notes**: + +- ``START_UP`` = acceleration (not direction!) +- ``START_DOWN`` = deceleration (not direction!) +- Actual movement direction is ``target_floor_direction`` (UP/DOWN) +- State transitions happen **before** movement + +Movement Physics +---------------- + +Speed by State +~~~~~~~~~~~~~~ + +Elevators move at different speeds depending on their state: + +.. code-block:: python + + def _move_elevators(self) -> None: + """Move all elevators towards their destinations""" + for elevator in self.elevators: + # Determine speed based on state + movement_speed = 0 + if elevator.run_status == ElevatorStatus.START_UP: + movement_speed = 1 # Accelerating: 0.1 floors/tick + elif elevator.run_status == ElevatorStatus.START_DOWN: + movement_speed = 1 # Decelerating: 0.1 floors/tick + elif elevator.run_status == ElevatorStatus.CONSTANT_SPEED: + movement_speed = 2 # Full speed: 0.2 floors/tick + + if movement_speed == 0: + continue + + # Apply movement in appropriate direction + if elevator.target_floor_direction == Direction.UP: + new_floor = elevator.position.floor_up_position_add(movement_speed) + elif elevator.target_floor_direction == Direction.DOWN: + new_floor = elevator.position.floor_up_position_add(-movement_speed) + +Position System +~~~~~~~~~~~~~~~ + +Positions use a **10-unit sub-floor** system: + +- ``current_floor = 2, floor_up_position = 0`` → exactly at floor 2 +- ``current_floor = 2, floor_up_position = 5`` → halfway between floors 2 and 3 +- ``current_floor = 2, floor_up_position = 10`` → advances to ``current_floor = 3, floor_up_position = 0`` + +This granularity allows smooth movement and precise deceleration timing. + +Deceleration Logic +~~~~~~~~~~~~~~~~~~ + +Elevators must decelerate before stopping: + +.. code-block:: python + + def _should_start_deceleration(self, elevator: ElevatorState) -> bool: + """Check if should start decelerating""" + distance = self._calculate_distance_to_target(elevator) + return distance == 1 # Start deceleration 1 position unit before target + + # In _move_elevators(): + if elevator.run_status == ElevatorStatus.CONSTANT_SPEED: + if self._should_start_deceleration(elevator): + elevator.run_status = ElevatorStatus.START_DOWN + +This ensures elevators don't overshoot their target floor. + +Event System +------------ + +Event Types +~~~~~~~~~~~ + +The simulation generates 8 types of events defined in ``EventType`` enum: + +.. code-block:: python + + class EventType(Enum): + UP_BUTTON_PRESSED = "up_button_pressed" + DOWN_BUTTON_PRESSED = "down_button_pressed" + PASSING_FLOOR = "passing_floor" + STOPPED_AT_FLOOR = "stopped_at_floor" + ELEVATOR_APPROACHING = "elevator_approaching" + IDLE = "idle" + PASSENGER_BOARD = "passenger_board" + PASSENGER_ALIGHT = "passenger_alight" + +Event Generation +~~~~~~~~~~~~~~~~ + +Events are generated during tick processing: + +**Passenger Arrival**: + +.. code-block:: python + + def _process_arrivals(self) -> None: + """Process new passenger arrivals""" + while self.traffic_queue and self.traffic_queue[0].tick <= self.tick: + traffic_entry = self.traffic_queue.pop(0) + passenger = PassengerInfo( + id=traffic_entry.id, + origin=traffic_entry.origin, + destination=traffic_entry.destination, + arrive_tick=self.tick, + ) + self.passengers[passenger.id] = passenger + + if passenger.destination > passenger.origin: + self.floors[passenger.origin].up_queue.append(passenger.id) + # Generate UP_BUTTON_PRESSED event + self._emit_event( + EventType.UP_BUTTON_PRESSED, + {"floor": passenger.origin, "passenger": passenger.id} + ) + else: + self.floors[passenger.origin].down_queue.append(passenger.id) + # Generate DOWN_BUTTON_PRESSED event + self._emit_event( + EventType.DOWN_BUTTON_PRESSED, + {"floor": passenger.origin, "passenger": passenger.id} + ) + +**Elevator Movement**: + +.. code-block:: python + + def _move_elevators(self) -> None: + for elevator in self.elevators: + # ... movement logic ... + + # Passing a floor + if old_floor != new_floor and new_floor != target_floor: + self._emit_event( + EventType.PASSING_FLOOR, + { + "elevator": elevator.id, + "floor": new_floor, + "direction": elevator.target_floor_direction.value + } + ) + + # About to arrive (during deceleration) + if self._near_next_stop(elevator): + self._emit_event( + EventType.ELEVATOR_APPROACHING, + { + "elevator": elevator.id, + "floor": elevator.target_floor, + "direction": elevator.target_floor_direction.value + } + ) + + # Arrived at target + if target_floor == new_floor and elevator.position.floor_up_position == 0: + elevator.run_status = ElevatorStatus.STOPPED + self._emit_event( + EventType.STOPPED_AT_FLOOR, + { + "elevator": elevator.id, + "floor": new_floor, + "reason": "move_reached" + } + ) + +**Boarding and Alighting**: + +.. code-block:: python + + def _process_elevator_stops(self) -> None: + for elevator in self.elevators: + if elevator.run_status != ElevatorStatus.STOPPED: + continue + + current_floor = elevator.current_floor + + # Passengers alight + passengers_to_remove = [] + for passenger_id in elevator.passengers: + passenger = self.passengers[passenger_id] + if passenger.destination == current_floor: + passenger.dropoff_tick = self.tick + passengers_to_remove.append(passenger_id) + + for passenger_id in passengers_to_remove: + elevator.passengers.remove(passenger_id) + self._emit_event( + EventType.PASSENGER_ALIGHT, + {"elevator": elevator.id, "floor": current_floor, "passenger": passenger_id} + ) + +**Idle Detection**: + +.. code-block:: python + + # If elevator stopped with no direction, it's idle + if elevator.last_tick_direction == Direction.STOPPED: + self._emit_event( + EventType.IDLE, + {"elevator": elevator.id, "floor": current_floor} + ) + +Event Processing in Controller +------------------------------- + +The ``ElevatorController`` base class automatically routes events to handler methods: + +.. code-block:: python + + class ElevatorController(ABC): + def _execute_events(self, events: List[SimulationEvent]) -> None: + """Process events and route to handlers""" + for event in events: + if event.type == EventType.UP_BUTTON_PRESSED: + passenger_id = event.data["passenger"] + floor = self.floors[event.data["floor"]] + passenger = ProxyPassenger(passenger_id, self.api_client) + self.on_passenger_call(passenger, floor, "up") + + elif event.type == EventType.DOWN_BUTTON_PRESSED: + passenger_id = event.data["passenger"] + floor = self.floors[event.data["floor"]] + passenger = ProxyPassenger(passenger_id, self.api_client) + self.on_passenger_call(passenger, floor, "down") + + elif event.type == EventType.STOPPED_AT_FLOOR: + elevator = self.elevators[event.data["elevator"]] + floor = self.floors[event.data["floor"]] + self.on_elevator_stopped(elevator, floor) + + elif event.type == EventType.IDLE: + elevator = self.elevators[event.data["elevator"]] + self.on_elevator_idle(elevator) + + # ... other event types ... + +Control Flow: Bus Example +-------------------------- + +The ``bus_example.py`` demonstrates a simple "bus route" algorithm: + +.. code-block:: python + + class ElevatorBusExampleController(ElevatorController): + def __init__(self): + super().__init__("http://127.0.0.1:8000", True) + self.all_passengers = [] + self.max_floor = 0 + + def on_init(self, elevators: List[ProxyElevator], floors: List[ProxyFloor]) -> None: + """Initialize elevators to starting positions""" + self.max_floor = floors[-1].floor + self.floors = floors + + for i, elevator in enumerate(elevators): + # Distribute elevators evenly across floors + target_floor = (i * (len(floors) - 1)) // len(elevators) + elevator.go_to_floor(target_floor, immediate=True) + + def on_event_execute_start(self, tick: int, events: List[SimulationEvent], + elevators: List[ProxyElevator], floors: List[ProxyFloor]) -> None: + """Print state before processing events""" + print(f"Tick {tick}: Processing {len(events)} events {[e.type.value for e in events]}") + for elevator in elevators: + print( + f"\t{elevator.id}[{elevator.target_floor_direction.value}," + f"{elevator.current_floor_float}/{elevator.target_floor}]" + + "👦" * len(elevator.passengers), + end="" + ) + print() + + def on_elevator_stopped(self, elevator: ProxyElevator, floor: ProxyFloor) -> None: + """Implement bus route logic""" + print(f"🛑 Elevator E{elevator.id} stopped at F{floor.floor}") + + # Bus algorithm: go up to top, then down to bottom, repeat + if elevator.last_tick_direction == Direction.UP and elevator.current_floor == self.max_floor: + # At top, start going down + elevator.go_to_floor(elevator.current_floor - 1) + elif elevator.last_tick_direction == Direction.DOWN and elevator.current_floor == 0: + # At bottom, start going up + elevator.go_to_floor(elevator.current_floor + 1) + elif elevator.last_tick_direction == Direction.UP: + # Continue upward + elevator.go_to_floor(elevator.current_floor + 1) + elif elevator.last_tick_direction == Direction.DOWN: + # Continue downward + elevator.go_to_floor(elevator.current_floor - 1) + + def on_elevator_idle(self, elevator: ProxyElevator) -> None: + """Send idle elevator to floor 1""" + elevator.go_to_floor(1) + +Execution Sequence +~~~~~~~~~~~~~~~~~~ + +Here's what happens in a typical tick: + +.. code-block:: text + + Server: Tick 42 + Phase 1: Update status + - Elevator 0: STOPPED → START_UP (has target) + Phase 2: Process arrivals + - Passenger 101 arrives at floor 0, going to floor 5 + - Event: UP_BUTTON_PRESSED + Phase 3: Move elevators + - Elevator 0: floor 2.0 → 2.1 (accelerating) + Phase 4: Process stops + - (no stops this tick) + + Events: [UP_BUTTON_PRESSED, PASSING_FLOOR] + + Client: Receive events + on_event_execute_start(tick=42, events=[...]) + - Print "Tick 42: Processing 2 events" + + _execute_events(): + - UP_BUTTON_PRESSED → on_passenger_call() + → Controller decides which elevator to send + - PASSING_FLOOR → on_elevator_passing_floor() + + on_event_execute_end(tick=42, events=[...]) + + Client: Send commands + - elevator.go_to_floor(0) → POST /api/elevators/0/go_to_floor + + Client: Step simulation + - POST /api/step → Server processes tick 43 + +Key Timing Concepts +------------------- + +Command vs. Execution +~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Tick 42: Controller sends command + elevator.go_to_floor(5, immediate=False) + # ← Command queued in elevator.next_target_floor + + # Tick 43: Server processes + # ← _update_elevator_status() assigns target + # ← Elevator starts moving + + # Tick 44-46: Elevator in motion + # ← Events: PASSING_FLOOR + + # Tick 47: Elevator arrives + # ← Event: STOPPED_AT_FLOOR + +There's a **one-tick delay** between command and execution (unless ``immediate=True``). + +Immediate vs. Queued +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Queued (default): Wait until current target reached + elevator.go_to_floor(5, immediate=False) + # ← Sets elevator.next_target_floor = 5 + # ← Processed when current_floor == target_floor + + # Immediate: Change target right away + elevator.go_to_floor(5, immediate=True) + # ← Sets elevator.position.target_floor = 5 immediately + # ← May interrupt current journey + +Use ``immediate=True`` for emergency redirects, ``immediate=False`` (default) for normal operation. + +Performance Metrics +------------------- + +Metrics are calculated from passenger data: + +.. code-block:: python + + def _calculate_metrics(self) -> MetricsResponse: + """Calculate performance metrics""" + completed = [p for p in self.state.passengers.values() + if p.status == PassengerStatus.COMPLETED] + + wait_times = [float(p.wait_time) for p in completed] + system_times = [float(p.system_time) for p in completed] + + return MetricsResponse( + done=len(completed), + total=len(self.state.passengers), + avg_wait=sum(wait_times) / len(wait_times) if wait_times else 0, + p95_wait=percentile(wait_times, 95), + avg_system=sum(system_times) / len(system_times) if system_times else 0, + p95_system=percentile(system_times, 95), + ) + +Key metrics: + +- **Wait time**: ``pickup_tick - arrive_tick`` (how long passenger waited) +- **System time**: ``dropoff_tick - arrive_tick`` (total time in system) +- **P95**: 95th percentile (worst-case for most passengers) + +Best Practices +-------------- + +1. **React to Events**: Don't poll state - implement event handlers +2. **Use Queued Commands**: Default ``immediate=False`` is safer +3. **Track Passengers**: Monitor ``on_passenger_call`` to know demand +4. **Optimize for Wait Time**: Reduce time between arrival and pickup +5. **Consider Load**: Check ``elevator.is_full`` before dispatching +6. **Handle Idle**: Always give idle elevators something to do (even if it's "go to floor 0") + +Example: Efficient Dispatch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + def on_passenger_call(self, passenger: ProxyPassenger, floor: ProxyFloor, direction: str) -> None: + """Dispatch nearest suitable elevator""" + best_elevator = None + best_cost = float('inf') + + for elevator in self.elevators: + # Skip if full + if elevator.is_full: + continue + + # Calculate cost (distance + current load) + distance = abs(elevator.current_floor - floor.floor) + load_penalty = elevator.load_factor * 10 + cost = distance + load_penalty + + # Check if going in right direction + if elevator.target_floor_direction.value == direction: + cost *= 0.5 # Prefer elevators already going that way + + if cost < best_cost: + best_cost = cost + best_elevator = elevator + + if best_elevator: + best_elevator.go_to_floor(floor.floor) + +Summary +------- + +The event-driven, tick-based architecture provides: + +- **Deterministic**: Same inputs always produce same results +- **Testable**: Easy to create test scenarios with traffic files +- **Debuggable**: Clear event trail shows what happened when +- **Flexible**: Easy to implement different dispatch algorithms +- **Scalable**: Can simulate large buildings and many passengers + +Next Steps +---------- + +- Study ``bus_example.py`` for a complete working example +- Implement your own controller by extending ``ElevatorController`` +- Experiment with different dispatch algorithms +- Analyze performance metrics to optimize your approach diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..0105f92 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,122 @@ +Welcome to Elevator Saga's Documentation! +========================================== + +.. image:: https://badge.fury.io/py/elevatorpy.svg + :target: https://badge.fury.io/py/elevatorpy + :alt: PyPI version + +.. image:: https://img.shields.io/pypi/pyversions/elevatorpy.svg + :target: https://pypi.org/project/elevatorpy/ + :alt: Python versions + +.. image:: https://github.com/ZGCA-Forge/Elevator/actions/workflows/ci.yml/badge.svg + :target: https://github.com/ZGCA-Forge/Elevator/actions + :alt: Build Status + +.. image:: https://img.shields.io/github/stars/ZGCA-Forge/Elevator.svg?style=social&label=Star + :target: https://github.com/ZGCA-Forge/Elevator + :alt: GitHub stars + +Elevator Saga is a Python implementation of an elevator `simulation game `_ with an event-driven architecture. Design and optimize elevator control algorithms to efficiently transport passengers in buildings. + +Features +-------- + +🏢 **Realistic Simulation**: Physics-based elevator movement with acceleration, deceleration, and realistic timing + +🎮 **Event-Driven Architecture**: React to various events such as button presses, elevator arrivals, and passenger boarding + +🔌 **Client-Server Model**: Separate simulation server from control logic for clean architecture + +📊 **Performance Metrics**: Track wait times, system times, and completion rates + +🎯 **Flexible Control**: Implement your own algorithms using a simple controller interface + +Installation +------------ + +Basic Installation +~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + pip install elevatorpy + +With Development Dependencies +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + pip install elevatorpy[dev] + +From Source +~~~~~~~~~~~ + +.. code-block:: bash + + git clone https://github.com/ZGCA-Forge/Elevator.git + cd Elevator + pip install -e .[dev] + +Quick Start +----------- + +Running the Simulation +~~~~~~~~~~~~~~~~~~~~~~ + +**Terminal #1: Start the backend simulator** + +.. code-block:: bash + + python -m elevator_saga.server.simulator + +**Terminal #2: Start your controller** + +.. code-block:: bash + + python -m elevator_saga.client_examples.bus_example + +Architecture Overview +--------------------- + +Elevator Saga follows a **client-server architecture**: + +- **Server** (`simulator.py`): Manages the simulation state, physics, and event generation +- **Client** (`base_controller.py`): Implements control algorithms and reacts to events +- **Communication** (`api_client.py`): HTTP-based API for state queries and commands +- **Data Models** (`models.py`): Unified data structures shared between client and server + +Contents +-------- + +.. toctree:: + :maxdepth: 2 + :caption: User Guide + + models + client + communication + events + +.. toctree:: + :maxdepth: 1 + :caption: API Reference + + api/modules + +Contributing +------------ + +Contributions are welcome! Please feel free to submit a Pull Request. + +License +------- + +This project is licensed under MIT License - see the LICENSE file for details. + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/models.rst b/docs/models.rst new file mode 100644 index 0000000..d249997 --- /dev/null +++ b/docs/models.rst @@ -0,0 +1,347 @@ +Data Models +=========== + +The Elevator Saga system uses a unified data model architecture defined in ``elevator_saga/core/models.py``. These models ensure type consistency and serialization between the client and server components. + +Overview +-------- + +All data models inherit from ``SerializableModel``, which provides: + +- **to_dict()**: Convert model to dictionary +- **to_json()**: Convert model to JSON string +- **from_dict()**: Create model instance from dictionary +- **from_json()**: Create model instance from JSON string + +This unified serialization approach ensures seamless data exchange over HTTP between client and server. + +Core Enumerations +----------------- + +Direction +~~~~~~~~~ + +Represents the direction of elevator movement or passenger travel: + +.. code-block:: python + + class Direction(Enum): + UP = "up" # Moving upward + DOWN = "down" # Moving downward + STOPPED = "stopped" # Not moving + +ElevatorStatus +~~~~~~~~~~~~~~ + +Represents the elevator's operational state in the state machine: + +.. code-block:: python + + class ElevatorStatus(Enum): + START_UP = "start_up" # Acceleration phase + START_DOWN = "start_down" # Deceleration phase + CONSTANT_SPEED = "constant_speed" # Constant speed phase + STOPPED = "stopped" # Stopped at floor + +**Important**: ``START_UP`` and ``START_DOWN`` refer to **acceleration/deceleration states**, not movement direction. The actual movement direction is determined by the ``target_floor_direction`` property. + +State Machine Transition: + +.. code-block:: text + + STOPPED → START_UP → CONSTANT_SPEED → START_DOWN → STOPPED + 1 tick 1 tick N ticks 1 tick + +PassengerStatus +~~~~~~~~~~~~~~~ + +Represents the passenger's current state: + +.. code-block:: python + + class PassengerStatus(Enum): + WAITING = "waiting" # Waiting at origin floor + IN_ELEVATOR = "in_elevator" # Inside an elevator + COMPLETED = "completed" # Reached destination + CANCELLED = "cancelled" # Cancelled (unused) + +EventType +~~~~~~~~~ + +Defines all possible simulation events: + +.. code-block:: python + + class EventType(Enum): + UP_BUTTON_PRESSED = "up_button_pressed" + DOWN_BUTTON_PRESSED = "down_button_pressed" + PASSING_FLOOR = "passing_floor" + STOPPED_AT_FLOOR = "stopped_at_floor" + ELEVATOR_APPROACHING = "elevator_approaching" + IDLE = "idle" + PASSENGER_BOARD = "passenger_board" + PASSENGER_ALIGHT = "passenger_alight" + +Core Data Models +---------------- + +Position +~~~~~~~~ + +Represents elevator position with sub-floor granularity: + +.. code-block:: python + + @dataclass + class Position(SerializableModel): + current_floor: int = 0 # Current floor number + target_floor: int = 0 # Target floor number + floor_up_position: int = 0 # Position within floor (0-9) + +- **floor_up_position**: Represents position between floors with 10 units per floor +- **current_floor_float**: Returns floating-point floor position (e.g., 2.5 = halfway between floors 2 and 3) + +Example: + +.. code-block:: python + + position = Position(current_floor=2, floor_up_position=5) + print(position.current_floor_float) # 2.5 + +ElevatorState +~~~~~~~~~~~~~ + +Complete state information for an elevator: + +.. code-block:: python + + @dataclass + class ElevatorState(SerializableModel): + id: int + position: Position + next_target_floor: Optional[int] = None + passengers: List[int] = [] # Passenger IDs + max_capacity: int = 10 + speed_pre_tick: float = 0.5 + run_status: ElevatorStatus = ElevatorStatus.STOPPED + last_tick_direction: Direction = Direction.STOPPED + indicators: ElevatorIndicators = field(default_factory=ElevatorIndicators) + passenger_destinations: Dict[int, int] = {} # passenger_id -> floor + energy_consumed: float = 0.0 + last_update_tick: int = 0 + +Key Properties: + +- ``current_floor``: Integer floor number +- ``current_floor_float``: Precise position including sub-floor +- ``target_floor``: Destination floor +- ``target_floor_direction``: Direction to target (UP/DOWN/STOPPED) +- ``is_idle``: Whether elevator is stopped +- ``is_full``: Whether elevator is at capacity +- ``is_running``: Whether elevator is in motion +- ``pressed_floors``: List of destination floors for current passengers +- ``load_factor``: Current load as fraction of capacity (0.0 to 1.0) + +FloorState +~~~~~~~~~~ + +State information for a building floor: + +.. code-block:: python + + @dataclass + class FloorState(SerializableModel): + floor: int + up_queue: List[int] = [] # Passenger IDs waiting to go up + down_queue: List[int] = [] # Passenger IDs waiting to go down + +Properties: + +- ``has_waiting_passengers``: Whether any passengers are waiting +- ``total_waiting``: Total number of waiting passengers + +PassengerInfo +~~~~~~~~~~~~~ + +Complete information about a passenger: + +.. code-block:: python + + @dataclass + class PassengerInfo(SerializableModel): + id: int + origin: int # Starting floor + destination: int # Target floor + arrive_tick: int # When passenger appeared + pickup_tick: int = 0 # When passenger boarded elevator + dropoff_tick: int = 0 # When passenger reached destination + elevator_id: Optional[int] = None + +Properties: + +- ``status``: Current PassengerStatus +- ``wait_time``: Ticks waited before boarding +- ``system_time``: Total ticks in system (arrive to dropoff) +- ``travel_direction``: UP/DOWN based on origin and destination + +SimulationState +~~~~~~~~~~~~~~~ + +Complete state of the simulation: + +.. code-block:: python + + @dataclass + class SimulationState(SerializableModel): + tick: int + elevators: List[ElevatorState] + floors: List[FloorState] + passengers: Dict[int, PassengerInfo] + metrics: PerformanceMetrics + events: List[SimulationEvent] + +Helper Methods: + +- ``get_elevator_by_id(id)``: Find elevator by ID +- ``get_floor_by_number(number)``: Find floor by number +- ``get_passengers_by_status(status)``: Filter passengers by status +- ``add_event(type, data)``: Add new event to queue + +Traffic and Configuration +------------------------- + +TrafficEntry +~~~~~~~~~~~~ + +Defines a single passenger arrival: + +.. code-block:: python + + @dataclass + class TrafficEntry(SerializableModel): + id: int + origin: int + destination: int + tick: int # When passenger arrives + +TrafficPattern +~~~~~~~~~~~~~~ + +Collection of traffic entries defining a test scenario: + +.. code-block:: python + + @dataclass + class TrafficPattern(SerializableModel): + name: str + description: str + entries: List[TrafficEntry] + metadata: Dict[str, Any] + +Properties: + +- ``total_passengers``: Number of passengers in pattern +- ``duration``: Tick when last passenger arrives + +Performance Metrics +------------------- + +PerformanceMetrics +~~~~~~~~~~~~~~~~~~ + +Tracks simulation performance: + +.. code-block:: python + + @dataclass + class PerformanceMetrics(SerializableModel): + completed_passengers: int = 0 + total_passengers: int = 0 + average_wait_time: float = 0.0 + p95_wait_time: float = 0.0 # 95th percentile + average_system_time: float = 0.0 + p95_system_time: float = 0.0 # 95th percentile + +Properties: + +- ``completion_rate``: Fraction of passengers completed (0.0 to 1.0) + +API Models +---------- + +The models also include HTTP API request/response structures: + +- ``APIRequest``: Base request with ID and timestamp +- ``APIResponse``: Base response with success flag +- ``StepRequest/StepResponse``: Advance simulation time +- ``StateRequest``: Query simulation state +- ``ElevatorCommand``: Send command to elevator +- ``GoToFloorCommand``: Specific command to move elevator + +Example Usage +------------- + +Creating a Simulation State +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from elevator_saga.core.models import ( + create_empty_simulation_state, + ElevatorState, + Position, + ) + + # Create a building with 3 elevators, 10 floors, capacity 8 + state = create_empty_simulation_state( + elevators=3, + floors=10, + max_capacity=8 + ) + + # Access elevator state + elevator = state.elevators[0] + print(f"Elevator {elevator.id} at floor {elevator.current_floor}") + +Working with Traffic Patterns +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from elevator_saga.core.models import ( + create_simple_traffic_pattern, + TrafficPattern, + ) + + # Create traffic pattern: (origin, destination, tick) + pattern = create_simple_traffic_pattern( + name="morning_rush", + passengers=[ + (0, 5, 10), # Floor 0→5 at tick 10 + (0, 8, 15), # Floor 0→8 at tick 15 + (2, 0, 20), # Floor 2→0 at tick 20 + ] + ) + + print(f"Pattern has {pattern.total_passengers} passengers") + print(f"Duration: {pattern.duration} ticks") + +Serialization +~~~~~~~~~~~~~ + +All models support JSON serialization: + +.. code-block:: python + + # Serialize to JSON + elevator = state.elevators[0] + json_str = elevator.to_json() + + # Deserialize from JSON + restored = ElevatorState.from_json(json_str) + + # Or use dictionaries + data = elevator.to_dict() + restored = ElevatorState.from_dict(data) + +This enables seamless transmission over HTTP between client and server. diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..3816737 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +sphinx>=5.0.0 +sphinx-rtd-theme>=1.0.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..b93f9a5 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Elevator Saga Test Suite +""" diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 0000000..a815f42 --- /dev/null +++ b/tests/test_imports.py @@ -0,0 +1,73 @@ +""" +Test module imports to ensure all modules can be imported successfully +""" + +import pytest + + +def test_import_core_models(): + """Test importing core data models""" + from elevator_saga.core.models import ( + Direction, + ElevatorState, + ElevatorStatus, + EventType, + FloorState, + PassengerInfo, + PassengerStatus, + SimulationState, + TrafficEntry, + TrafficPattern, + ) + + assert Direction is not None + assert ElevatorState is not None + assert ElevatorStatus is not None + assert EventType is not None + assert FloorState is not None + assert PassengerInfo is not None + assert PassengerStatus is not None + assert SimulationState is not None + assert TrafficEntry is not None + assert TrafficPattern is not None + + +def test_import_client_api(): + """Test importing client API""" + from elevator_saga.client.api_client import ElevatorAPIClient + + assert ElevatorAPIClient is not None + + +def test_import_proxy_models(): + """Test importing proxy models""" + from elevator_saga.client.proxy_models import ProxyElevator, ProxyFloor, ProxyPassenger + + assert ProxyElevator is not None + assert ProxyFloor is not None + assert ProxyPassenger is not None + + +def test_import_base_controller(): + """Test importing base controller""" + from elevator_saga.client.base_controller import ElevatorController + + assert ElevatorController is not None + + +def test_import_simulator(): + """Test importing simulator""" + from elevator_saga.server.simulator import ElevatorSimulation + + assert ElevatorSimulation is not None + + +def test_import_client_example(): + """Test importing client example""" + from elevator_saga.client_examples.bus_example import ElevatorBusExampleController + + assert ElevatorBusExampleController is not None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])