Add tests and docs

This commit is contained in:
Xuwznln
2025-10-01 17:23:30 +08:00
parent a9fc374d31
commit 1a8a0249c1
13 changed files with 2235 additions and 0 deletions

3
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
_build/
_static/
_templates/

20
docs/Makefile Normal file
View File

@@ -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)

61
docs/README.md Normal file
View File

@@ -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

39
docs/api/modules.rst Normal file
View File

@@ -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:

370
docs/client.rst Normal file
View File

@@ -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

544
docs/communication.rst Normal file
View File

@@ -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/<int:elevator_id>/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

59
docs/conf.py Normal file
View File

@@ -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

592
docs/events.rst Normal file
View File

@@ -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

122
docs/index.rst Normal file
View File

@@ -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 <https://play.elevatorsaga.com/>`_ 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`

347
docs/models.rst Normal file
View File

@@ -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.

2
docs/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
sphinx>=5.0.0
sphinx-rtd-theme>=1.0.0

3
tests/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
Elevator Saga Test Suite
"""

73
tests/test_imports.py Normal file
View File

@@ -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"])