Spaces:
Sleeping
Sleeping
| """FastAPI server for the Space Manufacturing RL OpenEnv environment.""" | |
| from __future__ import annotations | |
| import asyncio | |
| import os | |
| import sys | |
| import uvicorn | |
| from pathlib import Path | |
| from typing import Any, Dict, Optional | |
| # Make 'SpaceFactory' importable regardless of working directory. | |
| # server/app.py is at <root>/SpaceFactory/server/app.py β parentΓ3 = <root> | |
| _pkg_parent = str(Path(__file__).resolve().parent.parent.parent) | |
| if _pkg_parent not in sys.path: | |
| sys.path.insert(0, _pkg_parent) | |
| try: | |
| from fastapi import FastAPI, HTTPException, Query | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, HTMLResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from pydantic import BaseModel | |
| except ImportError as exc: | |
| raise SystemExit(f"FastAPI is required: pip install fastapi uvicorn\n{exc}") from exc | |
| from .ui_demo import ManufacturingUIDemo | |
| from SpaceFactory.env import ManufacturingTaskEnv | |
| from SpaceFactory.models import ManufacturingAction, ManufacturingObservation | |
| # ββ app ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI( | |
| title="Space Manufacturing RL", | |
| description="Custom space manufacturing frontend with mounted OpenEnv API routes.", | |
| version="0.1.0", | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["http://localhost:4173", "http://127.0.0.1:4173", "http://localhost:5173", "http://127.0.0.1:5173"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| async def _suppress_win32_connection_reset() -> None: | |
| """Suppress spurious WinError 10054 from asyncio ProactorEventLoop on Windows.""" | |
| if sys.platform != "win32": | |
| return | |
| loop = asyncio.get_event_loop() | |
| def _handler(loop: asyncio.AbstractEventLoop, context: dict) -> None: | |
| exc = context.get("exception") | |
| if isinstance(exc, ConnectionResetError): | |
| return # benign Windows ProactorEventLoop artifact β silently ignore | |
| loop.default_exception_handler(context) | |
| loop.set_exception_handler(_handler) | |
| # Single shared environment instance (stateful per process) | |
| _env = ManufacturingTaskEnv(task_name="easy") | |
| # UI demo instance | |
| _ui_demo = ManufacturingUIDemo() | |
| # Mount built frontend static assets if dist/ exists | |
| _here = Path(__file__).resolve().parent | |
| _dist = _here.parent / "frontend" / "dist" | |
| _assets = _dist / "assets" | |
| if _assets.exists(): | |
| app.mount("/assets", StaticFiles(directory=str(_assets)), name="assets") | |
| app.mount("/web/assets", StaticFiles(directory=str(_assets)), name="web-assets") | |
| # ββ helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class StepRequest(BaseModel): | |
| """Wrapper for a single environment step action.""" | |
| action: Dict[str, Any] | |
| timeout_s: Optional[float] = 30.0 | |
| # ββ OpenEnv core endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def reset(task: Optional[str] = Query(default=None)): | |
| """Reset the episode, optionally switching to a different task.""" | |
| if task is not None: | |
| try: | |
| _env.set_task(task) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail=str(exc)) from exc | |
| return _env.reset().model_dump() | |
| def step(body: StepRequest): | |
| """Advance the environment by one step.""" | |
| try: | |
| action = ManufacturingAction.model_validate(body.action) | |
| except Exception as exc: | |
| raise HTTPException(status_code=422, detail=f"Invalid action: {exc}") from exc | |
| obs, reward, done, info = _env.step(action) | |
| return {"observation": obs.model_dump(), "reward": reward.model_dump(), | |
| "done": done, "info": info} | |
| def state(): | |
| """Return the full current environment state snapshot.""" | |
| return _env.state().model_dump() | |
| def list_tasks(): | |
| """List all available task presets.""" | |
| return ManufacturingTaskEnv.list_tasks() | |
| def schema(): | |
| """Return action, observation, and state JSON schemas.""" | |
| return { | |
| "action_space": ManufacturingAction.model_json_schema(), | |
| "observation_space": ManufacturingObservation.model_json_schema(), | |
| "state": _env.state().model_json_schema(), | |
| } | |
| def health(): | |
| """Health probe used by OpenEnv validators.""" | |
| return {"status": "healthy"} | |
| def metadata(): | |
| """Environment metadata required by the OpenEnv runtime standard.""" | |
| return { | |
| "name": "space_manufacturing", | |
| "description": ( | |
| "Orbital manufacturing platform coordination environment " | |
| "for production and delivery scheduling." | |
| ), | |
| "version": "0.1.0", | |
| "tasks": list(ManufacturingTaskEnv.list_tasks().keys()), | |
| } | |
| # ββ UI demo endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def ui_demo_snapshot(): | |
| """Return the current UI demo snapshot (used by the React frontend).""" | |
| return _ui_demo.snapshot() | |
| def ui_demo_reset(task_name: str = "medium"): | |
| """Reset the UI demo to a specific task.""" | |
| if task_name not in ManufacturingTaskEnv.list_tasks(): | |
| raise HTTPException(status_code=400, detail=f"Unknown task: {task_name}") | |
| return _ui_demo.reset(task_name) | |
| def ui_demo_step(): | |
| """Advance the UI demo by one heuristic step.""" | |
| return _ui_demo.step() | |
| # ββ Frontend SPA routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def serve_web_index(): | |
| """Serve the React SPA index page.""" | |
| index = _dist / "index.html" | |
| if index.exists(): | |
| return FileResponse(str(index)) | |
| return HTMLResponse(_fallback_html()) | |
| def serve_web_app(full_path: str): | |
| """Serve built SPA assets, falling back to index.html for client-side routing.""" | |
| candidate = _dist / full_path | |
| if candidate.exists() and candidate.is_file(): | |
| return FileResponse(str(candidate)) | |
| index = _dist / "index.html" | |
| if index.exists(): | |
| return FileResponse(str(index)) | |
| return HTMLResponse(_fallback_html()) | |
| def _fallback_html() -> str: | |
| """Minimal status page shown when the frontend dist/ hasn't been built yet.""" | |
| s = _env.state() | |
| platforms_html = "".join( | |
| f"<li>Platform {p.id}: energy={p.energy:.1f}, " | |
| f"mat={p.material_stock:.1f}, comp={p.component_stock:.1f}, " | |
| f"prod={p.product_stock}, last={p.last_action}</li>" | |
| for p in s.platforms | |
| ) | |
| return ( | |
| f"<!DOCTYPE html><html><head><title>Space Manufacturing RL</title>" | |
| f"<style>body{{font-family:monospace;background:#06111f;color:#c8deff;padding:24px}}" | |
| f"h1{{color:#7cf7c9}}li{{margin:4px 0}}" | |
| f".warn{{color:#ffb86b;font-size:.85rem;margin-top:16px}}</style></head><body>" | |
| f"<h1>Space Manufacturing RL β {s.task_name}</h1>" | |
| f"<p>Step: {s.step_count} / {s.max_steps} | " | |
| f"Total reward: {s.total_reward:.2f} | Done: {s.done}</p>" | |
| f"<h2>Platforms</h2><ul>{platforms_html}</ul>" | |
| f"<p class='warn'>Frontend not built. Run: " | |
| f"cd frontend && npm install && npm run build</p>" | |
| f"</body></html>" | |
| ) | |
| # ββ Entry point ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main() -> None: | |
| """Start the uvicorn server with Windows-compatible event loop policy.""" | |
| # Windows ProactorEventLoop raises spurious ConnectionResetError (WinError 10054) | |
| # when clients close keep-alive connections. Selector loop avoids the issue. | |
| if sys.platform == "win32": | |
| asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) | |
| uvicorn.run("SpaceFactory.server.app:app", host="0.0.0.0", port=8000, reload=False) | |
| if __name__ == "__main__": | |
| main() | |