Update simulation/reward_calculator.py
Browse files- simulation/reward_calculator.py +42 -207
simulation/reward_calculator.py
CHANGED
|
@@ -1,220 +1,55 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Reward Calculator
|
| 3 |
-
=================
|
| 4 |
-
5-component reward function for spacecraft proximity operations.
|
| 5 |
-
|
| 6 |
-
Design principles (from hackathon self-serve guide):
|
| 7 |
-
1. Rich informative signal at every step — not just 0/1 at episode end
|
| 8 |
-
2. Multiple independent reward functions — harder to hack
|
| 9 |
-
3. Wide spread between good (+4 to +8) and bad (−2) episodes for GRPO
|
| 10 |
-
4. Hard to game — agent cannot get high reward without solving the real task
|
| 11 |
-
|
| 12 |
-
Component summary:
|
| 13 |
-
R_approach +0.0 to +2.0/step Dense: proportional to distance reduction
|
| 14 |
-
R_fuel −0.05/unit thrust Penalise wasteful burns
|
| 15 |
-
R_los −0.5 if violated LOS cone constraint (±45° at <50m)
|
| 16 |
-
R_blackout +0.3 bonus/step Sensible decisions during comms blackout
|
| 17 |
-
R_terminal +4 to +8 or −2 Sparse: docking success or failure
|
| 18 |
-
R_reasoning +0.1 if present Bonus for structured reasoning text
|
| 19 |
-
"""
|
| 20 |
-
|
| 21 |
-
from dataclasses import dataclass
|
| 22 |
-
from typing import Optional
|
| 23 |
import numpy as np
|
| 24 |
-
from
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
# ---------------------------------------------------------------------------
|
| 28 |
-
# Reward weights — tune these for GRPO convergence
|
| 29 |
-
# ---------------------------------------------------------------------------
|
| 30 |
-
W_APPROACH: float = 2.0 # max per-step approach reward
|
| 31 |
-
W_FUEL_PENALTY: float = 0.05 # per kg of fuel consumed
|
| 32 |
-
W_LOS_PENALTY: float = 0.5 # per LOS violation
|
| 33 |
-
W_BLACKOUT_BONUS: float = 0.3 # per step during blackout (if reasonable action)
|
| 34 |
-
W_TERMINAL_SUCCESS: float = 5.0 # base terminal success reward
|
| 35 |
-
W_TERMINAL_BONUS_FUEL: float = 3.0 # bonus for fuel conservation at docking
|
| 36 |
-
W_TERMINAL_BONUS_SPEED: float = 2.0 # bonus for gentle approach speed
|
| 37 |
-
W_TERMINAL_FAILURE: float = -2.0 # timeout / crash / abort
|
| 38 |
-
W_FORMAT_BONUS: float = 0.1 # agent provided structured reasoning
|
| 39 |
-
|
| 40 |
-
# Bounds for GRPO — must have spread
|
| 41 |
-
REWARD_EPISODE_MIN: float = -3.0
|
| 42 |
-
REWARD_EPISODE_MAX: float = 10.0
|
| 43 |
-
|
| 44 |
|
| 45 |
@dataclass
|
| 46 |
-
class
|
| 47 |
-
"""Detailed reward decomposition — logged during training for diagnostics."""
|
| 48 |
approach: float = 0.0
|
| 49 |
fuel: float = 0.0
|
| 50 |
los: float = 0.0
|
| 51 |
blackout: float = 0.0
|
| 52 |
terminal: float = 0.0
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
@property
|
| 56 |
-
def total(self) -> float:
|
| 57 |
-
return (self.approach + self.fuel + self.los +
|
| 58 |
-
self.blackout + self.terminal + self.reasoning)
|
| 59 |
-
|
| 60 |
-
def to_dict(self) -> dict:
|
| 61 |
-
return {
|
| 62 |
-
"r_approach": round(self.approach, 4),
|
| 63 |
-
"r_fuel": round(self.fuel, 4),
|
| 64 |
-
"r_los": round(self.los, 4),
|
| 65 |
-
"r_blackout": round(self.blackout, 4),
|
| 66 |
-
"r_terminal": round(self.terminal, 4),
|
| 67 |
-
"r_reasoning": round(self.reasoning, 4),
|
| 68 |
-
"r_total": round(self.total, 4),
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
|
| 72 |
class RewardCalculator:
|
| 73 |
-
"""
|
| 74 |
-
Computes step and episode rewards for spacecraft proximity operations.
|
| 75 |
-
|
| 76 |
-
GRPO needs variance. This calculator is designed to produce:
|
| 77 |
-
- Warm-start easy scenarios: +1.5 to +5.0 range
|
| 78 |
-
- Standard successful episode: +4.0 to +8.0
|
| 79 |
-
- Failed episode (timeout): −2.0 to +0.5
|
| 80 |
-
- Crashed episode: −2.0 to −1.0
|
| 81 |
-
"""
|
| 82 |
-
|
| 83 |
def __init__(self):
|
| 84 |
-
self.
|
| 85 |
-
|
| 86 |
def reset(self):
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
else:
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
# Penalise fuel consumption per step
|
| 118 |
-
fuel_consumed = info.get("fuel_consumed_kg", 0.0)
|
| 119 |
-
rb.fuel = -W_FUEL_PENALTY * fuel_consumed * 10.0 # scale to sensible range
|
| 120 |
-
|
| 121 |
-
# ── Component 3: LOS Constraint ───────────────────────────
|
| 122 |
-
if info.get("los_violation", False):
|
| 123 |
-
rb.los = -W_LOS_PENALTY
|
| 124 |
-
|
| 125 |
-
# ── Component 4: Comms Blackout Handling ──────────────────
|
| 126 |
-
# Bonus if agent makes a reasonable decision during blackout
|
| 127 |
-
# "Reasonable" = small thrust magnitude (not panicking) or holding course
|
| 128 |
-
if not prev_state.comms_active:
|
| 129 |
-
thrust_mag = action.magnitude
|
| 130 |
-
if thrust_mag < 0.5: # conservative action during blackout = good
|
| 131 |
-
rb.blackout = W_BLACKOUT_BONUS
|
| 132 |
-
elif thrust_mag < 1.0:
|
| 133 |
-
rb.blackout = W_BLACKOUT_BONUS * 0.5
|
| 134 |
-
# Large thrust during blackout = no bonus (blind firing)
|
| 135 |
-
|
| 136 |
-
# ── Component 6: Reasoning Quality ────────────────────────
|
| 137 |
-
if reasoning_text and len(reasoning_text.strip()) > 20:
|
| 138 |
-
rb.reasoning = W_FORMAT_BONUS
|
| 139 |
-
|
| 140 |
-
# Accumulate
|
| 141 |
-
self._episode_cumulative.approach += rb.approach
|
| 142 |
-
self._episode_cumulative.fuel += rb.fuel
|
| 143 |
-
self._episode_cumulative.los += rb.los
|
| 144 |
-
self._episode_cumulative.blackout += rb.blackout
|
| 145 |
-
self._episode_cumulative.reasoning += rb.reasoning
|
| 146 |
-
|
| 147 |
-
return rb
|
| 148 |
-
|
| 149 |
-
def terminal_reward(
|
| 150 |
-
self,
|
| 151 |
-
final_state: CWHState,
|
| 152 |
-
info: dict,
|
| 153 |
-
) -> RewardBreakdown:
|
| 154 |
-
"""
|
| 155 |
-
Compute terminal reward at episode end.
|
| 156 |
-
This is the biggest signal — dominates GRPO advantage estimation.
|
| 157 |
-
"""
|
| 158 |
-
rb = RewardBreakdown()
|
| 159 |
-
|
| 160 |
-
if final_state.docked:
|
| 161 |
-
# Base docking reward
|
| 162 |
-
rb.terminal = W_TERMINAL_SUCCESS
|
| 163 |
-
|
| 164 |
-
# Bonus for fuel conservation (proportional to remaining fuel)
|
| 165 |
-
fuel_ratio = final_state.fuel_kg / 10.0 # normalise to initial budget assumption
|
| 166 |
-
rb.terminal += W_TERMINAL_BONUS_FUEL * min(fuel_ratio, 1.0)
|
| 167 |
-
|
| 168 |
-
# Bonus for gentle closing velocity at docking
|
| 169 |
-
speed = final_state.speed
|
| 170 |
-
if speed < 0.02:
|
| 171 |
-
rb.terminal += W_TERMINAL_BONUS_SPEED # perfect approach
|
| 172 |
-
elif speed < 0.05:
|
| 173 |
-
rb.terminal += W_TERMINAL_BONUS_SPEED * 0.5 # good approach
|
| 174 |
-
# Above DOCKING_VELOCITY_MS threshold: no speed bonus
|
| 175 |
-
|
| 176 |
-
elif final_state.mission_aborted:
|
| 177 |
-
# Crash / out of bounds / fuel exhausted far from target
|
| 178 |
-
rb.terminal = W_TERMINAL_FAILURE
|
| 179 |
-
|
| 180 |
-
# Partial credit if they got close before failing
|
| 181 |
-
dist = final_state.distance
|
| 182 |
-
if dist < 10.0:
|
| 183 |
-
rb.terminal += 0.5 # got close, then something went wrong
|
| 184 |
-
elif dist < 30.0:
|
| 185 |
-
rb.terminal += 0.2
|
| 186 |
-
|
| 187 |
-
else:
|
| 188 |
-
# Timeout — ran out of steps
|
| 189 |
-
rb.terminal = W_TERMINAL_FAILURE
|
| 190 |
-
|
| 191 |
-
# Partial credit based on final distance
|
| 192 |
-
dist = final_state.distance
|
| 193 |
-
initial_dist = final_state.prev_distance # best proxy for initial
|
| 194 |
-
if dist < 2.0:
|
| 195 |
-
rb.terminal += 1.8 # basically docked, just didn't meet velocity
|
| 196 |
-
elif dist < 10.0:
|
| 197 |
-
rb.terminal += 1.2
|
| 198 |
-
elif dist < 30.0:
|
| 199 |
-
rb.terminal += 0.6
|
| 200 |
-
elif dist < 50.0:
|
| 201 |
-
rb.terminal += 0.2
|
| 202 |
-
|
| 203 |
-
self._episode_cumulative.terminal += rb.terminal
|
| 204 |
-
return rb
|
| 205 |
-
|
| 206 |
-
def episode_summary(self) -> dict:
|
| 207 |
-
"""Return full episode reward breakdown for logging."""
|
| 208 |
-
return {
|
| 209 |
-
**self._episode_cumulative.to_dict(),
|
| 210 |
-
"r_episode_total": round(self._episode_cumulative.total, 4),
|
| 211 |
-
}
|
| 212 |
-
|
| 213 |
-
@staticmethod
|
| 214 |
-
def normalize_for_grpo(reward: float) -> float:
|
| 215 |
-
"""
|
| 216 |
-
Normalize episode reward to a range suitable for GRPO.
|
| 217 |
-
GRPO works best when rewards are in roughly [-3, +10] range.
|
| 218 |
-
Clamp to prevent outlier episodes from dominating gradients.
|
| 219 |
-
"""
|
| 220 |
-
return float(np.clip(reward, REWARD_EPISODE_MIN, REWARD_EPISODE_MAX))
|
|
|
|
| 1 |
+
"""Reward function for GRPO training"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import numpy as np
|
| 3 |
+
from dataclasses import dataclass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
@dataclass
|
| 6 |
+
class RewardBundle:
|
|
|
|
| 7 |
approach: float = 0.0
|
| 8 |
fuel: float = 0.0
|
| 9 |
los: float = 0.0
|
| 10 |
blackout: float = 0.0
|
| 11 |
terminal: float = 0.0
|
| 12 |
+
total: float = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
class RewardCalculator:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
def __init__(self):
|
| 16 |
+
self.prev_dist = None
|
| 17 |
+
|
| 18 |
def reset(self):
|
| 19 |
+
self.prev_dist = None
|
| 20 |
+
|
| 21 |
+
def step_reward(self, prev_state, new_state, action, info, reasoning_text=''):
|
| 22 |
+
bundle = RewardBundle()
|
| 23 |
+
|
| 24 |
+
# Approach progress
|
| 25 |
+
if self.prev_dist is None:
|
| 26 |
+
self.prev_dist = prev_state.distance
|
| 27 |
+
progress = self.prev_dist - new_state.distance
|
| 28 |
+
bundle.approach = np.clip(progress * 2.0, -0.5, 2.0)
|
| 29 |
+
self.prev_dist = new_state.distance
|
| 30 |
+
|
| 31 |
+
# Fuel penalty
|
| 32 |
+
thrust_mag = np.sqrt(action.fx**2 + action.fy**2)
|
| 33 |
+
bundle.fuel = -0.05 * thrust_mag
|
| 34 |
+
|
| 35 |
+
# LOS violation penalty
|
| 36 |
+
if new_state.los_violation:
|
| 37 |
+
bundle.los = -0.5
|
| 38 |
+
|
| 39 |
+
# Blackout bonus
|
| 40 |
+
if not new_state.comms_active:
|
| 41 |
+
bundle.blackout = 0.3
|
| 42 |
+
|
| 43 |
+
bundle.total = bundle.approach + bundle.fuel + bundle.los + bundle.blackout
|
| 44 |
+
return bundle
|
| 45 |
+
|
| 46 |
+
def terminal_reward(self, state, info):
|
| 47 |
+
bundle = RewardBundle()
|
| 48 |
+
if state.docked:
|
| 49 |
+
# Docking success bonus scaled by fuel remaining
|
| 50 |
+
fuel_pct = state.fuel_kg / 80.0
|
| 51 |
+
bundle.terminal = 5.0 + 5.0 * fuel_pct
|
| 52 |
else:
|
| 53 |
+
bundle.terminal = -2.0
|
| 54 |
+
bundle.total = bundle.terminal
|
| 55 |
+
return bundle
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|