flip_landing_reward_wrapper.py
45.9 KB · 1601 lines · python Raw
1
2 from __future__ import annotations
3
4 from collections.abc import Mapping
5 import math
6
7 import gymnasium as gym
8 import numpy as np
9
10
11
12 DEFAULT_REWARD_CONFIG = {
13 # ------------------------------------------------------------------
14 # 1. Task-stage definitions
15 #
16 # These values define when the wrapper considers the flip and
17 # post-flip recovery stages complete.
18 # ------------------------------------------------------------------
19
20 "required_rotations": 1.0,
21 "rotation_direction": 1,
22 "upright_tolerance_radians": 0.30,
23 "recovery_angular_velocity_tolerance": 0.50,
24
25 # ------------------------------------------------------------------
26 # 2. Original LunarLander reward contribution
27 #
28 # The custom reward is added to a weighted version of Gymnasium's
29 # original LunarLander reward.
30 # ------------------------------------------------------------------
31
32 "pre_flip_original_reward_weight": 0.25,
33 "post_flip_original_reward_weight": 1.0,
34
35 # ------------------------------------------------------------------
36 # 2a. Optional pre-flip horizontal shaping
37 #
38 # These are zero by default so the existing Phase A-D
39 # configurations retain their previous behaviour.
40 # ------------------------------------------------------------------
41
42 "pre_flip_center_weight": 0.0,
43 "pre_flip_horizontal_speed_weight": 0.0,
44 "pre_flip_shaping_weight": 0.0,
45 "pre_flip_shaping_clip": 10.0,
46
47 # Penalise completing the flip outside a generous
48 # horizontal corridor around the landing pad.
49 "flip_corridor_half_width": 0.35,
50 "flip_entry_penalty_weight": 0.0,
51
52 # ------------------------------------------------------------------
53 # 3. Positive stage and terminal rewards
54 #
55 # Progress is paid only when the maximum achieved rotation progress
56 # increases. The other rewards are one-off stage rewards.
57 # ------------------------------------------------------------------
58
59 "rotation_progress_bonus": 150.0,
60 "flip_completion_bonus": 200.0,
61 "recovery_bonus": 100.0,
62 "flip_landing_bonus": 750.0,
63
64 # ------------------------------------------------------------------
65 # 4. Terminal failure penalties
66 # ------------------------------------------------------------------
67
68 "landing_without_flip_penalty": 300.0,
69 "no_flip_terminal_penalty": 300.0,
70 "failed_landing_penalty": 150.0,
71 "outside_zone_landing_penalty": 1_000.0,
72 "in_zone_crash_penalty": 1_200.0,
73
74 # ------------------------------------------------------------------
75 # 5. Potential-difference shaping controls
76 #
77 # These control how changes in post-flip state quality are converted
78 # into a dense per-step reward.
79 # ------------------------------------------------------------------
80
81 "post_flip_shaping_weight": 1.0,
82 "post_flip_shaping_gamma": 0.995,
83 "post_flip_shaping_clip": 10.0,
84
85 # ------------------------------------------------------------------
86 # 6. Post-flip state-quality weights
87 #
88 # These determine the relative importance of position, velocity,
89 # angular velocity and leg contact in the potential.
90 # ------------------------------------------------------------------
91
92 "post_flip_center_weight": 50.0,
93 "post_flip_horizontal_speed_weight": 30.0,
94 "post_flip_vertical_speed_weight": 30.0,
95 "post_flip_angle_weight": 20.0,
96 "post_flip_angular_speed_weight": 10.0,
97 "post_flip_leg_contact_weight": 10.0,
98
99 # ------------------------------------------------------------------
100 # 7. Horizontal landing-zone guidance
101 # ------------------------------------------------------------------
102
103 "landing_zone_half_width": 0.20, # Defines accepted zone (-0.2<=x<= 0.2)
104 "post_flip_zone_excess_weight": 200.0, # Penalises distance beyond the zone boundaries
105 "post_flip_target_vx_gain": 0.50, # Converts horizontal position into desired velocity towards the centre
106 "post_flip_max_target_vx": 0.35, # Caps magnitude of desired horizontal speed
107 "post_flip_horizontal_deadband": 0.08, # Stops rewarding tiny centering corrections close to the centre
108
109 # ------------------------------------------------------------------
110 # 8. Vertical descent and soft-touchdown guidance
111 # ------------------------------------------------------------------
112
113 "post_flip_target_vy_high": -0.45, # Desired vertical velocity while sufficiently high
114 "post_flip_target_vy_near_ground": -0.12, # Desired slower descent near the ground
115 "near_ground_height": 0.60, # Height below which the overspeed penalty progressively strengthens
116 "safe_touchdown_vertical_speed": 0.18, # Downward-speed threshold before the quadratic overspeed penalty starts
117 "near_ground_overspeed_weight": 120.0, # Strength of the quadratic near-ground overspeed penalty
118 }
119
120
121 class FlipLandingRewardWrapper(gym.Wrapper):
122 """
123 Stage-aware LunarLander objective:
124
125 1. Complete one full rotation in the selected direction.
126 2. Arrest the spin and recover to an upright attitude.
127 3. Re-centre, stabilise and land safely.
128
129 The original eight-value observation is extended with:
130 - signed rotation progress in [-1, 1];
131 - a flip-completed flag;
132 - a post-flip-recovery-completed flag.
133
134 Post-flip recovery uses a potential-difference reward based on
135 horizontal position, horizontal speed, attitude, angular speed,
136 and leg contact. This rewards improvement rather than repeatedly
137 paying the agent simply for occupying a favourable state.
138 """
139
140 def __init__(
141 self,
142 env: gym.Env,
143 *,
144 reward_config: (
145 Mapping[str, float | int]
146 | None
147 ) = None,
148 ):
149 """
150 Initialise the reward wrapper.
151
152 DEFAULT_REWARD_CONFIG is the single source of default values.
153 `reward_config` may override any subset of those values for a
154 particular curriculum phase.
155 """
156
157 super().__init__(env)
158
159 supplied_config = dict(
160 reward_config or {}
161 )
162
163 # Reject misspelled or obsolete parameter names. Without this
164 # check, an accidental typo could silently leave the default
165 # value active.
166 unknown_parameters = (
167 set(supplied_config)
168 - set(DEFAULT_REWARD_CONFIG)
169 )
170
171 if unknown_parameters:
172 raise ValueError(
173 "Unknown reward parameters: "
174 f"{sorted(unknown_parameters)}"
175 )
176
177 # Make a new dictionary so the global defaults are never mutated.
178 config = {
179 **DEFAULT_REWARD_CONFIG,
180 **supplied_config,
181 }
182
183 # ==============================================================
184 # 1. Task-stage definitions
185 # ==============================================================
186
187 self.required_rotations = float(
188 config["required_rotations"]
189 )
190
191 self.rotation_direction = int(
192 config["rotation_direction"]
193 )
194
195 self.upright_tolerance_radians = float(
196 config[
197 "upright_tolerance_radians"
198 ]
199 )
200
201 self.recovery_angular_velocity_tolerance = float(
202 config[
203 "recovery_angular_velocity_tolerance"
204 ]
205 )
206
207 # ==============================================================
208 # 2. Original LunarLander reward contribution
209 # ==============================================================
210
211 self.pre_flip_original_reward_weight = float(
212 config[
213 "pre_flip_original_reward_weight"
214 ]
215 )
216
217 self.post_flip_original_reward_weight = float(
218 config[
219 "post_flip_original_reward_weight"
220 ]
221 )
222
223 # ==============================================================
224 # 2a. Pre-flip horizontal guidance
225 # ==============================================================
226
227 self.pre_flip_center_weight = float(
228 config["pre_flip_center_weight"]
229 )
230
231 self.pre_flip_horizontal_speed_weight = float(
232 config[
233 "pre_flip_horizontal_speed_weight"
234 ]
235 )
236
237 self.pre_flip_shaping_weight = float(
238 config["pre_flip_shaping_weight"]
239 )
240
241 self.pre_flip_shaping_clip = float(
242 config["pre_flip_shaping_clip"]
243 )
244
245 self.flip_corridor_half_width = float(
246 config["flip_corridor_half_width"]
247 )
248
249 self.flip_entry_penalty_weight = float(
250 config["flip_entry_penalty_weight"]
251 )
252
253 # ==============================================================
254 # 3. Positive stage and terminal rewards
255 # ==============================================================
256
257 self.rotation_progress_bonus = float(
258 config[
259 "rotation_progress_bonus"
260 ]
261 )
262
263 self.flip_completion_bonus = float(
264 config[
265 "flip_completion_bonus"
266 ]
267 )
268
269 self.recovery_bonus = float(
270 config["recovery_bonus"]
271 )
272
273 self.flip_landing_bonus = float(
274 config["flip_landing_bonus"]
275 )
276
277 # ==============================================================
278 # 4. Terminal failure penalties
279 # ==============================================================
280
281 self.landing_without_flip_penalty = float(
282 config[
283 "landing_without_flip_penalty"
284 ]
285 )
286
287 self.no_flip_terminal_penalty = float(
288 config[
289 "no_flip_terminal_penalty"
290 ]
291 )
292
293 self.failed_landing_penalty = float(
294 config[
295 "failed_landing_penalty"
296 ]
297 )
298
299 self.outside_zone_landing_penalty = float(
300 config[
301 "outside_zone_landing_penalty"
302 ]
303 )
304
305 self.in_zone_crash_penalty = float(
306 config[
307 "in_zone_crash_penalty"
308 ]
309 )
310
311 # ==============================================================
312 # 5. Potential-difference shaping controls
313 # ==============================================================
314
315 self.post_flip_shaping_weight = float(
316 config[
317 "post_flip_shaping_weight"
318 ]
319 )
320
321 self.post_flip_shaping_gamma = float(
322 config[
323 "post_flip_shaping_gamma"
324 ]
325 )
326
327 self.post_flip_shaping_clip = float(
328 config[
329 "post_flip_shaping_clip"
330 ]
331 )
332
333 # ==============================================================
334 # 6. Post-flip state-quality weights
335 # ==============================================================
336
337 self.post_flip_center_weight = float(
338 config[
339 "post_flip_center_weight"
340 ]
341 )
342
343 self.post_flip_horizontal_speed_weight = float(
344 config[
345 "post_flip_horizontal_speed_weight"
346 ]
347 )
348
349 self.post_flip_vertical_speed_weight = float(
350 config[
351 "post_flip_vertical_speed_weight"
352 ]
353 )
354
355 self.post_flip_angle_weight = float(
356 config[
357 "post_flip_angle_weight"
358 ]
359 )
360
361 self.post_flip_angular_speed_weight = float(
362 config[
363 "post_flip_angular_speed_weight"
364 ]
365 )
366
367 self.post_flip_leg_contact_weight = float(
368 config[
369 "post_flip_leg_contact_weight"
370 ]
371 )
372
373 # ==============================================================
374 # 7. Horizontal landing-zone guidance
375 # ==============================================================
376
377 self.landing_zone_half_width = float(
378 config[
379 "landing_zone_half_width"
380 ]
381 )
382
383 self.post_flip_zone_excess_weight = float(
384 config[
385 "post_flip_zone_excess_weight"
386 ]
387 )
388
389 self.post_flip_target_vx_gain = float(
390 config[
391 "post_flip_target_vx_gain"
392 ]
393 )
394
395 self.post_flip_max_target_vx = float(
396 config[
397 "post_flip_max_target_vx"
398 ]
399 )
400
401 self.post_flip_horizontal_deadband = float(
402 config[
403 "post_flip_horizontal_deadband"
404 ]
405 )
406
407 # ==============================================================
408 # 8. Vertical descent and soft-touchdown guidance
409 # ==============================================================
410
411 self.post_flip_target_vy_high = float(
412 config[
413 "post_flip_target_vy_high"
414 ]
415 )
416
417 self.post_flip_target_vy_near_ground = float(
418 config[
419 "post_flip_target_vy_near_ground"
420 ]
421 )
422
423 self.near_ground_height = float(
424 config[
425 "near_ground_height"
426 ]
427 )
428
429 self.safe_touchdown_vertical_speed = float(
430 config[
431 "safe_touchdown_vertical_speed"
432 ]
433 )
434
435 self.near_ground_overspeed_weight = float(
436 config[
437 "near_ground_overspeed_weight"
438 ]
439 )
440
441 # ==============================================================
442 # Configuration validation
443 # ==============================================================
444
445 if self.required_rotations <= 0.0:
446 raise ValueError(
447 "required_rotations must be positive."
448 )
449
450 if self.rotation_direction not in (
451 -1,
452 1,
453 ):
454 raise ValueError(
455 "rotation_direction must be "
456 "either -1 or 1."
457 )
458
459 if not (
460 0.0
461 < self.upright_tolerance_radians
462 <= math.pi
463 ):
464 raise ValueError(
465 "upright_tolerance_radians must "
466 "be in the interval (0, pi]."
467 )
468
469 if (
470 self.recovery_angular_velocity_tolerance
471 < 0.0
472 ):
473 raise ValueError(
474 "recovery_angular_velocity_tolerance "
475 "must not be negative."
476 )
477
478 if not (
479 0.0
480 <= self.post_flip_shaping_gamma
481 <= 1.0
482 ):
483 raise ValueError(
484 "post_flip_shaping_gamma must "
485 "be in [0, 1]."
486 )
487
488 if self.post_flip_shaping_clip <= 0.0:
489 raise ValueError(
490 "post_flip_shaping_clip must "
491 "be positive."
492 )
493
494 if self.pre_flip_shaping_clip <= 0.0:
495 raise ValueError(
496 "pre_flip_shaping_clip must "
497 "be positive."
498 )
499
500 if not (
501 0.0
502 < self.flip_corridor_half_width
503 < 1.0
504 ):
505 raise ValueError(
506 "flip_corridor_half_width must "
507 "be between 0 and 1."
508 )
509
510 if not (
511 0.0
512 < self.landing_zone_half_width
513 < 1.0
514 ):
515 raise ValueError(
516 "landing_zone_half_width must "
517 "be between 0 and 1."
518 )
519
520 if self.post_flip_max_target_vx <= 0.0:
521 raise ValueError(
522 "post_flip_max_target_vx must "
523 "be positive."
524 )
525
526 if self.near_ground_height <= 0.0:
527 raise ValueError(
528 "near_ground_height must be "
529 "positive."
530 )
531
532 # All parameters in this collection represent rewards,
533 # penalties, weights, tolerances or magnitudes. Negative values
534 # would invert their intended meaning.
535 non_negative_parameters = {
536 "pre_flip_original_reward_weight": (
537 self.pre_flip_original_reward_weight
538 ),
539 "post_flip_original_reward_weight": (
540 self.post_flip_original_reward_weight
541 ),
542 "pre_flip_center_weight": (
543 self.pre_flip_center_weight
544 ),
545 "pre_flip_horizontal_speed_weight": (
546 self.pre_flip_horizontal_speed_weight
547 ),
548 "pre_flip_shaping_weight": (
549 self.pre_flip_shaping_weight
550 ),
551 "flip_entry_penalty_weight": (
552 self.flip_entry_penalty_weight
553 ),
554 "rotation_progress_bonus": (
555 self.rotation_progress_bonus
556 ),
557 "flip_completion_bonus": (
558 self.flip_completion_bonus
559 ),
560 "recovery_bonus": (
561 self.recovery_bonus
562 ),
563 "flip_landing_bonus": (
564 self.flip_landing_bonus
565 ),
566 "landing_without_flip_penalty": (
567 self.landing_without_flip_penalty
568 ),
569 "no_flip_terminal_penalty": (
570 self.no_flip_terminal_penalty
571 ),
572 "failed_landing_penalty": (
573 self.failed_landing_penalty
574 ),
575 "outside_zone_landing_penalty": (
576 self.outside_zone_landing_penalty
577 ),
578 "in_zone_crash_penalty": (
579 self.in_zone_crash_penalty
580 ),
581 "post_flip_shaping_weight": (
582 self.post_flip_shaping_weight
583 ),
584 "post_flip_center_weight": (
585 self.post_flip_center_weight
586 ),
587 "post_flip_horizontal_speed_weight": (
588 self.post_flip_horizontal_speed_weight
589 ),
590 "post_flip_vertical_speed_weight": (
591 self.post_flip_vertical_speed_weight
592 ),
593 "post_flip_angle_weight": (
594 self.post_flip_angle_weight
595 ),
596 "post_flip_angular_speed_weight": (
597 self.post_flip_angular_speed_weight
598 ),
599 "post_flip_leg_contact_weight": (
600 self.post_flip_leg_contact_weight
601 ),
602 "post_flip_zone_excess_weight": (
603 self.post_flip_zone_excess_weight
604 ),
605 "post_flip_target_vx_gain": (
606 self.post_flip_target_vx_gain
607 ),
608 "post_flip_horizontal_deadband": (
609 self.post_flip_horizontal_deadband
610 ),
611 "safe_touchdown_vertical_speed": (
612 self.safe_touchdown_vertical_speed
613 ),
614 "near_ground_overspeed_weight": (
615 self.near_ground_overspeed_weight
616 ),
617 }
618
619 for (
620 parameter_name,
621 parameter_value,
622 ) in non_negative_parameters.items():
623 if parameter_value < 0.0:
624 raise ValueError(
625 f"{parameter_name} must not "
626 "be negative."
627 )
628
629 # Downward velocity is represented by a negative value.
630 if self.post_flip_target_vy_high > 0.0:
631 raise ValueError(
632 "post_flip_target_vy_high must "
633 "be zero or negative."
634 )
635
636 if (
637 self.post_flip_target_vy_near_ground
638 > 0.0
639 ):
640 raise ValueError(
641 "post_flip_target_vy_near_ground "
642 "must be zero or negative."
643 )
644
645 # For example, -0.12 is slower than -0.45.
646 if (
647 self.post_flip_target_vy_near_ground
648 < self.post_flip_target_vy_high
649 ):
650 raise ValueError(
651 "The near-ground target vertical "
652 "speed must be slower than the "
653 "high-altitude target."
654 )
655
656 if (
657 self.post_flip_horizontal_deadband
658 > self.landing_zone_half_width
659 ):
660 raise ValueError(
661 "post_flip_horizontal_deadband "
662 "must not exceed "
663 "landing_zone_half_width."
664 )
665
666 # Convert the configured number of rotations to radians.
667 self.required_rotation = (
668 2.0
669 * math.pi
670 * self.required_rotations
671 )
672
673 # ==============================================================
674 # Per-episode state
675 #
676 # These values are reset at the start of every episode. They are
677 # not reward hyperparameters.
678 # ==============================================================
679
680 self.previous_angle = 0.0
681 self.cumulative_rotation = 0.0
682 self.maximum_rotation_progress = 0.0
683 self.flip_completed = False
684 self.recovery_completed = False
685
686 self.previous_post_flip_potential: (
687 float | None
688 ) = None
689
690 self.previous_pre_flip_potential: (
691 float | None
692 ) = None
693
694 # Episode-level diagnostics.
695 self.x_at_flip_completion = np.nan
696 self.vx_at_flip_completion = np.nan
697 self.y_at_flip_completion = np.nan
698
699 self.first_leg_contact_recorded = False
700 self.first_leg_contact_vx = np.nan
701 self.first_leg_contact_vy = np.nan
702
703 self.total_pre_flip_shaping = 0.0
704 self.total_post_flip_shaping = 0.0
705 self.total_overspeed_penalty = 0.0
706
707 self.shaping_step_count = 0
708 self.shaping_clip_count = 0
709
710 # ==============================================================
711 # Extended observation space
712 #
713 # Append:
714 # 1. signed rotation progress;
715 # 2. flip-completed flag;
716 # 3. recovery-completed flag.
717 # ==============================================================
718
719 base_space = self.env.observation_space
720
721 if not isinstance(
722 base_space,
723 gym.spaces.Box,
724 ):
725 raise TypeError(
726 "The wrapped environment must "
727 "have a Box observation space."
728 )
729
730 # Bottom bound for the 3 extra observation space parameters
731 extra_low = np.asarray(
732 [
733 -1.0,
734 0.0,
735 0.0,
736 ],
737 dtype=np.float32,
738 )
739
740 # Top bound for the 3 extra observation space parameters
741 extra_high = np.asarray(
742 [
743 1.0,
744 1.0,
745 1.0,
746 ],
747 dtype=np.float32,
748 )
749
750 self.observation_space = (
751 gym.spaces.Box(
752 low=np.concatenate(
753 [
754 base_space.low.astype(
755 np.float32
756 ),
757 extra_low,
758 ]
759 ),
760 high=np.concatenate(
761 [
762 base_space.high.astype(
763 np.float32
764 ),
765 extra_high,
766 ]
767 ),
768 dtype=np.float32,
769 )
770 )
771
772 @staticmethod
773 def _wrap_angle(angle: float) -> float:
774 """Wrap an angle to [-pi, pi]."""
775
776 return float(
777 np.arctan2(
778 np.sin(angle),
779 np.cos(angle),
780 )
781 )
782
783 def _signed_rotation_progress(self) -> float:
784 return float(
785 np.clip(
786 self.cumulative_rotation
787 / self.required_rotation,
788 -1.0,
789 1.0,
790 )
791 )
792
793 def _augment_observation(
794 self,
795 observation: np.ndarray,
796 ) -> np.ndarray:
797 """
798 Adds the custom recorded values to the original observation.
799 """
800 return np.concatenate(
801 [
802 np.asarray(
803 observation,
804 dtype=np.float32,
805 ),
806 np.asarray(
807 [
808 self._signed_rotation_progress(),
809 float(self.flip_completed),
810 float(self.recovery_completed),
811 ],
812 dtype=np.float32,
813 ),
814 ]
815 )
816
817 def _pre_flip_potential(
818 self,
819 observation: np.ndarray,
820 ) -> float:
821 """
822 Higher values represent better pre-flip horizontal states.
823
824 Only horizontal position and horizontal speed are used.
825 Angle is deliberately excluded so this shaping does not
826 oppose the required rotation.
827 """
828
829 x_position = float(
830 observation[0]
831 )
832
833 horizontal_speed = float(
834 observation[2]
835 )
836
837 return float(
838 -self.pre_flip_center_weight
839 * abs(x_position)
840
841 -self.pre_flip_horizontal_speed_weight
842 * abs(horizontal_speed)
843 )
844
845 def _post_flip_potential(
846 self,
847 observation: np.ndarray,
848 ) -> float:
849 """
850 Higher values represent safer post-flip states.
851
852 LunarLander-v3 observation positions:
853 0 x position, 2 x velocity, 4 angle, 5 angular velocity,
854 6 left-leg contact and 7 right-leg contact.
855 """
856
857 x_position = float(
858 observation[0]
859 )
860
861 y_position = float(
862 observation[1]
863 )
864
865 horizontal_speed = float(
866 observation[2]
867 )
868
869 vertical_speed = float(
870 observation[3]
871 )
872
873 height_ratio = float(
874 np.clip(
875 y_position
876 / self.near_ground_height,
877 0.0,
878 1.0,
879 )
880 )
881
882 target_vertical_speed = (
883 self.post_flip_target_vy_near_ground
884 + height_ratio
885 * (
886 self.post_flip_target_vy_high
887 - self.post_flip_target_vy_near_ground
888 )
889 )
890
891 vertical_speed_error = abs(
892 vertical_speed
893 - target_vertical_speed
894 )
895
896 wrapped_angle = abs(
897 self._wrap_angle(
898 float(observation[4])
899 )
900 )
901
902 angular_speed = abs(
903 float(observation[5])
904 )
905
906 leg_contacts = (
907 float(observation[6] > 0.5)
908 + float(observation[7] > 0.5)
909 )
910
911 # Distance beyond the two landing-zone flags.
912 zone_excess = max(
913 abs(x_position)
914 - self.landing_zone_half_width,
915 0.0,
916 )
917
918 # When left of the pad, target positive horizontal
919 # velocity. When right of the pad, target negative
920 # horizontal velocity. Close to the centre, target
921 # velocity approaches zero.
922 horizontal_guidance_factor = float(
923 np.clip(
924 y_position / 0.50,
925 0.0,
926 1.0,
927 )
928 )
929
930 horizontal_error = max(
931 abs(x_position)
932 - self.post_flip_horizontal_deadband,
933 0.0,
934 )
935
936 target_horizontal_speed = float(
937 np.clip(
938 -self.post_flip_target_vx_gain
939 * x_position
940 * horizontal_guidance_factor,
941 -self.post_flip_max_target_vx,
942 self.post_flip_max_target_vx,
943 )
944 )
945
946 horizontal_speed_error = abs(
947 horizontal_speed
948 - target_horizontal_speed
949 )
950
951 # Increase the importance of entering the landing
952 # zone as the lander approaches the ground.
953 near_ground_factor = float(
954 np.clip(
955 1.0
956 - max(y_position, 0.0)
957 / 0.75,
958 0.0,
959 1.0,
960 )
961 )
962
963 effective_zone_weight = (
964 self.post_flip_zone_excess_weight
965 * (1.0 + near_ground_factor)
966 )
967
968 return float(
969 -self.post_flip_center_weight
970 * horizontal_error
971
972 -effective_zone_weight
973 * zone_excess
974
975 -self.post_flip_horizontal_speed_weight
976 * horizontal_speed_error
977
978 -self.post_flip_vertical_speed_weight
979 * vertical_speed_error
980
981 -self.post_flip_angle_weight
982 * wrapped_angle
983
984 -self.post_flip_angular_speed_weight
985 * angular_speed
986
987 +self.post_flip_leg_contact_weight
988 * leg_contacts
989 )
990
991 def reset(
992 self,
993 *,
994 seed: int | None = None,
995 options: dict | None = None,
996 ):
997 observation, info = self.env.reset(
998 seed=seed,
999 options=options,
1000 )
1001
1002 self.previous_angle = float(observation[4])
1003 self.cumulative_rotation = 0.0
1004 self.maximum_rotation_progress = 0.0
1005 self.flip_completed = False
1006 self.recovery_completed = False
1007 self.previous_post_flip_potential = None
1008 self.previous_pre_flip_potential = (
1009 self._pre_flip_potential(
1010 observation
1011 )
1012 )
1013
1014 self.x_at_flip_completion = np.nan
1015 self.vx_at_flip_completion = np.nan
1016 self.y_at_flip_completion = np.nan
1017
1018 self.first_leg_contact_recorded = False
1019 self.first_leg_contact_vx = np.nan
1020 self.first_leg_contact_vy = np.nan
1021
1022 self.total_pre_flip_shaping = 0.0
1023 self.total_post_flip_shaping = 0.0
1024 self.total_overspeed_penalty = 0.0
1025
1026 self.shaping_step_count = 0
1027 self.shaping_clip_count = 0
1028
1029 info = dict(info)
1030 info.update(
1031 {
1032 "flip_completed": False,
1033 "recovery_completed": False,
1034 "rotation_progress": 0.0,
1035 "rotations_completed": 0.0,
1036 "rotation_progress_reward": 0.0,
1037 "flip_completion_reward": 0.0,
1038 "recovery_reward": 0.0,
1039 "post_flip_shaping_reward": 0.0,
1040
1041 "pre_flip_shaping_reward": 0.0,
1042 "flip_entry_penalty": 0.0,
1043
1044 "x_at_flip_completion": np.nan,
1045 "vx_at_flip_completion": np.nan,
1046 "y_at_flip_completion": np.nan,
1047
1048 "first_leg_contact_vx": np.nan,
1049 "first_leg_contact_vy": np.nan,
1050
1051 "total_pre_flip_shaping": 0.0,
1052 "total_post_flip_shaping": 0.0,
1053 "total_overspeed_penalty": 0.0,
1054
1055 "shaping_step_count": 0,
1056 "shaping_clip_count": 0,
1057 "shaping_clip_rate": 0.0,
1058
1059 "flip_landing_bonus": 0.0,
1060 "terminal_adjustment": 0.0,
1061 }
1062 )
1063
1064 return self._augment_observation(observation), info
1065
1066 def step(self, action):
1067 # 1. Advance the original LunarLander environment.
1068 (
1069 observation,
1070 original_reward,
1071 terminated,
1072 truncated,
1073 info,
1074 ) = self.env.step(action)
1075
1076 # Remember whether the flip had already been completed
1077 # before processing the current transition.
1078 flip_was_completed = self.flip_completed
1079
1080 # 2. Accumulate directed angular movement and reward new flip progress.
1081 current_angle = float(observation[4])
1082 angular_velocity = float(observation[5])
1083
1084 angle_change = self._wrap_angle(
1085 current_angle - self.previous_angle
1086 )
1087 self.previous_angle = current_angle
1088
1089 directed_change = (
1090 self.rotation_direction * angle_change
1091 )
1092 self.cumulative_rotation += directed_change
1093
1094 rotation_progress = float(
1095 np.clip(
1096 self.cumulative_rotation
1097 / self.required_rotation,
1098 0.0,
1099 1.0,
1100 )
1101 )
1102
1103 new_progress = max(
1104 0.0,
1105 rotation_progress
1106 - self.maximum_rotation_progress,
1107 )
1108 progress_reward = (
1109 self.rotation_progress_bonus
1110 * new_progress
1111 )
1112 self.maximum_rotation_progress = max(
1113 self.maximum_rotation_progress,
1114 rotation_progress,
1115 )
1116
1117 # Apply horizontal guidance before the flip is completed.
1118 # This rewards becoming more centred and reducing lateral
1119 # speed without applying any pressure to remain upright.
1120 pre_flip_shaping_reward = 0.0
1121
1122 if (
1123 not flip_was_completed
1124 and self.pre_flip_shaping_weight > 0.0
1125 ):
1126 current_pre_flip_potential = (
1127 self._pre_flip_potential(
1128 observation
1129 )
1130 )
1131
1132 if (
1133 self.previous_pre_flip_potential
1134 is not None
1135 ):
1136 raw_pre_flip_shaping = (
1137 self.pre_flip_shaping_weight
1138 * (
1139 self.post_flip_shaping_gamma
1140 * current_pre_flip_potential
1141 - self.previous_pre_flip_potential
1142 )
1143 )
1144
1145 pre_flip_shaping_reward = float(
1146 np.clip(
1147 raw_pre_flip_shaping,
1148 -self.pre_flip_shaping_clip,
1149 self.pre_flip_shaping_clip,
1150 )
1151 )
1152
1153 self.shaping_step_count += 1
1154
1155 if (
1156 abs(raw_pre_flip_shaping)
1157 >= self.pre_flip_shaping_clip
1158 ):
1159 self.shaping_clip_count += 1
1160
1161 self.previous_pre_flip_potential = (
1162 current_pre_flip_potential
1163 )
1164
1165 completion_reward = 0.0
1166 flip_entry_penalty = 0.0
1167
1168 # 3. Detect the first completed full rotation.
1169 if (
1170 rotation_progress >= 1.0
1171 and not self.flip_completed
1172 ):
1173 self.flip_completed = True
1174 completion_reward = (
1175 self.flip_completion_bonus
1176 )
1177
1178 self.x_at_flip_completion = float(
1179 observation[0]
1180 )
1181
1182 self.vx_at_flip_completion = float(
1183 observation[2]
1184 )
1185
1186 self.y_at_flip_completion = float(
1187 observation[1]
1188 )
1189
1190 off_center_at_flip = max(
1191 abs(self.x_at_flip_completion)
1192 - self.flip_corridor_half_width,
1193 0.0,
1194 )
1195
1196 flip_entry_penalty = (
1197 self.flip_entry_penalty_weight
1198 * off_center_at_flip
1199 )
1200
1201 # 4. Detect upright post-flip recovery.
1202 wrapped_angle = abs(
1203 self._wrap_angle(current_angle)
1204 )
1205 upright = (
1206 wrapped_angle
1207 <= self.upright_tolerance_radians
1208 )
1209
1210 recovery_reward = 0.0
1211
1212 if (
1213 self.flip_completed
1214 and not self.recovery_completed
1215 and upright
1216 and abs(angular_velocity)
1217 <= self.recovery_angular_velocity_tolerance
1218 ):
1219 self.recovery_completed = True
1220 recovery_reward = self.recovery_bonus
1221
1222
1223 post_flip_shaping_reward = 0.0
1224 post_flip_potential = None
1225 near_ground_overspeed_penalty = 0.0
1226
1227 # 5. Apply an explicit near-ground descent-overspeed penalty.
1228 if self.flip_completed:
1229 y_position = float(
1230 observation[1]
1231 )
1232
1233 vertical_speed = float(
1234 observation[3]
1235 )
1236
1237 near_ground_factor = float(
1238 np.clip(
1239 1.0
1240 - max(y_position, 0.0)
1241 / self.near_ground_height,
1242 0.0,
1243 1.0,
1244 )
1245 )
1246
1247 downward_speed = max(
1248 -vertical_speed,
1249 0.0,
1250 )
1251
1252 descent_overspeed = max(
1253 downward_speed
1254 - self.safe_touchdown_vertical_speed,
1255 0.0,
1256 )
1257
1258 near_ground_overspeed_penalty = (
1259 self.near_ground_overspeed_weight
1260 * near_ground_factor
1261 * descent_overspeed**2
1262 )
1263
1264 # 6. Apply continuous post-flip landing-quality shaping.
1265 if self.flip_completed:
1266 post_flip_potential = (
1267 self._post_flip_potential(observation)
1268 )
1269
1270 if self.previous_post_flip_potential is None:
1271 self.previous_post_flip_potential = (
1272 post_flip_potential
1273 )
1274 else:
1275 potential_difference = (
1276 self.post_flip_shaping_gamma
1277 * post_flip_potential
1278 - self.previous_post_flip_potential
1279 )
1280
1281 raw_post_flip_shaping = (
1282 self.post_flip_shaping_weight
1283 * potential_difference
1284 )
1285
1286 post_flip_shaping_reward = float(
1287 np.clip(
1288 raw_post_flip_shaping,
1289 -self.post_flip_shaping_clip,
1290 self.post_flip_shaping_clip,
1291 )
1292 )
1293
1294 self.shaping_step_count += 1
1295
1296 if (
1297 abs(raw_post_flip_shaping)
1298 >= self.post_flip_shaping_clip
1299 ):
1300 self.shaping_clip_count += 1
1301
1302 self.previous_post_flip_potential = (
1303 post_flip_potential
1304 )
1305
1306 left_leg_contact = bool(observation[6] > 0.5)
1307 right_leg_contact = bool(observation[7] > 0.5)
1308
1309 if (
1310 self.flip_completed
1311 and not self.first_leg_contact_recorded
1312 and (
1313 left_leg_contact
1314 or right_leg_contact
1315 )
1316 ):
1317 self.first_leg_contact_recorded = True
1318
1319 self.first_leg_contact_vx = float(
1320 observation[2]
1321 )
1322
1323 self.first_leg_contact_vy = float(
1324 observation[3]
1325 )
1326
1327 x_position = float(
1328 observation[0]
1329 )
1330
1331 inside_landing_zone = bool(
1332 abs(x_position)
1333 <= self.landing_zone_half_width
1334 )
1335
1336 stable_landing = bool(
1337 terminated
1338 and float(original_reward) > 0.0
1339 and left_leg_contact
1340 and right_leg_contact
1341 and upright
1342 )
1343
1344 # "Safe landing" now means stable and
1345 # between the landing-zone boundaries.
1346 landed_safely = bool(
1347 stable_landing
1348 and inside_landing_zone
1349 )
1350
1351 episode_finished = bool(
1352 terminated or truncated
1353 )
1354
1355 # 7. Classify the terminal outcome and apply its one-off adjustment.
1356 terminal_adjustment = 0.0
1357 outcome = "in_progress"
1358
1359 # Complete success:
1360 # flipped, settled safely and landed inside the zone.
1361 if (
1362 self.flip_completed
1363 and landed_safely
1364 ):
1365 terminal_adjustment = (
1366 self.flip_landing_bonus
1367 )
1368
1369 outcome = (
1370 "flip_and_safe_landing"
1371 )
1372
1373 elif episode_finished:
1374 # The episode ended before completing the flip.
1375 if not self.flip_completed:
1376 if stable_landing:
1377 terminal_adjustment = -(
1378 self.landing_without_flip_penalty
1379 )
1380
1381 outcome = (
1382 "stable_landing_without_flip"
1383 )
1384
1385 else:
1386 terminal_adjustment = -(
1387 self.no_flip_terminal_penalty
1388 )
1389
1390 outcome = (
1391 "episode_ended_without_flip"
1392 )
1393
1394 # Step E:
1395 # The flip was completed and the lander reached
1396 # the zone, but it crashed instead of settling.
1397 elif (
1398 terminated
1399 and inside_landing_zone
1400 and not stable_landing
1401 ):
1402 terminal_adjustment = -(
1403 self.in_zone_crash_penalty
1404 )
1405
1406 outcome = (
1407 "flip_and_crash_in_zone"
1408 )
1409
1410 # A stable landing occurred, but outside the flags.
1411 elif (
1412 stable_landing
1413 and not inside_landing_zone
1414 ):
1415 terminal_adjustment = -(
1416 self.outside_zone_landing_penalty
1417 )
1418
1419 outcome = (
1420 "flip_and_stable_landing_"
1421 "outside_zone"
1422 )
1423
1424 # Other post-flip failures, such as crashing
1425 # outside the zone or timing out.
1426 else:
1427 terminal_adjustment = -(
1428 self.failed_landing_penalty
1429 )
1430
1431 outcome = (
1432 "flip_but_failed_landing"
1433 )
1434
1435 self.total_pre_flip_shaping += (
1436 pre_flip_shaping_reward
1437 )
1438
1439 self.total_post_flip_shaping += (
1440 post_flip_shaping_reward
1441 )
1442
1443 self.total_overspeed_penalty += (
1444 near_ground_overspeed_penalty
1445 )
1446
1447 shaping_clip_rate = (
1448 self.shaping_clip_count
1449 / self.shaping_step_count
1450 if self.shaping_step_count > 0
1451 else 0.0
1452 )
1453
1454 original_weight = (
1455 self.post_flip_original_reward_weight
1456 if self.flip_completed
1457 else self.pre_flip_original_reward_weight
1458 )
1459
1460 # 8. Combine the original reward, dense shaping and terminal outcome.
1461 modified_reward = (
1462 original_weight * float(original_reward)
1463 + progress_reward
1464 + pre_flip_shaping_reward
1465 + completion_reward
1466 + recovery_reward
1467 + post_flip_shaping_reward
1468 - flip_entry_penalty
1469 - near_ground_overspeed_penalty
1470 + terminal_adjustment
1471 )
1472
1473 info = dict(info)
1474 info.update(
1475 {
1476 "original_reward": float(original_reward),
1477 "rotation_progress": rotation_progress,
1478 "rotation_progress_reward": progress_reward,
1479 "flip_completion_reward": completion_reward,
1480 "recovery_reward": recovery_reward,
1481 "post_flip_shaping_reward": (
1482 post_flip_shaping_reward
1483 ),
1484 "post_flip_potential": post_flip_potential,
1485 "cumulative_rotation": (
1486 self.cumulative_rotation
1487 ),
1488 "rotations_completed": (
1489 self.cumulative_rotation
1490 / (2.0 * math.pi)
1491 ),
1492 "flip_completed": self.flip_completed,
1493 "recovery_completed": (
1494 self.recovery_completed
1495 ),
1496 "landed_safely": landed_safely,
1497 "original_reward_weight": original_weight,
1498 "terminal_adjustment": (
1499 terminal_adjustment
1500 ),
1501 "flip_landing_bonus": (
1502 self.flip_landing_bonus
1503 if outcome
1504 == "flip_and_safe_landing"
1505 else 0.0
1506 ),
1507 "custom_outcome": outcome,
1508 "stable_landing": stable_landing,
1509 "inside_landing_zone": (
1510 inside_landing_zone
1511 ),
1512 "horizontal_position": (
1513 x_position
1514 ),
1515 "near_ground_overspeed_penalty": (
1516 near_ground_overspeed_penalty
1517 ),
1518 "vertical_speed": float(
1519 observation[3]
1520 ),
1521
1522 "pre_flip_shaping_reward": (
1523 pre_flip_shaping_reward
1524 ),
1525 "flip_entry_penalty": (
1526 flip_entry_penalty
1527 ),
1528
1529 "x_at_flip_completion": (
1530 self.x_at_flip_completion
1531 ),
1532 "vx_at_flip_completion": (
1533 self.vx_at_flip_completion
1534 ),
1535 "y_at_flip_completion": (
1536 self.y_at_flip_completion
1537 ),
1538
1539 "first_leg_contact_vx": (
1540 self.first_leg_contact_vx
1541 ),
1542 "first_leg_contact_vy": (
1543 self.first_leg_contact_vy
1544 ),
1545
1546 "total_pre_flip_shaping": (
1547 self.total_pre_flip_shaping
1548 ),
1549 "total_post_flip_shaping": (
1550 self.total_post_flip_shaping
1551 ),
1552 "total_overspeed_penalty": (
1553 self.total_overspeed_penalty
1554 ),
1555
1556 "shaping_step_count": (
1557 self.shaping_step_count
1558 ),
1559 "shaping_clip_count": (
1560 self.shaping_clip_count
1561 ),
1562 "shaping_clip_rate": (
1563 shaping_clip_rate
1564 ),
1565 }
1566 )
1567
1568 return (
1569 self._augment_observation(observation),
1570 modified_reward,
1571 terminated,
1572 truncated,
1573 info,
1574 )
1575
1576
1577 def make_flip_lander(
1578 *,
1579 render_mode: str | None = None,
1580 reward_config: (
1581 Mapping[str, float | int]
1582 | None
1583 ) = None,
1584 ) -> gym.Env:
1585 """
1586 Build the exact environment used for training and evaluation.
1587
1588 Pass the same reward_config to the uploader so the Hub model card
1589 records the environment accurately.
1590 """
1591
1592 base_env = gym.make(
1593 "LunarLander-v3",
1594 render_mode=render_mode,
1595 )
1596
1597 return FlipLandingRewardWrapper(
1598 base_env,
1599 reward_config=reward_config,
1600 )
1601