SAT / inference.py
KUMARISHWETA242
graders changed
f393161
Raw
History Blame Contribute Delete
20.9 kB
import asyncio
import importlib.util
import json
import math
import os
import re
import sys
import textwrap
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from openai import OpenAI
try:
from dotenv import load_dotenv
except Exception: # pragma: no cover - optional convenience dependency
load_dotenv = None
try:
from satellite import EasyTask, HardTask, MediumTask, SatelliteAction, SatelliteTaskEnv, TaskGrader
except ImportError: # pragma: no cover - script execution from repo root fallback
package_root = Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location(
"satellite",
package_root / "__init__.py",
submodule_search_locations=[str(package_root)],
)
if spec is None or spec.loader is None:
raise
satellite = importlib.util.module_from_spec(spec)
sys.modules["satellite"] = satellite
spec.loader.exec_module(satellite)
EasyTask = satellite.EasyTask
HardTask = satellite.HardTask
MediumTask = satellite.MediumTask
SatelliteAction = satellite.SatelliteAction
SatelliteTaskEnv = satellite.SatelliteTaskEnv
TaskGrader = satellite.TaskGrader
if load_dotenv is not None:
load_dotenv()
def warn_once(key: str, message: str) -> None:
if key in WARNINGS_EMITTED:
return
WARNINGS_EMITTED.add(key)
print(f"[warn] {message}", file=sys.stderr)
def read_float_env(name: str, default: float) -> float:
raw = os.getenv(name)
if raw is None:
return default
try:
return float(raw)
except (TypeError, ValueError):
warn_once(f"env:{name}", f"Invalid {name}={raw!r}; using default {default}.")
return default
def read_int_env(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
return int(raw)
except (TypeError, ValueError):
warn_once(f"env:{name}", f"Invalid {name}={raw!r}; using default {default}.")
return default
WARNINGS_EMITTED: set[str] = set()
API_BASE_URL = os.environ["API_BASE_URL"]
API_KEY = os.environ["API_KEY"]
MODEL_NAME = os.getenv("MODEL_NAME")
BASELINE_POLICY = os.getenv("BASELINE_POLICY", "openai").lower()
TEMPERATURE = read_float_env("TEMPERATURE", 0.0)
MAX_TOKENS = read_int_env("MAX_TOKENS", 300)
REQUEST_DELAY = read_float_env("REQUEST_DELAY", 0.0)
REQUEST_TIMEOUT = read_float_env("REQUEST_TIMEOUT", 30.0)
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
FALLBACK_ACTION = "idle"
TASK_ORDER = ["easy", "medium", "hard"]
TASK_TYPES = {
"easy": EasyTask,
"medium": MediumTask,
"hard": HardTask,
}
ACTION_PATTERN = re.compile(r"(capture|downlink|maintain|idle)", re.IGNORECASE)
ACTION_PREFIX_RE = re.compile(r"^(action|next action)\s*[:\-]\s*", re.IGNORECASE)
SYSTEM_PROMPT = textwrap.dedent(
"""
You are managing a real-world satellite constellation.
Reply with exactly one JSON object mapping satellite ids to actions.
Valid actions:
- capture
- downlink
- maintain
- idle
Decision guidance:
- prefer capture only when a visible image task exists and the satellite has battery/storage margin
- prefer downlink when a visible ground station task exists, especially if storage is high
- use maintain to recover low-battery satellites before they become risky
- avoid invalid, repeated, or wasteful actions
- keep the fleet healthy across the full episode, not just the current step
Output format:
{"0": "capture", "1": "idle"}
Do not include explanations or any extra text outside the JSON object.
"""
).strip()
@dataclass
class TaskRunResult:
task_name: str
score: float
total_reward: float
steps: int
done: bool
metrics: Dict[str, float]
def build_history_lines(history: List[str]) -> str:
if not history:
return "None"
return "\n".join(history[-6:])
def satellite_geo(position: Tuple[float, float, float]) -> Tuple[float, float, float]:
x, y, z = position
radius = math.sqrt((x * x) + (y * y) + (z * z))
if radius <= 0:
return 0.0, 0.0, 0.0
lat = math.degrees(math.asin(z / radius))
lon = math.degrees(math.atan2(y, x))
altitude = max(0.0, radius - 6371.0)
return float(lat), float(lon), float(altitude)
def visibility_radius_rad(altitude_km: float) -> float:
earth_radius_km = 6371.0
alt = max(0.0, altitude_km)
horizon = math.acos(min(1.0, earth_radius_km / (earth_radius_km + alt)))
return max(math.radians(35.0), min(math.radians(120.0), horizon + math.radians(50.0)))
def great_circle_distance_rad(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
lat1_rad = math.radians(lat1)
lon1_rad = math.radians(lon1)
lat2_rad = math.radians(lat2)
lon2_rad = math.radians(lon2)
d_lat = lat2_rad - lat1_rad
d_lon = lon2_rad - lon1_rad
a = (
math.sin(d_lat / 2.0) ** 2
+ math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(d_lon / 2.0) ** 2
)
return 2.0 * math.asin(min(1.0, math.sqrt(a)))
def normalize_pending_tasks(tasks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
normalized = []
for task in tasks:
normalized.append({key: value for key, value in task.items()})
return normalized
def extract_capture_regions(observation: Dict[str, Any]) -> Dict[str, Tuple[float, float]]:
regions = observation.get("capture_regions")
if isinstance(regions, dict) and regions:
return {
str(name): (float(coords[0]), float(coords[1]))
for name, coords in regions.items()
if isinstance(coords, (list, tuple)) and len(coords) == 2
}
return {
"region1": (18.5, 73.9),
"region2": (34.0, -117.0),
"region3": (-22.8, -43.2),
}
def find_visible_capture_task(
sat: Dict[str, Any],
observation: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
capture_regions = extract_capture_regions(observation)
sat_lat, sat_lon, sat_alt = satellite_geo(tuple(sat["position"]))
max_distance = visibility_radius_rad(sat_alt)
candidates: List[Tuple[float, str, Dict[str, Any]]] = []
weather = observation.get("weather_conditions", {})
for task in normalize_pending_tasks(observation.get("pending_tasks", [])):
if task.get("type") != "image_capture":
continue
region = str(task.get("region", ""))
if region not in capture_regions:
continue
reg_lat, reg_lon = capture_regions[region]
distance = great_circle_distance_rad(sat_lat, sat_lon, reg_lat, reg_lon)
if distance > max_distance:
continue
priority = float(task.get("priority", 1))
cloud = float(weather.get(region, 0.5))
score = (priority * 3.0) + ((1.0 - cloud) * 2.0) - distance
candidates.append((score, str(task.get("id", "")), task))
if not candidates:
return None
candidates.sort(key=lambda item: (-item[0], item[1]))
return candidates[0][2]
def find_visible_downlink_task(
sat: Dict[str, Any],
observation: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
stations = observation.get("ground_stations", [])
sat_lat, sat_lon, sat_alt = satellite_geo(tuple(sat["position"]))
max_distance = visibility_radius_rad(sat_alt)
candidates: List[Tuple[float, str, Dict[str, Any]]] = []
for task in normalize_pending_tasks(observation.get("pending_tasks", [])):
if task.get("type") != "data_downlink":
continue
station_id = int(task.get("station", 0))
if station_id < 0 or station_id >= len(stations):
continue
gs_lat, gs_lon = stations[station_id]
distance = great_circle_distance_rad(sat_lat, sat_lon, float(gs_lat), float(gs_lon))
if distance > max_distance:
continue
priority = float(task.get("priority", 1))
units_remaining = float(task.get("units_remaining", 20.0))
completion_bias = 0.75 if float(sat["storage"]) >= units_remaining else 0.0
score = (priority * 3.0) + completion_bias - distance
candidates.append((score, str(task.get("id", "")), task))
if not candidates:
return None
candidates.sort(key=lambda item: (-item[0], item[1]))
return candidates[0][2]
def format_observation(task_name: str, observation: Dict[str, Any]) -> str:
satellites_info = []
for sat in observation.get("satellites", []):
sat_lat, sat_lon, sat_alt = satellite_geo(tuple(sat["position"]))
capture_task = find_visible_capture_task(sat, observation)
downlink_task = find_visible_downlink_task(sat, observation)
satellites_info.append(
(
f" Satellite {sat['id']}: battery={sat['battery']:.1f}, "
f"storage={sat['storage']:.1f}, last={sat['last_action']}, "
f"lat={sat_lat:.1f}, lon={sat_lon:.1f}, alt={sat_alt:.1f}km, "
f"capture_visible={capture_task is not None}, "
f"downlink_visible={downlink_task is not None}"
)
)
tasks_info = []
for task in observation.get("pending_tasks", [])[:12]:
descriptor = task["type"]
if task["type"] == "image_capture":
descriptor += f" region={task.get('region')}"
if task["type"] == "data_downlink":
descriptor += f" station={task.get('station')}"
descriptor += f" units={task.get('units_remaining', 0)}"
tasks_info.append(f" - {descriptor} priority={task.get('priority', 1)}")
weather_info = ", ".join(
f"{region}={cover:.0%}"
for region, cover in observation.get("weather_conditions", {}).items()
)
return textwrap.dedent(
f"""
Task: {task_name}
Time Step: {observation.get('time_step', 0)}
Total Reward: {observation.get('total_reward', 0.0):.2f}
Weather: {weather_info or 'n/a'}
Satellites:
{chr(10).join(satellites_info) or ' None'}
Pending Tasks:
{chr(10).join(tasks_info) or ' - None'}
"""
).strip()
def build_user_prompt(
task_name: str,
step: int,
observation: Dict[str, Any],
history: List[str],
total_reward: float,
) -> str:
return textwrap.dedent(
f"""
Step: {step}
Aggregate reward so far: {total_reward:+.2f}
Current state:
{format_observation(task_name, observation)}
Previous steps:
{build_history_lines(history)}
Reply with exactly one JSON object.
"""
).strip()
def heuristic_action(observation: Dict[str, Any]) -> Dict[int, str]:
actions: Dict[int, str] = {}
pending_tasks = observation.get("pending_tasks", [])
has_capture_task = any(task.get("type") == "image_capture" for task in pending_tasks)
has_downlink_task = any(task.get("type") == "data_downlink" for task in pending_tasks)
for sat in observation.get("satellites", []):
sat_id = int(sat["id"])
battery = float(sat["battery"])
storage = float(sat["storage"])
visible_capture = find_visible_capture_task(sat, observation)
visible_downlink = find_visible_downlink_task(sat, observation)
if battery <= 12:
actions[sat_id] = "maintain"
continue
if battery < 28 and not visible_downlink:
actions[sat_id] = "maintain"
continue
if storage >= 85 and visible_downlink:
actions[sat_id] = "downlink"
continue
if visible_capture and battery >= 25 and storage <= 80:
actions[sat_id] = "capture"
continue
if visible_downlink and storage > 0:
actions[sat_id] = "downlink"
continue
if battery < 45 and not has_capture_task:
actions[sat_id] = "maintain"
continue
if battery < 35 and storage <= 5:
actions[sat_id] = "maintain"
continue
if has_downlink_task and storage >= 50:
actions[sat_id] = "idle"
continue
if has_capture_task and battery >= 30 and storage < 70:
actions[sat_id] = "idle"
continue
actions[sat_id] = "idle"
return actions
def parse_model_action(response_text: str, observation: Dict[str, Any]) -> Dict[int, str]:
if not response_text:
return safe_heuristic_action(observation, "empty model response")
cleaned = ACTION_PREFIX_RE.sub("", response_text.strip())
try:
json_match = re.search(r"\{.*\}", cleaned, re.DOTALL)
if json_match:
parsed = json.loads(json_match.group(0))
actions: Dict[int, str] = {}
valid_ids = {int(sat["id"]) for sat in observation.get("satellites", [])}
for key, value in parsed.items():
sat_id = int(key)
if sat_id not in valid_ids:
continue
action = str(value).strip().lower()
if not ACTION_PATTERN.fullmatch(action):
action = FALLBACK_ACTION
actions[sat_id] = action
if actions:
fallback = heuristic_action(observation)
for sat_id in valid_ids:
actions.setdefault(sat_id, fallback.get(sat_id, FALLBACK_ACTION))
return actions
except (json.JSONDecodeError, TypeError, ValueError):
pass
warn_once("parse:model-response", "Model response was not valid JSON; using heuristic actions.")
return safe_heuristic_action(observation, "invalid model response")
def observation_to_dict(observation: Any) -> Dict[str, Any]:
return {
"satellites": [
{
"id": sat.id,
"position": sat.position,
"battery": sat.battery,
"storage": sat.storage,
"last_action": sat.last_action,
}
for sat in observation.satellites
],
"time_step": observation.time_step,
"ground_stations": observation.ground_stations,
"weather_conditions": observation.weather_conditions,
"pending_tasks": observation.pending_tasks,
"total_reward": observation.total_reward,
"done": observation.done,
"reward": observation.reward,
"metadata": observation.metadata,
}
def build_idle_actions(observation: Dict[str, Any]) -> Dict[int, str]:
actions: Dict[int, str] = {}
for sat in observation.get("satellites", []):
try:
actions[int(sat["id"])] = FALLBACK_ACTION
except (KeyError, TypeError, ValueError):
continue
return actions
def safe_heuristic_action(observation: Dict[str, Any], reason: str) -> Dict[int, str]:
try:
return heuristic_action(observation)
except Exception as exc: # noqa: BLE001
warn_once(
f"heuristic:{reason}",
f"Heuristic fallback failed after {reason}: {exc}. Returning all-idle actions.",
)
return build_idle_actions(observation)
def validate_api_base_url(base_url: Optional[str]) -> Optional[str]:
if not base_url:
return None
cleaned = base_url.strip().rstrip("/")
parsed = urlparse(cleaned)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
warn_once(
"config:api-base-url",
f"Invalid API_BASE_URL={base_url!r}; falling back to heuristic policy.",
)
return None
return cleaned
def extract_response_text(completion: Any) -> str:
choices = getattr(completion, "choices", None)
if not choices:
return ""
message = getattr(choices[0], "message", None)
if message is None:
return ""
content = getattr(message, "content", "")
if isinstance(content, str):
return content
if isinstance(content, list):
parts: List[str] = []
for item in content:
if isinstance(item, dict):
text = item.get("text")
else:
text = getattr(item, "text", None)
if text:
parts.append(str(text))
return "\n".join(parts)
return str(content or "")
def build_client() -> OpenAI:
return OpenAI(
base_url=os.environ["API_BASE_URL"],
api_key=os.environ["API_KEY"],
timeout=REQUEST_TIMEOUT,
)
def choose_actions(
client: Optional[OpenAI],
task_name: str,
step: int,
observation: Dict[str, Any],
history: List[str],
total_reward: float,
) -> Dict[int, str]:
if client is None:
raise RuntimeError("OpenAI client not initialized")
user_prompt = build_user_prompt(task_name, step, observation, history, total_reward)
try:
completion = client.chat.completions.create(
model=MODEL_NAME,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=TEMPERATURE,
max_tokens=MAX_TOKENS,
)
response_text = extract_response_text(completion)
except Exception as exc: # noqa: BLE001
warn_once(
f"model:{task_name}",
f"[{task_name}] Model request failed at step {step}: {exc}. Using heuristic actions.",
)
return safe_heuristic_action(observation, f"model request failure on {task_name} step {step}")
if DEBUG:
print(f"[{task_name}] model response: {response_text[:300]}")
try:
return parse_model_action(response_text, observation)
except Exception as exc: # noqa: BLE001
warn_once(
f"parse:{task_name}",
f"[{task_name}] Failed to parse model response at step {step}: {exc}. "
"Using heuristic actions.",
)
return safe_heuristic_action(observation, f"parse failure on {task_name} step {step}")
async def run_task(task_name: str, client: Optional[OpenAI]) -> TaskRunResult:
env = SatelliteTaskEnv(task_name=task_name)
task = TASK_TYPES[task_name]()
grader = TaskGrader(task)
history: List[str] = []
observation = env.reset()
state = env.state()
step_limit = state.max_steps
print(f"[START] task={task_name}", flush=True)
for step in range(1, step_limit + 1):
obs_dict = observation_to_dict(observation)
actions = choose_actions(
client,
task_name,
step,
obs_dict,
history,
observation.total_reward,
)
observation, reward, done, info = env.step(
SatelliteAction(satellite_actions=actions)
)
reward_value = float(reward.value)
history.append(f"step {step}: {actions} -> reward {reward_value:+.2f}")
print(f"[STEP] step={step} reward={reward_value:.4f} total={observation.total_reward:.4f}",flush=True)
if REQUEST_DELAY > 0 and step < step_limit and not done:
time.sleep(REQUEST_DELAY)
if done:
break
else:
pass
final_state = env.state()
metrics = {key: float(value) for key, value in final_state.metrics.items()}
score = grader.grade_episode(env)
# epsilon = 1e-6
# score = max(epsilon, min(1.0 - epsilon, score))
print(f"[END] task={task_name} score={score:.6f} steps={final_state.step_count} done={final_state.done}",flush=True)
return TaskRunResult(
task_name=task_name,
score=score,
total_reward=final_state.total_reward,
steps=final_state.step_count,
done=final_state.done,
metrics=metrics,
)
def print_summary(results: List[TaskRunResult]) -> None:
aggregate = sum(result.score for result in results) / len(results)
print("\nInference Summary")
print("=" * 60)
for result in results:
print(
f"{result.task_name:<6} score={result.score:.4f} "
f"reward={result.total_reward:.2f} steps={result.steps} done={result.done}"
)
print("-" * 60)
print(f"aggregate_score={aggregate:.4f}")
print("=" * 60)
async def async_main() -> None:
client = build_client()
results = []
for task_name in TASK_ORDER:
results.append(await run_task(task_name, client))
# print_summary(results)
def main() -> None:
try:
asyncio.run(async_main())
except KeyboardInterrupt:
print("\nInference interrupted.", file=sys.stderr)
raise SystemExit(130) from None
except Exception as exc: # noqa: BLE001
print(f"\nInference failed: {exc}", file=sys.stderr)
raise SystemExit(1) from None
if __name__ == "__main__":
main()