Spaces:
Sleeping
Sleeping
| """Gymnasium API compliance tests for :class:`HospitalEnv`.""" | |
| from __future__ import annotations | |
| import warnings | |
| import numpy as np | |
| import pytest | |
| from gymnasium.utils.env_checker import check_env | |
| from hospital_env import HospitalEnv | |
| # --------------------------------------------------------------------- | |
| # API compliance | |
| # --------------------------------------------------------------------- | |
| def test_env_passes_check_env() -> None: | |
| env = HospitalEnv() | |
| with warnings.catch_warnings(): | |
| warnings.simplefilter("ignore") | |
| check_env(env.unwrapped, skip_render_check=True) | |
| def test_reset_returns_obs_info_tuple() -> None: | |
| env = HospitalEnv() | |
| out = env.reset(seed=0) | |
| assert isinstance(out, tuple) and len(out) == 2 | |
| obs, info = out | |
| assert isinstance(obs, dict) | |
| assert isinstance(info, dict) | |
| # All observation components must match their declared space. | |
| for key, space in env.observation_space.spaces.items(): | |
| assert key in obs | |
| assert obs[key].shape == space.shape | |
| assert obs[key].dtype == space.dtype | |
| def test_step_returns_five_tuple() -> None: | |
| env = HospitalEnv() | |
| env.reset(seed=0) | |
| out = env.step(0) | |
| assert len(out) == 5 | |
| obs, reward, terminated, truncated, info = out | |
| assert isinstance(obs, dict) | |
| assert isinstance(reward, float) | |
| assert isinstance(terminated, bool) | |
| assert isinstance(truncated, bool) | |
| assert isinstance(info, dict) | |
| # --------------------------------------------------------------------- | |
| # Reproducibility | |
| # --------------------------------------------------------------------- | |
| def test_seed_is_reproducible() -> None: | |
| env1 = HospitalEnv() | |
| env2 = HospitalEnv() | |
| obs1, _ = env1.reset(seed=42) | |
| obs2, _ = env2.reset(seed=42) | |
| for k in obs1: | |
| assert np.array_equal(obs1[k], obs2[k]), f"Mismatch in {k}" | |
| # Run identical action sequences and check identical rewards. | |
| rewards1, rewards2 = [], [] | |
| for a in [0, 1, 11, 0, 2, 0, 3, 15, 0]: | |
| _, r1, *_ = env1.step(a) | |
| _, r2, *_ = env2.step(a) | |
| rewards1.append(r1) | |
| rewards2.append(r2) | |
| assert rewards1 == rewards2 | |
| # --------------------------------------------------------------------- | |
| # Episode mechanics | |
| # --------------------------------------------------------------------- | |
| def test_full_random_episode_runs() -> None: | |
| env = HospitalEnv() | |
| env.reset(seed=0) | |
| steps = 0 | |
| terminated = truncated = False | |
| while not (terminated or truncated): | |
| _, _, terminated, truncated, _ = env.step(env.action_space.sample()) | |
| steps += 1 | |
| assert steps <= HospitalEnv.MAX_STEPS + 1 | |
| assert steps >= 1 | |
| def test_episode_truncates_at_max_steps() -> None: | |
| env = HospitalEnv(max_deaths=10_000) # prevent early termination | |
| env.reset(seed=0) | |
| for _ in range(HospitalEnv.MAX_STEPS): | |
| _, _, terminated, truncated, _ = env.step(0) | |
| if terminated or truncated: | |
| break | |
| assert truncated | |
| assert env.current_step == HospitalEnv.MAX_STEPS | |
| def test_invalid_action_yields_penalty() -> None: | |
| env = HospitalEnv() | |
| env.reset(seed=0) | |
| # Assigning to empty queue slots should be invalid and incur -1. | |
| _, reward, _, _, info = env.step( | |
| HospitalEnv.ACTION_ASSIGN_GENERAL_START + 9 # queue slot 9, unlikely populated | |
| ) | |
| if info["invalid_action"]: | |
| assert reward <= 0.0 | |
| def test_action_space_size() -> None: | |
| env = HospitalEnv() | |
| assert env.action_space.n == HospitalEnv.NUM_ACTIONS == 39 | |
| # --------------------------------------------------------------------- | |
| # Observation bounds | |
| # --------------------------------------------------------------------- | |
| def test_observations_within_space_bounds() -> None: | |
| env = HospitalEnv() | |
| obs, _ = env.reset(seed=7) | |
| rng = np.random.default_rng(7) | |
| for _ in range(50): | |
| action = int(rng.integers(0, env.action_space.n)) | |
| obs, _, terminated, truncated, _ = env.step(action) | |
| assert env.observation_space.contains(obs), obs | |
| if terminated or truncated: | |
| break | |
| def test_assign_queue_to_bed_actually_fills_bed() -> None: | |
| env = HospitalEnv(arrival_rate=3.0) # guarantee arrivals | |
| env.reset(seed=1) | |
| # Walk until there's at least one patient in the queue | |
| for _ in range(5): | |
| env.step(0) | |
| if len(env.hospital.waiting_queue) > 0: | |
| break | |
| before = sum(b is not None for b in env.hospital.general_beds) | |
| env.step(HospitalEnv.ACTION_ASSIGN_GENERAL_START + 0) | |
| after = sum(b is not None for b in env.hospital.general_beds) | |
| assert after == before + 1 | |
| def test_action_mask_shape_and_noop_always_valid() -> None: | |
| env = HospitalEnv() | |
| env.reset(seed=0) | |
| mask = env.action_masks() | |
| assert mask.shape == (HospitalEnv.NUM_ACTIONS,) | |
| assert mask.dtype == bool | |
| # No-op (action 0) must always be valid. | |
| assert mask[HospitalEnv.ACTION_NOOP] | |
| def test_action_mask_eliminates_invalid_actions() -> None: | |
| """A policy that only samples from the action mask must never trigger | |
| the invalid-action penalty for any of its actions.""" | |
| env = HospitalEnv(arrival_rate=2.0) | |
| rng = np.random.default_rng(0) | |
| env.reset(seed=0) | |
| invalid = 0 | |
| steps = 0 | |
| for _ in range(400): | |
| mask = env.action_masks() | |
| valid_indices = np.where(mask)[0] | |
| action = int(rng.choice(valid_indices)) | |
| _, _, term, trunc, info = env.step(action) | |
| steps += 1 | |
| if info["invalid_action"]: | |
| invalid += 1 | |
| if term or trunc: | |
| env.reset(seed=0) | |
| assert steps > 100 | |
| assert invalid == 0, f"masked policy still produced {invalid} invalid actions" | |
| def test_early_discharge_is_penalized_and_not_counted() -> None: | |
| """Regression test: closing the admit-then-discharge reward hack. | |
| Admitting a patient and immediately discharging them must: | |
| 1. produce a strictly negative net reward, and | |
| 2. NOT increment ``total_treated``. | |
| """ | |
| env = HospitalEnv(arrival_rate=3.0) | |
| env.reset(seed=2) | |
| # Walk a couple steps so the queue has at least one patient. | |
| while len(env.hospital.waiting_queue) == 0: | |
| env.step(0) | |
| treated_before = env.hospital.total_treated | |
| _, r_admit, *_ = env.step(HospitalEnv.ACTION_ASSIGN_GENERAL_START) # admit q[0] | |
| # Now bed[0] holds the freshly-admitted patient. Discharge it immediately. | |
| _, r_discharge, *_ = env.step(HospitalEnv.ACTION_DISCHARGE_GENERAL_START) | |
| treated_after = env.hospital.total_treated | |
| # Net reward of the admit + early-discharge must be negative. | |
| assert (r_admit + r_discharge) < 0, (r_admit, r_discharge) | |
| # Patient must NOT be counted as treated. | |
| assert treated_after == treated_before | |