mjlab/tests/test_terrain_proportion_spawning.py
Upstream Snapshot 32a241c28f
Some checks failed
nightly / Test against latest dependencies (py3.10) (push) Has been cancelled
nightly / Test against latest dependencies (py3.13) (push) Has been cancelled
tests / tests (3.13, locked) (push) Has been cancelled
tests / tests (3.13, unlocked) (push) Has been cancelled
tests / pyright (3.10) (push) Has been cancelled
tests / lint-format (push) Has been cancelled
tests / tests (3.10, locked) (push) Has been cancelled
tests / tests (3.11, locked) (push) Has been cancelled
tests / tests (3.12, locked) (push) Has been cancelled
tests / pyright (3.11) (push) Has been cancelled
tests / pyright (3.12) (push) Has been cancelled
tests / pyright (3.13) (push) Has been cancelled
tests / ty-check (3.10) (push) Has been cancelled
tests / ty-check (3.11) (push) Has been cancelled
tests / ty-check (3.12) (push) Has been cancelled
tests / ty-check (3.13) (push) Has been cancelled
tests / stubs (push) Has been cancelled
tests / smoke-test (push) Has been cancelled
Docker / check_paths (push) Has been cancelled
docs / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Import upstream snapshot c19f713c415a699a79d71cd96aa13c3104a05047
Upstream: https://github.com/michaelgillett/mjlab
Upstream-Commit: c19f713c415a699a79d71cd96aa13c3104a05047
Upstream-Branch: main
2026-08-28 15:42:17 +08:00

202 lines
6.3 KiB
Python

"""Tests for proportion-based robot spawning on terrain columns."""
from __future__ import annotations
import numpy as np
import pytest
import torch
from mjlab.terrains.primitive_terrains import BoxFlatTerrainCfg
from mjlab.terrains.terrain_entity import TerrainEntity, TerrainEntityCfg
from mjlab.terrains.terrain_generator import TerrainGenerator, TerrainGeneratorCfg
@pytest.fixture(scope="module")
def terrain_entity() -> TerrainEntity:
cfg = TerrainEntityCfg(terrain_type="plane", num_envs=1, env_spacing=2.0)
return TerrainEntity(cfg, device="cpu")
def _make_origins(num_rows: int, num_cols: int) -> torch.Tensor:
origins = torch.zeros(num_rows, num_cols, 3)
for r in range(num_rows):
for c in range(num_cols):
origins[r, c, 0] = float(r)
origins[r, c, 1] = float(c)
return origins
def _normalized(proportions: list[float]) -> np.ndarray:
p = np.array(proportions)
return p / p.sum()
def _get_counts(entity: TerrainEntity, num_cols: int) -> list[int]:
return [int((entity.terrain_types == col).sum().item()) for col in range(num_cols)]
# Fallback (no proportions).
def test_even_distribution_without_proportions(terrain_entity: TerrainEntity) -> None:
num_envs = 30
num_cols = 3
origins = _make_origins(5, num_cols)
terrain_entity._compute_env_origins_curriculum(num_envs, origins, None)
assert _get_counts(terrain_entity, num_cols) == [10, 10, 10]
# Proportional distribution.
def test_basic_proportions(terrain_entity: TerrainEntity) -> None:
num_envs = 100
proportions = _normalized([0.5, 0.3, 0.2])
origins = _make_origins(5, len(proportions))
terrain_entity._compute_env_origins_curriculum(num_envs, origins, proportions)
assert _get_counts(terrain_entity, len(proportions)) == [50, 30, 20]
@pytest.mark.parametrize("num_envs", [3, 7, 32, 64, 100, 512, 1024, 4096])
def test_counts_sum_to_num_envs(terrain_entity: TerrainEntity, num_envs: int) -> None:
"""Total terrain_types length must always equal num_envs."""
proportions = _normalized([0.6, 0.25, 0.1, 0.05])
origins = _make_origins(5, len(proportions))
terrain_entity._compute_env_origins_curriculum(num_envs, origins, proportions)
assert len(terrain_entity.terrain_types) == num_envs
@pytest.mark.parametrize(
"num_envs, proportions",
[
(3, [0.98, 0.01, 0.01]),
(5, [0.9, 0.05, 0.03, 0.01, 0.01]),
(100, [0.9, 0.05, 0.05]),
(10, [0.97, 0.01, 0.01, 0.01]),
],
)
def test_every_column_gets_at_least_one(
terrain_entity: TerrainEntity, num_envs: int, proportions: list[float]
) -> None:
"""When num_envs >= num_cols, every column gets at least 1 robot."""
p = _normalized(proportions)
origins = _make_origins(5, len(p))
terrain_entity._compute_env_origins_curriculum(num_envs, origins, p)
counts = _get_counts(terrain_entity, len(p))
for col, count in enumerate(counts):
assert count >= 1, f"Column {col} got 0 envs (counts={counts})"
def test_fewer_envs_than_cols(terrain_entity: TerrainEntity) -> None:
"""When num_envs < num_cols, should still work without errors."""
num_envs = 2
proportions = _normalized([0.5, 0.3, 0.1, 0.1])
origins = _make_origins(5, len(proportions))
result = terrain_entity._compute_env_origins_curriculum(
num_envs, origins, proportions
)
assert len(terrain_entity.terrain_types) == num_envs
assert result.shape == (num_envs, 3)
def test_equal_proportions_gives_equal_counts(terrain_entity: TerrainEntity) -> None:
num_envs = 30
proportions = _normalized([1.0, 1.0, 1.0, 1.0])
origins = _make_origins(5, len(proportions))
terrain_entity._compute_env_origins_curriculum(num_envs, origins, proportions)
counts = _get_counts(terrain_entity, len(proportions))
for c in counts:
assert 7 <= c <= 8, f"Expected 7 or 8 envs per column, got {c}"
def test_env_origins_shape(terrain_entity: TerrainEntity) -> None:
num_envs = 20
proportions = _normalized([0.5, 0.3, 0.2])
origins = _make_origins(5, len(proportions))
result = terrain_entity._compute_env_origins_curriculum(
num_envs, origins, proportions
)
assert result.shape == (num_envs, 3)
def test_env_origins_match_terrain_origins(terrain_entity: TerrainEntity) -> None:
"""Each env_origin must correspond to a valid (level, type) in origins."""
num_envs = 50
proportions = _normalized([0.6, 0.3, 0.1])
origins = _make_origins(5, len(proportions))
result = terrain_entity._compute_env_origins_curriculum(
num_envs, origins, proportions
)
for i in range(num_envs):
level = int(terrain_entity.terrain_levels[i].item())
ttype = int(terrain_entity.terrain_types[i].item())
expected = origins[level, ttype]
assert torch.allclose(result[i], expected), (
f"env {i}: origin {result[i].tolist()} != "
f"origins[{level},{ttype}]={expected.tolist()}"
)
def test_mismatched_cols_falls_back_to_even(terrain_entity: TerrainEntity) -> None:
"""When proportions length != num_cols, should fall back to even."""
num_envs = 30
num_cols = 3
# 4 proportions but only 3 columns in origins.
proportions = _normalized([0.4, 0.3, 0.2, 0.1])
origins = _make_origins(5, num_cols)
terrain_entity._compute_env_origins_curriculum(num_envs, origins, proportions)
assert _get_counts(terrain_entity, num_cols) == [10, 10, 10]
def test_proportions_are_respected_approximately(
terrain_entity: TerrainEntity,
) -> None:
"""With large num_envs, actual distribution should track proportions."""
num_envs = 10000
raw = [0.5, 0.3, 0.15, 0.05]
proportions = _normalized(raw)
origins = _make_origins(5, len(proportions))
terrain_entity._compute_env_origins_curriculum(num_envs, origins, proportions)
counts = _get_counts(terrain_entity, len(proportions))
for col, (count, prop) in enumerate(zip(counts, proportions, strict=True)):
expected = prop * num_envs
assert abs(count - expected) < 10, (
f"Column {col}: got {count}, expected ~{expected:.0f}"
)
def test_generator_does_not_mutate_cfg_num_cols() -> None:
"""TerrainGenerator must not mutate the caller's cfg.num_cols."""
cfg = TerrainGeneratorCfg(
size=(4.0, 4.0),
curriculum=True,
num_cols=1,
sub_terrains={
"a": BoxFlatTerrainCfg(proportion=0.5, size=(4.0, 4.0)),
"b": BoxFlatTerrainCfg(proportion=0.3, size=(4.0, 4.0)),
"c": BoxFlatTerrainCfg(proportion=0.2, size=(4.0, 4.0)),
},
)
TerrainGenerator(cfg)
assert cfg.num_cols == 1, f"cfg.num_cols was mutated to {cfg.num_cols}"