TBM Configuration Management System
- Config versioning with approval workflow (Draft/Approved/Archived) - Fleet management with real-time machine status - Deploy configs to machines with status tracking - Full audit logging for compliance - React dashboard + FastAPI backend + machine simulator
This commit is contained in:
370
simulator/machine.py
Normal file
370
simulator/machine.py
Normal file
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
TBM Individual Machine Simulator
|
||||
|
||||
Run one machine per terminal for realistic demos:
|
||||
python machine.py PRF4-001
|
||||
python machine.py PRF4-002
|
||||
python machine.py PRF5-001
|
||||
|
||||
Or run with custom settings:
|
||||
python machine.py PRF4-001 --poll-interval 5 --server http://localhost:8000
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
import httpx
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
import sys
|
||||
|
||||
API_BASE = "http://localhost:8000/api/machine-api"
|
||||
|
||||
# ANSI colors for terminal output
|
||||
class Colors:
|
||||
HEADER = '\033[95m'
|
||||
BLUE = '\033[94m'
|
||||
CYAN = '\033[96m'
|
||||
GREEN = '\033[92m'
|
||||
YELLOW = '\033[93m'
|
||||
RED = '\033[91m'
|
||||
BOLD = '\033[1m'
|
||||
DIM = '\033[2m'
|
||||
RESET = '\033[0m'
|
||||
|
||||
# Machine registry - add new machines here
|
||||
MACHINE_REGISTRY = {
|
||||
"PRF4-001": {"name": "Prufrock 4 - Alpha", "type": "PRUFROCK_4", "default_version": "2.4.1"},
|
||||
"PRF4-002": {"name": "Prufrock 4 - Beta", "type": "PRUFROCK_4", "default_version": "2.4.0"},
|
||||
"PRF4-003": {"name": "Prufrock 4 - Gamma", "type": "PRUFROCK_4", "default_version": "2.4.0"},
|
||||
"PRF5-001": {"name": "Prufrock 5 - Prototype", "type": "PRUFROCK_5", "default_version": "3.0.0"},
|
||||
"PRF5-002": {"name": "Prufrock 5 - Test Unit", "type": "PRUFROCK_5", "default_version": "3.0.0"},
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class MachineState:
|
||||
"""Simulated machine state"""
|
||||
machine_id: str
|
||||
name: str
|
||||
machine_type: str
|
||||
current_version: Optional[str] = None
|
||||
config: Optional[dict] = None
|
||||
status: str = "idle" # idle, drilling, maintenance
|
||||
uptime_seconds: int = 0
|
||||
configs_applied: int = 0
|
||||
last_config_at: Optional[str] = None
|
||||
|
||||
# Simulated operational metrics
|
||||
thrust_kn: float = 0.0
|
||||
rpm: float = 0.0
|
||||
advance_rate: float = 0.0
|
||||
face_pressure: float = 0.0
|
||||
|
||||
|
||||
def log(machine_id: str, level: str, message: str):
|
||||
"""Pretty print log messages"""
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
level_colors = {
|
||||
"INFO": Colors.CYAN,
|
||||
"CONFIG": Colors.GREEN,
|
||||
"DEPLOY": Colors.YELLOW,
|
||||
"ERROR": Colors.RED,
|
||||
"STATUS": Colors.BLUE,
|
||||
}
|
||||
color = level_colors.get(level, Colors.RESET)
|
||||
|
||||
print(f"{Colors.DIM}[{timestamp}]{Colors.RESET} {Colors.BOLD}[{machine_id}]{Colors.RESET} {color}[{level}]{Colors.RESET} {message}")
|
||||
|
||||
|
||||
def print_banner(machine: MachineState):
|
||||
"""Print startup banner"""
|
||||
print(f"""
|
||||
{Colors.BOLD}╔══════════════════════════════════════════════════════════════╗
|
||||
║ TBM MACHINE SIMULATOR ║
|
||||
╠══════════════════════════════════════════════════════════════╣
|
||||
║ Machine ID: {machine.machine_id:<45} ║
|
||||
║ Name: {machine.name:<45} ║
|
||||
║ Type: {machine.machine_type:<45} ║
|
||||
║ Config: v{machine.current_version or 'None':<44} ║
|
||||
╚══════════════════════════════════════════════════════════════╝{Colors.RESET}
|
||||
""")
|
||||
|
||||
|
||||
def print_config_details(config: dict):
|
||||
"""Pretty print configuration parameters"""
|
||||
print(f"\n{Colors.CYAN} ┌─ Configuration Parameters ─────────────────────────┐{Colors.RESET}")
|
||||
|
||||
if "excavation" in config:
|
||||
exc = config["excavation"]
|
||||
print(f" │ {Colors.BOLD}Excavation:{Colors.RESET}")
|
||||
print(f" │ Thrust: {exc.get('thrust_kn', 'N/A')} kN")
|
||||
print(f" │ RPM: {exc.get('cutterhead_rpm', 'N/A')}")
|
||||
print(f" │ Advance Rate: {exc.get('advance_rate_mm_min', 'N/A')} mm/min")
|
||||
|
||||
if "pressure" in config:
|
||||
prs = config["pressure"]
|
||||
print(f" │ {Colors.BOLD}Pressure:{Colors.RESET}")
|
||||
print(f" │ Face: {prs.get('face_pressure_bar', 'N/A')} bar")
|
||||
print(f" │ Range: {prs.get('min_pressure_bar', 'N/A')} - {prs.get('max_pressure_bar', 'N/A')} bar")
|
||||
|
||||
if "safety" in config:
|
||||
saf = config["safety"]
|
||||
print(f" │ {Colors.BOLD}Safety:{Colors.RESET}")
|
||||
print(f" │ Max Thrust: {saf.get('max_thrust_kn', 'N/A')} kN")
|
||||
print(f" │ E-Stop: {'Enabled' if saf.get('emergency_stop_enabled') else 'Disabled'}")
|
||||
|
||||
if "navigation" in config:
|
||||
nav = config["navigation"]
|
||||
print(f" │ {Colors.BOLD}Navigation:{Colors.RESET}")
|
||||
print(f" │ Steering: {nav.get('steering_mode', 'N/A')}")
|
||||
print(f" │ Tolerance: ±{nav.get('horizontal_tolerance_mm', 'N/A')}mm H, ±{nav.get('vertical_tolerance_mm', 'N/A')}mm V")
|
||||
|
||||
print(f"{Colors.CYAN} └─────────────────────────────────────────────────────┘{Colors.RESET}\n")
|
||||
|
||||
|
||||
async def apply_config(machine: MachineState, config: dict, version: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Simulate applying a configuration to the machine.
|
||||
Returns (success, error_message)
|
||||
"""
|
||||
log(machine.machine_id, "DEPLOY", f"Received config v{version}")
|
||||
log(machine.machine_id, "DEPLOY", "Starting configuration apply sequence...")
|
||||
|
||||
# Step 1: Backup current config
|
||||
await asyncio.sleep(0.3)
|
||||
log(machine.machine_id, "DEPLOY", " [1/5] Backing up current configuration...")
|
||||
|
||||
# Step 2: Validate schema
|
||||
await asyncio.sleep(0.2)
|
||||
log(machine.machine_id, "DEPLOY", " [2/5] Validating configuration schema...")
|
||||
|
||||
# Step 3: Validate safety bounds
|
||||
await asyncio.sleep(0.2)
|
||||
thrust = config.get("excavation", {}).get("thrust_kn", 0)
|
||||
max_thrust = config.get("safety", {}).get("max_thrust_kn", 26000)
|
||||
|
||||
if thrust > max_thrust:
|
||||
return False, f"Safety validation failed: thrust {thrust} exceeds max {max_thrust}"
|
||||
|
||||
log(machine.machine_id, "DEPLOY", " [3/5] Safety bounds validated ✓")
|
||||
|
||||
# Step 4: Apply to PLC (simulated)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 5% random failure rate for realism
|
||||
if random.random() < 0.05:
|
||||
return False, "PLC communication timeout"
|
||||
|
||||
log(machine.machine_id, "DEPLOY", " [4/5] Writing to PLC...")
|
||||
|
||||
# Step 5: Verify
|
||||
await asyncio.sleep(0.3)
|
||||
log(machine.machine_id, "DEPLOY", " [5/5] Verifying configuration...")
|
||||
|
||||
# Success - update machine state
|
||||
machine.current_version = version
|
||||
machine.config = config
|
||||
machine.configs_applied += 1
|
||||
machine.last_config_at = datetime.now().isoformat()
|
||||
|
||||
# Update simulated operational params from config
|
||||
if "excavation" in config:
|
||||
machine.thrust_kn = config["excavation"].get("thrust_kn", 0)
|
||||
machine.rpm = config["excavation"].get("cutterhead_rpm", 0)
|
||||
machine.advance_rate = config["excavation"].get("advance_rate_mm_min", 0)
|
||||
if "pressure" in config:
|
||||
machine.face_pressure = config["pressure"].get("face_pressure_bar", 0)
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
async def poll_server(client: httpx.AsyncClient, machine: MachineState) -> dict:
|
||||
"""Poll server for new configuration"""
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{API_BASE}/poll",
|
||||
json={
|
||||
"machine_id": machine.machine_id,
|
||||
"current_version": machine.current_version
|
||||
},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
log(machine.machine_id, "ERROR", f"Poll failed: HTTP {response.status_code}")
|
||||
return {"has_update": False, "error": True}
|
||||
|
||||
except httpx.ConnectError:
|
||||
log(machine.machine_id, "ERROR", "Cannot reach server - is backend running?")
|
||||
return {"has_update": False, "error": True}
|
||||
except Exception as e:
|
||||
log(machine.machine_id, "ERROR", f"Poll error: {e}")
|
||||
return {"has_update": False, "error": True}
|
||||
|
||||
|
||||
async def send_ack(client: httpx.AsyncClient, machine: MachineState,
|
||||
deployment_id: str, success: bool, error: str = None):
|
||||
"""Send acknowledgment back to server"""
|
||||
try:
|
||||
log(machine.machine_id, "ACK", f"Sending ACK: deployment={deployment_id[:8]}..., success={success}")
|
||||
response = await client.post(
|
||||
f"{API_BASE}/ack",
|
||||
json={
|
||||
"machine_id": machine.machine_id,
|
||||
"deployment_id": deployment_id,
|
||||
"success": success,
|
||||
"error": error
|
||||
},
|
||||
timeout=10.0
|
||||
)
|
||||
if response.status_code == 200:
|
||||
log(machine.machine_id, "ACK", f"ACK sent successfully")
|
||||
else:
|
||||
log(machine.machine_id, "ERROR", f"ACK failed: HTTP {response.status_code} - {response.text}")
|
||||
except Exception as e:
|
||||
log(machine.machine_id, "ERROR", f"Failed to send ACK: {e}")
|
||||
|
||||
|
||||
async def run_machine(machine: MachineState, poll_interval: int, server_url: str):
|
||||
"""Main machine loop"""
|
||||
global API_BASE
|
||||
API_BASE = f"{server_url}/api/machine-api"
|
||||
|
||||
print_banner(machine)
|
||||
log(machine.machine_id, "INFO", f"Starting machine simulation")
|
||||
log(machine.machine_id, "INFO", f"Server: {server_url}")
|
||||
log(machine.machine_id, "INFO", f"Poll interval: {poll_interval}s")
|
||||
log(machine.machine_id, "INFO", "Press Ctrl+C to stop\n")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
poll_count = 0
|
||||
consecutive_errors = 0
|
||||
|
||||
while True:
|
||||
poll_count += 1
|
||||
machine.uptime_seconds += poll_interval
|
||||
|
||||
# Poll server
|
||||
log(machine.machine_id, "STATUS", f"Polling server... (#{poll_count})")
|
||||
data = await poll_server(client, machine)
|
||||
|
||||
if data.get("error"):
|
||||
consecutive_errors += 1
|
||||
if consecutive_errors >= 3:
|
||||
log(machine.machine_id, "ERROR", "Multiple failures - check server connection")
|
||||
await asyncio.sleep(poll_interval)
|
||||
continue
|
||||
|
||||
consecutive_errors = 0
|
||||
|
||||
if data.get("has_update"):
|
||||
# New config available
|
||||
version = data["version"]
|
||||
config = data["config"]
|
||||
deployment_id = data["deployment_id"]
|
||||
|
||||
# Skip if we already have this version
|
||||
if version == machine.current_version:
|
||||
log(machine.machine_id, "STATUS", f"Already on v{version}, skipping")
|
||||
await send_ack(client, machine, deployment_id, True)
|
||||
await asyncio.sleep(poll_interval)
|
||||
continue
|
||||
|
||||
log(machine.machine_id, "CONFIG", f"New configuration available: v{version}")
|
||||
print_config_details(config)
|
||||
|
||||
# Apply the config
|
||||
success, error = await apply_config(machine, config, version)
|
||||
|
||||
if success:
|
||||
log(machine.machine_id, "CONFIG", f"✅ Configuration v{version} applied successfully!")
|
||||
await send_ack(client, machine, deployment_id, True)
|
||||
else:
|
||||
log(machine.machine_id, "ERROR", f"❌ Configuration failed: {error}")
|
||||
await send_ack(client, machine, deployment_id, False, error)
|
||||
else:
|
||||
log(machine.machine_id, "STATUS", f"Up to date (v{machine.current_version})")
|
||||
|
||||
# Wait for next poll
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="TBM Individual Machine Simulator",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python machine.py PRF4-001 # Run PRF4-001 with defaults
|
||||
python machine.py PRF4-002 --poll-interval 5 # Poll every 5 seconds
|
||||
python machine.py PRF5-001 --version 3.1.0 # Start with specific version
|
||||
python machine.py --list # List available machines
|
||||
|
||||
Available machines:
|
||||
PRF4-001 Prufrock 4 - Alpha
|
||||
PRF4-002 Prufrock 4 - Beta
|
||||
PRF4-003 Prufrock 4 - Gamma
|
||||
PRF5-001 Prufrock 5 - Prototype
|
||||
PRF5-002 Prufrock 5 - Test Unit
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument("machine_id", nargs="?", help="Machine ID (e.g., PRF4-001)")
|
||||
parser.add_argument("--poll-interval", "-p", type=int, default=10,
|
||||
help="Polling interval in seconds (default: 10)")
|
||||
parser.add_argument("--server", "-s", default="http://localhost:8000",
|
||||
help="Server URL (default: http://localhost:8000)")
|
||||
parser.add_argument("--version", "-v", help="Override starting config version")
|
||||
parser.add_argument("--list", "-l", action="store_true", help="List available machines")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# List machines
|
||||
if args.list:
|
||||
print("\nAvailable Machines:")
|
||||
print("-" * 60)
|
||||
for mid, info in MACHINE_REGISTRY.items():
|
||||
print(f" {mid:<10} {info['name']:<30} ({info['type']})")
|
||||
print()
|
||||
return
|
||||
|
||||
# Require machine_id
|
||||
if not args.machine_id:
|
||||
parser.print_help()
|
||||
print(f"\n{Colors.RED}Error: machine_id is required{Colors.RESET}")
|
||||
print(f"Example: python machine.py PRF4-001\n")
|
||||
sys.exit(1)
|
||||
|
||||
# Validate machine_id
|
||||
if args.machine_id not in MACHINE_REGISTRY:
|
||||
print(f"{Colors.RED}Error: Unknown machine '{args.machine_id}'{Colors.RESET}")
|
||||
print(f"Use --list to see available machines")
|
||||
sys.exit(1)
|
||||
|
||||
# Create machine state
|
||||
info = MACHINE_REGISTRY[args.machine_id]
|
||||
machine = MachineState(
|
||||
machine_id=args.machine_id,
|
||||
name=info["name"],
|
||||
machine_type=info["type"],
|
||||
current_version=args.version or info["default_version"]
|
||||
)
|
||||
|
||||
# Run
|
||||
try:
|
||||
asyncio.run(run_machine(machine, args.poll_interval, args.server))
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n\n{Colors.YELLOW}Machine {args.machine_id} shutting down...{Colors.RESET}")
|
||||
print(f" Uptime: {machine.uptime_seconds}s")
|
||||
print(f" Configs applied: {machine.configs_applied}")
|
||||
print(f" Final version: v{machine.current_version}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
simulator/requirements.txt
Normal file
1
simulator/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
httpx==0.26.0
|
||||
Reference in New Issue
Block a user