Skip to content

Commit 8f9a649

Browse files
committed
add train/utils unit cases
1 parent b51852b commit 8f9a649

3 files changed

Lines changed: 588 additions & 2 deletions

File tree

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
import json
2+
3+
import pytest
4+
import torch
5+
6+
from flagscale.models.utils.constants import (
7+
ACTION,
8+
DONE,
9+
OBS_IMAGE,
10+
OBS_IMAGES,
11+
OBS_STATE,
12+
REWARD,
13+
TRUNCATED,
14+
)
15+
from flagscale.train.processor.batch_processor import (
16+
AddBatchDimensionActionStep,
17+
AddBatchDimensionComplementaryDataStep,
18+
AddBatchDimensionObservationStep,
19+
AddBatchDimensionProcessorStep,
20+
)
21+
from flagscale.train.processor.converters import (
22+
batch_to_transition,
23+
create_transition,
24+
identity_transition,
25+
observation_to_transition,
26+
policy_action_to_transition,
27+
robot_action_observation_to_transition,
28+
robot_action_to_transition,
29+
to_tensor,
30+
transition_to_batch,
31+
transition_to_observation,
32+
transition_to_policy_action,
33+
transition_to_robot_action,
34+
)
35+
from flagscale.train.processor.core import TransitionKey
36+
from flagscale.train.processor.device_processor import (
37+
DeviceProcessorStep,
38+
get_safe_torch_device,
39+
)
40+
from flagscale.train.processor.factory import (
41+
make_default_processors,
42+
make_default_robot_action_processor,
43+
make_default_robot_observation_processor,
44+
make_default_teleop_action_processor,
45+
)
46+
from flagscale.train.processor.pipeline import (
47+
DataProcessorPipeline,
48+
IdentityProcessorStep,
49+
InfoProcessorStep,
50+
ProcessorMigrationError,
51+
ProcessorStep,
52+
ProcessorStepRegistry,
53+
)
54+
from flagscale.train.processor.rename_processor import (
55+
RenameObservationsProcessorStep,
56+
rename_stats,
57+
)
58+
59+
60+
class AppendInfoStep(InfoProcessorStep):
61+
def __init__(self, value="seen"):
62+
self.value = value
63+
self.reset_called = False
64+
65+
def info(self, info):
66+
info["marker"] = self.value
67+
return info
68+
69+
def get_config(self):
70+
return {"value": self.value}
71+
72+
def reset(self):
73+
self.reset_called = True
74+
75+
def transform_features(self, features):
76+
return {**features, "transformed": True}
77+
78+
79+
class RegistryOnlyStep(ProcessorStep):
80+
def __call__(self, transition):
81+
return transition
82+
83+
def transform_features(self, features):
84+
return features
85+
86+
87+
def test_to_tensor_converts_supported_inputs_and_rejects_unknown_types():
88+
assert to_tensor(torch.tensor([1]), dtype=torch.float64).dtype == torch.float64
89+
assert to_tensor([1, 2]).shape == (2,)
90+
assert to_tensor((1, 2), dtype=torch.int64).dtype == torch.int64
91+
assert to_tensor({"a": 1, "b": None, "c": {"d": 2}})["c"]["d"].item() == 2
92+
assert to_tensor({}) == {}
93+
94+
with pytest.raises(TypeError):
95+
to_tensor(object())
96+
97+
98+
def test_transition_converters_cover_success_and_validation_paths():
99+
action = {"joint": [1.0]}
100+
obs = {"camera": "frame"}
101+
transition = robot_action_observation_to_transition((action, obs))
102+
103+
assert transition_to_robot_action(transition) == action
104+
assert transition_to_observation(transition) == obs
105+
assert robot_action_to_transition(action)[TransitionKey.ACTION] == action
106+
assert observation_to_transition(obs)[TransitionKey.OBSERVATION] == obs
107+
108+
tensor_action = torch.ones(2)
109+
policy_transition = policy_action_to_transition(tensor_action)
110+
assert torch.equal(transition_to_policy_action(policy_transition), tensor_action)
111+
assert identity_transition(policy_transition) is policy_transition
112+
113+
with pytest.raises(ValueError):
114+
robot_action_observation_to_transition([action, obs])
115+
with pytest.raises(ValueError):
116+
robot_action_observation_to_transition((torch.ones(1), obs))
117+
with pytest.raises(ValueError):
118+
robot_action_to_transition(torch.ones(1))
119+
with pytest.raises(ValueError):
120+
observation_to_transition("bad")
121+
with pytest.raises(ValueError):
122+
transition_to_robot_action(create_transition(action=torch.ones(1)))
123+
with pytest.raises(ValueError):
124+
transition_to_policy_action(create_transition(action={"bad": 1}))
125+
with pytest.raises(ValueError):
126+
transition_to_observation(create_transition(observation=None))
127+
128+
129+
def test_batch_transition_round_trip_extracts_observation_and_complementary_data():
130+
batch = {
131+
OBS_STATE: torch.ones(3),
132+
ACTION: torch.zeros(2),
133+
REWARD: torch.tensor(1.0),
134+
DONE: torch.tensor(False),
135+
TRUNCATED: torch.tensor(True),
136+
"task": "pick",
137+
"index": torch.tensor(5),
138+
"padding_is_pad": torch.tensor(False),
139+
"info": {"episode": 1},
140+
}
141+
142+
transition = batch_to_transition(batch)
143+
assert transition[TransitionKey.OBSERVATION][OBS_STATE] is batch[OBS_STATE]
144+
assert transition[TransitionKey.COMPLEMENTARY_DATA]["task"] == "pick"
145+
146+
restored = transition_to_batch(transition)
147+
assert restored[OBS_STATE] is batch[OBS_STATE]
148+
assert restored["task"] == "pick"
149+
150+
with pytest.raises(ValueError):
151+
batch_to_transition("bad")
152+
with pytest.raises(ValueError):
153+
batch_to_transition({ACTION: {"not": "policy-action"}})
154+
with pytest.raises(ValueError):
155+
transition_to_batch("bad")
156+
157+
158+
def test_processor_registry_register_get_unregister_and_duplicate_errors():
159+
registry_name = "unit_test_identity_step"
160+
ProcessorStepRegistry.unregister(registry_name)
161+
162+
decorated = ProcessorStepRegistry.register(registry_name)(RegistryOnlyStep)
163+
assert decorated is RegistryOnlyStep
164+
assert ProcessorStepRegistry.get(registry_name) is RegistryOnlyStep
165+
assert registry_name in ProcessorStepRegistry.list()
166+
167+
with pytest.raises(ValueError, match="already registered"):
168+
ProcessorStepRegistry.register(registry_name)(RegistryOnlyStep)
169+
170+
ProcessorStepRegistry.unregister(registry_name)
171+
with pytest.raises(KeyError):
172+
ProcessorStepRegistry.get(registry_name)
173+
174+
175+
def test_data_processor_pipeline_hooks_slicing_processing_and_reset():
176+
step = AppendInfoStep("ok")
177+
calls = []
178+
pipeline = DataProcessorPipeline(
179+
steps=[step, IdentityProcessorStep()],
180+
name="demo pipeline",
181+
)
182+
pipeline.register_before_step_hook(lambda idx, transition: calls.append(("before", idx)))
183+
pipeline.register_after_step_hook(lambda idx, transition: calls.append(("after", idx)))
184+
185+
result = pipeline({"info": {"start": True}})
186+
187+
assert result["info"]["marker"] == "ok"
188+
assert calls == [("before", 0), ("after", 0), ("before", 1), ("after", 1)]
189+
assert len(pipeline) == 2
190+
assert isinstance(pipeline[0], AppendInfoStep)
191+
assert isinstance(pipeline[:1], DataProcessorPipeline)
192+
assert "steps=2" in repr(pipeline)
193+
assert (
194+
list(pipeline.step_through({"info": {"start": True}}))[-1][TransitionKey.INFO]["marker"]
195+
== "ok"
196+
)
197+
assert pipeline.process_info({"x": 1})["marker"] == "ok"
198+
assert pipeline.transform_features({})["transformed"] is True
199+
200+
pipeline.reset()
201+
assert step.reset_called is True
202+
203+
with pytest.raises(TypeError):
204+
DataProcessorPipeline(steps=[object()])
205+
with pytest.raises(ValueError):
206+
pipeline.unregister_before_step_hook(lambda idx, transition: None)
207+
with pytest.raises(ValueError):
208+
pipeline.unregister_after_step_hook(lambda idx, transition: None)
209+
210+
211+
def test_pipeline_save_load_config_validation_and_migration_errors(tmp_path):
212+
pipeline = DataProcessorPipeline(
213+
steps=[IdentityProcessorStep()],
214+
name="Policy Preprocessor",
215+
)
216+
pipeline.save_pretrained(tmp_path, config_filename="processor.json")
217+
218+
config_path = tmp_path / "processor.json"
219+
config = json.loads(config_path.read_text(encoding="utf-8"))
220+
assert config["name"] == "Policy Preprocessor"
221+
assert config["steps"][0]["class"].endswith("IdentityProcessorStep")
222+
223+
loaded_from_dir = DataProcessorPipeline.from_pretrained(tmp_path, "processor.json")
224+
loaded_from_file = DataProcessorPipeline.from_pretrained(config_path, "ignored.json")
225+
assert isinstance(loaded_from_dir.steps[0], IdentityProcessorStep)
226+
assert isinstance(loaded_from_file.steps[0], IdentityProcessorStep)
227+
228+
assert DataProcessorPipeline._is_processor_config({"steps": []}) is True
229+
assert DataProcessorPipeline._is_processor_config({"steps": [{}]}) is False
230+
assert DataProcessorPipeline._is_processor_config({"steps": "bad"}) is False
231+
232+
with pytest.raises(KeyError, match="Override keys"):
233+
DataProcessorPipeline.from_pretrained(
234+
tmp_path, "processor.json", overrides={"missing_step": {}}
235+
)
236+
237+
invalid_dir = tmp_path / "invalid"
238+
invalid_dir.mkdir()
239+
(invalid_dir / "config.json").write_text(json.dumps({"model_type": "old"}), encoding="utf-8")
240+
assert DataProcessorPipeline._should_suggest_migration(invalid_dir) is True
241+
with pytest.raises(ProcessorMigrationError):
242+
DataProcessorPipeline.from_pretrained(invalid_dir, "processor.json")
243+
244+
245+
def test_default_factory_processors_are_identity_pipelines():
246+
teleop = make_default_teleop_action_processor()
247+
robot_action = make_default_robot_action_processor()
248+
robot_obs = make_default_robot_observation_processor()
249+
all_processors = make_default_processors()
250+
251+
action = {"joint": 1}
252+
observation = {"camera": "frame"}
253+
assert teleop((action, observation)) == action
254+
assert robot_action((action, observation)) == action
255+
assert robot_obs(observation) == observation
256+
assert len(all_processors) == 3
257+
assert all(len(processor.steps) == 1 for processor in all_processors)
258+
assert all(
259+
isinstance(processor.steps[0], IdentityProcessorStep) for processor in all_processors
260+
)
261+
262+
263+
def test_batch_dimension_processor_steps_add_expected_leading_dimensions():
264+
action = torch.ones(3)
265+
obs = {
266+
OBS_STATE: torch.ones(4),
267+
OBS_IMAGE: torch.ones(3, 8, 8),
268+
f"{OBS_IMAGES}.cam": torch.ones(3, 4, 4),
269+
"already_batched": torch.ones(2, 3),
270+
}
271+
comp = {"task": "pick", "index": torch.tensor(1), "task_index": torch.tensor(2)}
272+
273+
assert AddBatchDimensionActionStep().action(action).shape == (1, 3)
274+
processed_obs = AddBatchDimensionObservationStep().observation(obs)
275+
assert processed_obs[OBS_STATE].shape == (1, 4)
276+
assert processed_obs[OBS_IMAGE].shape == (1, 3, 8, 8)
277+
assert processed_obs[f"{OBS_IMAGES}.cam"].shape == (1, 3, 4, 4)
278+
processed_comp = AddBatchDimensionComplementaryDataStep().complementary_data(comp)
279+
assert processed_comp["task"] == ["pick"]
280+
assert processed_comp["index"].shape == (1,)
281+
282+
transition = create_transition(
283+
observation={OBS_STATE: torch.ones(2)},
284+
action=torch.ones(2),
285+
complementary_data={"task": "place"},
286+
)
287+
processed = AddBatchDimensionProcessorStep()(transition)
288+
assert processed[TransitionKey.ACTION].shape == (1, 2)
289+
assert processed[TransitionKey.OBSERVATION][OBS_STATE].shape == (1, 2)
290+
assert processed[TransitionKey.COMPLEMENTARY_DATA]["task"] == ["place"]
291+
292+
293+
def test_device_processor_cpu_dtype_config_and_validation(monkeypatch):
294+
step = DeviceProcessorStep(device="cpu", float_dtype="float64")
295+
transition = create_transition(
296+
observation={OBS_STATE: torch.ones(2, dtype=torch.float32), "text": "keep"},
297+
action=torch.ones(2, dtype=torch.float32),
298+
reward=torch.tensor(1.0),
299+
done=torch.tensor(False),
300+
complementary_data={"index": torch.tensor(1)},
301+
)
302+
303+
processed = step(transition)
304+
assert processed[TransitionKey.ACTION].dtype == torch.float64
305+
assert processed[TransitionKey.OBSERVATION][OBS_STATE].dtype == torch.float64
306+
assert processed[TransitionKey.OBSERVATION]["text"] == "keep"
307+
assert step.get_config() == {"device": "cpu", "float_dtype": "float64"}
308+
assert get_safe_torch_device("cpu").type == "cpu"
309+
310+
with pytest.raises(ValueError, match="Invalid float_dtype"):
311+
DeviceProcessorStep(device="cpu", float_dtype="bad")
312+
with pytest.raises(ValueError, match="PolicyAction"):
313+
step(create_transition(action={"robot": 1}))
314+
315+
316+
def test_rename_observations_processor_and_stats_do_not_mutate_inputs():
317+
step = RenameObservationsProcessorStep(rename_map={"old": "new"})
318+
original_obs = {"old": torch.tensor(1), "keep": torch.tensor(2)}
319+
320+
processed = step.observation(original_obs)
321+
assert set(processed) == {"new", "keep"}
322+
assert step.get_config() == {"rename_map": {"old": "new"}}
323+
324+
stats = {"old": {"mean": [1]}, "keep": None}
325+
renamed = rename_stats(stats, {"old": "new"})
326+
renamed["new"]["mean"].append(2)
327+
assert stats["old"]["mean"] == [1]
328+
assert renamed["keep"] == {}
329+
assert rename_stats({}, {"old": "new"}) == {}

0 commit comments

Comments
 (0)