2025-08-31 20:38:52 +02:00
|
|
|
import math
|
2025-08-11 11:07:55 +02:00
|
|
|
import time
|
|
|
|
|
from dataclasses import dataclass
|
2025-09-04 16:28:49 +02:00
|
|
|
from typing import Any, Protocol, TypeVar, runtime_checkable
|
2025-08-11 11:07:55 +02:00
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
import torch
|
|
|
|
|
import torchvision.transforms.functional as F # noqa: N812
|
|
|
|
|
|
|
|
|
|
from lerobot.configs.types import PolicyFeature
|
2025-08-31 22:53:13 +02:00
|
|
|
from lerobot.constants import ACTION
|
2025-09-02 18:26:59 +02:00
|
|
|
from lerobot.teleoperators.teleoperator import Teleoperator
|
|
|
|
|
from lerobot.teleoperators.utils import TeleopEvents
|
|
|
|
|
|
|
|
|
|
from .core import EnvTransition, TransitionKey
|
|
|
|
|
from .pipeline import (
|
2025-09-03 17:30:47 +02:00
|
|
|
ComplementaryDataProcessorStep,
|
|
|
|
|
InfoProcessorStep,
|
|
|
|
|
ObservationProcessorStep,
|
2025-08-31 20:38:52 +02:00
|
|
|
ProcessorStep,
|
2025-08-11 11:07:55 +02:00
|
|
|
ProcessorStepRegistry,
|
2025-09-03 17:30:47 +02:00
|
|
|
TruncatedProcessorStep,
|
2025-08-11 11:07:55 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
GRIPPER_KEY = "gripper"
|
2025-08-31 22:53:13 +02:00
|
|
|
DISCRETE_PENALTY_KEY = "discrete_penalty"
|
|
|
|
|
TELEOP_ACTION_KEY = "teleop_action"
|
2025-08-11 11:07:55 +02:00
|
|
|
|
|
|
|
|
|
2025-09-04 16:28:49 +02:00
|
|
|
@runtime_checkable
|
|
|
|
|
class HasTeleopEvents(Protocol):
|
|
|
|
|
"""Minimal protocol for objects that provide teleoperation events.
|
|
|
|
|
|
|
|
|
|
This protocol only defines the additional get_teleop_events() method,
|
|
|
|
|
avoiding duplication of the entire Teleoperator interface.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def get_teleop_events(self) -> dict[str, Any]:
|
|
|
|
|
"""Get extra control events from the teleoperator.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dictionary containing control events such as:
|
|
|
|
|
- is_intervention: bool - Whether human is currently intervening
|
|
|
|
|
- terminate_episode: bool - Whether to terminate the current episode
|
|
|
|
|
- success: bool - Whether the episode was successful
|
|
|
|
|
- rerecord_episode: bool - Whether to rerecord the episode
|
|
|
|
|
"""
|
|
|
|
|
...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Type variable constrained to Teleoperator subclasses that also implement events
|
|
|
|
|
TeleopWithEvents = TypeVar("TeleopWithEvents", bound=Teleoperator)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _check_teleop_with_events(teleop: Teleoperator) -> None:
|
|
|
|
|
"""Runtime check that a teleoperator implements get_teleop_events."""
|
|
|
|
|
if not isinstance(teleop, HasTeleopEvents):
|
|
|
|
|
raise TypeError(
|
|
|
|
|
f"Teleoperator {type(teleop).__name__} must implement get_teleop_events() method. "
|
|
|
|
|
f"Compatible teleoperators: GamepadTeleop, KeyboardEndEffectorTeleop"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-08-11 11:07:55 +02:00
|
|
|
@ProcessorStepRegistry.register("add_teleop_action_as_complementary_data")
|
|
|
|
|
@dataclass
|
2025-09-03 18:12:11 +02:00
|
|
|
class AddTeleopActionAsComplimentaryDataStep(ComplementaryDataProcessorStep):
|
2025-08-11 11:07:55 +02:00
|
|
|
"""Add teleoperator action to transition complementary data."""
|
|
|
|
|
|
|
|
|
|
teleop_device: Teleoperator
|
|
|
|
|
|
2025-08-31 20:38:52 +02:00
|
|
|
def complementary_data(self, complementary_data: dict) -> dict:
|
|
|
|
|
new_complementary_data = dict(complementary_data)
|
2025-08-31 22:53:13 +02:00
|
|
|
new_complementary_data[TELEOP_ACTION_KEY] = self.teleop_device.get_action()
|
2025-08-31 20:38:52 +02:00
|
|
|
return new_complementary_data
|
2025-08-11 11:07:55 +02:00
|
|
|
|
2025-09-02 17:15:01 +02:00
|
|
|
def transform_features(self, features: dict[str, PolicyFeature]) -> dict[str, PolicyFeature]:
|
|
|
|
|
return features
|
|
|
|
|
|
2025-08-11 11:07:55 +02:00
|
|
|
|
|
|
|
|
@ProcessorStepRegistry.register("add_teleop_action_as_info")
|
|
|
|
|
@dataclass
|
2025-09-03 18:12:11 +02:00
|
|
|
class AddTeleopEventsAsInfoStep(InfoProcessorStep):
|
2025-09-04 16:28:49 +02:00
|
|
|
"""Add teleoperator control events to transition info.
|
2025-08-11 11:07:55 +02:00
|
|
|
|
2025-09-04 16:28:49 +02:00
|
|
|
This processor step extracts control events from teleoperators that support
|
|
|
|
|
event-based interaction (intervention detection, episode termination, etc.).
|
|
|
|
|
|
|
|
|
|
Works with any teleoperator that inherits from Teleoperator and implements the
|
|
|
|
|
get_teleop_events() method, including custom user-defined teleoperators.
|
|
|
|
|
|
|
|
|
|
Built-in compatible teleoperators:
|
|
|
|
|
- GamepadTeleop: Uses gamepad buttons for control events
|
|
|
|
|
- KeyboardEndEffectorTeleop: Uses keyboard keys for control events
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
teleop_device: TeleopWithEvents
|
|
|
|
|
|
|
|
|
|
def __post_init__(self):
|
|
|
|
|
"""Validate that the teleoperator supports events."""
|
|
|
|
|
_check_teleop_with_events(self.teleop_device)
|
2025-08-11 11:07:55 +02:00
|
|
|
|
2025-08-31 20:38:52 +02:00
|
|
|
def info(self, info: dict) -> dict:
|
|
|
|
|
new_info = dict(info)
|
2025-09-04 16:28:49 +02:00
|
|
|
|
|
|
|
|
teleop_events = self.teleop_device.get_teleop_events()
|
2025-08-31 20:38:52 +02:00
|
|
|
new_info.update(teleop_events)
|
|
|
|
|
return new_info
|
2025-08-11 11:07:55 +02:00
|
|
|
|
2025-09-02 17:15:01 +02:00
|
|
|
def transform_features(self, features: dict[str, PolicyFeature]) -> dict[str, PolicyFeature]:
|
|
|
|
|
return features
|
|
|
|
|
|
2025-08-11 11:07:55 +02:00
|
|
|
|
|
|
|
|
@ProcessorStepRegistry.register("image_crop_resize_processor")
|
|
|
|
|
@dataclass
|
2025-09-03 18:12:11 +02:00
|
|
|
class ImageCropResizeProcessorStep(ObservationProcessorStep):
|
2025-08-11 11:07:55 +02:00
|
|
|
"""Crop and resize image observations."""
|
|
|
|
|
|
|
|
|
|
crop_params_dict: dict[str, tuple[int, int, int, int]] | None = None
|
|
|
|
|
resize_size: tuple[int, int] | None = None
|
|
|
|
|
|
2025-08-31 20:38:52 +02:00
|
|
|
def observation(self, observation: dict) -> dict:
|
2025-08-11 11:07:55 +02:00
|
|
|
if self.resize_size is None and not self.crop_params_dict:
|
|
|
|
|
return observation
|
|
|
|
|
|
|
|
|
|
new_observation = dict(observation)
|
|
|
|
|
|
|
|
|
|
# Process all image keys in the observation
|
|
|
|
|
for key in observation:
|
|
|
|
|
if "image" not in key:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
image = observation[key]
|
|
|
|
|
device = image.device
|
|
|
|
|
# NOTE (maractingi): No mps kernel for crop and resize, so we need to move to cpu
|
|
|
|
|
if device.type == "mps":
|
|
|
|
|
image = image.cpu()
|
|
|
|
|
# Crop if crop params are provided for this key
|
|
|
|
|
if self.crop_params_dict is not None and key in self.crop_params_dict:
|
|
|
|
|
crop_params = self.crop_params_dict[key]
|
|
|
|
|
image = F.crop(image, *crop_params)
|
|
|
|
|
if self.resize_size is not None:
|
|
|
|
|
image = F.resize(image, self.resize_size)
|
|
|
|
|
image = image.clamp(0.0, 1.0)
|
|
|
|
|
new_observation[key] = image.to(device)
|
|
|
|
|
|
|
|
|
|
return new_observation
|
|
|
|
|
|
|
|
|
|
def get_config(self) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"crop_params_dict": self.crop_params_dict,
|
|
|
|
|
"resize_size": self.resize_size,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def transform_features(self, features: dict[str, PolicyFeature]) -> dict[str, PolicyFeature]:
|
|
|
|
|
if self.resize_size is None:
|
|
|
|
|
return features
|
|
|
|
|
for key in features:
|
|
|
|
|
if "image" in key:
|
|
|
|
|
features[key] = PolicyFeature(type=features[key].type, shape=self.resize_size)
|
|
|
|
|
return features
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
@ProcessorStepRegistry.register("time_limit_processor")
|
2025-09-03 18:12:11 +02:00
|
|
|
class TimeLimitProcessorStep(TruncatedProcessorStep):
|
2025-08-11 11:07:55 +02:00
|
|
|
"""Track episode steps and enforce time limits."""
|
|
|
|
|
|
|
|
|
|
max_episode_steps: int
|
|
|
|
|
current_step: int = 0
|
|
|
|
|
|
2025-08-31 20:38:52 +02:00
|
|
|
def truncated(self, truncated):
|
2025-08-11 11:07:55 +02:00
|
|
|
self.current_step += 1
|
|
|
|
|
if self.current_step >= self.max_episode_steps:
|
|
|
|
|
truncated = True
|
2025-08-31 20:38:52 +02:00
|
|
|
# TODO (steven): missing an else truncated = False?
|
|
|
|
|
return truncated
|
2025-08-11 11:07:55 +02:00
|
|
|
|
|
|
|
|
def get_config(self) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"max_episode_steps": self.max_episode_steps,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def reset(self) -> None:
|
|
|
|
|
self.current_step = 0
|
|
|
|
|
|
2025-09-02 17:15:01 +02:00
|
|
|
def transform_features(self, features: dict[str, PolicyFeature]) -> dict[str, PolicyFeature]:
|
|
|
|
|
return features
|
|
|
|
|
|
2025-08-11 11:07:55 +02:00
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
@ProcessorStepRegistry.register("gripper_penalty_processor")
|
2025-09-03 18:12:11 +02:00
|
|
|
class GripperPenaltyProcessorStep(ComplementaryDataProcessorStep):
|
2025-08-11 11:07:55 +02:00
|
|
|
"""Apply penalty for inappropriate gripper usage."""
|
|
|
|
|
|
|
|
|
|
penalty: float = -0.01
|
|
|
|
|
max_gripper_pos: float = 30.0
|
|
|
|
|
|
2025-08-31 20:38:52 +02:00
|
|
|
def complementary_data(self, complementary_data):
|
2025-08-11 11:07:55 +02:00
|
|
|
"""Calculate gripper penalty and add to complementary data."""
|
2025-08-31 20:38:52 +02:00
|
|
|
action = self.transition.get(TransitionKey.ACTION)
|
2025-08-11 11:07:55 +02:00
|
|
|
|
|
|
|
|
current_gripper_pos = complementary_data.get("raw_joint_positions", None).get(GRIPPER_KEY, None)
|
|
|
|
|
if current_gripper_pos is None:
|
2025-08-31 20:38:52 +02:00
|
|
|
return complementary_data
|
2025-08-11 11:07:55 +02:00
|
|
|
|
2025-08-31 22:53:13 +02:00
|
|
|
gripper_action = action[f"{ACTION}.{GRIPPER_KEY}.pos"]
|
2025-08-11 11:07:55 +02:00
|
|
|
gripper_action_normalized = gripper_action / self.max_gripper_pos
|
|
|
|
|
|
|
|
|
|
# Normalize gripper state and action
|
|
|
|
|
gripper_state_normalized = current_gripper_pos / self.max_gripper_pos
|
|
|
|
|
|
|
|
|
|
# Calculate penalty boolean as in original
|
|
|
|
|
gripper_penalty_bool = (gripper_state_normalized < 0.5 and gripper_action_normalized > 0.5) or (
|
|
|
|
|
gripper_state_normalized > 0.75 and gripper_action_normalized < 0.5
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
gripper_penalty = self.penalty * int(gripper_penalty_bool)
|
|
|
|
|
|
|
|
|
|
# Create new complementary data with penalty info
|
|
|
|
|
new_complementary_data = dict(complementary_data)
|
2025-08-31 22:53:13 +02:00
|
|
|
new_complementary_data[DISCRETE_PENALTY_KEY] = gripper_penalty
|
2025-08-11 11:07:55 +02:00
|
|
|
|
2025-08-31 20:38:52 +02:00
|
|
|
return new_complementary_data
|
2025-08-11 11:07:55 +02:00
|
|
|
|
|
|
|
|
def get_config(self) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"penalty": self.penalty,
|
|
|
|
|
"max_gripper_pos": self.max_gripper_pos,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def reset(self) -> None:
|
|
|
|
|
"""Reset the processor state."""
|
|
|
|
|
self.last_gripper_state = None
|
|
|
|
|
|
2025-09-02 17:15:01 +02:00
|
|
|
def transform_features(self, features: dict[str, PolicyFeature]) -> dict[str, PolicyFeature]:
|
|
|
|
|
return features
|
|
|
|
|
|
2025-08-11 11:07:55 +02:00
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
@ProcessorStepRegistry.register("intervention_action_processor")
|
2025-09-03 18:12:11 +02:00
|
|
|
class InterventionActionProcessorStep(ProcessorStep):
|
2025-08-11 11:07:55 +02:00
|
|
|
"""Handle human intervention actions and episode termination."""
|
|
|
|
|
|
|
|
|
|
use_gripper: bool = False
|
|
|
|
|
terminate_on_success: bool = True
|
|
|
|
|
|
|
|
|
|
def __call__(self, transition: EnvTransition) -> EnvTransition:
|
|
|
|
|
action = transition.get(TransitionKey.ACTION)
|
|
|
|
|
if action is None:
|
|
|
|
|
return transition
|
|
|
|
|
|
|
|
|
|
# Get intervention signals from complementary data
|
|
|
|
|
info = transition.get(TransitionKey.INFO, {})
|
2025-08-31 20:38:52 +02:00
|
|
|
complementary_data = transition.get(TransitionKey.COMPLEMENTARY_DATA, {})
|
2025-08-31 22:53:13 +02:00
|
|
|
teleop_action = complementary_data.get(TELEOP_ACTION_KEY, {})
|
2025-08-11 11:07:55 +02:00
|
|
|
is_intervention = info.get(TeleopEvents.IS_INTERVENTION, False)
|
|
|
|
|
terminate_episode = info.get(TeleopEvents.TERMINATE_EPISODE, False)
|
|
|
|
|
success = info.get(TeleopEvents.SUCCESS, False)
|
|
|
|
|
rerecord_episode = info.get(TeleopEvents.RERECORD_EPISODE, False)
|
|
|
|
|
|
|
|
|
|
new_transition = transition.copy()
|
|
|
|
|
|
|
|
|
|
# Override action if intervention is active
|
|
|
|
|
if is_intervention and teleop_action is not None:
|
|
|
|
|
if isinstance(teleop_action, dict):
|
|
|
|
|
# Convert teleop_action dict to tensor format
|
|
|
|
|
action_list = [
|
2025-08-31 22:53:13 +02:00
|
|
|
teleop_action.get(f"{ACTION}.delta_x", 0.0),
|
|
|
|
|
teleop_action.get(f"{ACTION}.delta_y", 0.0),
|
|
|
|
|
teleop_action.get(f"{ACTION}.delta_z", 0.0),
|
2025-08-11 11:07:55 +02:00
|
|
|
]
|
|
|
|
|
if self.use_gripper:
|
2025-08-31 22:53:13 +02:00
|
|
|
action_list.append(teleop_action.get(GRIPPER_KEY, 1.0))
|
2025-08-11 11:07:55 +02:00
|
|
|
elif isinstance(teleop_action, np.ndarray):
|
|
|
|
|
action_list = teleop_action.tolist()
|
|
|
|
|
else:
|
|
|
|
|
action_list = teleop_action
|
|
|
|
|
|
|
|
|
|
teleop_action_tensor = torch.tensor(action_list, dtype=action.dtype, device=action.device)
|
|
|
|
|
new_transition[TransitionKey.ACTION] = teleop_action_tensor
|
|
|
|
|
|
|
|
|
|
# Handle episode termination
|
|
|
|
|
new_transition[TransitionKey.DONE] = bool(terminate_episode) or (
|
|
|
|
|
self.terminate_on_success and success
|
|
|
|
|
)
|
|
|
|
|
new_transition[TransitionKey.REWARD] = float(success)
|
|
|
|
|
|
|
|
|
|
# Update info with intervention metadata
|
|
|
|
|
info = new_transition.get(TransitionKey.INFO, {})
|
|
|
|
|
info[TeleopEvents.IS_INTERVENTION] = is_intervention
|
|
|
|
|
info[TeleopEvents.RERECORD_EPISODE] = rerecord_episode
|
|
|
|
|
info[TeleopEvents.SUCCESS] = success
|
|
|
|
|
new_transition[TransitionKey.INFO] = info
|
|
|
|
|
|
|
|
|
|
# Update complementary data with teleop action
|
|
|
|
|
complementary_data = new_transition.get(TransitionKey.COMPLEMENTARY_DATA, {})
|
2025-08-31 22:53:13 +02:00
|
|
|
complementary_data[TELEOP_ACTION_KEY] = new_transition.get(TransitionKey.ACTION)
|
2025-08-11 11:07:55 +02:00
|
|
|
new_transition[TransitionKey.COMPLEMENTARY_DATA] = complementary_data
|
|
|
|
|
|
|
|
|
|
return new_transition
|
|
|
|
|
|
|
|
|
|
def get_config(self) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"use_gripper": self.use_gripper,
|
2025-08-31 20:38:52 +02:00
|
|
|
"terminate_on_success": self.terminate_on_success,
|
2025-08-11 11:07:55 +02:00
|
|
|
}
|
|
|
|
|
|
2025-09-02 17:15:01 +02:00
|
|
|
def transform_features(self, features: dict[str, PolicyFeature]) -> dict[str, PolicyFeature]:
|
|
|
|
|
return features
|
|
|
|
|
|
2025-08-11 11:07:55 +02:00
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
@ProcessorStepRegistry.register("reward_classifier_processor")
|
2025-09-03 18:12:11 +02:00
|
|
|
class RewardClassifierProcessorStep(ProcessorStep):
|
2025-08-11 11:07:55 +02:00
|
|
|
"""Apply reward classification to image observations."""
|
|
|
|
|
|
|
|
|
|
pretrained_path: str | None = None
|
|
|
|
|
device: str = "cpu"
|
|
|
|
|
success_threshold: float = 0.5
|
|
|
|
|
success_reward: float = 1.0
|
|
|
|
|
terminate_on_success: bool = True
|
|
|
|
|
|
|
|
|
|
reward_classifier: Any = None
|
|
|
|
|
|
|
|
|
|
def __post_init__(self):
|
|
|
|
|
"""Initialize the reward classifier after dataclass initialization."""
|
|
|
|
|
if self.pretrained_path is not None:
|
|
|
|
|
from lerobot.policies.sac.reward_model.modeling_classifier import Classifier
|
|
|
|
|
|
|
|
|
|
self.reward_classifier = Classifier.from_pretrained(self.pretrained_path)
|
|
|
|
|
self.reward_classifier.to(self.device)
|
|
|
|
|
self.reward_classifier.eval()
|
|
|
|
|
|
|
|
|
|
def __call__(self, transition: EnvTransition) -> EnvTransition:
|
2025-09-02 17:57:49 +02:00
|
|
|
new_transition = transition.copy()
|
|
|
|
|
observation = new_transition.get(TransitionKey.OBSERVATION)
|
2025-08-11 11:07:55 +02:00
|
|
|
if observation is None or self.reward_classifier is None:
|
2025-09-02 17:57:49 +02:00
|
|
|
return new_transition
|
2025-08-11 11:07:55 +02:00
|
|
|
|
|
|
|
|
# Extract images from observation
|
|
|
|
|
images = {key: value for key, value in observation.items() if "image" in key}
|
|
|
|
|
|
|
|
|
|
if not images:
|
2025-09-02 17:57:49 +02:00
|
|
|
return new_transition
|
2025-08-11 11:07:55 +02:00
|
|
|
|
|
|
|
|
# Run reward classifier
|
|
|
|
|
start_time = time.perf_counter()
|
|
|
|
|
with torch.inference_mode():
|
|
|
|
|
success = self.reward_classifier.predict_reward(images, threshold=self.success_threshold)
|
|
|
|
|
|
|
|
|
|
classifier_frequency = 1 / (time.perf_counter() - start_time)
|
|
|
|
|
|
|
|
|
|
# Calculate reward and termination
|
2025-09-02 17:57:49 +02:00
|
|
|
reward = new_transition.get(TransitionKey.REWARD, 0.0)
|
|
|
|
|
terminated = new_transition.get(TransitionKey.DONE, False)
|
2025-08-11 11:07:55 +02:00
|
|
|
|
2025-08-31 20:38:52 +02:00
|
|
|
if math.isclose(success, 1, abs_tol=1e-2):
|
2025-08-11 11:07:55 +02:00
|
|
|
reward = self.success_reward
|
|
|
|
|
if self.terminate_on_success:
|
|
|
|
|
terminated = True
|
|
|
|
|
|
|
|
|
|
# Update transition
|
|
|
|
|
new_transition[TransitionKey.REWARD] = reward
|
|
|
|
|
new_transition[TransitionKey.DONE] = terminated
|
|
|
|
|
|
|
|
|
|
# Update info with classifier frequency
|
|
|
|
|
info = new_transition.get(TransitionKey.INFO, {})
|
|
|
|
|
info["reward_classifier_frequency"] = classifier_frequency
|
|
|
|
|
new_transition[TransitionKey.INFO] = info
|
|
|
|
|
|
|
|
|
|
return new_transition
|
|
|
|
|
|
|
|
|
|
def get_config(self) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"device": self.device,
|
|
|
|
|
"success_threshold": self.success_threshold,
|
|
|
|
|
"success_reward": self.success_reward,
|
|
|
|
|
"terminate_on_success": self.terminate_on_success,
|
|
|
|
|
}
|
2025-09-02 17:15:01 +02:00
|
|
|
|
|
|
|
|
def transform_features(self, features: dict[str, PolicyFeature]) -> dict[str, PolicyFeature]:
|
|
|
|
|
return features
|