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:
SpartanTech
2025-11-30 21:15:43 -06:00
commit f62ef2cf05
32 changed files with 9438 additions and 0 deletions

370
simulator/machine.py Normal file
View 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()

View File

@@ -0,0 +1 @@
httpx==0.26.0