From 5e2d8603ca1cbe3204fdf2d77ee7c52765d7bf5b Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Mon, 22 Jan 2024 13:46:17 -0800 Subject: [PATCH 01/24] bagel example not working yet, still need to handle input states properly --- omnigibson/object_states/factory.py | 11 + omnigibson/object_states/on_top.py | 2 +- omnigibson/systems/system_base.py | 4 +- omnigibson/transition_rules.py | 722 +++++++++++++++++++++------- omnigibson/utils/bddl_utils.py | 2 - tests/test_transition_rules.py | 39 ++ tests/utils.py | 3 + 7 files changed, 604 insertions(+), 179 deletions(-) diff --git a/omnigibson/object_states/factory.py b/omnigibson/object_states/factory.py index 731ee14b7..f4b840c6c 100644 --- a/omnigibson/object_states/factory.py +++ b/omnigibson/object_states/factory.py @@ -67,6 +67,15 @@ ] ) +_SYSTEM_STATE_SET = frozenset( + [ + Covered, + Saturated, + Filled, + Contains, + ] +) + _VISUAL_STATE_SET = frozenset(_FIRE_STATE_SET | _STEAM_STATE_SET | _TEXTURE_CHANGE_STATE_SET) _TEXTURE_CHANGE_PRIORITY = { @@ -77,6 +86,8 @@ ToggledOn: 0, } +def get_system_states(): + return _SYSTEM_STATE_SET def get_fire_states(): return _FIRE_STATE_SET diff --git a/omnigibson/object_states/on_top.py b/omnigibson/object_states/on_top.py index 2ed8e3041..8d48fc9e7 100644 --- a/omnigibson/object_states/on_top.py +++ b/omnigibson/object_states/on_top.py @@ -36,4 +36,4 @@ def _get_value(self, other): adjacency = self.obj.states[VerticalAdjacency].get_value() other_adjacency = other.states[VerticalAdjacency].get_value() - return other in adjacency.negative_neighbors and other not in adjacency.positive_neighbors and self.obj not in other_adjacency.negative_neighbors + return other in adjacency.negative_neighbors and other not in adjacency.positive_neighbors diff --git a/omnigibson/systems/system_base.py b/omnigibson/systems/system_base.py index 149669e75..e73930d2a 100644 --- a/omnigibson/systems/system_base.py +++ b/omnigibson/systems/system_base.py @@ -1213,7 +1213,9 @@ def import_og_systems(): def is_system_active(system_name): - assert system_name in REGISTERED_SYSTEMS, f"System {system_name} not in REGISTERED_SYSTEMS." + if system_name not in REGISTERED_SYSTEMS: + return False + # assert system_name in REGISTERED_SYSTEMS, f"System {system_name} not in REGISTERED_SYSTEMS." system = REGISTERED_SYSTEMS[system_name] return system.initialized diff --git a/omnigibson/transition_rules.py b/omnigibson/transition_rules.py index ac65b730b..ab73d68c2 100644 --- a/omnigibson/transition_rules.py +++ b/omnigibson/transition_rules.py @@ -6,11 +6,15 @@ from copy import copy import itertools import os +from collections import defaultdict + import omnigibson as og from omnigibson.macros import gm, create_module_macros from omnigibson.systems import get_system, is_system_active, PhysicalParticleSystem, VisualParticleSystem, REGISTERED_SYSTEMS from omnigibson.objects.dataset_object import DatasetObject from omnigibson.object_states import * +from omnigibson.object_states.factory import get_system_states +from omnigibson.object_states.object_state_base import AbsoluteObjectState, RelativeObjectState from omnigibson.utils.asset_utils import get_all_object_category_models from omnigibson.utils.constants import PrimType from omnigibson.utils.python_utils import Registerable, classproperty, subclass_factory @@ -18,10 +22,15 @@ import omnigibson.utils.transform_utils as T from omnigibson.utils.ui_utils import disclaimer, create_module_logger from omnigibson.utils.usd_utils import RigidContactAPI +import bddl +from bddl.object_taxonomy import ObjectTaxonomy +from omnigibson.utils.bddl_utils import is_substance_synset, get_system_name_by_synset, SUPPORTED_PREDICATES # Create module logger log = create_module_logger(module_name=__name__) +OBJECT_TAXONOMY = ObjectTaxonomy() + # Create settings for this module m = create_module_macros(module_path=__file__) @@ -34,6 +43,8 @@ # Default "trash" system if an invalid mixing rule transition occurs m.DEFAULT_GARBAGE_SYSTEM = "sludge" +m.DEBUG = False + # Tuple of attributes of objects created in transitions. # `states` field is dict mapping object state class to arguments to pass to setter for that class _attrs_fields = ["category", "model", "name", "scale", "obj", "pos", "orn", "bb_pos", "bb_orn", "states", "callback"] @@ -46,10 +57,21 @@ TransitionResults = namedtuple( "TransitionResults", ["add", "remove"], defaults=(None, None)) +# Mapping from transition rule json files to rule classe names +_JSON_FILES_TO_RULES = { + "dicing.json": ["DicingRule"], + "heat_cook.json": ["CookingObjectRule", "CookingSystemRule"], + "melting.json": ["MeltingRule"], + "mixing_stick.json": ["MixingToolRule"], + "single_toggleable_machine.json": ["ToggleableMachineRule"], + "slicing.json": ["SlicingRule"], + "substance_cooking.json": ["CookingPhysicalParticleRule"], + "substance_watercooking.json": ["CookingPhysicalParticleRule"], + # TODO: washer and dryer +} # Global dicts that will contain mappings REGISTERED_RULES = dict() - class TransitionRuleAPI: """ Monolithic class containing methods to check and execute arbitrary discrete state transitions within the simulator @@ -551,7 +573,6 @@ class BaseTransitionRule(Registerable): candidates = None def __init_subclass__(cls, **kwargs): - # Call super first super().__init_subclass__(**kwargs) # Register this system, and @@ -799,7 +820,7 @@ def transition(cls, object_candidates): objs_to_remove = [] for diceable_obj in object_candidates["diceable"]: - system = get_system(f"diced_{diceable_obj.category}") + system = get_system(f"diced__{diceable_obj.category}") system.generate_particles_from_link(diceable_obj, diceable_obj.root_link, check_contact=False, use_visual_meshes=False) # Delete original object from stage. @@ -808,48 +829,6 @@ def transition(cls, object_candidates): return TransitionResults(add=[], remove=objs_to_remove) -class CookingPhysicalParticleRule(BaseTransitionRule): - """ - Transition rule to apply to "cook" physicl particles - """ - @classproperty - def candidate_filters(cls): - # We want to track all possible fillable heatable objects - return {"fillable": AndFilter(filters=(AbilityFilter("fillable"), AbilityFilter("heatable")))} - - @classmethod - def _generate_conditions(cls): - # Only heated objects are valid - return [StateCondition(filter_name="fillable", state=Heated, val=True, op=operator.eq)] - - @classmethod - def transition(cls, object_candidates): - fillable_objs = object_candidates["fillable"] - - # Iterate over all active physical particle systems, and for any non-cooked particles inside, - # convert into cooked particles - for name, system in PhysicalParticleSystem.get_active_systems().items(): - # Skip any systems that are already cooked - if "cooked" in name: - continue - - # Iterate over all fillables -- a given particle should become hot if it is contained in any of the - # fillable objects - in_volume = np.zeros(system.n_particles).astype(bool) - for fillable_obj in fillable_objs: - in_volume |= fillable_obj.states[ContainedParticles].get_value(system).in_volume - - # If any are in volume, convert particles - in_volume_idx = np.where(in_volume)[0] - if len(in_volume_idx) > 0: - cooked_system = get_system(f"cooked_{system.name}") - particle_positions = fillable_obj.states[ContainedParticles].get_value(system).positions - system.remove_particles(idxs=in_volume_idx) - cooked_system.generate_particles(positions=particle_positions[in_volume_idx]) - - return TransitionResults(add=[], remove=[]) - - class MeltingRule(BaseTransitionRule): """ Transition rule to apply to meltable objects to simulate melting @@ -909,10 +888,11 @@ def __init_subclass__(cls, **kwargs): def add_recipe( cls, name, - input_objects=None, - input_systems=None, - output_objects=None, - output_systems=None, + input_synsets, + output_synsets, + input_states=None, + output_states=None, + fillable_categories=None, **kwargs, ): """ @@ -921,31 +901,114 @@ def add_recipe( Args: name (str): Name of the recipe - input_objects (None or dict): Maps object category to number of instances required for the recipe, or None - if no objects required - input_systems (None or list of str): Required system names for the recipe, or None if no systems required - output_objects (None or dict): Maps object category to number of instances to be spawned in the container - when the recipe executes, or None if no objects are to be spawned - output_systems (None or list of str): Output system name(s) that will replace all contained objects - if the recipe is executed, or None if no system is to be spawned - + input_synsets (dict): Maps synsets to number of instances required for the recipe + output_synsets (dict): Maps synsets to number of instances to be spawned in the container when the recipe executes + input_states (dict or None): Maps input synsets to states that must be satisfied for the recipe to execute, + or None if no states are required + otuput_states (dict or None): Map output synsets to states that should be set when spawned when the recipe executes, + or None if no states are required + fillable_categories (None or set of str): If specified, set of fillable categories which are allowed + for this recipe. If None, any fillable is allowed kwargs (dict): Any additional keyword-arguments to be stored as part of this recipe """ - # For now, assert only one of output_objects or output_systems is not None - # TODO: How to handle both? - assert output_objects is None or output_systems is None, \ - "Recipe can only generate output objects or output systems, but not both!" - # Store information for this recipe cls._RECIPES[name] = { "name": name, - "input_objects": dict() if input_objects is None else input_objects, - "input_systems": [] if input_systems is None else input_systems, - "output_objects": dict() if output_objects is None else output_objects, - "output_systems": [] if output_systems is None else output_systems, + + # To be filled in later + "input_objects": dict(), + "input_systems": list(), + "output_objects": dict(), + "output_systems": list(), + "input_states": defaultdict(list), + "output_states": defaultdict(list), + "root_input_objects": + "fillable_categories": None, + **kwargs, } + # Map input/output synsets into input/output objects and systems. + for synsets, obj_key, system_key in zip((input_synsets, output_synsets), ("input_objects", "output_objects"), ("input_systems", "output_systems")): + for synset, count in synsets.items(): + assert OBJECT_TAXONOMY.is_leaf(synset), f"Synset {synset} must be a leaf node in the taxonomy!" + if is_substance_synset(synset): + cls._RECIPES[name][system_key].append(get_system_name_by_synset(synset)) + else: + obj_categories = OBJECT_TAXONOMY.get_categories(synset) + assert len(obj_categories) == 1, f"Object synset {synset} must map to exactly one object category! Now: {obj_categories}." + cls._RECIPES[name][obj_key][obj_categories[0]] = count + + # Assert only one of output_objects or output_systems is not None + assert len(cls._RECIPES[name]["output_objects"]) == 0 or len(cls._RECIPES[name]["output_systems"]) == 0, \ + "Recipe can only generate output objects or output systems, but not both!" + + # Apply post-processing for input/output states if specified + for synsets_to_states, states_key in zip((input_states, output_states), ("input_states", "output_states")): + if synsets_to_states is None: + continue + for synsets, states in synsets_to_states.items(): + # For unary/binary states, synsets is a single synset or a comma-separated pair of synsets, respectively + synset_split = synsets.split(",") + if len(synset_split) == 1: + first_synset = synset_split[0] + second_synset = None + else: + first_synset, second_synset = synset_split + + assert OBJECT_TAXONOMY.is_leaf(first_synset), f"Input/output state synset {first_synset} must be a leaf node in the taxonomy!" + assert not is_substance_synset(first_synset), f"Input/output state synset {first_synset} must be applied to an object, not a substance!" + obj_categories = OBJECT_TAXONOMY.get_categories(first_synset) + assert len(obj_categories) == 1, f"Input/output state synset {first_synset} must map to exactly one object category! Now: {obj_categories}." + first_obj_category = obj_categories[0] + + if second_synset is None: + for state_type, state_value in states: + state_class = SUPPORTED_PREDICATES[state_type].STATE_CLASS + assert issubclass(state_class, AbsoluteObjectState), f"Input/output state type {state_type} must be a unary state!" + cls._RECIPES[name][states_key][first_obj_category].append((state_class, None, state_value)) + else: + assert OBJECT_TAXONOMY.is_leaf(second_synset), f"Input/output state synset {second_synset} must be a leaf node in the taxonomy!" + obj_categories = OBJECT_TAXONOMY.get_categories(second_synset) + if is_substance_synset(second_synset): + second_obj_category = get_system_name_by_synset(second_synset) + is_substance = True + else: + obj_categories = OBJECT_TAXONOMY.get_categories(second_synset) + assert len(obj_categories) == 1, f"Input/output state synset {second_synset} must map to exactly one object category! Now: {obj_categories}." + second_obj_category = obj_categories[0] + is_substance = False + + for state_type, state_value in states: + state_class = SUPPORTED_PREDICATES[state_type].STATE_CLASS + assert issubclass(state_class, RelativeObjectState), f"Input/output state type {state_type} must be a binary state!" + assert is_substance == (state_class in get_system_states()), f"Input/output state type {state_type} system state inconsistency found!" + cls._RECIPES[name][states_key][first_obj_category].append((state_class, second_obj_category, state_value)) + + if fillable_categories is not None: + cls._RECIPES[name]["fillable_categories"] = set() + for synset in fillable_categories: + assert OBJECT_TAXONOMY.is_leaf(synset), f"Synset {synset} must be a leaf node in the taxonomy!" + assert not is_substance_synset(synset), f"Synset {synset} must be applied to an object, not a substance!" + for category in OBJECT_TAXONOMY.get_categories(synset): + cls._RECIPES[name]["fillable_categories"].add(category) + + @classmethod + def _validate_recipe_container_is_valid(cls, recipe, container): + """ + Validates that @container's category satisfies @recipe's fillable_categories + + Args: + recipe (dict): Recipe whose fillable_categories should be checked against @container + container (StatefulObject): Container whose category should match one of @recipe's fillable_categories, + if specified + + Returns: + bool: True if @container is valid, else False + """ + fillable_categories = recipe["fillable_categories"] + return fillable_categories is None or container.category in fillable_categories + @classmethod def _validate_recipe_systems_are_contained(cls, recipe, container): """ @@ -976,17 +1039,16 @@ def _validate_nonrecipe_systems_not_contained(cls, recipe, container): Returns: bool: True if none of the non-relevant systems are contained """ - relevant_systems = set(recipe["input_systems"]) for system in og.sim.scene.system_registry.objects: # Skip cloth system if system.name == "cloth": continue - if system.name not in relevant_systems and container.states[Contains].get_value(system=system): + if system.name not in recipe["input_systems"] and container.states[Contains].get_value(system=system): return False return True @classmethod - def _validate_recipe_objects_are_contained(cls, recipe, in_volume): + def _validate_recipe_objects_are_contained_and_states_satisfied(cls, recipe, in_volume): """ Validates whether @recipe's input_objects are all contained in the container represented by @in_volume @@ -998,6 +1060,11 @@ def _validate_recipe_objects_are_contained(cls, recipe, in_volume): Returns: bool: True if all the input object quantities are contained """ + if m.DEBUG: + print("_validate_recipe_objects_are_contained_and_states_satisfied") + from IPython import embed; embed(); + # TODO: add another field to container_info: objects that are contained AND recipe relevant (category match, states satisfied) + # TODO: add another field to container_info: how many copies of recipe are successful for obj_category, obj_quantity in recipe["input_objects"].items(): if np.sum(in_volume[cls._CATEGORY_IDXS[obj_category]]) < obj_quantity: return False @@ -1053,6 +1120,29 @@ def _validate_recipe_objects_exist(cls, recipe): return False return True + @classmethod + def _validate_recipe_fillables_exist(cls, recipe): + """ + Validates that recipe @recipe's necessary fillable categorie(s) exist in the current scene + + Args: + recipe (dict): Recipe whose fillable categories should be checked + + Returns: + bool: True if there is at least a single valid fillable category in the current scene, else False + """ + fillable_categories = recipe["fillable_categories"] + if fillable_categories is None: + # Any is valid + return True + # Otherwise, at least one valid type must exist + for category in fillable_categories: + if len(og.sim.scene.object_registry("category", category, default_val=set())) > 0: + return True + + # None found, return False + return False + @classmethod def _is_recipe_active(cls, recipe): """ @@ -1072,6 +1162,10 @@ def _is_recipe_active(cls, recipe): if not cls._validate_recipe_objects_exist(recipe=recipe): return False + # Check valid fillable categories + if not cls._validate_recipe_fillables_exist(recipe=recipe): + return False + return True @classmethod @@ -1092,16 +1186,20 @@ def _is_recipe_executable(cls, recipe, container, global_info, container_info): """ in_volume = container_info["in_volume"] + # Verify the container category is valid + if not cls._validate_recipe_container_is_valid(recipe=recipe, container=container): + return False + # Verify all required systems are contained in the container - if not cls._validate_recipe_systems_are_contained(recipe=recipe, container=container): + if not cls.relax_recipe_systems and not cls._validate_recipe_systems_are_contained(recipe=recipe, container=container): return False - # Verify all required object quantities are contained in the container - if not cls._validate_recipe_objects_are_contained(recipe=recipe, in_volume=in_volume): + # Verify all required object quantities are contained in the container and their states are satisfied + if not cls._validate_recipe_objects_are_contained_and_states_satisfied(recipe=recipe, in_volume=in_volume): return False # Verify no non-relevant system is contained - if not cls._validate_nonrecipe_systems_not_contained(recipe=recipe, container=container): + if not cls.ignore_nonrecipe_systems and not cls._validate_nonrecipe_systems_not_contained(recipe=recipe, container=container): return False # Verify no non-relevant object is contained if we're not ignoring them @@ -1227,7 +1325,7 @@ def transition(cls, object_candidates): # Otherwise, if we didn't find a valid recipe, we execute a garbage transition instead if requested if recipe_results is None and cls.use_garbage_fallback_recipe: - og.log.info(f"Did not find a valid recipe; generating {m.DEFAULT_GARBAGE_SYSTEM} in {container.name}!") + og.log.info(f"Did not find a valid recipe for rule {cls.__name__}; generating {m.DEFAULT_GARBAGE_SYSTEM} in {container.name}!") # Generate garbage fluid garbage_results = cls._execute_recipe( @@ -1268,18 +1366,23 @@ def _execute_recipe(cls, container, recipe, in_volume): # Compute total volume of all contained items volume = 0 - # Remove all recipe system particles contained in the container + # Remove either all systems or only the ones specified in the input systems of the recipe contained_particles_state = container.states[ContainedParticles] + + # TODO: if cls.relax_contained_particles, then we should remove the particles that are relevant to the recipe, e.g. the ones that cover the bagel for system in PhysicalParticleSystem.get_active_systems().values(): - if container.states[Contains].get_value(system): - volume += contained_particles_state.get_value(system).n_in_volume * np.pi * (system.particle_radius ** 3) * 4 / 3 - container.states[Contains].set_value(system, False) + if not cls.ignore_nonrecipe_systems or system.name in recipe["input_systems"]: + if container.states[Contains].get_value(system): + volume += contained_particles_state.get_value(system).n_in_volume * np.pi * (system.particle_radius ** 3) * 4 / 3 + container.states[Contains].set_value(system, False) for system in VisualParticleSystem.get_active_systems().values(): - group_name = system.get_group_name(container) - if group_name in system.groups and system.num_group_particles(group_name) > 0: - system.remove_all_group_particles(group=group_name) + if not cls.ignore_nonrecipe_systems or system.name in recipe["input_systems"]: + group_name = system.get_group_name(container) + if group_name in system.groups and system.num_group_particles(group_name) > 0: + system.remove_all_group_particles(group=group_name) - # Remove either all objects or only the recipe-relevant objects inside the container + # Remove either all objects or only the ones specified in the input objects of the recipe + # TODO: remove the ones that are relevant to the recipe, e.g. the ones that are contained and satisfy the states object_mask = in_volume.copy() if cls.ignore_nonrecipe_objects: object_category_mask = np.zeros_like(object_mask, dtype=bool) @@ -1298,6 +1401,7 @@ def _spawn_object_in_container(obj): assert obj.states[state].set_value(container, True) # Spawn in new objects + # TODO: multiply n_instances by the number of successful recipe executions for category, n_instances in recipe["output_objects"].items(): n_category_objs = len(og.sim.scene.object_registry("category", category, [])) models = get_all_object_category_models(category=category) @@ -1329,14 +1433,29 @@ def _spawn_object_in_container(obj): # Return transition results return TransitionResults(add=objs_to_add, remove=objs_to_remove) + @classproperty + def relax_recipe_systems(cls): + """ + Returns: + bool: Whether to relax the requirement of having all systems in the recipe contained in the container + """ + raise NotImplementedError("Must be implemented by subclass!") + + @classproperty + def ignore_nonrecipe_systems(cls): + """ + Returns: + bool: Whether contained systems not relevant to the recipe should be ignored or not + """ + raise NotImplementedError("Must be implemented by subclass!") + @classproperty def ignore_nonrecipe_objects(cls): """ Returns: bool: Whether contained rigid objects not relevant to the recipe should be ignored or not """ - # False by default - return False + raise NotImplementedError("Must be implemented by subclass!") @classproperty def use_garbage_fallback_recipe(cls): @@ -1345,8 +1464,7 @@ def use_garbage_fallback_recipe(cls): bool: Whether this recipe rule should use a garbage fallback recipe if all conditions are met but no valid recipe is found for a given container """ - # False by default - return False + raise NotImplementedError("Must be implemented by subclass!") @classproperty def _do_not_register_classes(cls): @@ -1356,17 +1474,131 @@ def _do_not_register_classes(cls): return classes -# TODO: Make category-specific, e.g.: blender, coffee_maker, etc. -class BlenderRule(RecipeRule): +class CookingPhysicalParticleRule(RecipeRule): + """ + Transition rule to apply to "cook" physicl particles + """ + @classmethod + def add_recipe(cls, name, input_synsets, output_synsets): + super().add_recipe( + name=name, + input_synsets=input_synsets, + output_synsets=output_synsets, + input_states=None, + output_states=None, + fillable_categories=None, + ) + + input_objects = cls._RECIPES[name]["input_objects"] + output_objects = cls._RECIPES[name]["output_objects"] + assert len(input_objects) == 0, f"No input objects can be specified for {cls.__name__}, recipe: {name}!" + assert len(output_objects) == 0, f"No output objects can be specified for {cls.__name__}, recipe: {name}!" + + input_systems = cls._RECIPES[name]["input_systems"] + output_systems = cls._RECIPES[name]["output_systems"] + assert len(input_systems) == 1 or len(input_systems) == 2, \ + f"Only one or two input systems can be specified for {cls.__name__}, recipe: {name}!" + if len(input_systems) == 2: + assert input_systems[1] == "water", \ + f"Second input system must be water for {cls.__name__}, recipe: {name}!" + assert len(output_systems) == 1, \ + f"Exactly one output system needs to be specified for {cls.__name__}, recipe: {name}!" + + @classproperty + def candidate_filters(cls): + # Modify the container filter to include "blender" ability as well + candidate_filters = super().candidate_filters + candidate_filters["container"] = AndFilter(filters=[candidate_filters["container"], AbilityFilter(ability="heatable")]) + return candidate_filters + + @classmethod + def _generate_conditions(cls): + # Only heated objects are valid + return [StateCondition(filter_name="container", state=Heated, val=True, op=operator.eq)] + + @classproperty + def relax_recipe_systems(cls): + return False + + @classproperty + def ignore_nonrecipe_systems(cls): + return True + + @classproperty + def ignore_nonrecipe_objects(cls): + return True + + @classproperty + def use_garbage_fallback_recipe(cls): + return False + + @classmethod + def _execute_recipe(cls, container, recipe, in_volume): + system = get_system(recipe["input_systems"][0]) + contained_particles_state = container.states[ContainedParticles].get_value(system) + in_volume_idx = np.where(contained_particles_state.in_volume)[0] + assert len(in_volume_idx) > 0, "No particles found in the container when executing recipe!" + + # Remove uncooked particles + system.remove_particles(idxs=in_volume_idx) + + # Generate cooked particles + cooked_system = get_system(recipe["output_systems"][0]) + particle_positions = contained_particles_state.positions[in_volume_idx] + cooked_system.generate_particles(positions=particle_positions) + + # Remove water if the cooking requires water + if len(recipe["input_systems"]) > 1: + water_system = get_system(recipe["input_systems"][1]) + container.states[Contains].set_value(water_system, False) + + return TransitionResults(add=[], remove=[]) + + +class ToggleableMachineRule(RecipeRule): """ Transition mixing rule that leverages "blender" ability objects, which require toggledOn in order to trigger the recipe event """ + + @classmethod + def add_recipe( + cls, + name, + input_synsets, + output_synsets, + fillable_categories, + input_states=None, + output_states=None, + ): + """ + Adds a recipe to this cooking recipe rule to check against. This defines a valid mapping of inputs that + will transform into the outputs + + Args: + name (str): Name of the recipe + input_synsets (dict): Maps synsets to number of instances required for the recipe + output_synsets (dict): Maps synsets to number of instances to be spawned in the container when the recipe executes + fillable_categories (set of str): Set of toggleable machine categories which are allowed for this recipe + input_states (dict or None): Maps input synsets to states that must be satisfied for the recipe to execute, + or None if no states are required + otuput_states (dict or None): Map output synsets to states that should be set when spawned when the recipe executes, + or None if no states are required + """ + super().add_recipe( + name=name, + input_synsets=input_synsets, + output_synsets=output_synsets, + input_states=input_states, + output_states=output_states, + fillable_categories=fillable_categories + ) + @classproperty def candidate_filters(cls): # Modify the container filter to include "blender" ability as well candidate_filters = super().candidate_filters - candidate_filters["container"] = AndFilter(filters=[candidate_filters["container"], AbilityFilter(ability="blender")]) + candidate_filters["container"] = AndFilter(filters=[candidate_filters["container"], AbilityFilter(ability="toggleable")]) return candidate_filters @classmethod @@ -1376,6 +1608,18 @@ def _generate_conditions(cls): condition=StateCondition(filter_name="container", state=ToggledOn, val=True, op=operator.eq) )] + @classproperty + def relax_recipe_systems(cls): + return False + + @classproperty + def ignore_nonrecipe_systems(cls): + return False + + @classproperty + def ignore_nonrecipe_objects(cls): + return False + @classproperty def use_garbage_fallback_recipe(cls): return True @@ -1387,21 +1631,25 @@ class MixingToolRule(RecipeRule): and a container in order to trigger the recipe event """ @classmethod - def add_recipe(cls, name, input_objects=None, input_systems=None, output_objects=None, output_systems=None, **kwargs): - # We do not allow any input objects to be specified! Assert empty list - assert input_objects is None or len(input_objects) == 0, \ - f"No input_objects should be specified for {cls.__name__}!" - - # Call super + def add_recipe(cls, name, input_synsets, output_synsets, input_states=None, output_states=None): super().add_recipe( name=name, - input_objects=input_objects, - input_systems=input_systems, - output_objects=output_objects, - output_systems=output_systems, - **kwargs, + input_synsets=input_synsets, + output_synsets=output_synsets, + input_states=input_states, + output_states=output_states, + fillable_categories=None, ) + output_objects = cls._RECIPES[name]["output_objects"] + assert len(output_objects) == 0, f"No output objects can be specified for {cls.__name__}, recipe: {name}!" + + input_systems = cls._RECIPES[name]["input_systems"] + output_systems = cls._RECIPES[name]["output_systems"] + assert len(input_systems) > 0, f"Some input systems need to be specified for {cls.__name__}, recipe: {name}!" + assert len(output_systems) == 1, \ + f"Exactly one output system needs to be specified for {cls.__name__}, recipe: {name}!" + @classproperty def candidate_filters(cls): # Add mixing tool filter as well @@ -1416,6 +1664,14 @@ def _generate_conditions(cls): condition=TouchingAnyCondition(filter_1_name="container", filter_2_name="mixingTool") )] + @classproperty + def ignore_nonrecipe_objects(cls): + return False + + @classproperty + def ignore_nonrecipe_systems(cls): + return False + @classproperty def ignore_nonrecipe_objects(cls): return True @@ -1498,22 +1754,6 @@ def _validate_recipe_heatsources_exist(cls, recipe): # None found, return False return False - @classmethod - def _validate_recipe_container_is_valid(cls, recipe, container): - """ - Validates that @container's category satisfies @recipe's fillable_categories - - Args: - recipe (dict): Recipe whose fillable_categories should be checked against @container - container (StatefulObject): Container whose category should match one of @recipe's fillable_categories, - if specified - - Returns: - bool: True if @container is valid, else False - """ - fillable_categories = recipe["fillable_categories"] - return fillable_categories is None or container.category in fillable_categories - @classmethod def _validate_recipe_heatsource_is_valid(cls, recipe, heatsource_categories): """ @@ -1539,16 +1779,13 @@ def _compute_container_info(cls, object_candidates, container, global_info): # Compute whether each heatsource is affecting the container info["heatsource_categories"] = set(obj.category for obj in object_candidates["heatSource"] if - obj.states[HeatSourceOrSink].affects_obj(container)) + obj.states[HeatSourceOrSink].affects_obj(container)) return info @classmethod def _is_recipe_active(cls, recipe): - # Check for fillable and heatsource categories first - if not cls._validate_recipe_fillables_exist(recipe=recipe): - return False - + # Check for heatsource categories first if not cls._validate_recipe_heatsources_exist(recipe=recipe): return False @@ -1557,10 +1794,7 @@ def _is_recipe_active(cls, recipe): @classmethod def _is_recipe_executable(cls, recipe, container, global_info, container_info): - # Check for container and heatsource compatibility first - if not cls._validate_recipe_container_is_valid(recipe=recipe, container=container): - return False - + # Check for heatsource compatibility first if not cls._validate_recipe_heatsource_is_valid(recipe=recipe, heatsource_categories=container_info["heatsource_categories"]): return False @@ -1580,7 +1814,7 @@ def _is_recipe_executable(cls, recipe, container, global_info, container_info): cls._LAST_HEAT_TIMESTEP[name] = cls.COUNTER # If valid number of timesteps met, recipe is indeed executable - executable = cls._HEAT_STEPS[name] >= recipe["n_heat_steps"] + executable = cls._HEAT_STEPS[name] >= recipe["timesteps"] return executable @@ -1588,14 +1822,13 @@ def _is_recipe_executable(cls, recipe, container, global_info, container_info): def add_recipe( cls, name, - input_objects=None, - input_systems=None, - output_objects=None, - output_systems=None, + input_synsets, + output_synsets, + input_states=None, + output_states=None, fillable_categories=None, heatsource_categories=None, - n_heat_steps=1, - **kwargs, + timesteps=None, ): """ Adds a recipe to this cooking recipe rule to check against. This defines a valid mapping of inputs that @@ -1603,37 +1836,39 @@ def add_recipe( Args: name (str): Name of the recipe - input_objects (None or dict): Maps object category to number of instances required for the recipe, or None - if no objects required - input_systems (None or list of str): Required system names for the recipe, or None if no systems required - output_objects (None or dict): Maps object category to number of instances to be spawned in the container - when the recipe executes, or None if no objects are to be spawned - output_systems (None or list of str): Output system name(s) that will replace all contained objects - if the recipe is executed, or None if no system is to be spawned - fillable_categories (None or list of str): If specified, list of fillable categories which are allowed + input_synsets (dict): Maps synsets to number of instances required for the recipe + output_synsets (dict): Maps synsets to number of instances to be spawned in the container when the recipe executes + input_states (dict or None): Maps input synsets to states that must be satisfied for the recipe to execute, + or None if no states are required + otuput_states (dict or None): Map output synsets to states that should be set when spawned when the recipe executes, + or None if no states are required + fillable_categories (None or set of str): If specified, set of fillable categories which are allowed for this recipe. If None, any fillable is allowed - heatsource_categories (None or list of str): If specified, list of heatsource categories which are allowed + heatsource_categories (None or set of str): If specified, set of heatsource categories which are allowed for this recipe. If None, any heatsource is allowed - n_heat_steps (int): Number of subsequent heating steps required for the recipe to execute. Default is 1 - step, i.e.: instantaneous execution + timesteps (None or int): Number of subsequent heating steps required for the recipe to execute. If None, + it will be set to be 1, i.e.: instantaneous execution + """ + if heatsource_categories is not None: + heatsource_categories_postprocessed = set() + for synset in heatsource_categories: + assert OBJECT_TAXONOMY.is_leaf(synset), f"Synset {synset} must be a leaf node in the taxonomy!" + assert not is_substance_synset(synset), f"Synset {synset} must be applied to an object, not a substance!" + for category in OBJECT_TAXONOMY.get_categories(synset): + heatsource_categories_postprocessed.add(category) + heatsource_categories = heatsource_categories_postprocessed - kwargs (dict): Any additional keyword-arguments to be stored as part of this recipe - """ - # Call super first super().add_recipe( name=name, - input_objects=input_objects, - input_systems=input_systems, - output_objects=output_objects, - output_systems=output_systems, - **kwargs, + input_synsets=input_synsets, + output_synsets=output_synsets, + input_states=input_states, + output_states=output_states, + fillable_categories=fillable_categories, + heatsource_categories=heatsource_categories, + timesteps=1 if timesteps is None else timesteps, ) - # Add additional kwargs - cls._RECIPES[name]["fillable_categories"] = None if fillable_categories is None else set(fillable_categories) - cls._RECIPES[name]["heatsource_categories"] = None if heatsource_categories is None else set(heatsource_categories) - cls._RECIPES[name]["n_heat_steps"] = n_heat_steps - @classproperty def candidate_filters(cls): # Add mixing tool filter as well @@ -1662,24 +1897,161 @@ def modifies_filter_names(self): StateCondition(filter_name="heatSource", state=HeatSourceOrSink, val=True, op=operator.eq), ] + @classproperty + def use_garbage_fallback_recipe(cls): + return False + + @classproperty + def _do_not_register_classes(cls): + # Don't register this class since it's an abstract template + classes = super()._do_not_register_classes + classes.add("CookingRule") + return classes + +class CookingObjectRule(CookingRule): + @classmethod + def add_recipe( + cls, + name, + input_synsets, + output_synsets, + input_states=None, + output_states=None, + fillable_categories=None, + heatsource_categories=None, + timesteps=None, + ): + """ + Adds a recipe to this cooking recipe rule to check against. This defines a valid mapping of inputs that + will transform into the outputs + + Args: + name (str): Name of the recipe + input_synsets (dict): Maps synsets to number of instances required for the recipe + output_synsets (dict): Maps synsets to number of instances to be spawned in the container when the recipe executes + input_states (dict or None): Maps input synsets to states that must be satisfied for the recipe to execute, + or None if no states are required + otuput_states (dict or None): Map output synsets to states that should be set when spawned when the recipe executes, + or None if no states are required + fillable_categories (None or set of str): If specified, set of fillable categories which are allowed + for this recipe. If None, any fillable is allowed + heatsource_categories (None or set of str): If specified, set of heatsource categories which are allowed + for this recipe. If None, any heatsource is allowed + timesteps (None or int): Number of subsequent heating steps required for the recipe to execute. If None, + it will be set to be 1, i.e.: instantaneous execution + """ + super().add_recipe( + name=name, + input_synsets=input_synsets, + output_synsets=output_synsets, + input_states=input_states, + output_states=output_states, + fillable_categories=fillable_categories, + heatsource_categories=heatsource_categories, + timesteps=timesteps, + ) + output_systems = cls._RECIPES[name]["output_systems"] + assert len(output_systems) == 0, f"No output systems can be specified for {cls.__name__}, recipe: {name}!" + + @classproperty + def relax_recipe_systems(cls): + # We don't require systems like seasoning/cheese/sesame seeds/etc. to be contained in the baking sheet + return True + + @classproperty + def ignore_nonrecipe_objects(cls): + return True + + @classproperty + def ignore_nonrecipe_systems(cls): + return True + + +class CookingSystemRule(CookingRule): + @classmethod + def add_recipe( + cls, + name, + input_synsets, + output_synsets, + input_states=None, + output_states=None, + fillable_categories=None, + heatsource_categories=None, + timesteps=None, + ): + """ + Adds a recipe to this cooking recipe rule to check against. This defines a valid mapping of inputs that + will transform into the outputs + + Args: + name (str): Name of the recipe + input_synsets (dict): Maps synsets to number of instances required for the recipe + output_synsets (dict): Maps synsets to number of instances to be spawned in the container when the recipe executes + input_states (dict or None): Maps input synsets to states that must be satisfied for the recipe to execute, + or None if no states are required + otuput_states (dict or None): Map output synsets to states that should be set when spawned when the recipe executes, + or None if no states are required + fillable_categories (None or set of str): If specified, set of fillable categories which are allowed + for this recipe. If None, any fillable is allowed + heatsource_categories (None or set of str): If specified, set of heatsource categories which are allowed + for this recipe. If None, any heatsource is allowed + timesteps (None or int): Number of subsequent heating steps required for the recipe to execute. If None, + it will be set to be 1, i.e.: instantaneous execution + """ + super().add_recipe( + name=name, + input_synsets=input_synsets, + output_synsets=output_synsets, + input_states=input_states, + output_states=output_states, + fillable_categories=fillable_categories, + heatsource_categories=heatsource_categories, + timesteps=timesteps, + ) + output_objects = cls._RECIPES[name]["output_objects"] + assert len(output_objects) == 0, f"No output objects can be specified for {cls.__name__}, recipe: {name}!" + + @classproperty + def relax_recipe_systems(cls): + return False + + @classproperty + def ignore_nonrecipe_objects(cls): + return False + + @classproperty + def ignore_nonrecipe_systems(cls): + return False def import_recipes(): - # Wrap bddl here so it's only imported if necessary - import bddl - recipe_fpath = f"{os.path.dirname(bddl.__file__)}/generated_data/transition_rule_recipes.json" - if not os.path.exists(recipe_fpath): - log.warning(f"Cannot find recipe file at {recipe_fpath}. Skipping importing recipes.") - return - with open(recipe_fpath, "r") as f: - rule_recipes = json.load(f) - for rule_name, recipes in rule_recipes.items(): - rule = REGISTERED_RULES[rule_name] - for recipe in recipes: - rule.add_recipe(**recipe) - -# Optionally import bddl for rule recipes -try: - import_recipes() - -except ImportError: - log.warning("BDDL could not be imported - rule recipes will be unavailable.") + for json_file, rule_names in _JSON_FILES_TO_RULES.items(): + recipe_fpath = os.path.join(os.path.dirname(bddl.__file__), "generated_data", "transition_map", "tm_jsons", json_file) + if not os.path.exists(recipe_fpath): + log.warning(f"Cannot find recipe file at {recipe_fpath}. Skipping importing recipes.") + # return + with open(recipe_fpath, "r") as f: + rule_recipes = json.load(f) + for rule_name in rule_names: + rule = REGISTERED_RULES[rule_name] + if issubclass(rule, RecipeRule): + for recipe in rule_recipes: + if "rule_name" in recipe: + recipe["name"] = recipe.pop("rule_name") + if "container" in recipe: + recipe["fillable_categories"] = set(recipe.pop("container").keys()) + if "heat_source" in recipe: + recipe["heatsource_categories"] = set(recipe.pop("heat_source").keys()) + if "machine" in recipe: + recipe["fillable_categories"] = set(recipe.pop("machine").keys()) + + satisfied = True + output_synsets = set(recipe["output_synsets"].keys()) + has_substance = any([s for s in output_synsets if is_substance_synset(s)]) + if (rule_name == "CookingObjectRule" and has_substance) or (rule_name == "CookingSystemRule" and not has_substance): + satisfied = False + if satisfied: + rule.add_recipe(**recipe) + print(f"All recipes of rule {rule_name} imported successfully.") + +import_recipes() \ No newline at end of file diff --git a/omnigibson/utils/bddl_utils.py b/omnigibson/utils/bddl_utils.py index 5ddee997a..45f561b88 100644 --- a/omnigibson/utils/bddl_utils.py +++ b/omnigibson/utils/bddl_utils.py @@ -166,8 +166,6 @@ def process_single_condition(condition): # BEHAVIOR-related OBJECT_TAXONOMY = ObjectTaxonomy() -# TODO (Josiah): Remove floor synset once we have new bddl release -FLOOR_SYNSET = "floor.n.01" BEHAVIOR_ACTIVITIES = sorted(os.listdir(os.path.join(os.path.dirname(bddl.__file__), "activity_definitions"))) diff --git a/tests/test_transition_rules.py b/tests/test_transition_rules.py index 76a8348d3..65d8f0a45 100644 --- a/tests/test_transition_rules.py +++ b/tests/test_transition_rules.py @@ -14,6 +14,43 @@ import pytest import numpy as np +@og_test +def test_cooking_object_rule(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + from IPython import embed; print("test_cooking_object_rule"); embed() + + oven = og.sim.scene.object_registry("name", "oven") + baking_sheet = og.sim.scene.object_registry("name", "baking_sheet") + bagel_dough = og.sim.scene.object_registry("name", "bagel_dough") + raw_egg = og.sim.scene.object_registry("name", "raw_egg") + sesame_seed = get_system("sesame_seed") + + place_obj_on_floor_plane(oven) + og.sim.step() + + baking_sheet.set_position_orientation([0, 0, 0.455], [0, 0, 0, 1]) + og.sim.step() + assert baking_sheet.states[Inside].get_value(oven) + + bagel_dough.set_position_orientation([0, 0, 0.495], [0, 0, 0, 1]) + og.sim.step() + assert bagel_dough.states[OnTop].get_value(baking_sheet) + + raw_egg.set_position_orientation([0.02, 0, 0.535], [0, 0, 0, 1]) + og.sim.step() + assert raw_egg.states[OnTop].get_value(bagel_dough) + + assert raw_egg.states[Covered].set_value(sesame_seed, True) + og.sim.step() + + assert oven.states[ToggledOn].set_value(True) + og.sim.step() + + + +# @og_test +# def test_slicing_rule(): +# assert len(REGISTERED_RULES) > 0, "No rules registered!" @og_test def test_blender_rule(): @@ -102,3 +139,5 @@ def test_cooking_rule(): if dough_exists: og.sim.remove_object(dough) og.sim.remove_object(sheet) + +test_cooking_object_rule() \ No newline at end of file diff --git a/tests/utils.py b/tests/utils.py index 0e0f4961b..35d34260a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -68,6 +68,9 @@ def assert_test_scene(): get_obj_cfg("vacuum", "vacuum", "bdmsbr", visual_only=True, abilities={"toggleable": {}, "particleRemover": {"method": ParticleModifyMethod.PROJECTION, "conditions": {"water": [(ParticleModifyCondition.TOGGLEDON, True)]}}}), get_obj_cfg("blender", "blender", "cwkvib", bounding_box=[0.316, 0.318, 0.649], abilities={"fillable": {}, "blender": {}, "toggleable": {}}), get_obj_cfg("oven", "oven", "cgtaer", bounding_box=[0.943, 0.837, 1.297]), + get_obj_cfg("baking_sheet", "baking_sheet", "yhurut"), + get_obj_cfg("bagel_dough", "bagel_dough", "iuembm"), + get_obj_cfg("raw_egg", "raw_egg", "ydgivr"), ], "robots": [ { From 65fb0c0651cf3d0c6dff38cb6ffe619ae75d9fcb Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Tue, 23 Jan 2024 14:11:36 -0800 Subject: [PATCH 02/24] bagel demo works, add more comments --- omnigibson/transition_rules.py | 360 ++++++++++++++++++++++++++------- tests/test_transition_rules.py | 4 +- tests/utils.py | 7 +- 3 files changed, 296 insertions(+), 75 deletions(-) diff --git a/omnigibson/transition_rules.py b/omnigibson/transition_rules.py index 200474ddb..d6e2e34e8 100644 --- a/omnigibson/transition_rules.py +++ b/omnigibson/transition_rules.py @@ -7,6 +7,7 @@ import itertools import os from collections import defaultdict +import networkx as nx import omnigibson as og from omnigibson.macros import gm, create_module_macros @@ -29,6 +30,7 @@ # Create module logger log = create_module_logger(module_name=__name__) +# Create object taxonomy OBJECT_TAXONOMY = ObjectTaxonomy() # Create settings for this module @@ -43,8 +45,6 @@ # Default "trash" system if an invalid mixing rule transition occurs m.DEFAULT_GARBAGE_SYSTEM = "sludge" -m.DEBUG = False - # Tuple of attributes of objects created in transitions. # `states` field is dict mapping object state class to arguments to pass to setter for that class _attrs_fields = ["category", "model", "name", "scale", "obj", "pos", "orn", "bb_pos", "bb_orn", "states", "callback"] @@ -156,6 +156,7 @@ def step(cls): Steps all active transition rules, checking if any are satisfied, and if so, executing their transition """ # First apply any transition object init states from before, and then clear the dictionary + # TODO: figure out if this is still needed for obj, info in cls._INIT_INFO.items(): if info["states"] is not None: for state, args in info["states"].items(): @@ -916,17 +917,23 @@ def add_recipe( # Store information for this recipe cls._RECIPES[name] = { "name": name, - - # To be filled in later + # Maps object categories to number of instances required for the recipe "input_objects": dict(), + # List of system names required for the recipe "input_systems": list(), + # Maps object categories to number of instances to be spawned in the container when the recipe executes "output_objects": dict(), + # List of system names to be spawned in the container when the recipe executes. Currently the length is 1. "output_systems": list(), - "input_states": defaultdict(list), - "output_states": defaultdict(list), - "root_input_objects": + # Maps object categories to ["unary", "bianry_system", "binary_object"] to a list of states that must be satisfied for the recipe to execute + "input_states": defaultdict(lambda: defaultdict(list)), + # Maps object categories to ["unary", "bianry_system", "binary_object"] to a list of states that should be set after the output objects are spawned + "output_states": defaultdict(lambda: defaultdict(list)), + # Set of fillable categories which are allowed for this recipe "fillable_categories": None, - + # networkx DiGraph that represents the kinematic dependency graph of the input objects + # If input_states has no kinematic states between pairs of objects, this will be None. + "input_object_tree": None, **kwargs, } @@ -958,6 +965,7 @@ def add_recipe( else: first_synset, second_synset = synset_split + # Assert the first synset is an object because the systems don't have any states. assert OBJECT_TAXONOMY.is_leaf(first_synset), f"Input/output state synset {first_synset} must be a leaf node in the taxonomy!" assert not is_substance_synset(first_synset), f"Input/output state synset {first_synset} must be applied to an object, not a substance!" obj_categories = OBJECT_TAXONOMY.get_categories(first_synset) @@ -965,10 +973,12 @@ def add_recipe( first_obj_category = obj_categories[0] if second_synset is None: + # Unary states for the first synset for state_type, state_value in states: state_class = SUPPORTED_PREDICATES[state_type].STATE_CLASS assert issubclass(state_class, AbsoluteObjectState), f"Input/output state type {state_type} must be a unary state!" - cls._RECIPES[name][states_key][first_obj_category].append((state_class, None, state_value)) + # Example: (Cooked, True) + cls._RECIPES[name][states_key][first_obj_category]["unary"].append((state_class, state_value)) else: assert OBJECT_TAXONOMY.is_leaf(second_synset), f"Input/output state synset {second_synset} must be a leaf node in the taxonomy!" obj_categories = OBJECT_TAXONOMY.get_categories(second_synset) @@ -985,8 +995,36 @@ def add_recipe( state_class = SUPPORTED_PREDICATES[state_type].STATE_CLASS assert issubclass(state_class, RelativeObjectState), f"Input/output state type {state_type} must be a binary state!" assert is_substance == (state_class in get_system_states()), f"Input/output state type {state_type} system state inconsistency found!" - cls._RECIPES[name][states_key][first_obj_category].append((state_class, second_obj_category, state_value)) - + if is_substance: + # Non-kinematic binary states, e.g. Covered, Saturated, Filled, Contains. + # Example: (Covered, "sesame_seed", True) + cls._RECIPES[name][states_key][first_obj_category]["binary_system"].append( + (state_class, second_obj_category, state_value)) + else: + # Kinematic binary states w.r.t. the second object. + # Example: (OnTop, "raw_egg", True) + assert cls.is_multi_instance, f"Input/output state type {state_type} can only be used in multi-instance recipes!" + assert states_key != "output_states", f"Output state type {state_type} can only be used in input states!" + cls._RECIPES[name][states_key][first_obj_category]["binary_object"].append( + (state_class, second_obj_category, state_value)) + + if cls.is_multi_instance and len(cls._RECIPES[name]["input_objects"]) > 0: + # Build a tree of input objects according to the kinematic binary states + # Example: 'raw_egg': {'binary_object': [(OnTop, 'bagel_dough', True)]} results in an edge + # from 'bagel_dough' to 'raw_egg', i.e. 'bagel_dough' is the parent of 'raw_egg'. + input_object_tree = nx.DiGraph() + for obj_category, state_checks in cls._RECIPES[name]["input_states"].items(): + for state_class, second_obj_category, state_value in state_checks["binary_object"]: + input_object_tree.add_edge(second_obj_category, obj_category) + + if not nx.is_empty(input_object_tree): + assert nx.is_tree(input_object_tree), f"Input object tree must be a tree! Now: {input_object_tree}." + root_nodes = [node for node in input_object_tree.nodes() if input_object_tree.in_degree(node) == 0] + assert len(root_nodes) == 1, f"Input object tree must have exactly one root node! Now: {root_nodes}." + assert cls._RECIPES[name]["input_objects"][root_nodes[0]] == 1, f"Input object tree root node must have exactly one instance! Now: {cls._RECIPES[name]['input_objects'][root_nodes[0]]}." + cls._RECIPES[name]["input_object_tree"] = input_object_tree + + # Map fillable synsets to fillable object categories. if fillable_categories is not None: cls._RECIPES[name]["fillable_categories"] = set() for synset in fillable_categories: @@ -1050,44 +1088,186 @@ def _validate_nonrecipe_systems_not_contained(cls, recipe, container): return True @classmethod - def _validate_recipe_objects_are_contained_and_states_satisfied(cls, recipe, in_volume): + def _validate_recipe_objects_are_contained_and_states_satisfied(cls, recipe, container_info): """ - Validates whether @recipe's input_objects are all contained in the container represented by @in_volume + Validates whether @recipe's input_objects are contained in the container and whether their states are satisfied Args: recipe (dict): Recipe whose objects should be checked - in_volume (n-array): (N,) flat boolean array corresponding to whether every object from - cls._OBJECTS is inside the corresponding container + container_info (dict): Output of @cls._compute_container_info(); container-specific information which may + be relevant for computing whether recipe is executable. This will be populated with execution info. Returns: bool: True if all the input object quantities are contained """ - if m.DEBUG: - print("_validate_recipe_objects_are_contained_and_states_satisfied") - from IPython import embed; embed(); - # TODO: add another field to container_info: objects that are contained AND recipe relevant (category match, states satisfied) - # TODO: add another field to container_info: how many copies of recipe are successful - for obj_category, obj_quantity in recipe["input_objects"].items(): - if np.sum(in_volume[cls._CATEGORY_IDXS[obj_category]]) < obj_quantity: - return False - return True + in_volume = container_info["in_volume"] + + container_info["execution_info"] = dict() + + # Filter input objects based on a subset of input states (unary states and binary system states) + obj_category_to_valid_objs = dict() + for obj_category in recipe["input_objects"]: + if obj_category not in recipe["input_states"]: + # If there are no input states, all objects of this category are valid + obj_category_to_valid_objs[obj_category] = cls._CATEGORY_IDXS[obj_category] + else: + obj_category_to_valid_objs[obj_category] = [] + for idx in cls._CATEGORY_IDXS[obj_category]: + obj = cls._OBJECTS[idx] + success = True + + # Check if unary states are satisfied + for state_class, state_value in recipe["input_states"][obj_category]["unary"]: + if obj.states[state_class].get_value() != state_value: + success = False + break + if not success: + continue + + # Check if binary system states are satisfied + for state_class, system_name, state_value in recipe["input_states"][obj_category]["binary_system"]: + if obj.states[state_class].get_value(system=get_system(system_name)) != state_value: + success = False + break + if not success: + continue + + obj_category_to_valid_objs[obj_category].append(idx) + + # Convert to numpy array for faster indexing + obj_category_to_valid_objs[obj_category] = np.array(obj_category_to_valid_objs[obj_category], dtype=np.int) + + container_info["execution_info"]["obj_category_to_valid_objs"] = obj_category_to_valid_objs + if not cls.is_multi_instance: + # Check if sufficiently number of objects are contained + for obj_category, obj_quantity in recipe["input_objects"].items(): + if np.sum(in_volume[obj_category_to_valid_objs[obj_category]]) < obj_quantity: + return False + return True + else: + input_object_tree = recipe["input_object_tree"] + # If multi-instance is True but doesn't require kinematic states between objects + if input_object_tree is None: + num_instances = np.inf + # Compute how many instances of this recipe can be produced. + # Example: if a recipe requires 1 apple and 2 bananas, and there are 3 apples and 4 bananas in the + # container, then 2 instance of the recipe can be produced. + for obj_category, obj_quantity in recipe["input_objects"].items(): + quantity_in_volume = np.sum(in_volume[obj_category_to_valid_objs[obj_category]]) + num_inst = quantity_in_volume // obj_quantity + if num_inst < 1: + return False + num_instances = min(num_instances, num_inst) + + # Map object category to a set of objects that are used in this execution + relevant_objects = defaultdict(set) + for obj_category, obj_quantity in recipe["input_objects"].items(): + quantity_used = num_instances * obj_quantity + relevant_objects[obj_category] = set(obj_category_to_valid_objs[obj_category][:quantity_used]) + + # If multi-instance is True and requires kinematic states between objects + else: + root_node_category = [node for node in input_object_tree.nodes() if input_object_tree.in_degree(node) == 0][0] + # A list of objects belonging to the root node category + root_nodes = cls._OBJECTS[cls._CATEGORY_IDXS[root_node_category]] + input_states = recipe["input_states"] + + # Recursively check if the kinematic tree is satisfied. + # Return True/False, and a set of objects that belong to the subtree rooted at the current node + def check_kinematic_tree(obj, should_check_in_volume=False): + # Check if obj is in volume + if should_check_in_volume and not in_volume[cls._OBJECTS_TO_IDX[obj]]: + return False, set() + + # If the object is a leaf node, return True and the set containing the object + if input_object_tree.out_degree(obj.category) == 0: + return True, set([obj]) + + children_categories = list(input_object_tree.successors(obj.category)) + + all_subtree_objs = set() + for child_cat in children_categories: + assert len(input_states[child_cat]["binary_object"]) == 1, \ + "Each child node should have exactly one binary object state, i.e. one parent in the input_object_tree" + state_class, _, state_value = input_states[child_cat]["binary_object"][0] + num_valid_children = 0 + for child_obj in cls._OBJECTS[cls._CATEGORY_IDXS[child_cat]]: + # If the child doesn't satisfy the binary object state, skip + if child_obj.states[state_class].get_value(obj) != state_value: + continue + # Recursively check if the subtree rooted at the child is valid + subtree_valid, subtree_objs = check_kinematic_tree(child_obj) + # If the subtree is valid, increment the number of valid children and aggregate the objects + if subtree_valid: + num_valid_children += 1 + all_subtree_objs |= subtree_objs + + # If there are not enough valid children, return False + if num_valid_children < recipe["input_objects"][child_cat]: + return False, set() + + # If all children categories have sufficient number of objects that satisfy the binary object state, + # e.g. five pieces of pepperoni and two pieces of basil on the pizza, the subtree rooted at the + # current node is valid. Return True and the set of objects in the subtree (all descendants plus + # the current node) + return True, all_subtree_objs | {obj} + + num_instances = 0 + relevant_objects = defaultdict(set) + for root_node in root_nodes: + # should_check_in_volume is True only for the root nodes. + # Example: the bagel dough needs to be in_volume of the container, but the raw egg on top doesn't. + tree_valid, relevant_object_set = check_kinematic_tree(root_node, should_check_in_volume=True) + if tree_valid: + # For each valid tree, increment the number of instances and aggregate the objects + num_instances += 1 + for obj in relevant_object_set: + relevant_objects[obj.category].add(obj) + + # If there are no valid trees, return False + if num_instances == 0: + return False + + # Map system name to a set of particle indices that are used in this execution + relevant_systems = defaultdict(set) + for obj_category, objs in relevant_objects.items(): + for state_class, system_name, state_value in recipe["input_states"][obj_category]["binary_system"]: + if state_class in [Filled, Contains]: + for obj in objs: + contained_particle_idx = obj.states[ContainedParticles].get_value(get_system(system_name)).in_volume.nonzero()[0] + relevant_systems[system_name] |= contained_particle_idx + elif state_class in [Covered]: + for obj in objs: + covered_particle_idx = obj.states[ContactParticles].get_value(get_system(system_name)) + relevant_systems[system_name] |= covered_particle_idx + + # Now we populate the execution info with the relevant objects and systems as well as the number of + # instances of the recipe that can be produced. + container_info["execution_info"]["relevant_objects"] = relevant_objects + container_info["execution_info"]["relevant_systems"] = relevant_systems + container_info["execution_info"]["num_instances"] = num_instances + return True @classmethod - def _validate_nonrecipe_objects_not_contained(cls, recipe, in_volume): + def _validate_nonrecipe_objects_not_contained(cls, recipe, container_info): """ Validates whether all objects not relevant to @recipe are not contained in the container represented by @in_volume Args: recipe (dict): Recipe whose systems should be checked - in_volume (n-array): (N,) flat boolean array corresponding to whether every object from - cls._OBJECTS is inside the corresponding container + container_info (dict): Output of @cls._compute_container_info(); container-specific information + which may be relevant for computing whether recipe is executable Returns: bool: True if none of the non-relevant objects are contained """ + in_volume = container_info["in_volume"] + # These are object indices whose objects satisfy the input states + obj_category_to_valid_objs = container_info["execution_info"]["obj_category_to_valid_objs"] nonrecipe_objects_in_volume = in_volume if len(recipe["input_objects"]) == 0 else \ - np.delete(in_volume, np.concatenate([cls._CATEGORY_IDXS[obj_category] for obj_category in recipe["input_objects"].keys()])) + np.delete(in_volume, np.concatenate([obj_category_to_valid_objs[obj_category] + for obj_category in obj_category_to_valid_objs])) return not np.any(nonrecipe_objects_in_volume) @classmethod @@ -1197,7 +1377,7 @@ def _is_recipe_executable(cls, recipe, container, global_info, container_info): return False # Verify all required object quantities are contained in the container and their states are satisfied - if not cls._validate_recipe_objects_are_contained_and_states_satisfied(recipe=recipe, in_volume=in_volume): + if not cls._validate_recipe_objects_are_contained_and_states_satisfied(recipe=recipe, container_info=container_info): return False # Verify no non-relevant system is contained @@ -1205,7 +1385,7 @@ def _is_recipe_executable(cls, recipe, container, global_info, container_info): return False # Verify no non-relevant object is contained if we're not ignoring them - if not cls.ignore_nonrecipe_objects and not cls._validate_nonrecipe_objects_not_contained(recipe=recipe, in_volume=in_volume): + if not cls.ignore_nonrecipe_objects and not cls._validate_nonrecipe_objects_not_contained(recipe=recipe, container_info=container_info): return False return True @@ -1319,7 +1499,7 @@ def transition(cls, object_candidates): recipe_results = cls._execute_recipe( container=container, recipe=recipe, - in_volume=container_info["in_volume"], + container_info=container_info, ) objs_to_add += recipe_results.add objs_to_remove += recipe_results.remove @@ -1338,8 +1518,9 @@ def transition(cls, object_candidates): input_systems=[], output_objects=dict(), output_systems=[m.DEFAULT_GARBAGE_SYSTEM], + output_states=defaultdict(lambda: defaultdict(list)), ), - in_volume=container_info["in_volume"], + container_info=container_info, ) objs_to_add += garbage_results.add objs_to_remove += garbage_results.remove @@ -1347,7 +1528,7 @@ def transition(cls, object_candidates): return TransitionResults(add=objs_to_add, remove=objs_to_remove) @classmethod - def _execute_recipe(cls, container, recipe, in_volume): + def _execute_recipe(cls, container, recipe, container_info): """ Transforms all items contained in @container into @output_system, generating volume of @output_system proportional to the number of items transformed. @@ -1357,41 +1538,55 @@ def _execute_recipe(cls, container, recipe, in_volume): @output_system recipe (dict): Recipe to execute. Should include, at the minimum, "input_objects", "input_systems", "output_objects", and "output_systems" keys - in_volume (n-array): (n_objects,) boolean array specifying whether every object from og.sim.scene.objects - is contained in @container or not + container_info (dict): Output of @cls._compute_container_info(); container-specific information which may + be relevant for computing whether recipe is executable. Returns: TransitionResults: Results of the executed recipe transition """ objs_to_add, objs_to_remove = [], [] + in_volume = container_info["in_volume"] + if cls.is_multi_instance: + execution_info = container_info["execution_info"] + # Compute total volume of all contained items volume = 0 - # Remove either all systems or only the ones specified in the input systems of the recipe - contained_particles_state = container.states[ContainedParticles] - - # TODO: if cls.relax_contained_particles, then we should remove the particles that are relevant to the recipe, e.g. the ones that cover the bagel - for system in PhysicalParticleSystem.get_active_systems().values(): - if not cls.ignore_nonrecipe_systems or system.name in recipe["input_systems"]: - if container.states[Contains].get_value(system): - volume += contained_particles_state.get_value(system).n_in_volume * np.pi * (system.particle_radius ** 3) * 4 / 3 - container.states[Contains].set_value(system, False) - for system in VisualParticleSystem.get_active_systems().values(): - if not cls.ignore_nonrecipe_systems or system.name in recipe["input_systems"]: - group_name = system.get_group_name(container) - if group_name in system.groups and system.num_group_particles(group_name) > 0: - system.remove_all_group_particles(group=group_name) - - # Remove either all objects or only the ones specified in the input objects of the recipe - # TODO: remove the ones that are relevant to the recipe, e.g. the ones that are contained and satisfy the states - object_mask = in_volume.copy() - if cls.ignore_nonrecipe_objects: - object_category_mask = np.zeros_like(object_mask, dtype=bool) - for obj_category in recipe["input_objects"].keys(): - object_category_mask[cls._CATEGORY_IDXS[obj_category]] = True - object_mask &= object_category_mask - objs_to_remove.extend(cls._OBJECTS[object_mask]) + if not cls.is_multi_instance: + # Remove either all systems or only the ones specified in the input systems of the recipe + contained_particles_state = container.states[ContainedParticles] + for system in PhysicalParticleSystem.get_active_systems().values(): + if not cls.ignore_nonrecipe_systems or system.name in recipe["input_systems"]: + if container.states[Contains].get_value(system): + volume += contained_particles_state.get_value(system).n_in_volume * np.pi * (system.particle_radius ** 3) * 4 / 3 + container.states[Contains].set_value(system, False) + for system in VisualParticleSystem.get_active_systems().values(): + if not cls.ignore_nonrecipe_systems or system.name in recipe["input_systems"]: + group_name = system.get_group_name(container) + if group_name in system.groups and system.num_group_particles(group_name) > 0: + system.remove_all_group_particles(group=group_name) + else: + # Remove the particles that are involved in this execution + for system_name, particle_idxs in execution_info["relevant_systems"].items(): + system = get_system(system_name) + volume += len(particle_idxs) * np.pi * (system.particle_radius ** 3) * 4 / 3 + system.remove_particles(idxs=np.array(list(particle_idxs))) + + if not cls.is_multi_instance: + # Remove either all objects or only the ones specified in the input objects of the recipe + object_mask = in_volume.copy() + if cls.ignore_nonrecipe_objects: + object_category_mask = np.zeros_like(object_mask, dtype=bool) + for obj_category in recipe["input_objects"].keys(): + object_category_mask[cls._CATEGORY_IDXS[obj_category]] = True + object_mask &= object_category_mask + objs_to_remove.extend(cls._OBJECTS[object_mask]) + else: + # Remove the objects that are involved in this execution + for obj_category, objs in execution_info["relevant_objects"].items(): + objs_to_remove.extend(objs) + volume += sum(obj.volume for obj in objs_to_remove) # Define callback for spawning new objects inside container @@ -1400,13 +1595,24 @@ def _spawn_object_in_container(obj): # TODO: Can we sample inside intelligently? state = OnTop # TODO: What to do if setter fails? - assert obj.states[state].set_value(container, True) + if not obj.states[state].set_value(container, True): + log.warning(f"Failed to spawn object {obj.name} in container {container.name}!") # Spawn in new objects - # TODO: multiply n_instances by the number of successful recipe executions for category, n_instances in recipe["output_objects"].items(): + # Multiply by number of instances of execution if this is a multi-instance recipe + if cls.is_multi_instance: + n_instances *= execution_info["num_instances"] + + output_states = dict() + for state_type, state_value in recipe["output_states"][category]["unary"]: + output_states[state_type] = (state_value,) + for state_type, system_name, state_value in recipe["output_states"][category]["binary_system"]: + output_states[state_type] = (get_system(system_name), state_value) + n_category_objs = len(og.sim.scene.object_registry("category", category, [])) models = get_all_object_category_models(category=category) + for i in range(n_instances): obj = DatasetObject( name=f"{category}_{n_category_objs + i}", @@ -1416,6 +1622,7 @@ def _spawn_object_in_container(obj): new_obj_attrs = ObjectAttrs( obj=obj, callback=_spawn_object_in_container, + states=output_states, pos=np.ones(3) * (100.0 + i), ) objs_to_add.append(new_obj_attrs) @@ -1428,7 +1635,8 @@ def _spawn_object_in_container(obj): out_system.generate_particles_from_link( obj=container, link=contained_particles_state.link, - check_contact=cls.ignore_nonrecipe_objects, + # In these two cases, we don't necessarily have removed all objects in the container. + check_contact=cls.ignore_nonrecipe_objects or cls.is_multi_instance, max_samples=int(volume / (np.pi * (out_system.particle_radius ** 3) * 4 / 3)), ) @@ -1468,6 +1676,14 @@ def use_garbage_fallback_recipe(cls): """ raise NotImplementedError("Must be implemented by subclass!") + @classproperty + def is_multi_instance(cls): + """ + Returns: + bool: Whether this rule can be applied multiple times to the same container, e.g. to cook multiple doughs + """ + return False + @classproperty def _do_not_register_classes(cls): # Don't register this class since it's an abstract template @@ -1595,6 +1811,10 @@ def add_recipe( output_states=output_states, fillable_categories=fillable_categories ) + output_objects = cls._RECIPES[name]["output_objects"] + if len(output_objects) > 0: + assert len(output_objects) == 1, f"Only one category of output object can be specified for {cls.__name__}, recipe: {name}!" + assert output_objects[list(output_objects.keys())[0]] == 1, f"Only one instance of output object can be specified for {cls.__name__}, recipe: {name}!" @classproperty def candidate_filters(cls): @@ -1666,10 +1886,6 @@ def _generate_conditions(cls): condition=TouchingAnyCondition(filter_1_name="container", filter_2_name="mixingTool") )] - @classproperty - def ignore_nonrecipe_objects(cls): - return False - @classproperty def ignore_nonrecipe_systems(cls): return False @@ -1961,13 +2177,16 @@ def relax_recipe_systems(cls): return True @classproperty - def ignore_nonrecipe_objects(cls): + def ignore_nonrecipe_systems(cls): return True @classproperty - def ignore_nonrecipe_systems(cls): + def ignore_nonrecipe_objects(cls): return True + @classproperty + def is_multi_instance(cls): + return True class CookingSystemRule(CookingRule): @classmethod @@ -2019,11 +2238,11 @@ def relax_recipe_systems(cls): return False @classproperty - def ignore_nonrecipe_objects(cls): + def ignore_nonrecipe_systems(cls): return False @classproperty - def ignore_nonrecipe_systems(cls): + def ignore_nonrecipe_objects(cls): return False def import_recipes(): @@ -2053,6 +2272,7 @@ def import_recipes(): if (rule_name == "CookingObjectRule" and has_substance) or (rule_name == "CookingSystemRule" and not has_substance): satisfied = False if satisfied: + print(recipe) rule.add_recipe(**recipe) print(f"All recipes of rule {rule_name} imported successfully.") diff --git a/tests/test_transition_rules.py b/tests/test_transition_rules.py index 65d8f0a45..12fe900c3 100644 --- a/tests/test_transition_rules.py +++ b/tests/test_transition_rules.py @@ -8,6 +8,7 @@ from omnigibson.objects import DatasetObject from omnigibson.transition_rules import REGISTERED_RULES import omnigibson as og +from omnigibson.macros import macros as m from utils import og_test, get_random_pose, place_objA_on_objB_bbox, place_obj_on_floor_plane @@ -17,7 +18,6 @@ @og_test def test_cooking_object_rule(): assert len(REGISTERED_RULES) > 0, "No rules registered!" - from IPython import embed; print("test_cooking_object_rule"); embed() oven = og.sim.scene.object_registry("name", "oven") baking_sheet = og.sim.scene.object_registry("name", "baking_sheet") @@ -46,7 +46,7 @@ def test_cooking_object_rule(): assert oven.states[ToggledOn].set_value(True) og.sim.step() - + from IPython import embed; print("test_cooking_object_rule"); embed() # @og_test # def test_slicing_rule(): diff --git a/tests/utils.py b/tests/utils.py index 35d34260a..07ac3f767 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -38,7 +38,7 @@ def get_obj_cfg(name, category, model, prim_type=PrimType.RIGID, scale=None, bou } def assert_test_scene(): - if og.sim.scene is None: + if og.sim is None or og.sim.scene is None: cfg = { "scene": { "type": "Scene", @@ -82,8 +82,9 @@ def assert_test_scene(): ] } - # Make sure sim is stopped - og.sim.stop() + if og.sim is not None: + # Make sure sim is stopped + og.sim.stop() # Make sure GPU dynamics are enabled (GPU dynamics needed for cloth) and no flatcache gm.ENABLE_OBJECT_STATES = True From 632653ec285a185dd1f04c06911f2c042fe52f5f Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Tue, 23 Jan 2024 17:54:31 -0800 Subject: [PATCH 03/24] remove unneccesary code in transition rule class, ready to implement tests across different types of rules --- omnigibson/object_states/factory.py | 1 - omnigibson/transition_rules.py | 26 ++++++-------------------- tests/test_transition_rules.py | 2 +- tests/utils.py | 6 +++--- 4 files changed, 10 insertions(+), 25 deletions(-) diff --git a/omnigibson/object_states/factory.py b/omnigibson/object_states/factory.py index b644bd18d..e47adf94e 100644 --- a/omnigibson/object_states/factory.py +++ b/omnigibson/object_states/factory.py @@ -6,7 +6,6 @@ _ABILITY_TO_STATE_MAPPING = { "robot": [IsGrasping], "attachable": [AttachedTo], - "blender": [], "particleApplier": [ParticleApplier], "particleRemover": [ParticleRemover], "particleSource": [ParticleSource], diff --git a/omnigibson/transition_rules.py b/omnigibson/transition_rules.py index d6e2e34e8..1021f627d 100644 --- a/omnigibson/transition_rules.py +++ b/omnigibson/transition_rules.py @@ -39,9 +39,6 @@ # Default melting temperature m.MELTING_TEMPERATURE = 100.0 -# Where to place objects far out of the scene -m.OBJECT_GRAVEYARD_POS = (100.0, 100.0, 100.0) - # Default "trash" system if an invalid mixing rule transition occurs m.DEFAULT_GARBAGE_SYSTEM = "sludge" @@ -156,7 +153,6 @@ def step(cls): Steps all active transition rules, checking if any are satisfied, and if so, executing their transition """ # First apply any transition object init states from before, and then clear the dictionary - # TODO: figure out if this is still needed for obj, info in cls._INIT_INFO.items(): if info["states"] is not None: for state, args in info["states"].items(): @@ -198,14 +194,10 @@ def execute_transition(cls, added_obj_attrs, removed_objs): ) # First remove pre-existing objects for i, removed_obj in enumerate(removed_objs): - # TODO: Ideally we want to remove objects, but because of Omniverse's bug on GPU physics, we simply move - # the objects into a graveyard for now - removed_obj.set_position(np.array(m.OBJECT_GRAVEYARD_POS) + np.ones(3) * i) - # og.sim.remove_object(removed_obj) + og.sim.remove_object(removed_obj) # Then add new objects if len(added_obj_attrs) > 0: - # TODO: Can we avoid this? Currently Rigid contact checking fails if we import objects dynamically state = og.sim.dump_state() for added_obj_attr in added_obj_attrs: new_obj = added_obj_attr.obj @@ -226,11 +218,6 @@ def execute_transition(cls, added_obj_attrs, removed_objs): "states": added_obj_attr.states, "callback": added_obj_attr.callback, } - gm.ENABLE_TRANSITION_RULES = False - og.sim.stop() - og.sim.play() - gm.ENABLE_TRANSITION_RULES = True - og.sim.load_state(state) @classmethod def clear(cls): @@ -843,7 +830,6 @@ def candidate_filters(cls): @classmethod def _generate_conditions(cls): - # Only heated objects are valid return [StateCondition(filter_name="meltable", state=Temperature, val=m.MELTING_TEMPERATURE, op=operator.ge)] @classmethod @@ -1135,7 +1121,7 @@ def _validate_recipe_objects_are_contained_and_states_satisfied(cls, recipe, con obj_category_to_valid_objs[obj_category].append(idx) # Convert to numpy array for faster indexing - obj_category_to_valid_objs[obj_category] = np.array(obj_category_to_valid_objs[obj_category], dtype=np.int) + obj_category_to_valid_objs[obj_category] = np.array(obj_category_to_valid_objs[obj_category], dtype=int) container_info["execution_info"]["obj_category_to_valid_objs"] = obj_category_to_valid_objs if not cls.is_multi_instance: @@ -1724,7 +1710,7 @@ def add_recipe(cls, name, input_synsets, output_synsets): @classproperty def candidate_filters(cls): - # Modify the container filter to include "blender" ability as well + # Modify the container filter to include the heatable ability as well candidate_filters = super().candidate_filters candidate_filters["container"] = AndFilter(filters=[candidate_filters["container"], AbilityFilter(ability="heatable")]) return candidate_filters @@ -1775,8 +1761,8 @@ def _execute_recipe(cls, container, recipe, in_volume): class ToggleableMachineRule(RecipeRule): """ - Transition mixing rule that leverages "blender" ability objects, which require toggledOn in order to trigger - the recipe event + Transition mixing rule that leverages a single toggleable machine (e.g. electric mixer, coffee machine, blender), + which require toggledOn in order to trigger the recipe event """ @classmethod @@ -1818,7 +1804,7 @@ def add_recipe( @classproperty def candidate_filters(cls): - # Modify the container filter to include "blender" ability as well + # Modify the container filter to include toggleable ability as well candidate_filters = super().candidate_filters candidate_filters["container"] = AndFilter(filters=[candidate_filters["container"], AbilityFilter(ability="toggleable")]) return candidate_filters diff --git a/tests/test_transition_rules.py b/tests/test_transition_rules.py index 12fe900c3..cc2c66f0c 100644 --- a/tests/test_transition_rules.py +++ b/tests/test_transition_rules.py @@ -36,7 +36,7 @@ def test_cooking_object_rule(): og.sim.step() assert bagel_dough.states[OnTop].get_value(baking_sheet) - raw_egg.set_position_orientation([0.02, 0, 0.535], [0, 0, 0, 1]) + raw_egg.set_position_orientation([0.02, 0, 0.54], [0, 0, 0, 1]) og.sim.step() assert raw_egg.states[OnTop].get_value(bagel_dough) diff --git a/tests/utils.py b/tests/utils.py index 07ac3f767..cb6c624c8 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -61,14 +61,14 @@ def assert_test_scene(): get_obj_cfg("bracelet", "bracelet", "thqqmo"), get_obj_cfg("oyster", "oyster", "enzocs"), get_obj_cfg("sink", "sink", "egwapq", scale=np.ones(3)), - get_obj_cfg("stockpot", "stockpot", "dcleem", abilities={"fillable": {}}), + get_obj_cfg("stockpot", "stockpot", "dcleem", abilities={"fillable": {}, "heatable": {}}), get_obj_cfg("applier_dishtowel", "dishtowel", "dtfspn", abilities={"particleApplier": {"method": ParticleModifyMethod.ADJACENCY, "conditions": {"water": []}}}), get_obj_cfg("remover_dishtowel", "dishtowel", "dtfspn", abilities={"particleRemover": {"method": ParticleModifyMethod.ADJACENCY, "conditions": {"water": []}}}), get_obj_cfg("spray_bottle", "spray_bottle", "asztxi", visual_only=True, abilities={"toggleable": {}, "particleApplier": {"method": ParticleModifyMethod.PROJECTION, "conditions": {"water": [(ParticleModifyCondition.TOGGLEDON, True)]}}}), get_obj_cfg("vacuum", "vacuum", "bdmsbr", visual_only=True, abilities={"toggleable": {}, "particleRemover": {"method": ParticleModifyMethod.PROJECTION, "conditions": {"water": [(ParticleModifyCondition.TOGGLEDON, True)]}}}), - get_obj_cfg("blender", "blender", "cwkvib", bounding_box=[0.316, 0.318, 0.649], abilities={"fillable": {}, "blender": {}, "toggleable": {}}), + get_obj_cfg("blender", "blender", "cwkvib", bounding_box=[0.316, 0.318, 0.649], abilities={"fillable": {}, "toggleable": {}, "heatable": {}}), get_obj_cfg("oven", "oven", "cgtaer", bounding_box=[0.943, 0.837, 1.297]), - get_obj_cfg("baking_sheet", "baking_sheet", "yhurut"), + get_obj_cfg("baking_sheet", "baking_sheet", "yhurut", bounding_box=[0.41607812, 0.43617093, 0.02281223]), get_obj_cfg("bagel_dough", "bagel_dough", "iuembm"), get_obj_cfg("raw_egg", "raw_egg", "ydgivr"), ], From 2433fa14c30cd3758492747d0910c55629dc5947 Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Fri, 26 Jan 2024 16:48:21 -0800 Subject: [PATCH 04/24] fix bug for category_to_valid_indices --- omnigibson/transition_rules.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/omnigibson/transition_rules.py b/omnigibson/transition_rules.py index 1021f627d..635927094 100644 --- a/omnigibson/transition_rules.py +++ b/omnigibson/transition_rules.py @@ -1091,13 +1091,14 @@ def _validate_recipe_objects_are_contained_and_states_satisfied(cls, recipe, con container_info["execution_info"] = dict() # Filter input objects based on a subset of input states (unary states and binary system states) - obj_category_to_valid_objs = dict() + # Map object categories (str) to valid indices (np.ndarray) + category_to_valid_indices = dict() for obj_category in recipe["input_objects"]: if obj_category not in recipe["input_states"]: # If there are no input states, all objects of this category are valid - obj_category_to_valid_objs[obj_category] = cls._CATEGORY_IDXS[obj_category] + category_to_valid_indices[obj_category] = cls._CATEGORY_IDXS[obj_category] else: - obj_category_to_valid_objs[obj_category] = [] + category_to_valid_indices[obj_category] = [] for idx in cls._CATEGORY_IDXS[obj_category]: obj = cls._OBJECTS[idx] success = True @@ -1118,16 +1119,16 @@ def _validate_recipe_objects_are_contained_and_states_satisfied(cls, recipe, con if not success: continue - obj_category_to_valid_objs[obj_category].append(idx) + category_to_valid_indices[obj_category].append(idx) # Convert to numpy array for faster indexing - obj_category_to_valid_objs[obj_category] = np.array(obj_category_to_valid_objs[obj_category], dtype=int) + category_to_valid_indices[obj_category] = np.array(category_to_valid_indices[obj_category], dtype=int) - container_info["execution_info"]["obj_category_to_valid_objs"] = obj_category_to_valid_objs + container_info["execution_info"]["category_to_valid_indices"] = category_to_valid_indices if not cls.is_multi_instance: # Check if sufficiently number of objects are contained for obj_category, obj_quantity in recipe["input_objects"].items(): - if np.sum(in_volume[obj_category_to_valid_objs[obj_category]]) < obj_quantity: + if np.sum(in_volume[category_to_valid_indices[obj_category]]) < obj_quantity: return False return True else: @@ -1139,7 +1140,7 @@ def _validate_recipe_objects_are_contained_and_states_satisfied(cls, recipe, con # Example: if a recipe requires 1 apple and 2 bananas, and there are 3 apples and 4 bananas in the # container, then 2 instance of the recipe can be produced. for obj_category, obj_quantity in recipe["input_objects"].items(): - quantity_in_volume = np.sum(in_volume[obj_category_to_valid_objs[obj_category]]) + quantity_in_volume = np.sum(in_volume[category_to_valid_indices[obj_category]]) num_inst = quantity_in_volume // obj_quantity if num_inst < 1: return False @@ -1149,13 +1150,13 @@ def _validate_recipe_objects_are_contained_and_states_satisfied(cls, recipe, con relevant_objects = defaultdict(set) for obj_category, obj_quantity in recipe["input_objects"].items(): quantity_used = num_instances * obj_quantity - relevant_objects[obj_category] = set(obj_category_to_valid_objs[obj_category][:quantity_used]) + relevant_objects[obj_category] = set(cls._OBJECTS[category_to_valid_indices[obj_category][:quantity_used]]) # If multi-instance is True and requires kinematic states between objects else: root_node_category = [node for node in input_object_tree.nodes() if input_object_tree.in_degree(node) == 0][0] # A list of objects belonging to the root node category - root_nodes = cls._OBJECTS[cls._CATEGORY_IDXS[root_node_category]] + root_nodes = cls._OBJECTS[category_to_valid_indices[root_node_category]] input_states = recipe["input_states"] # Recursively check if the kinematic tree is satisfied. @@ -1177,7 +1178,8 @@ def check_kinematic_tree(obj, should_check_in_volume=False): "Each child node should have exactly one binary object state, i.e. one parent in the input_object_tree" state_class, _, state_value = input_states[child_cat]["binary_object"][0] num_valid_children = 0 - for child_obj in cls._OBJECTS[cls._CATEGORY_IDXS[child_cat]]: + children_objs = cls._OBJECTS[category_to_valid_indices[child_cat]] + for child_obj in children_objs: # If the child doesn't satisfy the binary object state, skip if child_obj.states[state_class].get_value(obj) != state_value: continue @@ -1250,10 +1252,10 @@ def _validate_nonrecipe_objects_not_contained(cls, recipe, container_info): """ in_volume = container_info["in_volume"] # These are object indices whose objects satisfy the input states - obj_category_to_valid_objs = container_info["execution_info"]["obj_category_to_valid_objs"] + category_to_valid_indices = container_info["execution_info"]["category_to_valid_indices"] nonrecipe_objects_in_volume = in_volume if len(recipe["input_objects"]) == 0 else \ - np.delete(in_volume, np.concatenate([obj_category_to_valid_objs[obj_category] - for obj_category in obj_category_to_valid_objs])) + np.delete(in_volume, np.concatenate([category_to_valid_indices[obj_category] + for obj_category in category_to_valid_indices])) return not np.any(nonrecipe_objects_in_volume) @classmethod From 7e072d21307677532d861b992bd50f7e2ccc124b Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Thu, 1 Feb 2024 15:09:27 -0800 Subject: [PATCH 05/24] move objects to graveyard before removing them, finish cook_object rule tests, output objects sampling can still occasionally fail --- omnigibson/simulator.py | 69 +++++- omnigibson/transition_rules.py | 3 +- tests/test_transition_rules.py | 384 +++++++++++++++++++++++++++------ tests/utils.py | 12 ++ 4 files changed, 394 insertions(+), 74 deletions(-) diff --git a/omnigibson/simulator.py b/omnigibson/simulator.py index 46e94900f..b8f789a61 100644 --- a/omnigibson/simulator.py +++ b/omnigibson/simulator.py @@ -40,6 +40,8 @@ m.DEFAULT_VIEWER_CAMERA_POS = (-0.201028, -2.72566 , 1.0654) m.DEFAULT_VIEWER_CAMERA_QUAT = (0.68196617, -0.00155408, -0.00166678, 0.73138017) +m.OBJECT_GRAVEYARD_POS = (100.0, 100.0, 100.0) + # Helper functions for starting omnigibson def print_save_usd_warning(_): log.warning("Exporting individual USDs has been disabled in OG due to copyrights.") @@ -503,10 +505,70 @@ def import_object(self, obj, register=True): # Lastly, additionally add this object automatically to be initialized as soon as another simulator step occurs self.initialize_object_on_next_sim_step(obj=obj) + def remove_objects(self, objs): + """ + Remove a list of non-robot object from the simulator. + + Args: + objs (List[BaseObject]): list of non-robot objects to remove + """ + state = self.dump_state() + + # Omniverse has a strange bug where if GPU dynamics is on and the object to remove is in contact with + # with another object (in some specific configuration only, not always), the simulator crashes. Therefore, + # we first move the object to a safe location, then remove it. + pos = list(m.OBJECT_GRAVEYARD_POS) + for obj in objs: + obj.set_position_orientation(pos, [0, 0, 0, 1]) + pos[0] += max(obj.aabb_extent) + + # One timestep will elapse + self.app.update() + + for obj in objs: + self._remove_object(obj) + + # Update all handles that are now broken because objects have changed + self.update_handles() + + # Load the state back + self.load_state(state) + + # Refresh all current rules + TransitionRuleAPI.prune_active_rules() + def remove_object(self, obj): """ Remove a non-robot object from the simulator. + Args: + obj (BaseObject): a non-robot object to remove + """ + state = self.dump_state() + + # Omniverse has a strange bug where if GPU dynamics is on and the object to remove is in contact with + # with another object (in some specific configuration only, not always), the simulator crashes. Therefore, + # we first move the object to a safe location, then remove it. + obj.set_position_orientation(m.OBJECT_GRAVEYARD_POS, [0, 0, 0, 1]) + + # One timestep will elapse + self.app.update() + + self._remove_object(obj) + + # Update all handles that are now broken because objects have changed + self.update_handles() + + # Load the state back + self.load_state(state) + + # Refresh all current rules + TransitionRuleAPI.prune_active_rules() + + def _remove_object(self, obj): + """ + Remove a non-robot object from the simulator. Should not be called directly by the user. + Args: obj (BaseObject): a non-robot object to remove """ @@ -523,15 +585,8 @@ def remove_object(self, obj): if obj.name == initialize_obj.name: self._objects_to_initialize.pop(i) break - self._scene.remove_object(obj) - self.app.update() - # Update all handles that are now broken because objects have changed - self.update_handles() - - # Refresh all current rules - TransitionRuleAPI.prune_active_rules() def remove_prim(self, prim): """ diff --git a/omnigibson/transition_rules.py b/omnigibson/transition_rules.py index 635927094..60271712e 100644 --- a/omnigibson/transition_rules.py +++ b/omnigibson/transition_rules.py @@ -193,8 +193,7 @@ def execute_transition(cls, added_obj_attrs, removed_objs): f"the scene." ) # First remove pre-existing objects - for i, removed_obj in enumerate(removed_objs): - og.sim.remove_object(removed_obj) + og.sim.remove_objects(removed_objs) # Then add new objects if len(added_obj_attrs) > 0: diff --git a/tests/test_transition_rules.py b/tests/test_transition_rules.py index cc2c66f0c..e0ae2a494 100644 --- a/tests/test_transition_rules.py +++ b/tests/test_transition_rules.py @@ -10,13 +10,13 @@ import omnigibson as og from omnigibson.macros import macros as m -from utils import og_test, get_random_pose, place_objA_on_objB_bbox, place_obj_on_floor_plane +from utils import og_test, get_random_pose, place_objA_on_objB_bbox, place_obj_on_floor_plane, retrieve_obj_cfg import pytest import numpy as np @og_test -def test_cooking_object_rule(): +def test_cooking_object_rule_failure_unary_states(): assert len(REGISTERED_RULES) > 0, "No rules registered!" oven = og.sim.scene.object_registry("name", "oven") @@ -25,6 +25,8 @@ def test_cooking_object_rule(): raw_egg = og.sim.scene.object_registry("name", "raw_egg") sesame_seed = get_system("sesame_seed") + initial_bagels = og.sim.scene.object_registry("category", "bagel").copy() + place_obj_on_floor_plane(oven) og.sim.step() @@ -33,111 +35,363 @@ def test_cooking_object_rule(): assert baking_sheet.states[Inside].get_value(oven) bagel_dough.set_position_orientation([0, 0, 0.495], [0, 0, 0, 1]) + raw_egg.set_position_orientation([0.02, 0, 0.54], [0, 0, 0, 1]) og.sim.step() assert bagel_dough.states[OnTop].get_value(baking_sheet) + assert raw_egg.states[OnTop].get_value(bagel_dough) + + # This fails the recipe because it requires the bagel dough and the raw egg to be not cooked + assert bagel_dough.states[Cooked].set_value(True) + assert raw_egg.states[Cooked].set_value(True) + og.sim.step() + + assert bagel_dough.states[Covered].set_value(sesame_seed, True) + assert raw_egg.states[Covered].set_value(sesame_seed, True) + og.sim.step() + + assert oven.states[ToggledOn].set_value(True) + og.sim.step() + + final_bagels = og.sim.scene.object_registry("category", "bagel").copy() + assert len(final_bagels) == len(initial_bagels) + + # Clean up + sesame_seed.remove_all_particles() + +@og_test +def test_cooking_object_rule_failure_binary_system_states(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + + oven = og.sim.scene.object_registry("name", "oven") + baking_sheet = og.sim.scene.object_registry("name", "baking_sheet") + bagel_dough = og.sim.scene.object_registry("name", "bagel_dough") + raw_egg = og.sim.scene.object_registry("name", "raw_egg") + sesame_seed = get_system("sesame_seed") + + initial_bagels = og.sim.scene.object_registry("category", "bagel").copy() + + place_obj_on_floor_plane(oven) + og.sim.step() + + baking_sheet.set_position_orientation([0, 0, 0.455], [0, 0, 0, 1]) + og.sim.step() + assert baking_sheet.states[Inside].get_value(oven) + bagel_dough.set_position_orientation([0, 0, 0.495], [0, 0, 0, 1]) raw_egg.set_position_orientation([0.02, 0, 0.54], [0, 0, 0, 1]) og.sim.step() + assert bagel_dough.states[OnTop].get_value(baking_sheet) assert raw_egg.states[OnTop].get_value(bagel_dough) + assert bagel_dough.states[Cooked].set_value(False) + assert raw_egg.states[Cooked].set_value(False) + og.sim.step() + + # This fails the recipe because it requires the bagel dough and the raw egg to be covered with sesame seed + assert bagel_dough.states[Covered].set_value(sesame_seed, False) + assert raw_egg.states[Covered].set_value(sesame_seed, False) + og.sim.step() + + assert oven.states[ToggledOn].set_value(True) + og.sim.step() + + final_bagels = og.sim.scene.object_registry("category", "bagel").copy() + assert len(final_bagels) == len(initial_bagels) + + # Clean up + sesame_seed.remove_all_particles() + +@og_test +def test_cooking_object_rule_failure_binary_object_states(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + + oven = og.sim.scene.object_registry("name", "oven") + baking_sheet = og.sim.scene.object_registry("name", "baking_sheet") + bagel_dough = og.sim.scene.object_registry("name", "bagel_dough") + raw_egg = og.sim.scene.object_registry("name", "raw_egg") + sesame_seed = get_system("sesame_seed") + + initial_bagels = og.sim.scene.object_registry("category", "bagel").copy() + + place_obj_on_floor_plane(oven) + og.sim.step() + + baking_sheet.set_position_orientation([0, 0, 0.455], [0, 0, 0, 1]) + og.sim.step() + assert baking_sheet.states[Inside].get_value(oven) + + bagel_dough.set_position_orientation([0, 0, 0.495], [0, 0, 0, 1]) + raw_egg.set_position_orientation([0.12, 0.15, 0.47], [0, 0, 0, 1]) + og.sim.step() + assert bagel_dough.states[OnTop].get_value(baking_sheet) + # This fails the recipe because it requires the raw egg to be on top of the bagel dough + assert not raw_egg.states[OnTop].get_value(bagel_dough) + + assert bagel_dough.states[Cooked].set_value(False) + assert raw_egg.states[Cooked].set_value(False) + og.sim.step() + + assert bagel_dough.states[Covered].set_value(sesame_seed, True) assert raw_egg.states[Covered].set_value(sesame_seed, True) og.sim.step() assert oven.states[ToggledOn].set_value(True) og.sim.step() - from IPython import embed; print("test_cooking_object_rule"); embed() + final_bagels = og.sim.scene.object_registry("category", "bagel").copy() + assert len(final_bagels) == len(initial_bagels) -# @og_test -# def test_slicing_rule(): -# assert len(REGISTERED_RULES) > 0, "No rules registered!" + # Clean up + sesame_seed.remove_all_particles() + +@og_test +def test_cooking_object_rule_failure_wrong_container(): + # from IPython import embed; print("debug"); embed() + assert len(REGISTERED_RULES) > 0, "No rules registered!" + + oven = og.sim.scene.object_registry("name", "oven") + stockpot = og.sim.scene.object_registry("name", "stockpot") + bagel_dough = og.sim.scene.object_registry("name", "bagel_dough") + raw_egg = og.sim.scene.object_registry("name", "raw_egg") + sesame_seed = get_system("sesame_seed") + + initial_bagels = og.sim.scene.object_registry("category", "bagel").copy() + + place_obj_on_floor_plane(oven) + og.sim.step() + + # This fails the recipe because it requires the baking sheet to be inside the oven, not the stockpot + stockpot.set_position_orientation([0, 0, 0.47], [0, 0, 0, 1]) + og.sim.step() + assert stockpot.states[Inside].get_value(oven) + + bagel_dough.set_position_orientation([0, 0, 0.45], [0, 0, 0, 1]) + raw_egg.set_position_orientation([0.02, 0, 0.50], [0, 0, 0, 1]) + og.sim.step() + assert bagel_dough.states[OnTop].get_value(stockpot) + assert raw_egg.states[OnTop].get_value(bagel_dough) + + assert bagel_dough.states[Cooked].set_value(False) + assert raw_egg.states[Cooked].set_value(False) + og.sim.step() + + assert bagel_dough.states[Covered].set_value(sesame_seed, True) + assert raw_egg.states[Covered].set_value(sesame_seed, True) + og.sim.step() + + assert oven.states[ToggledOn].set_value(True) + og.sim.step() + + final_bagels = og.sim.scene.object_registry("category", "bagel").copy() + assert len(final_bagels) == len(initial_bagels) + + # Clean up + sesame_seed.remove_all_particles() @og_test -def test_blender_rule(): +def test_cooking_object_rule_failure_wrong_heat_source(): assert len(REGISTERED_RULES) > 0, "No rules registered!" - blender = og.sim.scene.object_registry("name", "blender") - blender.set_orientation([0, 0, 0, 1]) - place_obj_on_floor_plane(blender) + stove = og.sim.scene.object_registry("name", "stove") + baking_sheet = og.sim.scene.object_registry("name", "baking_sheet") + bagel_dough = og.sim.scene.object_registry("name", "bagel_dough") + raw_egg = og.sim.scene.object_registry("name", "raw_egg") + sesame_seed = get_system("sesame_seed") + + initial_bagels = og.sim.scene.object_registry("category", "bagel").copy() + + # This fails the recipe because it requires the oven to be the heat source, not the stove + place_obj_on_floor_plane(stove) og.sim.step() - milk = get_system("whole_milk") - chocolate_sauce = get_system("chocolate_sauce") - milkshake = get_system("milkshake") - milk.generate_particles(positions=np.array([[0.02, 0, 0.5]])) - chocolate_sauce.generate_particles(positions=np.array([[0, -0.02, 0.5]])) + heat_source_position = stove.states[HeatSourceOrSink].link.get_position() + baking_sheet.set_position_orientation([-0.20, 0, 0.80], [0, 0, 0, 1]) + og.sim.step() - ice_cream = DatasetObject( - name="ice_cream", - category="scoop_of_ice_cream", - model="dodndj", - bounding_box=[0.076, 0.077, 0.065], - ) - og.sim.import_object(ice_cream) - ice_cream.set_position([0, 0, 0.54]) + bagel_dough.set_position_orientation([-0.20, 0, 0.84], [0, 0, 0, 1]) + raw_egg.set_position_orientation([-0.18, 0, 0.845], [0, 0, 0, 1]) + og.sim.step() + assert bagel_dough.states[OnTop].get_value(baking_sheet) + assert raw_egg.states[OnTop].get_value(bagel_dough) - for i in range(5): - og.sim.step() + assert bagel_dough.states[Cooked].set_value(True) + assert raw_egg.states[Cooked].set_value(True) + og.sim.step() - assert milkshake.n_particles == 0 + assert bagel_dough.states[Covered].set_value(sesame_seed, True) + assert raw_egg.states[Covered].set_value(sesame_seed, True) + og.sim.step() - blender.states[ToggledOn].set_value(True) + assert stove.states[ToggledOn].set_value(True) og.sim.step() - assert milk.n_particles == 0 - assert chocolate_sauce.n_particles == 0 - assert milkshake.n_particles > 0 + # Make sure the stove affects the baking sheet + assert stove.states[HeatSourceOrSink].affects_obj(baking_sheet) - # Remove objects and systems from recipe output - milkshake.remove_all_particles() - if og.sim.scene.object_registry("name", "ice_cream") is not None: - og.sim.remove_object(obj=ice_cream) + final_bagels = og.sim.scene.object_registry("category", "bagel").copy() + assert len(final_bagels) == len(initial_bagels) + # Clean up + sesame_seed.remove_all_particles() @og_test -def test_cooking_rule(): +def test_cooking_object_rule_success(): assert len(REGISTERED_RULES) > 0, "No rules registered!" + oven = og.sim.scene.object_registry("name", "oven") - oven.keep_still() - oven.set_orientation([0, 0, -0.707, 0.707]) + baking_sheet = og.sim.scene.object_registry("name", "baking_sheet") + bagel_dough = og.sim.scene.object_registry("name", "bagel_dough") + raw_egg = og.sim.scene.object_registry("name", "raw_egg") + sesame_seed = get_system("sesame_seed") + + deleted_objs = [bagel_dough, raw_egg] + deleted_objs_cfg = [retrieve_obj_cfg(obj) for obj in deleted_objs] + + initial_bagels = og.sim.scene.object_registry("category", "bagel").copy() + place_obj_on_floor_plane(oven) og.sim.step() - sheet = DatasetObject( - name="sheet", - category="baking_sheet", - model="yhurut", - bounding_box=[0.520, 0.312, 0.0395], - ) + baking_sheet.set_position_orientation([0, 0, 0.455], [0, 0, 0, 1]) + og.sim.step() + assert baking_sheet.states[Inside].get_value(oven) - og.sim.import_object(sheet) - sheet.set_position_orientation([0.072, 0.004, 0.455], [0, 0, 0, 1]) + bagel_dough.set_position_orientation([0, 0, 0.495], [0, 0, 0, 1]) + raw_egg.set_position_orientation([0.02, 0, 0.54], [0, 0, 0, 1]) + og.sim.step() + assert bagel_dough.states[OnTop].get_value(baking_sheet) + assert raw_egg.states[OnTop].get_value(bagel_dough) - dough = DatasetObject( - name="dough", - category="sugar_cookie_dough", - model="qewbbb", - bounding_box=[0.200, 0.192, 0.0957], - ) - og.sim.import_object(dough) - dough.set_position_orientation([0.072, 0.004, 0.555], [0, 0, 0, 1]) + assert bagel_dough.states[Cooked].set_value(False) + assert raw_egg.states[Cooked].set_value(False) + og.sim.step() - for i in range(10): - og.sim.step() + assert bagel_dough.states[Covered].set_value(sesame_seed, True) + assert raw_egg.states[Covered].set_value(sesame_seed, True) + og.sim.step() + + assert oven.states[ToggledOn].set_value(True) + og.sim.step() + + final_bagels = og.sim.scene.object_registry("category", "bagel").copy() - assert len(og.sim.scene.object_registry("category", "sugar_cookie", default_val=[])) == 0 + # Recipe should execute successfully: new bagels should be created, and the ingredients should be deleted + assert len(final_bagels) > len(initial_bagels) + for obj in deleted_objs: + assert og.sim.scene.object_registry("name", obj.name) is None - oven.states[ToggledOn].set_value(True) + # Need to step again for the new bagels to be initialized, placed in the container, and cooked. og.sim.step() + + # All new bagels should be cooked + new_bagels = final_bagels - initial_bagels + for bagel in new_bagels: + assert bagel.states[Cooked].get_value() + assert bagel.states[OnTop].get_value(baking_sheet) + + # Clean up + sesame_seed.remove_all_particles() og.sim.step() - dough_exists = og.sim.scene.object_registry("name", "dough") is not None - assert not dough_exists or not dough.states[OnTop].get_value(sheet) - assert len(og.sim.scene.object_registry("category", "sugar_cookie")) > 0 + og.sim.remove_objects(new_bagels) + og.sim.step() - # Remove objects - if dough_exists: - og.sim.remove_object(dough) - og.sim.remove_object(sheet) + for obj_cfg in deleted_objs_cfg: + obj = DatasetObject(**obj_cfg) + og.sim.import_object(obj) + og.sim.step() -test_cooking_object_rule() \ No newline at end of file +# @og_test +# def test_slicing_rule(): +# assert len(REGISTERED_RULES) > 0, "No rules registered!" + +# @og_test +# def test_blender_rule(): +# assert len(REGISTERED_RULES) > 0, "No rules registered!" +# blender = og.sim.scene.object_registry("name", "blender") +# +# blender.set_orientation([0, 0, 0, 1]) +# place_obj_on_floor_plane(blender) +# og.sim.step() +# +# milk = get_system("whole_milk") +# chocolate_sauce = get_system("chocolate_sauce") +# milkshake = get_system("milkshake") +# milk.generate_particles(positions=np.array([[0.02, 0, 0.5]])) +# chocolate_sauce.generate_particles(positions=np.array([[0, -0.02, 0.5]])) +# +# ice_cream = DatasetObject( +# name="ice_cream", +# category="scoop_of_ice_cream", +# model="dodndj", +# bounding_box=[0.076, 0.077, 0.065], +# ) +# og.sim.import_object(ice_cream) +# ice_cream.set_position([0, 0, 0.54]) +# +# for i in range(5): +# og.sim.step() +# +# assert milkshake.n_particles == 0 +# +# blender.states[ToggledOn].set_value(True) +# og.sim.step() +# +# assert milk.n_particles == 0 +# assert chocolate_sauce.n_particles == 0 +# assert milkshake.n_particles > 0 +# +# # Remove objects and systems from recipe output +# milkshake.remove_all_particles() +# if og.sim.scene.object_registry("name", "ice_cream") is not None: +# og.sim.remove_object(obj=ice_cream) +# +# +# @og_test +# def test_cooking_rule(): +# assert len(REGISTERED_RULES) > 0, "No rules registered!" +# oven = og.sim.scene.object_registry("name", "oven") +# oven.keep_still() +# oven.set_orientation([0, 0, -0.707, 0.707]) +# place_obj_on_floor_plane(oven) +# og.sim.step() +# +# sheet = DatasetObject( +# name="sheet", +# category="baking_sheet", +# model="yhurut", +# bounding_box=[0.520, 0.312, 0.0395], +# ) +# +# og.sim.import_object(sheet) +# sheet.set_position_orientation([0.072, 0.004, 0.455], [0, 0, 0, 1]) +# +# dough = DatasetObject( +# name="dough", +# category="sugar_cookie_dough", +# model="qewbbb", +# bounding_box=[0.200, 0.192, 0.0957], +# ) +# og.sim.import_object(dough) +# dough.set_position_orientation([0.072, 0.004, 0.555], [0, 0, 0, 1]) +# +# for i in range(10): +# og.sim.step() +# +# assert len(og.sim.scene.object_registry("category", "sugar_cookie", default_val=[])) == 0 +# +# oven.states[ToggledOn].set_value(True) +# og.sim.step() +# og.sim.step() +# +# dough_exists = og.sim.scene.object_registry("name", "dough") is not None +# assert not dough_exists or not dough.states[OnTop].get_value(sheet) +# assert len(og.sim.scene.object_registry("category", "sugar_cookie")) > 0 +# +# # Remove objects +# if dough_exists: +# og.sim.remove_object(dough) +# og.sim.remove_object(sheet) + +# test_cooking_object_rule_failure_wrong_container() +# test_cooking_object_rule_failure_unary_states() \ No newline at end of file diff --git a/tests/utils.py b/tests/utils.py index cb6c624c8..a8251c228 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -20,6 +20,18 @@ def wrapper(): num_objs = 0 +def retrieve_obj_cfg(obj): + return { + "name": obj.name, + "category": obj.category, + "model": obj.model, + "prim_type": obj.prim_type, + "position": obj.get_position(), + "scale": obj.scale, + "abilities": obj.abilities, + "visual_only": obj.visual_only, + } + def get_obj_cfg(name, category, model, prim_type=PrimType.RIGID, scale=None, bounding_box=None, abilities=None, visual_only=False): global num_objs num_objs += 1 From 06adb7c79b056d33fad83747c5e84f2a8a69c425 Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Thu, 1 Feb 2024 15:14:16 -0800 Subject: [PATCH 06/24] relax test for output bagels --- tests/test_transition_rules.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_transition_rules.py b/tests/test_transition_rules.py index e0ae2a494..8829c75a6 100644 --- a/tests/test_transition_rules.py +++ b/tests/test_transition_rules.py @@ -287,8 +287,10 @@ def test_cooking_object_rule_success(): new_bagels = final_bagels - initial_bagels for bagel in new_bagels: assert bagel.states[Cooked].get_value() - assert bagel.states[OnTop].get_value(baking_sheet) - + # This assertion occasionally fails, because when four bagels are sampled on top of the baking sheet one by one, + # there is no guarantee that all four of them will be on top of the baking sheet at the end. + # assert bagel.states[OnTop].get_value(baking_sheet) + assert bagel.states[Inside].get_value(oven) # Clean up sesame_seed.remove_all_particles() og.sim.step() From 9cdf64ab66be2b12e66c2cd3ca12cd5b886cd09c Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Fri, 2 Feb 2024 15:01:41 -0800 Subject: [PATCH 07/24] remove required_systems from transition rules --- omnigibson/systems/system_base.py | 14 ++++---------- omnigibson/transition_rules.py | 21 ++++----------------- 2 files changed, 8 insertions(+), 27 deletions(-) diff --git a/omnigibson/systems/system_base.py b/omnigibson/systems/system_base.py index 122a4de20..79a0a135d 100644 --- a/omnigibson/systems/system_base.py +++ b/omnigibson/systems/system_base.py @@ -139,11 +139,8 @@ def initialize(cls): # Add to registry SYSTEM_REGISTRY.add(obj=cls) - # Make sure to refresh any transition rules that require this system - # Import now to avoid circular imports - from omnigibson.transition_rules import TransitionRuleAPI, RULES_REGISTRY - system_rules = RULES_REGISTRY("required_systems", cls.name, default_val=[]) - TransitionRuleAPI.refresh_rules(rules=system_rules) + + TransitionRuleAPI.refresh_all_rules() # Run any callbacks for callback in _CALLBACKS_ON_SYSTEM_INIT.values(): @@ -218,11 +215,8 @@ def clear(cls): # Remove from active registry SYSTEM_REGISTRY.remove(obj=cls) - # Make sure to refresh any transition rules that require this system - # Import now to avoid circular imports - from omnigibson.transition_rules import TransitionRuleAPI, RULES_REGISTRY - system_rules = RULES_REGISTRY("required_systems", cls.name, default_val=[]) - TransitionRuleAPI.refresh_rules(rules=system_rules) + + TransitionRuleAPI.refresh_all_rules() @classmethod def reset(cls): diff --git a/omnigibson/transition_rules.py b/omnigibson/transition_rules.py index 60271712e..6d014f54e 100644 --- a/omnigibson/transition_rules.py +++ b/omnigibson/transition_rules.py @@ -575,16 +575,6 @@ def __init_subclass__(cls, **kwargs): # Store conditions cls.conditions = cls._generate_conditions() - @classproperty - def required_systems(cls): - """ - Particle systems that this transition rule cares about. Should be specified by subclass. - - Returns: - list of str: Particle system names which must be active in order for the transition rule to occur - """ - return [] - @classproperty def candidate_filters(cls): """ @@ -635,12 +625,10 @@ def get_object_candidates(cls, objects): filters = cls.candidate_filters obj_dict = {filter_name: [] for filter_name in filters.keys()} - # Only compile candidates if all active system requirements are met - if np.all([is_system_active(system_name=name) for name in cls.required_systems]): - for obj in objects: - for fname, f in filters.items(): - if f(obj): - obj_dict[fname].append(obj) + for obj in objects: + for fname, f in filters.items(): + if f(obj): + obj_dict[fname].append(obj) return obj_dict @@ -712,7 +700,6 @@ def _cls_registry(cls): name="TransitionRuleRegistry", class_types=BaseTransitionRule, default_key="__name__", - group_keys=["required_systems"], ) From 4a9dde152024bf8656fee4de442a0fd55dee3ddf Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Mon, 5 Feb 2024 16:28:23 -0800 Subject: [PATCH 08/24] add tests for toggleable single machine and cook objects --- omnigibson/systems/system_base.py | 6 +- omnigibson/transition_rules.py | 13 +- tests/test_object_states.py | 2 +- tests/test_transition_rules.py | 668 ++++++++++++++++++++++++------ tests/utils.py | 4 + 5 files changed, 550 insertions(+), 143 deletions(-) diff --git a/omnigibson/systems/system_base.py b/omnigibson/systems/system_base.py index 79a0a135d..1652e8aea 100644 --- a/omnigibson/systems/system_base.py +++ b/omnigibson/systems/system_base.py @@ -32,7 +32,7 @@ # Modifiers denoting a semantic difference in the system -SYSTEM_PREFIXES = {"diced", "cooked"} +SYSTEM_PREFIXES = {"diced", "cooked", "melted"} class BaseSystem(SerializableNonInstance, UniquelyNamedNonInstance): @@ -140,6 +140,8 @@ def initialize(cls): # Add to registry SYSTEM_REGISTRY.add(obj=cls) + # Avoid circular import + from omnigibson.transition_rules import TransitionRuleAPI TransitionRuleAPI.refresh_all_rules() # Run any callbacks @@ -216,6 +218,8 @@ def clear(cls): # Remove from active registry SYSTEM_REGISTRY.remove(obj=cls) + # Avoid circular import + from omnigibson.transition_rules import TransitionRuleAPI TransitionRuleAPI.refresh_all_rules() @classmethod diff --git a/omnigibson/transition_rules.py b/omnigibson/transition_rules.py index 6d014f54e..4dac53bf8 100644 --- a/omnigibson/transition_rules.py +++ b/omnigibson/transition_rules.py @@ -474,19 +474,15 @@ def __init__( values have changed since the previous time this condition was called """ self._condition = condition - self._last_valid_candidates = None + self._last_valid_candidates = {filter_name: set() for filter_name in self.modifies_filter_names} def refresh(self, object_candidates): # Refresh nested condition self._condition.refresh(object_candidates=object_candidates) - # Clear last valid candidates - self._last_valid_candidates = {filter_name: set() for filter_name in self.modifies_filter_names} - def __call__(self, object_candidates): # Call wrapped method first valid = self._condition(object_candidates=object_candidates) - # Iterate over all current candidates -- if there's a mismatch in last valid candidates and current, # then we store it, otherwise, we don't for filter_name in self.modifies_filter_names: @@ -824,7 +820,7 @@ def transition(cls, object_candidates): # Convert the meltable object into its melted substance for meltable_obj in object_candidates["meltable"]: - system = get_system(f"melted_{meltable_obj.category}") + system = get_system(f"melted__{meltable_obj.category}") system.generate_particles_from_link(meltable_obj, meltable_obj.root_link, check_contact=False, use_visual_meshes=False) # Delete original object from stage. @@ -1344,22 +1340,27 @@ def _is_recipe_executable(cls, recipe, container, global_info, container_info): # Verify the container category is valid if not cls._validate_recipe_container_is_valid(recipe=recipe, container=container): + print("recipe container is not valid") return False # Verify all required systems are contained in the container if not cls.relax_recipe_systems and not cls._validate_recipe_systems_are_contained(recipe=recipe, container=container): + print("recipe systems are not contained") return False # Verify all required object quantities are contained in the container and their states are satisfied if not cls._validate_recipe_objects_are_contained_and_states_satisfied(recipe=recipe, container_info=container_info): + print("recipe objects are not contained or their states are not satisfied") return False # Verify no non-relevant system is contained if not cls.ignore_nonrecipe_systems and not cls._validate_nonrecipe_systems_not_contained(recipe=recipe, container=container): + print("non-recipe systems are contained") return False # Verify no non-relevant object is contained if we're not ignoring them if not cls.ignore_nonrecipe_objects and not cls._validate_nonrecipe_objects_not_contained(recipe=recipe, container_info=container_info): + print("non-recipe objects are contained") return False return True diff --git a/tests/test_object_states.py b/tests/test_object_states.py index 47df5627a..80a995273 100644 --- a/tests/test_object_states.py +++ b/tests/test_object_states.py @@ -1057,7 +1057,7 @@ def test_filled(): systems = ( get_system("water"), get_system("raspberry"), - get_system("diced_apple"), + get_system("diced__apple"), ) for system in systems: stockpot.set_position_orientation(position=np.ones(3) * 50.0, orientation=[0, 0, 0, 1.0]) diff --git a/tests/test_transition_rules.py b/tests/test_transition_rules.py index 8829c75a6..62d1265e2 100644 --- a/tests/test_transition_rules.py +++ b/tests/test_transition_rules.py @@ -16,7 +16,50 @@ import numpy as np @og_test -def test_cooking_object_rule_failure_unary_states(): +def test_cooking_object_rule_failure_wrong_container(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + + oven = og.sim.scene.object_registry("name", "oven") + stockpot = og.sim.scene.object_registry("name", "stockpot") + bagel_dough = og.sim.scene.object_registry("name", "bagel_dough") + raw_egg = og.sim.scene.object_registry("name", "raw_egg") + sesame_seed = get_system("sesame_seed") + + initial_bagels = og.sim.scene.object_registry("category", "bagel", set()).copy() + + place_obj_on_floor_plane(oven) + og.sim.step() + + # This fails the recipe because it requires the baking sheet to be inside the oven, not the stockpot + stockpot.set_position_orientation([0, 0, 0.47], [0, 0, 0, 1]) + og.sim.step() + assert stockpot.states[Inside].get_value(oven) + + bagel_dough.set_position_orientation([0, 0, 0.45], [0, 0, 0, 1]) + raw_egg.set_position_orientation([0.02, 0, 0.50], [0, 0, 0, 1]) + og.sim.step() + assert bagel_dough.states[OnTop].get_value(stockpot) + assert raw_egg.states[OnTop].get_value(bagel_dough) + + assert bagel_dough.states[Cooked].set_value(False) + assert raw_egg.states[Cooked].set_value(False) + og.sim.step() + + assert bagel_dough.states[Covered].set_value(sesame_seed, True) + assert raw_egg.states[Covered].set_value(sesame_seed, True) + og.sim.step() + + assert oven.states[ToggledOn].set_value(True) + og.sim.step() + + final_bagels = og.sim.scene.object_registry("category", "bagel", set()).copy() + assert len(final_bagels) == len(initial_bagels) + + # Clean up + sesame_seed.remove_all_particles() + +@og_test +def test_cooking_object_rule_failure_recipe_objects(): assert len(REGISTERED_RULES) > 0, "No rules registered!" oven = og.sim.scene.object_registry("name", "oven") @@ -25,7 +68,7 @@ def test_cooking_object_rule_failure_unary_states(): raw_egg = og.sim.scene.object_registry("name", "raw_egg") sesame_seed = get_system("sesame_seed") - initial_bagels = og.sim.scene.object_registry("category", "bagel").copy() + initial_bagels = og.sim.scene.object_registry("category", "bagel", set()).copy() place_obj_on_floor_plane(oven) og.sim.step() @@ -34,15 +77,15 @@ def test_cooking_object_rule_failure_unary_states(): og.sim.step() assert baking_sheet.states[Inside].get_value(oven) - bagel_dough.set_position_orientation([0, 0, 0.495], [0, 0, 0, 1]) - raw_egg.set_position_orientation([0.02, 0, 0.54], [0, 0, 0, 1]) + # This fails the recipe because it requires the bagel dough to be on top of the baking sheet + bagel_dough.set_position_orientation([1, 0, 0.495], [0, 0, 0, 1]) + raw_egg.set_position_orientation([1.02, 0, 0.54], [0, 0, 0, 1]) og.sim.step() - assert bagel_dough.states[OnTop].get_value(baking_sheet) + assert not bagel_dough.states[OnTop].get_value(baking_sheet) assert raw_egg.states[OnTop].get_value(bagel_dough) - # This fails the recipe because it requires the bagel dough and the raw egg to be not cooked - assert bagel_dough.states[Cooked].set_value(True) - assert raw_egg.states[Cooked].set_value(True) + assert bagel_dough.states[Cooked].set_value(False) + assert raw_egg.states[Cooked].set_value(False) og.sim.step() assert bagel_dough.states[Covered].set_value(sesame_seed, True) @@ -52,14 +95,14 @@ def test_cooking_object_rule_failure_unary_states(): assert oven.states[ToggledOn].set_value(True) og.sim.step() - final_bagels = og.sim.scene.object_registry("category", "bagel").copy() + final_bagels = og.sim.scene.object_registry("category", "bagel", set()).copy() assert len(final_bagels) == len(initial_bagels) # Clean up sesame_seed.remove_all_particles() @og_test -def test_cooking_object_rule_failure_binary_system_states(): +def test_cooking_object_rule_failure_unary_states(): assert len(REGISTERED_RULES) > 0, "No rules registered!" oven = og.sim.scene.object_registry("name", "oven") @@ -68,7 +111,7 @@ def test_cooking_object_rule_failure_binary_system_states(): raw_egg = og.sim.scene.object_registry("name", "raw_egg") sesame_seed = get_system("sesame_seed") - initial_bagels = og.sim.scene.object_registry("category", "bagel").copy() + initial_bagels = og.sim.scene.object_registry("category", "bagel", set()).copy() place_obj_on_floor_plane(oven) og.sim.step() @@ -83,26 +126,26 @@ def test_cooking_object_rule_failure_binary_system_states(): assert bagel_dough.states[OnTop].get_value(baking_sheet) assert raw_egg.states[OnTop].get_value(bagel_dough) - assert bagel_dough.states[Cooked].set_value(False) - assert raw_egg.states[Cooked].set_value(False) + # This fails the recipe because it requires the bagel dough and the raw egg to be not cooked + assert bagel_dough.states[Cooked].set_value(True) + assert raw_egg.states[Cooked].set_value(True) og.sim.step() - # This fails the recipe because it requires the bagel dough and the raw egg to be covered with sesame seed - assert bagel_dough.states[Covered].set_value(sesame_seed, False) - assert raw_egg.states[Covered].set_value(sesame_seed, False) + assert bagel_dough.states[Covered].set_value(sesame_seed, True) + assert raw_egg.states[Covered].set_value(sesame_seed, True) og.sim.step() assert oven.states[ToggledOn].set_value(True) og.sim.step() - final_bagels = og.sim.scene.object_registry("category", "bagel").copy() + final_bagels = og.sim.scene.object_registry("category", "bagel", set()).copy() assert len(final_bagels) == len(initial_bagels) # Clean up sesame_seed.remove_all_particles() @og_test -def test_cooking_object_rule_failure_binary_object_states(): +def test_cooking_object_rule_failure_binary_system_states(): assert len(REGISTERED_RULES) > 0, "No rules registered!" oven = og.sim.scene.object_registry("name", "oven") @@ -111,7 +154,7 @@ def test_cooking_object_rule_failure_binary_object_states(): raw_egg = og.sim.scene.object_registry("name", "raw_egg") sesame_seed = get_system("sesame_seed") - initial_bagels = og.sim.scene.object_registry("category", "bagel").copy() + initial_bagels = og.sim.scene.object_registry("category", "bagel", set()).copy() place_obj_on_floor_plane(oven) og.sim.step() @@ -121,55 +164,54 @@ def test_cooking_object_rule_failure_binary_object_states(): assert baking_sheet.states[Inside].get_value(oven) bagel_dough.set_position_orientation([0, 0, 0.495], [0, 0, 0, 1]) - raw_egg.set_position_orientation([0.12, 0.15, 0.47], [0, 0, 0, 1]) + raw_egg.set_position_orientation([0.02, 0, 0.54], [0, 0, 0, 1]) og.sim.step() assert bagel_dough.states[OnTop].get_value(baking_sheet) - # This fails the recipe because it requires the raw egg to be on top of the bagel dough - assert not raw_egg.states[OnTop].get_value(bagel_dough) + assert raw_egg.states[OnTop].get_value(bagel_dough) assert bagel_dough.states[Cooked].set_value(False) assert raw_egg.states[Cooked].set_value(False) og.sim.step() - assert bagel_dough.states[Covered].set_value(sesame_seed, True) - assert raw_egg.states[Covered].set_value(sesame_seed, True) + # This fails the recipe because it requires the bagel dough and the raw egg to be covered with sesame seed + assert bagel_dough.states[Covered].set_value(sesame_seed, False) + assert raw_egg.states[Covered].set_value(sesame_seed, False) og.sim.step() assert oven.states[ToggledOn].set_value(True) og.sim.step() - final_bagels = og.sim.scene.object_registry("category", "bagel").copy() + final_bagels = og.sim.scene.object_registry("category", "bagel", set()).copy() assert len(final_bagels) == len(initial_bagels) # Clean up sesame_seed.remove_all_particles() @og_test -def test_cooking_object_rule_failure_wrong_container(): - # from IPython import embed; print("debug"); embed() +def test_cooking_object_rule_failure_binary_object_states(): assert len(REGISTERED_RULES) > 0, "No rules registered!" oven = og.sim.scene.object_registry("name", "oven") - stockpot = og.sim.scene.object_registry("name", "stockpot") + baking_sheet = og.sim.scene.object_registry("name", "baking_sheet") bagel_dough = og.sim.scene.object_registry("name", "bagel_dough") raw_egg = og.sim.scene.object_registry("name", "raw_egg") sesame_seed = get_system("sesame_seed") - initial_bagels = og.sim.scene.object_registry("category", "bagel").copy() + initial_bagels = og.sim.scene.object_registry("category", "bagel", set()).copy() place_obj_on_floor_plane(oven) og.sim.step() - # This fails the recipe because it requires the baking sheet to be inside the oven, not the stockpot - stockpot.set_position_orientation([0, 0, 0.47], [0, 0, 0, 1]) + baking_sheet.set_position_orientation([0, 0, 0.455], [0, 0, 0, 1]) og.sim.step() - assert stockpot.states[Inside].get_value(oven) + assert baking_sheet.states[Inside].get_value(oven) - bagel_dough.set_position_orientation([0, 0, 0.45], [0, 0, 0, 1]) - raw_egg.set_position_orientation([0.02, 0, 0.50], [0, 0, 0, 1]) + bagel_dough.set_position_orientation([0, 0, 0.495], [0, 0, 0, 1]) + raw_egg.set_position_orientation([0.12, 0.15, 0.47], [0, 0, 0, 1]) og.sim.step() - assert bagel_dough.states[OnTop].get_value(stockpot) - assert raw_egg.states[OnTop].get_value(bagel_dough) + assert bagel_dough.states[OnTop].get_value(baking_sheet) + # This fails the recipe because it requires the raw egg to be on top of the bagel dough + assert not raw_egg.states[OnTop].get_value(bagel_dough) assert bagel_dough.states[Cooked].set_value(False) assert raw_egg.states[Cooked].set_value(False) @@ -182,7 +224,7 @@ def test_cooking_object_rule_failure_wrong_container(): assert oven.states[ToggledOn].set_value(True) og.sim.step() - final_bagels = og.sim.scene.object_registry("category", "bagel").copy() + final_bagels = og.sim.scene.object_registry("category", "bagel", set()).copy() assert len(final_bagels) == len(initial_bagels) # Clean up @@ -198,7 +240,7 @@ def test_cooking_object_rule_failure_wrong_heat_source(): raw_egg = og.sim.scene.object_registry("name", "raw_egg") sesame_seed = get_system("sesame_seed") - initial_bagels = og.sim.scene.object_registry("category", "bagel").copy() + initial_bagels = og.sim.scene.object_registry("category", "bagel", set()).copy() # This fails the recipe because it requires the oven to be the heat source, not the stove place_obj_on_floor_plane(stove) @@ -228,7 +270,7 @@ def test_cooking_object_rule_failure_wrong_heat_source(): # Make sure the stove affects the baking sheet assert stove.states[HeatSourceOrSink].affects_obj(baking_sheet) - final_bagels = og.sim.scene.object_registry("category", "bagel").copy() + final_bagels = og.sim.scene.object_registry("category", "bagel", set()).copy() assert len(final_bagels) == len(initial_bagels) # Clean up @@ -247,7 +289,7 @@ def test_cooking_object_rule_success(): deleted_objs = [bagel_dough, raw_egg] deleted_objs_cfg = [retrieve_obj_cfg(obj) for obj in deleted_objs] - initial_bagels = og.sim.scene.object_registry("category", "bagel").copy() + initial_bagels = og.sim.scene.object_registry("category", "bagel", set()).copy() place_obj_on_floor_plane(oven) og.sim.step() @@ -273,7 +315,7 @@ def test_cooking_object_rule_success(): assert oven.states[ToggledOn].set_value(True) og.sim.step() - final_bagels = og.sim.scene.object_registry("category", "bagel").copy() + final_bagels = og.sim.scene.object_registry("category", "bagel", set()).copy() # Recipe should execute successfully: new bagels should be created, and the ingredients should be deleted assert len(final_bagels) > len(initial_bagels) @@ -291,6 +333,7 @@ def test_cooking_object_rule_success(): # there is no guarantee that all four of them will be on top of the baking sheet at the end. # assert bagel.states[OnTop].get_value(baking_sheet) assert bagel.states[Inside].get_value(oven) + # Clean up sesame_seed.remove_all_particles() og.sim.step() @@ -303,97 +346,452 @@ def test_cooking_object_rule_success(): og.sim.import_object(obj) og.sim.step() -# @og_test -# def test_slicing_rule(): -# assert len(REGISTERED_RULES) > 0, "No rules registered!" - -# @og_test -# def test_blender_rule(): -# assert len(REGISTERED_RULES) > 0, "No rules registered!" -# blender = og.sim.scene.object_registry("name", "blender") -# -# blender.set_orientation([0, 0, 0, 1]) -# place_obj_on_floor_plane(blender) -# og.sim.step() -# -# milk = get_system("whole_milk") -# chocolate_sauce = get_system("chocolate_sauce") -# milkshake = get_system("milkshake") -# milk.generate_particles(positions=np.array([[0.02, 0, 0.5]])) -# chocolate_sauce.generate_particles(positions=np.array([[0, -0.02, 0.5]])) -# -# ice_cream = DatasetObject( -# name="ice_cream", -# category="scoop_of_ice_cream", -# model="dodndj", -# bounding_box=[0.076, 0.077, 0.065], -# ) -# og.sim.import_object(ice_cream) -# ice_cream.set_position([0, 0, 0.54]) -# -# for i in range(5): -# og.sim.step() -# -# assert milkshake.n_particles == 0 -# -# blender.states[ToggledOn].set_value(True) -# og.sim.step() -# -# assert milk.n_particles == 0 -# assert chocolate_sauce.n_particles == 0 -# assert milkshake.n_particles > 0 -# -# # Remove objects and systems from recipe output -# milkshake.remove_all_particles() -# if og.sim.scene.object_registry("name", "ice_cream") is not None: -# og.sim.remove_object(obj=ice_cream) -# -# -# @og_test -# def test_cooking_rule(): -# assert len(REGISTERED_RULES) > 0, "No rules registered!" -# oven = og.sim.scene.object_registry("name", "oven") -# oven.keep_still() -# oven.set_orientation([0, 0, -0.707, 0.707]) -# place_obj_on_floor_plane(oven) -# og.sim.step() -# -# sheet = DatasetObject( -# name="sheet", -# category="baking_sheet", -# model="yhurut", -# bounding_box=[0.520, 0.312, 0.0395], -# ) -# -# og.sim.import_object(sheet) -# sheet.set_position_orientation([0.072, 0.004, 0.455], [0, 0, 0, 1]) -# -# dough = DatasetObject( -# name="dough", -# category="sugar_cookie_dough", -# model="qewbbb", -# bounding_box=[0.200, 0.192, 0.0957], -# ) -# og.sim.import_object(dough) -# dough.set_position_orientation([0.072, 0.004, 0.555], [0, 0, 0, 1]) -# -# for i in range(10): -# og.sim.step() -# -# assert len(og.sim.scene.object_registry("category", "sugar_cookie", default_val=[])) == 0 -# -# oven.states[ToggledOn].set_value(True) -# og.sim.step() -# og.sim.step() -# -# dough_exists = og.sim.scene.object_registry("name", "dough") is not None -# assert not dough_exists or not dough.states[OnTop].get_value(sheet) -# assert len(og.sim.scene.object_registry("category", "sugar_cookie")) > 0 -# -# # Remove objects -# if dough_exists: -# og.sim.remove_object(dough) -# og.sim.remove_object(sheet) - -# test_cooking_object_rule_failure_wrong_container() -# test_cooking_object_rule_failure_unary_states() \ No newline at end of file +@og_test +def test_single_toggleable_machine_rule_output_system_failure_wrong_container(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + food_processor = og.sim.scene.object_registry("name", "food_processor") + ice_cream = og.sim.scene.object_registry("name", "scoop_of_ice_cream") + milk = get_system("whole_milk") + chocolate_sauce = get_system("chocolate_sauce") + milkshake = get_system("milkshake") + sludge = get_system("sludge") + + deleted_objs = [ice_cream] + deleted_objs_cfg = [retrieve_obj_cfg(obj) for obj in deleted_objs] + + # This fails the recipe because it requires the blender to be the container, not the food processor + place_obj_on_floor_plane(food_processor) + og.sim.step() + + milk.generate_particles(positions=np.array([[0.02, 0, 0.25]])) + chocolate_sauce.generate_particles(positions=np.array([[0, -0.02, 0.25]])) + ice_cream.set_position([0, 0, 0.2]) + + og.sim.step() + + assert food_processor.states[Contains].get_value(milk) + assert food_processor.states[Contains].get_value(chocolate_sauce) + assert ice_cream.states[Inside].get_value(food_processor) + + assert milkshake.n_particles == 0 + assert sludge.n_particles == 0 + + food_processor.states[ToggledOn].set_value(True) + og.sim.step() + + # Recipe should fail: no milkshake should be created, and sludge should be created. + assert milkshake.n_particles == 0 + assert sludge.n_particles > 0 + assert milk.n_particles == 0 + assert chocolate_sauce.n_particles == 0 + for obj in deleted_objs: + assert og.sim.scene.object_registry("name", obj.name) is None + + # Clean up + sludge.remove_all_particles() + og.sim.step() + + for obj_cfg in deleted_objs_cfg: + obj = DatasetObject(**obj_cfg) + og.sim.import_object(obj) + og.sim.step() + +@og_test +def test_single_toggleable_machine_rule_output_system_failure_recipe_systems(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + blender = og.sim.scene.object_registry("name", "blender") + ice_cream = og.sim.scene.object_registry("name", "scoop_of_ice_cream") + milk = get_system("whole_milk") + chocolate_sauce = get_system("chocolate_sauce") + milkshake = get_system("milkshake") + sludge = get_system("sludge") + + deleted_objs = [ice_cream] + deleted_objs_cfg = [retrieve_obj_cfg(obj) for obj in deleted_objs] + + place_obj_on_floor_plane(blender) + og.sim.step() + + # This fails the recipe because it requires the milk to be in the blender + milk.generate_particles(positions=np.array([[0.02, 0, 1.5]])) + chocolate_sauce.generate_particles(positions=np.array([[0, -0.02, 0.5]])) + ice_cream.set_position([0, 0, 0.54]) + + og.sim.step() + + assert not blender.states[Contains].get_value(milk) + assert blender.states[Contains].get_value(chocolate_sauce) + assert ice_cream.states[Inside].get_value(blender) + + assert milkshake.n_particles == 0 + assert sludge.n_particles == 0 + + blender.states[ToggledOn].set_value(True) + og.sim.step() + + # Recipe should fail: no milkshake should be created, and sludge should be created. + assert milkshake.n_particles == 0 + assert sludge.n_particles > 0 + assert chocolate_sauce.n_particles == 0 + for obj in deleted_objs: + assert og.sim.scene.object_registry("name", obj.name) is None + + # Clean up + sludge.remove_all_particles() + milk.remove_all_particles() + og.sim.step() + + for obj_cfg in deleted_objs_cfg: + obj = DatasetObject(**obj_cfg) + og.sim.import_object(obj) + og.sim.step() + +@og_test +def test_single_toggleable_machine_rule_output_system_failure_recipe_objects(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + blender = og.sim.scene.object_registry("name", "blender") + ice_cream = og.sim.scene.object_registry("name", "scoop_of_ice_cream") + milk = get_system("whole_milk") + chocolate_sauce = get_system("chocolate_sauce") + milkshake = get_system("milkshake") + sludge = get_system("sludge") + + place_obj_on_floor_plane(blender) + og.sim.step() + + milk.generate_particles(positions=np.array([[0.02, 0, 0.5]])) + chocolate_sauce.generate_particles(positions=np.array([[0, -0.02, 0.5]])) + # This fails the recipe because it requires the ice cream to be inside the blender + ice_cream.set_position([0, 0, 1.54]) + + og.sim.step() + + assert blender.states[Contains].get_value(milk) + assert blender.states[Contains].get_value(chocolate_sauce) + assert not ice_cream.states[Inside].get_value(blender) + + assert milkshake.n_particles == 0 + assert sludge.n_particles == 0 + + blender.states[ToggledOn].set_value(True) + og.sim.step() + + # Recipe should fail: no milkshake should be created, and sludge should be created. + assert milkshake.n_particles == 0 + assert sludge.n_particles > 0 + assert milk.n_particles == 0 + assert chocolate_sauce.n_particles == 0 + + # Clean up + sludge.remove_all_particles() + og.sim.step() + +@og_test +def test_single_toggleable_machine_rule_output_system_failure_nonrecipe_systems(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + blender = og.sim.scene.object_registry("name", "blender") + ice_cream = og.sim.scene.object_registry("name", "scoop_of_ice_cream") + milk = get_system("whole_milk") + chocolate_sauce = get_system("chocolate_sauce") + milkshake = get_system("milkshake") + sludge = get_system("sludge") + water = get_system("water") + + deleted_objs = [ice_cream] + deleted_objs_cfg = [retrieve_obj_cfg(obj) for obj in deleted_objs] + + place_obj_on_floor_plane(blender) + og.sim.step() + + milk.generate_particles(positions=np.array([[0.02, 0, 0.5]])) + chocolate_sauce.generate_particles(positions=np.array([[0, -0.02, 0.5]])) + # This fails the recipe because water (nonrecipe system) is in the blender + water.generate_particles(positions=np.array([[0, 0, 0.5]])) + ice_cream.set_position([0, 0, 0.54]) + + og.sim.step() + + assert blender.states[Contains].get_value(milk) + assert blender.states[Contains].get_value(chocolate_sauce) + assert blender.states[Contains].get_value(water) + assert ice_cream.states[Inside].get_value(blender) + + assert milkshake.n_particles == 0 + assert sludge.n_particles == 0 + + blender.states[ToggledOn].set_value(True) + og.sim.step() + + # Recipe should fail: no milkshake should be created, and sludge should be created. + assert milkshake.n_particles == 0 + assert sludge.n_particles > 0 + assert milk.n_particles == 0 + assert chocolate_sauce.n_particles == 0 + assert water.n_particles == 0 + + # Clean up + sludge.remove_all_particles() + og.sim.step() + for obj_cfg in deleted_objs_cfg: + obj = DatasetObject(**obj_cfg) + og.sim.import_object(obj) + og.sim.step() + +@og_test +def test_single_toggleable_machine_rule_output_system_failure_nonrecipe_systems(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + blender = og.sim.scene.object_registry("name", "blender") + ice_cream = og.sim.scene.object_registry("name", "scoop_of_ice_cream") + bowl = og.sim.scene.object_registry("name", "bowl") + milk = get_system("whole_milk") + chocolate_sauce = get_system("chocolate_sauce") + milkshake = get_system("milkshake") + sludge = get_system("sludge") + + deleted_objs = [ice_cream, bowl] + deleted_objs_cfg = [retrieve_obj_cfg(obj) for obj in deleted_objs] + + place_obj_on_floor_plane(blender) + og.sim.step() + + milk.generate_particles(positions=np.array([[0.02, 0, 0.5]])) + chocolate_sauce.generate_particles(positions=np.array([[0, -0.02, 0.5]])) + ice_cream.set_position([0, 0, 0.54]) + # This fails the recipe because the bowl (nonrecipe object) is in the blender + bowl.set_position([0, 0, 0.6]) + + og.sim.step() + + assert blender.states[Contains].get_value(milk) + assert blender.states[Contains].get_value(chocolate_sauce) + assert ice_cream.states[Inside].get_value(blender) + assert bowl.states[Inside].get_value(blender) + + assert milkshake.n_particles == 0 + assert sludge.n_particles == 0 + + blender.states[ToggledOn].set_value(True) + og.sim.step() + + # Recipe should fail: no milkshake should be created, and sludge should be created. + assert milkshake.n_particles == 0 + assert sludge.n_particles > 0 + assert milk.n_particles == 0 + assert chocolate_sauce.n_particles == 0 + + # Clean up + sludge.remove_all_particles() + og.sim.step() + for obj_cfg in deleted_objs_cfg: + obj = DatasetObject(**obj_cfg) + og.sim.import_object(obj) + og.sim.step() + +@og_test +def test_single_toggleable_machine_rule_output_system_success(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + blender = og.sim.scene.object_registry("name", "blender") + ice_cream = og.sim.scene.object_registry("name", "scoop_of_ice_cream") + milk = get_system("whole_milk") + chocolate_sauce = get_system("chocolate_sauce") + milkshake = get_system("milkshake") + sludge = get_system("sludge") + + deleted_objs = [ice_cream] + deleted_objs_cfg = [retrieve_obj_cfg(obj) for obj in deleted_objs] + + place_obj_on_floor_plane(blender) + og.sim.step() + + milk.generate_particles(positions=np.array([[0.02, 0, 0.5]])) + chocolate_sauce.generate_particles(positions=np.array([[0, -0.02, 0.5]])) + ice_cream.set_position([0, 0, 0.54]) + + og.sim.step() + + assert blender.states[Contains].get_value(milk) + assert blender.states[Contains].get_value(chocolate_sauce) + assert ice_cream.states[Inside].get_value(blender) + + assert milkshake.n_particles == 0 + assert sludge.n_particles == 0 + + blender.states[ToggledOn].set_value(True) + og.sim.step() + + # Recipe should execute successfully: new milkshake should be created, and the ingredients should be deleted + assert milkshake.n_particles > 0 + assert sludge.n_particles == 0 + assert milk.n_particles == 0 + assert chocolate_sauce.n_particles == 0 + for obj in deleted_objs: + assert og.sim.scene.object_registry("name", obj.name) is None + + # Clean up + milkshake.remove_all_particles() + og.sim.step() + + for obj_cfg in deleted_objs_cfg: + obj = DatasetObject(**obj_cfg) + og.sim.import_object(obj) + og.sim.step() + +@og_test +def test_single_toggleable_machine_rule_output_object_failure_unary_states(): + from IPython import embed; embed() + assert len(REGISTERED_RULES) > 0, "No rules registered!" + electric_mixer = og.sim.scene.object_registry("name", "electric_mixer") + raw_egg = og.sim.scene.object_registry("name", "raw_egg") + another_raw_egg = og.sim.scene.object_registry("name", "another_raw_egg") + flour = get_system("flour") + granulated_sugar = get_system("granulated_sugar") + vanilla = get_system("vanilla") + melted_butter = get_system("melted__butter") + baking_powder = get_system("baking_powder") + salt = get_system("salt") + sludge = get_system("sludge") + + initial_doughs = og.sim.scene.object_registry("category", "sugar_cookie_dough", set()).copy() + + deleted_objs = [raw_egg, another_raw_egg] + deleted_objs_cfg = [retrieve_obj_cfg(obj) for obj in deleted_objs] + + place_obj_on_floor_plane(electric_mixer) + og.sim.step() + + another_raw_egg.set_position_orientation([0, 0.1, 0.2], [0, 0, 0, 1]) + raw_egg.set_position_orientation([0, 0.1, 0.17], [0, 0, 0, 1]) + flour.generate_particles(positions=np.array([[-0.02, 0.06, 0.15]])) + granulated_sugar.generate_particles(positions=np.array([[0.0, 0.06, 0.15]])) + vanilla.generate_particles(positions=np.array([[0.02, 0.06, 0.15]])) + melted_butter.generate_particles(positions=np.array([[-0.02, 0.08, 0.15]])) + baking_powder.generate_particles(positions=np.array([[0.0, 0.08, 0.15]])) + salt.generate_particles(positions=np.array([[0.02, 0.08, 0.15]])) + # This fails the recipe because the egg should not be cooked + raw_egg.states[Cooked].set_value(True) + og.sim.step() + + assert electric_mixer.states[Contains].get_value(flour) + assert electric_mixer.states[Contains].get_value(granulated_sugar) + assert electric_mixer.states[Contains].get_value(vanilla) + assert electric_mixer.states[Contains].get_value(melted_butter) + assert electric_mixer.states[Contains].get_value(baking_powder) + assert electric_mixer.states[Contains].get_value(salt) + assert raw_egg.states[Inside].get_value(electric_mixer) + assert raw_egg.states[Cooked].get_value() + assert another_raw_egg.states[Inside].get_value(electric_mixer) + assert not another_raw_egg.states[Cooked].get_value() + + assert sludge.n_particles == 0 + + electric_mixer.states[ToggledOn].set_value(True) + og.sim.step() + + # Recipe should fail: no dough should be created, and sludge should be created. + final_doughs = og.sim.scene.object_registry("category", "sugar_cookie_dough", set()).copy() + + # Recipe should execute successfully: new dough should be created, and the ingredients should be deleted + assert len(final_doughs) == len(initial_doughs) + for obj in deleted_objs: + assert og.sim.scene.object_registry("name", obj.name) is None + assert flour.n_particles == 0 + assert granulated_sugar.n_particles == 0 + assert vanilla.n_particles == 0 + assert melted_butter.n_particles == 0 + assert baking_powder.n_particles == 0 + assert salt.n_particles == 0 + assert sludge.n_particles > 0 + + # Clean up + sludge.remove_all_particles() + og.sim.step() + + for obj_cfg in deleted_objs_cfg: + obj = DatasetObject(**obj_cfg) + og.sim.import_object(obj) + og.sim.step() + +@og_test +def test_single_toggleable_machine_rule_output_object_success(): + from IPython import embed; embed() + assert len(REGISTERED_RULES) > 0, "No rules registered!" + electric_mixer = og.sim.scene.object_registry("name", "electric_mixer") + raw_egg = og.sim.scene.object_registry("name", "raw_egg") + another_raw_egg = og.sim.scene.object_registry("name", "another_raw_egg") + flour = get_system("flour") + granulated_sugar = get_system("granulated_sugar") + vanilla = get_system("vanilla") + melted_butter = get_system("melted__butter") + baking_powder = get_system("baking_powder") + salt = get_system("salt") + sludge = get_system("sludge") + + initial_doughs = og.sim.scene.object_registry("category", "sugar_cookie_dough", set()).copy() + + deleted_objs = [raw_egg, another_raw_egg] + deleted_objs_cfg = [retrieve_obj_cfg(obj) for obj in deleted_objs] + + place_obj_on_floor_plane(electric_mixer) + og.sim.step() + + another_raw_egg.set_position_orientation([0, 0.1, 0.2], [0, 0, 0, 1]) + raw_egg.set_position_orientation([0, 0.1, 0.17], [0, 0, 0, 1]) + flour.generate_particles(positions=np.array([[-0.02, 0.06, 0.15]])) + granulated_sugar.generate_particles(positions=np.array([[0.0, 0.06, 0.15]])) + vanilla.generate_particles(positions=np.array([[0.02, 0.06, 0.15]])) + melted_butter.generate_particles(positions=np.array([[-0.02, 0.08, 0.15]])) + baking_powder.generate_particles(positions=np.array([[0.0, 0.08, 0.15]])) + salt.generate_particles(positions=np.array([[0.02, 0.08, 0.15]])) + + og.sim.step() + + assert electric_mixer.states[Contains].get_value(flour) + assert electric_mixer.states[Contains].get_value(granulated_sugar) + assert electric_mixer.states[Contains].get_value(vanilla) + assert electric_mixer.states[Contains].get_value(melted_butter) + assert electric_mixer.states[Contains].get_value(baking_powder) + assert electric_mixer.states[Contains].get_value(salt) + assert raw_egg.states[Inside].get_value(electric_mixer) + assert not raw_egg.states[Cooked].get_value() + assert another_raw_egg.states[Inside].get_value(electric_mixer) + assert not another_raw_egg.states[Cooked].get_value() + + assert sludge.n_particles == 0 + + electric_mixer.states[ToggledOn].set_value(True) + og.sim.step() + + # Recipe should execute successfully: new dough should be created, and the ingredients should be deleted + final_doughs = og.sim.scene.object_registry("category", "sugar_cookie_dough", set()).copy() + + # Recipe should execute successfully: new dough should be created, and the ingredients should be deleted + assert len(final_doughs) > len(initial_doughs) + for obj in deleted_objs: + assert og.sim.scene.object_registry("name", obj.name) is None + assert flour.n_particles == 0 + assert granulated_sugar.n_particles == 0 + assert vanilla.n_particles == 0 + assert melted_butter.n_particles == 0 + assert baking_powder.n_particles == 0 + assert salt.n_particles == 0 + + # Need to step again for the new dough to be initialized, placed in the container, and cooked. + og.sim.step() + + # All new doughs should not be cooked + new_doughs = final_doughs - initial_doughs + for dough in new_doughs: + assert not dough.states[Cooked].get_value() + assert dough.states[OnTop].get_value(electric_mixer) + + # Clean up + og.sim.remove_objects(new_doughs) + og.sim.step() + + for obj_cfg in deleted_objs_cfg: + obj = DatasetObject(**obj_cfg) + og.sim.import_object(obj) + og.sim.step() + + +test_single_toggleable_machine_rule_output_object_failure_unary_states() diff --git a/tests/utils.py b/tests/utils.py index a8251c228..6a995083a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -83,6 +83,10 @@ def assert_test_scene(): get_obj_cfg("baking_sheet", "baking_sheet", "yhurut", bounding_box=[0.41607812, 0.43617093, 0.02281223]), get_obj_cfg("bagel_dough", "bagel_dough", "iuembm"), get_obj_cfg("raw_egg", "raw_egg", "ydgivr"), + get_obj_cfg("scoop_of_ice_cream", "scoop_of_ice_cream", "dodndj", bounding_box=[0.076, 0.077, 0.065]), + get_obj_cfg("food_processor", "food_processor", "gamkbo"), + get_obj_cfg("electric_mixer", "electric_mixer", "ceaeqf"), + get_obj_cfg("another_raw_egg", "raw_egg", "ydgivr"), ], "robots": [ { From 5ddf6bf91f46dd91d1bcb01361f80a2b67d25e1b Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Tue, 6 Feb 2024 18:37:55 -0800 Subject: [PATCH 09/24] finish all tests; ready to implement dryer and washer rules --- omnigibson/object_states/factory.py | 2 +- omnigibson/objects/stateful_object.py | 4 + omnigibson/simulator.py | 4 + omnigibson/systems/macro_particle_system.py | 21 +- omnigibson/transition_rules.py | 49 +- tests/test_transition_rules.py | 650 +++++++++++++++++++- tests/utils.py | 6 + 7 files changed, 703 insertions(+), 33 deletions(-) diff --git a/omnigibson/object_states/factory.py b/omnigibson/object_states/factory.py index e47adf94e..53c2a42b6 100644 --- a/omnigibson/object_states/factory.py +++ b/omnigibson/object_states/factory.py @@ -16,7 +16,7 @@ "freezable": [Frozen], "heatable": [Heated], "heatSource": [HeatSourceOrSink], - "meltable": [], + "meltable": [MaxTemperature], "mixingTool": [], "openable": [Open], "flammable": [OnFire], diff --git a/omnigibson/objects/stateful_object.py b/omnigibson/objects/stateful_object.py index 9eaec129e..3c243f4d6 100644 --- a/omnigibson/objects/stateful_object.py +++ b/omnigibson/objects/stateful_object.py @@ -480,6 +480,10 @@ def _load_state(self, state): # Call super method first super()._load_state(state=state) + # Load non-kinematic states + self.load_non_kin_state(state) + + def load_non_kin_state(self, state): # Load all states that are stateful for state_type, state_instance in self._states.items(): state_name = get_state_name(state_type) diff --git a/omnigibson/simulator.py b/omnigibson/simulator.py index b8f789a61..a0936481a 100644 --- a/omnigibson/simulator.py +++ b/omnigibson/simulator.py @@ -29,6 +29,7 @@ from omnigibson.object_states.factory import get_states_by_dependency_order from omnigibson.object_states.update_state_mixin import UpdateStateMixin from omnigibson.sensors.vision_sensor import VisionSensor +from omnigibson.systems.macro_particle_system import MacroPhysicalParticleSystem from omnigibson.transition_rules import TransitionRuleAPI # Create module logger @@ -621,6 +622,9 @@ def update_handles(self): # Only need to update if object is already initialized as well if obj.initialized: obj.update_handles() + for system in self.scene.systems: + if issubclass(system, MacroPhysicalParticleSystem): + system.refresh_particles_view() # Finally update any unified views RigidContactAPI.initialize_view() diff --git a/omnigibson/systems/macro_particle_system.py b/omnigibson/systems/macro_particle_system.py index 00d413caa..c03b62bb8 100644 --- a/omnigibson/systems/macro_particle_system.py +++ b/omnigibson/systems/macro_particle_system.py @@ -1102,6 +1102,9 @@ class MacroPhysicalParticleSystem(PhysicalParticleSystem, MacroParticleSystem): _particle_radius = None _particle_offset = None + # We need to manually call refresh_particles_view the first time when particle count goes from 0 to non-zero + _has_refreshed_particles_view = False + @classmethod def initialize(cls): # Run super method first @@ -1111,11 +1114,17 @@ def initialize(cls): og.sim.stage.DefinePrim(f"{cls.prim_path}/particles", "Scope") # A new view needs to be created every time once sim is playing, so we add a callback now - og.sim.add_callback_on_play(name=f"{cls.name}_particles_view", callback=cls._refresh_particles_view) + og.sim.add_callback_on_play(name=f"{cls.name}_particles_view", callback=cls.refresh_particles_view) # If sim is already playing, refresh particles immediately if og.sim.is_playing(): - cls._refresh_particles_view() + cls.refresh_particles_view() + + @classmethod + def update(cls): + if not cls._has_refreshed_particles_view and cls.n_particles > 0: + cls.refresh_particles_view() + cls._has_refreshed_particles_view = True @classmethod def _load_new_particle(cls, prim_path, name): @@ -1142,7 +1151,7 @@ def set_particle_template_object(cls, obj): cls._particle_offset, cls._particle_radius = trimesh.nsphere.minimum_nsphere(trimesh.Trimesh(vertices=vertices)) @classmethod - def _refresh_particles_view(cls): + def refresh_particles_view(cls): """ Internal helper method to refresh the particles' rigid body view to grab state @@ -1166,7 +1175,7 @@ def remove_particle_by_name(cls, name): super().remove_particle_by_name(name=name) # Refresh particles view - cls._refresh_particles_view() + cls.refresh_particles_view() @classmethod def add_particle(cls, prim_path, scale, idn=None): @@ -1174,7 +1183,7 @@ def add_particle(cls, prim_path, scale, idn=None): particle = super().add_particle(prim_path=prim_path, scale=scale, idn=idn) # Refresh particles view - cls._refresh_particles_view() + cls.refresh_particles_view() return particle @@ -1441,7 +1450,7 @@ def _load_state(cls, state): super()._load_state(state=state) # Make sure view is refreshed - cls._refresh_particles_view() + cls.refresh_particles_view() # Make sure we update all the velocities cls.set_particles_velocities(state["lin_velocities"], state["ang_velocities"]) diff --git a/omnigibson/transition_rules.py b/omnigibson/transition_rules.py index 4dac53bf8..c976ab72a 100644 --- a/omnigibson/transition_rules.py +++ b/omnigibson/transition_rules.py @@ -184,14 +184,6 @@ def execute_transition(cls, added_obj_attrs, removed_objs): """ # Process all transition results if len(removed_objs) > 0: - disclaimer( - f"We are attempting to remove objects during the transition rule phase of the simulator step.\n" - f"However, Omniverse currently has a bug when using GPU dynamics where a segfault will occur if an " - f"object in contact with another object is attempted to be removed.\n" - f"This bug should be fixed by the next Omniverse release.\n" - f"In the meantime, we instead teleport these objects to a graveyard location located far outside of " - f"the scene." - ) # First remove pre-existing objects og.sim.remove_objects(removed_objs) @@ -705,9 +697,8 @@ class SlicingRule(BaseTransitionRule): """ @classproperty def candidate_filters(cls): - # TODO: Remove hacky filter once half category is updated properly return { - "sliceable": AndFilter(filters=[AbilityFilter("sliceable"), NotFilter(NameFilter(name="half"))]), + "sliceable": AbilityFilter("sliceable"), "slicer": AbilityFilter("slicer"), } @@ -720,6 +711,11 @@ def _generate_conditions(cls): @classmethod def transition(cls, object_candidates): objs_to_add, objs_to_remove = [], [] + + # Define callback for propagating non-kinematic state from whole objects to half objects + def _get_load_non_kin_state_callback(state): + return lambda obj: obj.load_non_kin_state(state) + for sliceable_obj in object_candidates["sliceable"]: # Object parts offset annotation are w.r.t the base link of the whole object. pos, orn = sliceable_obj.get_position_orientation() @@ -749,18 +745,19 @@ def transition(cls, object_candidates): part_bb_orn = T.quat_multiply(orn, part_bb_orn) part_obj_name = f"half_{sliceable_obj.name}_{i}" part_obj = DatasetObject( - prim_path=f"/World/{part_obj_name}", name=part_obj_name, category=part["category"], model=part["model"], bounding_box=part["bb_size"] * scale, # equiv. to scale=(part["bb_size"] / self.native_bbox) * (scale) ) + # Propagate non-physical states of the whole object to the half objects, e.g. cooked, saturated, etc. # Add the new object to the results. new_obj_attrs = ObjectAttrs( obj=part_obj, bb_pos=part_bb_pos, bb_orn=part_bb_orn, + callback=_get_load_non_kin_state_callback(sliceable_obj.dump_state()) ) objs_to_add.append(new_obj_attrs) @@ -792,7 +789,11 @@ def transition(cls, object_candidates): objs_to_remove = [] for diceable_obj in object_candidates["diceable"]: - system = get_system(f"diced__{diceable_obj.category}") + obj_category = diceable_obj.category + system_name = "diced__" + diceable_obj.category.removeprefix("half_") + if Cooked in diceable_obj.states and diceable_obj.states[Cooked].get_value(): + system_name = "cooked__" + system_name + system = get_system(system_name) system.generate_particles_from_link(diceable_obj, diceable_obj.root_link, check_contact=False, use_visual_meshes=False) # Delete original object from stage. @@ -812,7 +813,7 @@ def candidate_filters(cls): @classmethod def _generate_conditions(cls): - return [StateCondition(filter_name="meltable", state=Temperature, val=m.MELTING_TEMPERATURE, op=operator.ge)] + return [StateCondition(filter_name="meltable", state=MaxTemperature, val=m.MELTING_TEMPERATURE, op=operator.ge)] @classmethod def transition(cls, object_candidates): @@ -1610,8 +1611,8 @@ def _spawn_object_in_container(obj): out_system.generate_particles_from_link( obj=container, link=contained_particles_state.link, - # In these two cases, we don't necessarily have removed all objects in the container. - check_contact=cls.ignore_nonrecipe_objects or cls.is_multi_instance, + # We don't necessarily have removed all objects in the container. + check_contact=cls.ignore_nonrecipe_objects, max_samples=int(volume / (np.pi * (out_system.particle_radius ** 3) * 4 / 3)), ) @@ -1692,8 +1693,8 @@ def add_recipe(cls, name, input_synsets, output_synsets): assert len(input_systems) == 1 or len(input_systems) == 2, \ f"Only one or two input systems can be specified for {cls.__name__}, recipe: {name}!" if len(input_systems) == 2: - assert input_systems[1] == "water", \ - f"Second input system must be water for {cls.__name__}, recipe: {name}!" + assert input_systems[1] == "cooked__water", \ + f"Second input system must be cooked__water for {cls.__name__}, recipe: {name}!" assert len(output_systems) == 1, \ f"Exactly one output system needs to be specified for {cls.__name__}, recipe: {name}!" @@ -1726,7 +1727,8 @@ def use_garbage_fallback_recipe(cls): return False @classmethod - def _execute_recipe(cls, container, recipe, in_volume): + def _execute_recipe(cls, container, recipe, container_info): + in_volume = container_info["in_volume"] system = get_system(recipe["input_systems"][0]) contained_particles_state = container.states[ContainedParticles].get_value(system) in_volume_idx = np.where(contained_particles_state.in_volume)[0] @@ -1742,8 +1744,8 @@ def _execute_recipe(cls, container, recipe, in_volume): # Remove water if the cooking requires water if len(recipe["input_systems"]) > 1: - water_system = get_system(recipe["input_systems"][1]) - container.states[Contains].set_value(water_system, False) + cooked_water_system = get_system(recipe["input_systems"][1]) + container.states[Contains].set_value(cooked_water_system, False) return TransitionResults(add=[], remove=[]) @@ -1861,6 +1863,10 @@ def _generate_conditions(cls): condition=TouchingAnyCondition(filter_1_name="container", filter_2_name="mixingTool") )] + @classproperty + def relax_recipe_systems(cls): + return False + @classproperty def ignore_nonrecipe_systems(cls): return False @@ -2101,6 +2107,7 @@ def _do_not_register_classes(cls): classes.add("CookingRule") return classes + class CookingObjectRule(CookingRule): @classmethod def add_recipe( @@ -2163,6 +2170,7 @@ def ignore_nonrecipe_objects(cls): def is_multi_instance(cls): return True + class CookingSystemRule(CookingRule): @classmethod def add_recipe( @@ -2220,6 +2228,7 @@ def ignore_nonrecipe_systems(cls): def ignore_nonrecipe_objects(cls): return False + def import_recipes(): for json_file, rule_names in _JSON_FILES_TO_RULES.items(): recipe_fpath = os.path.join(os.path.dirname(bddl.__file__), "generated_data", "transition_map", "tm_jsons", json_file) diff --git a/tests/test_transition_rules.py b/tests/test_transition_rules.py index 62d1265e2..1f54d7adf 100644 --- a/tests/test_transition_rules.py +++ b/tests/test_transition_rules.py @@ -9,12 +9,655 @@ from omnigibson.transition_rules import REGISTERED_RULES import omnigibson as og from omnigibson.macros import macros as m +from scipy.spatial.transform import Rotation as R from utils import og_test, get_random_pose, place_objA_on_objB_bbox, place_obj_on_floor_plane, retrieve_obj_cfg import pytest import numpy as np +@og_test +def test_slicing_rule(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + apple = og.sim.scene.object_registry("name", "apple") + table_knife = og.sim.scene.object_registry("name", "table_knife") + + deleted_objs = [apple] + deleted_objs_cfg = [retrieve_obj_cfg(obj) for obj in deleted_objs] + + assert apple.states[Cooked].set_value(True) + + initial_half_apples = og.sim.scene.object_registry("category", "half_apple", set()).copy() + + place_obj_on_floor_plane(apple) + og.sim.step() + + table_knife.set_position_orientation([-0.05, 0.0, 0.15], R.from_euler("xyz", [-np.pi / 2, 0, 0]).as_quat()) + og.sim.step() + assert not table_knife.states[Touching].get_value(apple) + final_half_apples = og.sim.scene.object_registry("category", "half_apple", set()).copy() + assert len(final_half_apples) == len(initial_half_apples) + for obj in deleted_objs: + assert og.sim.scene.object_registry("name", obj.name) is not None + + table_knife.set_position_orientation([-0.05, 0.0, 0.10], R.from_euler("xyz", [-np.pi / 2, 0, 0]).as_quat()) + og.sim.step() + final_half_apples = og.sim.scene.object_registry("category", "half_apple", set()).copy() + assert len(final_half_apples) > len(initial_half_apples) + for obj in deleted_objs: + assert og.sim.scene.object_registry("name", obj.name) is None + + # One more step for the half apples to be initialized + og.sim.step() + + # All new half_apple should be cooked + new_half_apples = final_half_apples - initial_half_apples + for half_apple in new_half_apples: + assert half_apple.states[Cooked].get_value() + + # Clean up + og.sim.remove_objects(new_half_apples) + og.sim.step() + + for obj_cfg in deleted_objs_cfg: + obj = DatasetObject(**obj_cfg) + og.sim.import_object(obj) + og.sim.step() + +@og_test +def test_dicing_rule_cooked(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + half_apple = og.sim.scene.object_registry("name", "half_apple") + table_knife = og.sim.scene.object_registry("name", "table_knife") + cooked_diced_apple = get_system("cooked__diced__apple") + + deleted_objs = [half_apple] + deleted_objs_cfg = [retrieve_obj_cfg(obj) for obj in deleted_objs] + + place_obj_on_floor_plane(half_apple) + og.sim.step() + + assert half_apple.states[Cooked].set_value(True) + + assert cooked_diced_apple.n_particles == 0 + + table_knife.set_position_orientation([-0.05, 0.0, 0.15], R.from_euler("xyz", [-np.pi / 2, 0, 0]).as_quat()) + og.sim.step() + + assert not table_knife.states[Touching].get_value(half_apple) + assert cooked_diced_apple.n_particles == 0 + for obj in deleted_objs: + assert og.sim.scene.object_registry("name", obj.name) is not None + + table_knife.set_position_orientation([-0.05, 0.0, 0.10], R.from_euler("xyz", [-np.pi / 2, 0, 0]).as_quat()) + og.sim.step() + + assert cooked_diced_apple.n_particles > 0 + for obj in deleted_objs: + assert og.sim.scene.object_registry("name", obj.name) is None + + # Clean up + cooked_diced_apple.remove_all_particles() + og.sim.step() + + for obj_cfg in deleted_objs_cfg: + obj = DatasetObject(**obj_cfg) + og.sim.import_object(obj) + og.sim.step() + +@og_test +def test_dicing_rule_uncooked(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + half_apple = og.sim.scene.object_registry("name", "half_apple") + table_knife = og.sim.scene.object_registry("name", "table_knife") + diced_apple = get_system("diced__apple") + + deleted_objs = [half_apple] + deleted_objs_cfg = [retrieve_obj_cfg(obj) for obj in deleted_objs] + + place_obj_on_floor_plane(half_apple) + og.sim.step() + + assert diced_apple.n_particles == 0 + + table_knife.set_position_orientation([-0.05, 0.0, 0.15], R.from_euler("xyz", [-np.pi / 2, 0, 0]).as_quat()) + og.sim.step() + + assert not table_knife.states[Touching].get_value(half_apple) + assert diced_apple.n_particles == 0 + for obj in deleted_objs: + assert og.sim.scene.object_registry("name", obj.name) is not None + + table_knife.set_position_orientation([-0.05, 0.0, 0.10], R.from_euler("xyz", [-np.pi / 2, 0, 0]).as_quat()) + og.sim.step() + + assert diced_apple.n_particles > 0 + for obj in deleted_objs: + assert og.sim.scene.object_registry("name", obj.name) is None + + # Clean up + diced_apple.remove_all_particles() + og.sim.step() + + for obj_cfg in deleted_objs_cfg: + obj = DatasetObject(**obj_cfg) + og.sim.import_object(obj) + og.sim.step() + +@og_test +def test_melting_rule(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + stove = og.sim.scene.object_registry("name", "stove") + stockpot = og.sim.scene.object_registry("name", "stockpot") + swiss_cheese = og.sim.scene.object_registry("name", "swiss_cheese") + melted_swiss_cheese = get_system("melted__swiss_cheese") + + deleted_objs = [swiss_cheese] + deleted_objs_cfg = [retrieve_obj_cfg(obj) for obj in deleted_objs] + + place_obj_on_floor_plane(stove) + og.sim.step() + + stockpot.set_position_orientation([-0.24, 0.11, 0.89], [0, 0, 0, 1]) + og.sim.step() + assert stockpot.states[OnTop].get_value(stove) + + swiss_cheese.set_position_orientation([-0.24, 0.11, 0.92], [0, 0, 0, 1]) + og.sim.step() + assert swiss_cheese.states[Inside].get_value(stockpot) + + assert melted_swiss_cheese.n_particles == 0 + + # To save time, directly set the temperature of the swiss cheese to be below the melting point + assert swiss_cheese.states[Temperature].set_value(m.transition_rules.MELTING_TEMPERATURE - 1) + og.sim.step() + + assert melted_swiss_cheese.n_particles == 0 + for obj in deleted_objs: + assert og.sim.scene.object_registry("name", obj.name) is not None + + # To save time, directly set the temperature of the swiss cheese to be above the melting point + assert swiss_cheese.states[Temperature].set_value(m.transition_rules.MELTING_TEMPERATURE + 1) + og.sim.step() + + # Recipe should execute successfully: new melted swiss cheese should be created, and the ingredients should be deleted + assert melted_swiss_cheese.n_particles > 0 + for obj in deleted_objs: + assert og.sim.scene.object_registry("name", obj.name) is None + + # Clean up + melted_swiss_cheese.remove_all_particles() + og.sim.step() + + for obj_cfg in deleted_objs_cfg: + obj = DatasetObject(**obj_cfg) + og.sim.import_object(obj) + og.sim.step() + +@og_test +def test_cooking_physical_particle_rule_failure_recipe_systems(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + stove = og.sim.scene.object_registry("name", "stove") + stockpot = og.sim.scene.object_registry("name", "stockpot") + arborio_rice = get_system("arborio_rice") + water = get_system("water") + cooked_water = get_system("cooked__water") + cooked_arborio_rice = get_system("cooked__arborio_rice") + + place_obj_on_floor_plane(stove) + og.sim.step() + + stockpot.set_position_orientation([-0.24, 0.11, 0.89], [0, 0, 0, 1]) + og.sim.step() + assert stockpot.states[OnTop].get_value(stove) + + arborio_rice.generate_particles(positions=[[-0.25, 0.13, 0.97]]) + # This fails the recipe because water (recipe system) is not in the stockpot + water.generate_particles(positions=[[-0.25, 0.17, 1.97]]) + + assert stockpot.states[Contains].get_value(arborio_rice) + assert not stockpot.states[Contains].get_value(water) + + assert cooked_arborio_rice.n_particles == 0 + + # To save time, directly set the stockpot to be heated + assert stockpot.states[Heated].set_value(True) + og.sim.step() + + # Recipe should fail: no cooked arborio rice should be created + assert water.n_particles > 0 + assert cooked_water.n_particles == 0 + assert arborio_rice.n_particles > 0 + assert cooked_arborio_rice.n_particles == 0 + + # Clean up + water.remove_all_particles() + arborio_rice.remove_all_particles() + og.sim.step() + +@og_test +def test_cooking_physical_particle_rule_success(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + stove = og.sim.scene.object_registry("name", "stove") + stockpot = og.sim.scene.object_registry("name", "stockpot") + arborio_rice = get_system("arborio_rice") + water = get_system("water") + cooked_water = get_system("cooked__water") + cooked_arborio_rice = get_system("cooked__arborio_rice") + + place_obj_on_floor_plane(stove) + og.sim.step() + + stockpot.set_position_orientation([-0.24, 0.11, 0.89], [0, 0, 0, 1]) + og.sim.step() + assert stockpot.states[OnTop].get_value(stove) + + arborio_rice.generate_particles(positions=[[-0.25, 0.13, 0.97]]) + water.generate_particles(positions=[[-0.25, 0.17, 0.97]]) + + assert stockpot.states[Contains].get_value(arborio_rice) + assert stockpot.states[Contains].get_value(water) + + assert cooked_arborio_rice.n_particles == 0 + assert cooked_water.n_particles == 0 + + # To save time, directly set the stockpot to be heated + assert stockpot.states[Heated].set_value(True) + og.sim.step() + + assert water.n_particles == 0 + assert cooked_water.n_particles > 0 + assert arborio_rice.n_particles > 0 + assert cooked_arborio_rice.n_particles == 0 + + # Recipe should execute successfully: new cooked arborio rice should be created, and the ingredients should be deleted + og.sim.step() + + assert water.n_particles == 0 + assert cooked_water.n_particles == 0 + assert arborio_rice.n_particles == 0 + assert cooked_arborio_rice.n_particles > 0 + + # Clean up + cooked_arborio_rice.remove_all_particles() + og.sim.step() + +@og_test +def test_mixing_rule_failure_recipe_systems(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + bowl = og.sim.scene.object_registry("name", "bowl") + tablespoon = og.sim.scene.object_registry("name", "tablespoon") + water = get_system("water") + granulated_sugar = get_system("granulated_sugar") + lemon_juice = get_system("lemon_juice") + lemonade = get_system("lemonade") + sludge = get_system("sludge") + + place_obj_on_floor_plane(bowl) + og.sim.step() + + water.generate_particles(positions=[[-0.02, 0, 0.03]]) + granulated_sugar.generate_particles(positions=[[0.0, 0.0, 0.03]]) + # This fails the recipe because lemon juice (recipe system) is not in the bowl + lemon_juice.generate_particles(positions=[[0.02, 0.0, 1.03]]) + + assert bowl.states[Contains].get_value(water) + assert bowl.states[Contains].get_value(granulated_sugar) + assert not bowl.states[Contains].get_value(lemon_juice) + + assert lemonade.n_particles == 0 + assert sludge.n_particles == 0 + + tablespoon.set_position_orientation([0.04, 0.0, 0.09], [0, 0, 0, 1]) + og.sim.step() + + assert tablespoon.states[Touching].get_value(bowl) + + # Recipe should fail: no milkshake should be created, and sludge should be created. + assert lemonade.n_particles == 0 + assert sludge.n_particles > 0 + assert water.n_particles == 0 + assert granulated_sugar.n_particles == 0 + + # Clean up + sludge.remove_all_particles() + lemon_juice.remove_all_particles() + og.sim.step() + +@og_test +def test_mixing_rule_failure_nonrecipe_systems(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + bowl = og.sim.scene.object_registry("name", "bowl") + tablespoon = og.sim.scene.object_registry("name", "tablespoon") + water = get_system("water") + granulated_sugar = get_system("granulated_sugar") + lemon_juice = get_system("lemon_juice") + lemonade = get_system("lemonade") + salt = get_system("salt") + sludge = get_system("sludge") + + place_obj_on_floor_plane(bowl) + og.sim.step() + + water.generate_particles(positions=[[-0.02, 0, 0.03]]) + granulated_sugar.generate_particles(positions=[[0.0, 0.0, 0.03]]) + lemon_juice.generate_particles(positions=[[0.02, 0.0, 0.03]]) + # This fails the recipe because salt (nonrecipe system) is in the bowl + salt.generate_particles(positions=[[0.0, 0.02, 0.03]]) + + assert bowl.states[Contains].get_value(water) + assert bowl.states[Contains].get_value(granulated_sugar) + assert bowl.states[Contains].get_value(lemon_juice) + assert bowl.states[Contains].get_value(salt) + + assert lemonade.n_particles == 0 + assert sludge.n_particles == 0 + + tablespoon.set_position_orientation([0.04, 0.0, 0.09], [0, 0, 0, 1]) + og.sim.step() + + assert tablespoon.states[Touching].get_value(bowl) + + # Recipe should fail: no milkshake should be created, and sludge should be created. + assert lemonade.n_particles == 0 + assert sludge.n_particles > 0 + assert water.n_particles == 0 + assert granulated_sugar.n_particles == 0 + assert lemon_juice.n_particles == 0 + assert salt.n_particles == 0 + + # Clean up + sludge.remove_all_particles() + og.sim.step() + +@og_test +def test_mixing_rule_success(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + bowl = og.sim.scene.object_registry("name", "bowl") + tablespoon = og.sim.scene.object_registry("name", "tablespoon") + water = get_system("water") + granulated_sugar = get_system("granulated_sugar") + lemon_juice = get_system("lemon_juice") + lemonade = get_system("lemonade") + + place_obj_on_floor_plane(bowl) + og.sim.step() + + water.generate_particles(positions=[[-0.02, 0, 0.03]]) + granulated_sugar.generate_particles(positions=[[0.0, 0.0, 0.03]]) + lemon_juice.generate_particles(positions=[[0.02, 0.0, 0.03]]) + + assert bowl.states[Contains].get_value(water) + assert bowl.states[Contains].get_value(granulated_sugar) + assert bowl.states[Contains].get_value(lemon_juice) + + assert lemonade.n_particles == 0 + + tablespoon.set_position_orientation([0.04, 0.0, 0.09], [0, 0, 0, 1]) + og.sim.step() + + assert tablespoon.states[Touching].get_value(bowl) + + # Recipe should execute successfully: new lemonade should be created, and the ingredients should be deleted + assert lemonade.n_particles > 0 + assert water.n_particles == 0 + assert granulated_sugar.n_particles == 0 + assert lemon_juice.n_particles == 0 + + # Clean up + lemonade.remove_all_particles() + og.sim.step() + +@og_test +def test_cooking_system_rule_failure_recipe_systems(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + stove = og.sim.scene.object_registry("name", "stove") + stockpot = og.sim.scene.object_registry("name", "stockpot") + chicken = og.sim.scene.object_registry("name", "chicken") + chicken_broth = get_system("chicken_broth") + diced_carrot = get_system("diced__carrot") + diced_celery = get_system("diced__celery") + salt = get_system("salt") + rosemary = get_system("rosemary") + chicken_soup = get_system("cooked__chicken_soup") + + place_obj_on_floor_plane(stove) + og.sim.step() + + stockpot.set_position_orientation([-0.24, 0.11, 0.89], [0, 0, 0, 1]) + og.sim.step() + assert stockpot.states[OnTop].get_value(stove) + + chicken.set_position_orientation([-0.24, 0.11, 0.88], [0, 0, 0, 1]) + # This fails the recipe because chicken broth (recipe system) is not in the stockpot + chicken_broth.generate_particles(positions=[[-0.25, 0.13, 1.97]]) + diced_carrot.generate_particles(positions=[[-0.25, 0.17, 0.97]]) + diced_celery.generate_particles(positions=[[-0.15, 0.13, 0.97]]) + salt.generate_particles(positions=[[-0.15, 0.15, 0.97]]) + rosemary.generate_particles(positions=[[-0.15, 0.17, 0.97]]) + og.sim.step() + + assert chicken.states[Inside].get_value(stockpot) + assert not chicken.states[Cooked].get_value() + assert not stockpot.states[Contains].get_value(chicken_broth) + assert stockpot.states[Contains].get_value(diced_carrot) + assert stockpot.states[Contains].get_value(diced_celery) + assert stockpot.states[Contains].get_value(salt) + assert stockpot.states[Contains].get_value(rosemary) + + assert chicken_soup.n_particles == 0 + + assert stove.states[ToggledOn].set_value(True) + og.sim.step() + + # Recipe should fail: no chicken soup should be created + assert chicken_soup.n_particles == 0 + assert chicken_broth.n_particles > 0 + assert diced_carrot.n_particles > 0 + assert diced_celery.n_particles > 0 + assert salt.n_particles > 0 + assert rosemary.n_particles > 0 + assert og.sim.scene.object_registry("name", "chicken") is not None + + # Clean up + chicken_broth.remove_all_particles() + diced_carrot.remove_all_particles() + diced_celery.remove_all_particles() + salt.remove_all_particles() + rosemary.remove_all_particles() + og.sim.step() + +@og_test +def test_cooking_system_rule_failure_nonrecipe_systems(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + stove = og.sim.scene.object_registry("name", "stove") + stockpot = og.sim.scene.object_registry("name", "stockpot") + chicken = og.sim.scene.object_registry("name", "chicken") + water = get_system("water") + chicken_broth = get_system("chicken_broth") + diced_carrot = get_system("diced__carrot") + diced_celery = get_system("diced__celery") + salt = get_system("salt") + rosemary = get_system("rosemary") + chicken_soup = get_system("cooked__chicken_soup") + + place_obj_on_floor_plane(stove) + og.sim.step() + + stockpot.set_position_orientation([-0.24, 0.11, 0.89], [0, 0, 0, 1]) + og.sim.step() + assert stockpot.states[OnTop].get_value(stove) + + chicken.set_position_orientation([-0.24, 0.11, 0.88], [0, 0, 0, 1]) + # This fails the recipe because water (nonrecipe system) is inside the stockpot + water.generate_particles(positions=[[-0.24, 0.11, 0.95]]) + chicken_broth.generate_particles(positions=[[-0.25, 0.13, 0.97]]) + diced_carrot.generate_particles(positions=[[-0.25, 0.17, 0.97]]) + diced_celery.generate_particles(positions=[[-0.15, 0.13, 0.97]]) + salt.generate_particles(positions=[[-0.15, 0.15, 0.97]]) + rosemary.generate_particles(positions=[[-0.15, 0.17, 0.97]]) + og.sim.step() + + assert chicken.states[Inside].get_value(stockpot) + assert not chicken.states[Cooked].get_value() + assert stockpot.states[Contains].get_value(water) + assert stockpot.states[Contains].get_value(chicken_broth) + assert stockpot.states[Contains].get_value(diced_carrot) + assert stockpot.states[Contains].get_value(diced_celery) + assert stockpot.states[Contains].get_value(salt) + assert stockpot.states[Contains].get_value(rosemary) + + assert chicken_soup.n_particles == 0 + + assert stove.states[ToggledOn].set_value(True) + og.sim.step() + + # Recipe should fail: no chicken soup should be created + assert chicken_soup.n_particles == 0 + assert chicken_broth.n_particles > 0 + assert diced_carrot.n_particles > 0 + assert diced_celery.n_particles > 0 + assert salt.n_particles > 0 + assert rosemary.n_particles > 0 + assert water.n_particles > 0 + assert og.sim.scene.object_registry("name", "chicken") is not None + + # Clean up + chicken_broth.remove_all_particles() + diced_carrot.remove_all_particles() + diced_celery.remove_all_particles() + salt.remove_all_particles() + rosemary.remove_all_particles() + water.remove_all_particles() + og.sim.step() + +@og_test +def test_cooking_system_rule_failure_nonrecipe_objects(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + stove = og.sim.scene.object_registry("name", "stove") + stockpot = og.sim.scene.object_registry("name", "stockpot") + chicken = og.sim.scene.object_registry("name", "chicken") + bowl = og.sim.scene.object_registry("name", "bowl") + chicken_broth = get_system("chicken_broth") + diced_carrot = get_system("diced__carrot") + diced_celery = get_system("diced__celery") + salt = get_system("salt") + rosemary = get_system("rosemary") + chicken_soup = get_system("cooked__chicken_soup") + + place_obj_on_floor_plane(stove) + og.sim.step() + + stockpot.set_position_orientation([-0.24, 0.11, 0.89], [0, 0, 0, 1]) + og.sim.step() + assert stockpot.states[OnTop].get_value(stove) + + chicken.set_position_orientation([-0.24, 0.11, 0.88], [0, 0, 0, 1]) + # This fails the recipe because the bowl (nonrecipe object) is inside the stockpot + bowl.set_position_orientation([-0.24, 0.11, 0.95], [0, 0, 0, 1]) + chicken_broth.generate_particles(positions=[[-0.25, 0.13, 0.97]]) + diced_carrot.generate_particles(positions=[[-0.25, 0.17, 0.97]]) + diced_celery.generate_particles(positions=[[-0.15, 0.13, 0.97]]) + salt.generate_particles(positions=[[-0.15, 0.15, 0.97]]) + rosemary.generate_particles(positions=[[-0.15, 0.17, 0.97]]) + og.sim.step() + + assert chicken.states[Inside].get_value(stockpot) + assert bowl.states[Inside].get_value(stockpot) + assert not chicken.states[Cooked].get_value() + assert stockpot.states[Contains].get_value(chicken_broth) + assert stockpot.states[Contains].get_value(diced_carrot) + assert stockpot.states[Contains].get_value(diced_celery) + assert stockpot.states[Contains].get_value(salt) + assert stockpot.states[Contains].get_value(rosemary) + + assert chicken_soup.n_particles == 0 + + assert stove.states[ToggledOn].set_value(True) + og.sim.step() + + # Recipe should fail: no chicken soup should be created + assert chicken_soup.n_particles == 0 + assert chicken_broth.n_particles > 0 + assert diced_carrot.n_particles > 0 + assert diced_celery.n_particles > 0 + assert salt.n_particles > 0 + assert rosemary.n_particles > 0 + assert og.sim.scene.object_registry("name", "chicken") is not None + assert og.sim.scene.object_registry("name", "bowl") is not None + + # Clean up + chicken_broth.remove_all_particles() + diced_carrot.remove_all_particles() + diced_celery.remove_all_particles() + salt.remove_all_particles() + rosemary.remove_all_particles() + og.sim.step() + +@og_test +def test_cooking_system_rule_success(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + stove = og.sim.scene.object_registry("name", "stove") + stockpot = og.sim.scene.object_registry("name", "stockpot") + chicken = og.sim.scene.object_registry("name", "chicken") + chicken_broth = get_system("chicken_broth") + diced_carrot = get_system("diced__carrot") + diced_celery = get_system("diced__celery") + salt = get_system("salt") + rosemary = get_system("rosemary") + chicken_soup = get_system("cooked__chicken_soup") + + deleted_objs = [chicken] + deleted_objs_cfg = [retrieve_obj_cfg(obj) for obj in deleted_objs] + + place_obj_on_floor_plane(stove) + og.sim.step() + + stockpot.set_position_orientation([-0.24, 0.11, 0.89], [0, 0, 0, 1]) + og.sim.step() + assert stockpot.states[OnTop].get_value(stove) + + chicken.set_position_orientation([-0.24, 0.11, 0.88], [0, 0, 0, 1]) + chicken_broth.generate_particles(positions=[[-0.25, 0.13, 0.97]]) + diced_carrot.generate_particles(positions=[[-0.25, 0.17, 0.97]]) + diced_celery.generate_particles(positions=[[-0.15, 0.13, 0.97]]) + salt.generate_particles(positions=[[-0.15, 0.15, 0.97]]) + rosemary.generate_particles(positions=[[-0.15, 0.17, 0.97]]) + og.sim.step() + + assert chicken.states[Inside].get_value(stockpot) + assert not chicken.states[Cooked].get_value() + assert stockpot.states[Contains].get_value(chicken_broth) + assert stockpot.states[Contains].get_value(diced_carrot) + assert stockpot.states[Contains].get_value(diced_celery) + assert stockpot.states[Contains].get_value(salt) + assert stockpot.states[Contains].get_value(rosemary) + + assert chicken_soup.n_particles == 0 + + assert stove.states[ToggledOn].set_value(True) + og.sim.step() + + # Recipe should execute successfully: new chicken soup should be created, and the ingredients should be deleted + assert chicken_soup.n_particles > 0 + assert chicken_broth.n_particles == 0 + assert diced_carrot.n_particles == 0 + assert diced_celery.n_particles == 0 + assert salt.n_particles == 0 + assert rosemary.n_particles == 0 + + for obj in deleted_objs: + assert og.sim.scene.object_registry("name", obj.name) is None + + # Clean up + chicken_soup.remove_all_particles() + og.sim.step() + + for obj_cfg in deleted_objs_cfg: + obj = DatasetObject(**obj_cfg) + og.sim.import_object(obj) + og.sim.step() + @og_test def test_cooking_object_rule_failure_wrong_container(): assert len(REGISTERED_RULES) > 0, "No rules registered!" @@ -538,7 +1181,7 @@ def test_single_toggleable_machine_rule_output_system_failure_nonrecipe_systems( og.sim.step() @og_test -def test_single_toggleable_machine_rule_output_system_failure_nonrecipe_systems(): +def test_single_toggleable_machine_rule_output_system_failure_nonrecipe_objects(): assert len(REGISTERED_RULES) > 0, "No rules registered!" blender = og.sim.scene.object_registry("name", "blender") ice_cream = og.sim.scene.object_registry("name", "scoop_of_ice_cream") @@ -638,7 +1281,6 @@ def test_single_toggleable_machine_rule_output_system_success(): @og_test def test_single_toggleable_machine_rule_output_object_failure_unary_states(): - from IPython import embed; embed() assert len(REGISTERED_RULES) > 0, "No rules registered!" electric_mixer = og.sim.scene.object_registry("name", "electric_mixer") raw_egg = og.sim.scene.object_registry("name", "raw_egg") @@ -713,7 +1355,6 @@ def test_single_toggleable_machine_rule_output_object_failure_unary_states(): @og_test def test_single_toggleable_machine_rule_output_object_success(): - from IPython import embed; embed() assert len(REGISTERED_RULES) > 0, "No rules registered!" electric_mixer = og.sim.scene.object_registry("name", "electric_mixer") raw_egg = og.sim.scene.object_registry("name", "raw_egg") @@ -792,6 +1433,3 @@ def test_single_toggleable_machine_rule_output_object_success(): obj = DatasetObject(**obj_cfg) og.sim.import_object(obj) og.sim.step() - - -test_single_toggleable_machine_rule_output_object_failure_unary_states() diff --git a/tests/utils.py b/tests/utils.py index 6a995083a..069cf91d4 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -87,6 +87,12 @@ def assert_test_scene(): get_obj_cfg("food_processor", "food_processor", "gamkbo"), get_obj_cfg("electric_mixer", "electric_mixer", "ceaeqf"), get_obj_cfg("another_raw_egg", "raw_egg", "ydgivr"), + get_obj_cfg("chicken", "chicken", "nppsmz"), + get_obj_cfg("tablespoon", "tablespoon", "huudhe"), + get_obj_cfg("swiss_cheese", "swiss_cheese", "hwxeto"), + get_obj_cfg("apple", "apple", "agveuv"), + get_obj_cfg("table_knife", "table_knife", "jxdfyy"), + get_obj_cfg("half_apple", "half_apple", "sguztn"), ], "robots": [ { From 88beabb07043c487389a4756a6a6cc5618c7b82e Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Tue, 6 Feb 2024 23:12:40 -0800 Subject: [PATCH 10/24] improve robustness for one toggleable machine test --- tests/test_transition_rules.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_transition_rules.py b/tests/test_transition_rules.py index 1f54d7adf..de2258703 100644 --- a/tests/test_transition_rules.py +++ b/tests/test_transition_rules.py @@ -1008,7 +1008,7 @@ def test_single_toggleable_machine_rule_output_system_failure_wrong_container(): milk.generate_particles(positions=np.array([[0.02, 0, 0.25]])) chocolate_sauce.generate_particles(positions=np.array([[0, -0.02, 0.25]])) - ice_cream.set_position([0, 0, 0.2]) + ice_cream.set_position([-0.03, 0.02, 0.25]) og.sim.step() From e09369f7ebc47d650e3cf19781c59eb8046e9b01 Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Wed, 7 Feb 2024 17:13:20 -0800 Subject: [PATCH 11/24] add washer and dryer rule, change particleModifier input condition format --- omnigibson/object_states/contains.py | 15 +- omnigibson/object_states/particle_modifier.py | 40 ++- .../object_states/particle_source_or_sink.py | 13 +- omnigibson/systems/system_base.py | 8 + omnigibson/transition_rules.py | 288 +++++++++++++++--- tests/test_transition_rules.py | 114 +++++++ tests/utils.py | 2 + 7 files changed, 412 insertions(+), 68 deletions(-) diff --git a/omnigibson/object_states/contains.py b/omnigibson/object_states/contains.py index da32896af..5e45670ad 100644 --- a/omnigibson/object_states/contains.py +++ b/omnigibson/object_states/contains.py @@ -34,7 +34,6 @@ def __init__(self, obj): self.check_in_volume = None # Function to check whether particles are in volume for this container self._volume = None # Volume of this container self._compute_info = None # Intermediate computation information to store - self._visual_particle_group = None # Name corresponding to this object's set of visual particles @classproperty def metalink_prefix(cls): @@ -61,12 +60,11 @@ def _get_value(self, system): # First, we check what type of system # Currently, we support VisualParticleSystems and PhysicalParticleSystems if is_visual_particle_system(system_name=system.name): - if self._visual_particle_group in system.groups: - # Grab global particle poses and offset them in the direction of their orientation - raw_positions, quats = system.get_group_particles_position_orientation(group=self._visual_particle_group) - unit_z = np.zeros((len(raw_positions), 3, 1)) - unit_z[:, -1, :] = m.VISUAL_PARTICLE_OFFSET - checked_positions = (T.quat2mat(quats) @ unit_z).reshape(-1, 3) + raw_positions + # Grab global particle poses and offset them in the direction of their orientation + raw_positions, quats = system.get_particles_position_orientation() + unit_z = np.zeros((len(raw_positions), 3, 1)) + unit_z[:, -1, :] = m.VISUAL_PARTICLE_OFFSET + checked_positions = (T.quat2mat(quats) @ unit_z).reshape(-1, 3) + raw_positions elif is_physical_particle_system(system_name=system.name): raw_positions = system.get_particles_position_orientation()[0] checked_positions = raw_positions @@ -92,9 +90,6 @@ def _initialize(self): # Calculate volume self._volume = calculate_volume() - # Grab group name - self._visual_particle_group = VisualParticleSystem.get_group_name(obj=self.obj) - @property def volume(self): """ diff --git a/omnigibson/object_states/particle_modifier.py b/omnigibson/object_states/particle_modifier.py index bd9bb630a..8e35290a7 100644 --- a/omnigibson/object_states/particle_modifier.py +++ b/omnigibson/object_states/particle_modifier.py @@ -16,7 +16,7 @@ from omnigibson.object_states.toggle import ToggledOn from omnigibson.object_states.update_state_mixin import UpdateStateMixin from omnigibson.systems.system_base import VisualParticleSystem, PhysicalParticleSystem, get_system, \ - is_visual_particle_system, is_physical_particle_system, is_system_active, REGISTERED_SYSTEMS + is_visual_particle_system, is_physical_particle_system, is_fluid_system, is_system_active, REGISTERED_SYSTEMS from omnigibson.utils.constants import ParticleModifyMethod, ParticleModifyCondition, PrimType from omnigibson.utils.geometry_utils import generate_points_in_volume_checker_function, \ get_particle_positions_in_frame, get_particle_positions_from_frame @@ -179,7 +179,7 @@ class ParticleModifier(IntrinsicObjectState, LinkBasedStateMixin, UpdateStateMix Args: obj (StatefulObject): Object to which this state will be applied conditions (dict): Dictionary mapping the names of ParticleSystem (str) to None or list of 2-tuples, where - None represents no conditions, or each 2-tuple is interpreted as a single condition in the form of + None represents "never", empty list represents "always", or each 2-tuple is interpreted as a single condition in the form of (ParticleModifyCondition, value) necessary in order for this particle modifier to be able to modify particles belonging to @ParticleSystem. Expected types of val are as follows: @@ -234,7 +234,7 @@ def is_compatible(cls, obj, **kwargs): # Check whether this state has toggledon if required or saturated if required for any condition conditions = kwargs.get("conditions", dict()) - cond_types = {cond[0] for _, conds in conditions.items() for cond in conds} + cond_types = {cond[0] for _, conds in conditions.items() if conds is not None for cond in conds} for cond_type, state_type in zip((ParticleModifyCondition.TOGGLEDON,), (ToggledOn,)): if cond_type in cond_types and state_type not in obj.states: return False, f"{cls.__name__} requires {state_type.__name__} state!" @@ -250,7 +250,7 @@ def is_compatible_asset(cls, prim, **kwargs): # Check whether this state has toggledon if required or saturated if required for any condition conditions = kwargs.get("conditions", dict()) - cond_types = {cond[0] for _, conds in conditions.items() for cond in conds} + cond_types = {cond[0] for _, conds in conditions.items() if conds is not None for cond in conds} for cond_type, state_type in zip((ParticleModifyCondition.TOGGLEDON,), (ToggledOn,)): if cond_type in cond_types and not state_type.is_compatible_asset(prim=prim, **kwargs): return False, f"{cls.__name__} requires {state_type.__name__} state!" @@ -269,7 +269,10 @@ def postprocess_ability_params(cls, params): # The original key can be either a system name or a system synset. If it's a synset, we need to convert it. system_name = sys if sys in REGISTERED_SYSTEMS.keys() else get_system_name_by_synset(sys) params["conditions"][system_name] = params["conditions"].pop(sys) - for cond in params["conditions"][system_name]: + conds = params["conditions"][system_name] + if conds is None: + continue + for cond in conds: cond_type, cond_sys = cond if cond_type == ParticleModifyCondition.SATURATED: cond[1] = cond_sys if cond_sys in REGISTERED_SYSTEMS.keys() else get_system_name_by_synset(cond_sys) @@ -455,7 +458,7 @@ def _parse_conditions(self, conditions): Args: conditions (dict): Dictionary mapping the names of ParticleSystem (str) to None or list of 2-tuples, where - None represents no conditions, or each 2-tuple is interpreted as a single condition in the form of + None represents "never", empty list represents "always", or each 2-tuple is interpreted as a single condition in the form of (ParticleModifyCondition, value) necessary in order for this particle modifier to be able to modify particles belonging to @ParticleSystem. Expected types of val are as follows: @@ -483,7 +486,9 @@ def condition(obj) --> bool assert is_visual_particle_system(system_name) or is_physical_particle_system(system_name), \ f"Unsupported system for ParticleModifier: {system_name}" # Make sure conds isn't empty and is a list - conds = [] if conds is None else list(conds) + if conds is None: + continue + assert type(conds) == list, f"Expected list of conditions for system {system_name}, got {conds}" system_conditions = [] for cond_type, cond_val in conds: cond = self._generate_condition(condition_type=cond_type, value=cond_val) @@ -676,7 +681,7 @@ class ParticleRemover(ParticleModifier): Args: obj (StatefulObject): Object to which this state will be applied conditions (dict): Dictionary mapping the names of ParticleSystem (str) to None or list of 2-tuples, where - None represents no conditions, or each 2-tuple is interpreted as a single condition in the form of + None represents "never", empty list represents "always", or each 2-tuple is interpreted as a single condition in the form of (ParticleModifyCondition, value) necessary in order for this particle modifier to be able to modify particles belonging to @ParticleSystem. Expected types of val are as follows: @@ -703,10 +708,14 @@ def condition(obj) --> bool If None, information found from @obj.metadata will be used instead. NOTE: x-direction should align with the projection mesh's height (i.e.: z) parameter in @extents! - default_physical_conditions (None or list): Condition(s) needed to remove any physical particles not explicitly + default_fluid_conditions (None or list): Condition(s) needed to remove any fluid particles not explicitly specified in @conditions. If None, then it is assumed that no other physical particles can be removed. If not None, should be in same format as an entry in @conditions, i.e.: list of (ParticleModifyCondition, val) 2-tuples + default_physical_conditions (None or list): Condition(s) needed to remove any physical (excluding fluid) + particles not explicitly specified in @conditions. If None, then it is assumed that no other physical + particles can be removed. If not None, should be in same format as an entry in @conditions, i.e.: list of + (ParticleModifyCondition, val) 2-tuples default_visual_conditions (None or list): Condition(s) needed to remove any visual particles not explicitly specified in @conditions. If None, then it is assumed that no other visual particles can be removed. If not None, should be in same format as an entry in @conditions, i.e.: list of (ParticleModifyCondition, val) @@ -718,10 +727,13 @@ def __init__( conditions, method=ParticleModifyMethod.ADJACENCY, projection_mesh_params=None, + default_fluid_conditions=None, default_physical_conditions=None, default_visual_conditions=None, ): # Store values + self._default_fluid_conditions = default_fluid_conditions if default_fluid_conditions is None else \ + [self._generate_condition(cond_type, cond_val) for cond_type, cond_val in default_fluid_conditions] self._default_physical_conditions = default_physical_conditions if default_physical_conditions is None else \ [self._generate_condition(cond_type, cond_val) for cond_type, cond_val in default_physical_conditions] self._default_visual_conditions = default_visual_conditions if default_visual_conditions is None else \ @@ -737,7 +749,13 @@ def _parse_conditions(self, conditions): # Create set of default system to condition mappings based on settings all_conditions = dict() for system_name in REGISTERED_SYSTEMS.keys(): - if is_physical_particle_system(system_name): + # If the system is already explicitly specified in conditions, continue + if system_name in conditions: + continue + # Since fluid system is a subclass of physical system, we need to check for fluid first + elif is_fluid_system(system_name): + default_system_conditions = self._default_fluid_conditions + elif is_physical_particle_system(system_name): default_system_conditions = self._default_physical_conditions elif is_visual_particle_system(system_name): default_system_conditions = self._default_visual_conditions @@ -813,7 +831,7 @@ class ParticleApplier(ParticleModifier): Args: obj (StatefulObject): Object to which this state will be applied conditions (dict): Dictionary mapping the names of ParticleSystem (str) to None or list of 2-tuples, where - None represents no conditions, or each 2-tuple is interpreted as a single condition in the form of + None represents "never", empty list represents "always", or each 2-tuple is interpreted as a single condition in the form of (ParticleModifyCondition, value) necessary in order for this particle modifier to be able to modify particles belonging to @ParticleSystem. Expected types of val are as follows: diff --git a/omnigibson/object_states/particle_source_or_sink.py b/omnigibson/object_states/particle_source_or_sink.py index 4adfbe71b..db11a2f87 100644 --- a/omnigibson/object_states/particle_source_or_sink.py +++ b/omnigibson/object_states/particle_source_or_sink.py @@ -40,7 +40,7 @@ class ParticleSource(ParticleApplier): Args: obj (StatefulObject): Object to which this state will be applied conditions (dict): Dictionary mapping the names of ParticleSystem (str) to None or list of 2-tuples, where - None represents no conditions, or each 2-tuple is interpreted as a single condition in the form of + None represents "never", empty list represents "always", or each 2-tuple is interpreted as a single condition in the form of (ParticleModifyCondition, value) necessary in order for this particle modifier to be able to modify particles belonging to @ParticleSystem. Expected types of val are as follows: @@ -148,7 +148,7 @@ class ParticleSink(ParticleRemover): Args: obj (StatefulObject): Object to which this state will be applied conditions (dict): Dictionary mapping the names of ParticleSystem (str) to None or list of 2-tuples, where - None represents no conditions, or each 2-tuple is interpreted as a single condition in the form of + None represents "never", empty list represents "always", or each 2-tuple is interpreted as a single condition in the form of (ParticleModifyCondition, value) necessary in order for this particle modifier to be able to modify particles belonging to @ParticleSystem. Expected types of val are as follows: @@ -170,11 +170,14 @@ def condition(obj) --> bool sink_height (None or float): Height of the cylinder representing particles' sinking volume, if specified. If both @sink_radius and @sink_height are None, values will be inferred directly from the underlying object asset, otherwise, it will be set to a default value - - default_physical_conditions (None or list): Condition(s) needed to remove any physical particles not explicitly + default_fluid_conditions (None or list): Condition(s) needed to remove any fluid particles not explicitly specified in @conditions. If None, then it is assumed that no other physical particles can be removed. If not None, should be in same format as an entry in @conditions, i.e.: list of (ParticleModifyCondition, val) 2-tuples + default_physical_conditions (None or list): Condition(s) needed to remove any physical (excluding fluid) + particles not explicitly specified in @conditions. If None, then it is assumed that no other physical + particles can be removed. If not None, should be in same format as an entry in @conditions, i.e.: list of + (ParticleModifyCondition, val) 2-tuples default_visual_conditions (None or list): Condition(s) needed to remove any visual particles not explicitly specified in @conditions. If None, then it is assumed that no other visual particles can be removed. If not None, should be in same format as an entry in @conditions, i.e.: list of (ParticleModifyCondition, val) @@ -186,6 +189,7 @@ def __init__( conditions, sink_radius=None, sink_height=None, + default_fluid_conditions=None, default_physical_conditions=None, default_visual_conditions=None, ): @@ -209,6 +213,7 @@ def __init__( conditions=conditions, method=ParticleModifyMethod.PROJECTION, projection_mesh_params=projection_mesh_params, + default_fluid_conditions=default_fluid_conditions, default_physical_conditions=default_physical_conditions, default_visual_conditions=default_visual_conditions, ) diff --git a/omnigibson/systems/system_base.py b/omnigibson/systems/system_base.py index 1652e8aea..2b568001e 100644 --- a/omnigibson/systems/system_base.py +++ b/omnigibson/systems/system_base.py @@ -1230,6 +1230,14 @@ def is_physical_particle_system(system_name): return issubclass(system, PhysicalParticleSystem) +def is_fluid_system(system_name): + assert system_name in REGISTERED_SYSTEMS, f"System {system_name} not in REGISTERED_SYSTEMS." + system = REGISTERED_SYSTEMS[system_name] + # Avoid circular imports + from omnigibson.systems.micro_particle_system import FluidSystem + return issubclass(system, FluidSystem) + + def get_system(system_name, force_active=True): # Make sure scene exists assert og.sim.scene is not None, "Cannot get systems until scene is imported!" diff --git a/omnigibson/transition_rules.py b/omnigibson/transition_rules.py index c976ab72a..ab5ac4c8d 100644 --- a/omnigibson/transition_rules.py +++ b/omnigibson/transition_rules.py @@ -64,7 +64,7 @@ "slicing.json": ["SlicingRule"], "substance_cooking.json": ["CookingPhysicalParticleRule"], "substance_watercooking.json": ["CookingPhysicalParticleRule"], - # TODO: washer and dryer + "washer.json": ["WasherRule"], } # Global dicts that will contain mappings REGISTERED_RULES = dict() @@ -521,14 +521,52 @@ def __call__(self, object_candidates): pruned_candidates[condition] = copy(object_candidates) condition(object_candidates=pruned_candidates[condition]) - # Now, take the union over all keys in the candidates -- - # if the result is empty, then we immediately return False - for filter_name, old_candidates in object_candidates.keys(): - # If an entry was already empty, we skip it - if len(old_candidates) == 0: - continue + # For each filter, take the union over object candidates across each condition. + # If the result is empty, we immediately return False. + for filter_name in object_candidates: + object_candidates[filter_name] = \ + list(set.union(*[set(candidates[filter_name]) for candidates in pruned_candidates.values()])) + if len(object_candidates[filter_name]) == 0: + return False + + return True + + @property + def modifies_filter_names(self): + # Return all wrapped names + return set.union(*(condition.modifies_filter_names for condition in self._conditions)) + + +class AndConditionWrapper(RuleCondition): + """ + Logical AND between multiple RuleConditions + """ + def __init__(self, conditions): + """ + Args: + conditions (list of RuleConditions): Conditions to take logical OR over. This will generate + the UNION of all pruning between the two sets + """ + self._conditions = conditions + + def refresh(self, object_candidates): + # Refresh nested conditions + for condition in self._conditions: + condition.refresh(object_candidates=object_candidates) + + def __call__(self, object_candidates): + # Iterate over all conditions and aggregate their results + pruned_candidates = dict() + for condition in self._conditions: + # Copy the candidates because they get modified in place + pruned_candidates[condition] = copy(object_candidates) + condition(object_candidates=pruned_candidates[condition]) + + # For each filter, take the intersection over object candidates across each condition. + # If the result is empty, we immediately return False. + for filter_name in object_candidates: object_candidates[filter_name] = \ - list(set(np.concatenate([candidates[filter_name] for candidates in pruned_candidates.values()]))) + list(set.intersection(*[set(candidates[filter_name]) for candidates in pruned_candidates.values()])) if len(object_candidates[filter_name]) == 0: return False @@ -691,6 +729,169 @@ def _cls_registry(cls): ) +class WasherDryerRule(BaseTransitionRule): + """ + Transition rule to apply to cloth washers and dryers. + """ + @classmethod + def _generate_conditions(cls): + assert len(cls.candidate_filters.keys()) == 1 + machine_type = list(cls.candidate_filters.keys())[0] + return [ChangeConditionWrapper( + condition=AndConditionWrapper(conditions=[ + StateCondition(filter_name=machine_type, state=ToggledOn, val=True, op=operator.eq), + StateCondition(filter_name=machine_type, state=Open, val=False, op=operator.eq), + ]) + )] + + @classmethod + def _compute_global_rule_info(cls): + """ + Helper function to compute global information necessary for checking rules. This is executed exactly + once per cls.transition() step + + Returns: + dict: Keyword-mapped global rule information + """ + # Compute all obj + obj_positions = np.array([obj.aabb_center for obj in og.sim.scene.objects]) + return dict(obj_positions=obj_positions) + + @classmethod + def _compute_container_info(cls, object_candidates, container, global_info): + """ + Helper function to compute container-specific information necessary for checking rules. This is executed once + per container per cls.transition() step + + Args: + object_candidates (dict): Dictionary mapping corresponding keys from @cls.filters to list of individual + object instances where the filter is satisfied + container (StatefulObject): Relevant container object for computing information + global_info (dict): Output of @cls._compute_global_rule_info(); global information which may be + relevant for computing container information + + Returns: + dict: Keyword-mapped container information + """ + del object_candidates + obj_positions = global_info["obj_positions"] + in_volume = container.states[ContainedParticles].check_in_volume(obj_positions) + + in_volume_objs = list(np.array(og.sim.scene.objects)[in_volume]) + # Remove the container itself + if container in in_volume_objs: + in_volume_objs.remove(container) + + return dict(in_volume_objs=in_volume_objs) + + @classproperty + def _do_not_register_classes(cls): + # Don't register this class since it's an abstract template + classes = super()._do_not_register_classes + classes.add("WasherDryerRule") + return classes + +class WasherRule(WasherDryerRule): + _CLEANING_CONDITONS = None + + @classmethod + def register_cleaning_conditions(cls, conditions): + """ + Register cleaning conditions for this rule. + + Args: + conditions (dict): Dictionary mapping the synset of ParticleSystem (str) to None or list of synsets of + ParticleSystem (str). None represents "never", empty list represents "always", or non-empty list represents + at least one of the systems in the list needs to be present in the washer for the key system to be removed. + E.g. "rust.n.01" -> None: "never remove rust.n.01 from the washer" + E.g. "dust.n.01" -> []: "always remove dust.n.01 from the washer" + E.g. "cooking_oil.n.01" -> ["sodium_carbonate.n.01", "vinegar.n.01"]: "remove cooking_oil.n.01 from the + washer if either sodium_carbonate.n.01 or vinegar.n.01 is present" + For keys not present in the dictionary, the default is []: "always remove" + """ + cls._CLEANING_CONDITONS = dict() + for solute, solvents in conditions.items(): + assert OBJECT_TAXONOMY.is_leaf(solute), f"Synset {solute} must be a leaf node in the taxonomy!" + assert is_substance_synset(solute), f"Synset {solute} must be a substance synset!" + solute_name = get_system_name_by_synset(solute) + if solvents is None: + cls._CLEANING_CONDITONS[solute_name] = None + else: + solvent_names = [] + for solvent in solvents: + assert OBJECT_TAXONOMY.is_leaf(solvent), f"Synset {solvent} must be a leaf node in the taxonomy!" + assert is_substance_synset(solvent), f"Synset {solvent} must be a substance synset!" + solvent_name = get_system_name_by_synset(solvent) + solvent_names.append(solvent_name) + cls._CLEANING_CONDITONS[solute_name] = solvent_names + + @classproperty + def candidate_filters(cls): + return { + "washer": CategoryFilter("washer"), + } + + @classmethod + def transition(cls, object_candidates): + water = get_system("water") + global_info = cls._compute_global_rule_info() + for washer in object_candidates["washer"]: + # Remove the systems if the conditions are met + systems_to_remove = [] + for system in ParticleRemover.supported_active_systems: + # Never remove + if system.name in cls._CLEANING_CONDITONS and cls._CLEANING_CONDITONS[system.name] is None: + continue + if not washer.states[Contains].get_value(system): + continue + + solvents = cls._CLEANING_CONDITONS.get(system.name, []) + # Always remove + if len(solvents) == 0: + systems_to_remove.append(system) + else: + solvents = [get_system(solvent) for solvent in solvents if is_system_active(solvent)] + # If any of the solvents are present + if any(washer.states[Contains].get_value(solvent) for solvent in solvents): + systems_to_remove.append(system) + + for system in systems_to_remove: + washer.states[Contains].set_value(system, False) + + # Make the objects wet + container_info = cls._compute_container_info(object_candidates=object_candidates, container=washer, global_info=global_info) + in_volume_objs = container_info["in_volume_objs"] + for obj in in_volume_objs: + if Saturated in obj.states: + obj.states[Saturated].set_value(water, True) + else: + obj.states[Covered].set_value(water, True) + + return TransitionResults(add=[], remove=[]) + + +class DryerRule(WasherDryerRule): + @classproperty + def candidate_filters(cls): + return { + "dryer": CategoryFilter("clothes_dryer"), + } + + @classmethod + def transition(cls, object_candidates): + water = get_system("water") + global_info = cls._compute_global_rule_info() + for dryer in object_candidates["dryer"]: + container_info = cls._compute_container_info(object_candidates=object_candidates, container=dryer, global_info=global_info) + in_volume_objs = container_info["in_volume_objs"] + for obj in in_volume_objs: + if Saturated in obj.states: + obj.states[Saturated].set_value(water, False) + dryer.states[Contains].set_value(water, False) + + return TransitionResults(add=[], remove=[]) + + class SlicingRule(BaseTransitionRule): """ Transition rule to apply to sliced / slicer object pairs. @@ -1367,21 +1568,16 @@ def _is_recipe_executable(cls, recipe, container, global_info, container_info): return True @classmethod - def _compute_global_rule_info(cls, object_candidates): + def _compute_global_rule_info(cls): """ Helper function to compute global information necessary for checking rules. This is executed exactly once per cls.transition() step - Args: - object_candidates (dict): Dictionary mapping corresponding keys from @cls.filters to list of individual - object instances where the filter is satisfied - Returns: dict: Keyword-mapped global rule information """ # Compute all relevant object AABB positions obj_positions = np.array([obj.aabb_center for obj in cls._OBJECTS]) - return dict(obj_positions=obj_positions) @classmethod @@ -1400,6 +1596,7 @@ def _compute_container_info(cls, object_candidates, container, global_info): Returns: dict: Keyword-mapped container information """ + del object_candidates obj_positions = global_info["obj_positions"] # Compute in volume for all relevant object positions # We check for either the object AABB being contained OR the object being on top of the container, in the @@ -1453,7 +1650,7 @@ def transition(cls, object_candidates): objs_to_add, objs_to_remove = [], [] # Compute global info - global_info = cls._compute_global_rule_info(object_candidates=object_candidates) + global_info = cls._compute_global_rule_info() # Iterate over all fillable objects, to execute recipes for each one for container in object_candidates["container"]: @@ -1539,9 +1736,8 @@ def _execute_recipe(cls, container, recipe, container_info): container.states[Contains].set_value(system, False) for system in VisualParticleSystem.get_active_systems().values(): if not cls.ignore_nonrecipe_systems or system.name in recipe["input_systems"]: - group_name = system.get_group_name(container) - if group_name in system.groups and system.num_group_particles(group_name) > 0: - system.remove_all_group_particles(group=group_name) + if container.states[Contains].get_value(system): + container.states[Contains].set_value(system, False) else: # Remove the particles that are involved in this execution for system_name, particle_idxs in execution_info["relevant_systems"].items(): @@ -1728,7 +1924,6 @@ def use_garbage_fallback_recipe(cls): @classmethod def _execute_recipe(cls, container, recipe, container_info): - in_volume = container_info["in_volume"] system = get_system(recipe["input_systems"][0]) contained_particles_state = container.states[ContainedParticles].get_value(system) in_volume_idx = np.where(contained_particles_state.in_volume)[0] @@ -1797,7 +1992,12 @@ def add_recipe( def candidate_filters(cls): # Modify the container filter to include toggleable ability as well candidate_filters = super().candidate_filters - candidate_filters["container"] = AndFilter(filters=[candidate_filters["container"], AbilityFilter(ability="toggleable")]) + candidate_filters["container"] = AndFilter(filters=[ + candidate_filters["container"], + AbilityFilter(ability="toggleable"), + NotFilter(CategoryFilter("washer")), + NotFilter(CategoryFilter("clothes_dryer")), + ]) return candidate_filters @classmethod @@ -2234,30 +2434,32 @@ def import_recipes(): recipe_fpath = os.path.join(os.path.dirname(bddl.__file__), "generated_data", "transition_map", "tm_jsons", json_file) if not os.path.exists(recipe_fpath): log.warning(f"Cannot find recipe file at {recipe_fpath}. Skipping importing recipes.") - # return + with open(recipe_fpath, "r") as f: rule_recipes = json.load(f) - for rule_name in rule_names: - rule = REGISTERED_RULES[rule_name] - if issubclass(rule, RecipeRule): - for recipe in rule_recipes: - if "rule_name" in recipe: - recipe["name"] = recipe.pop("rule_name") - if "container" in recipe: - recipe["fillable_categories"] = set(recipe.pop("container").keys()) - if "heat_source" in recipe: - recipe["heatsource_categories"] = set(recipe.pop("heat_source").keys()) - if "machine" in recipe: - recipe["fillable_categories"] = set(recipe.pop("machine").keys()) - - satisfied = True - output_synsets = set(recipe["output_synsets"].keys()) - has_substance = any([s for s in output_synsets if is_substance_synset(s)]) - if (rule_name == "CookingObjectRule" and has_substance) or (rule_name == "CookingSystemRule" and not has_substance): - satisfied = False - if satisfied: - print(recipe) - rule.add_recipe(**recipe) - print(f"All recipes of rule {rule_name} imported successfully.") + + for rule_name in rule_names: + rule = REGISTERED_RULES[rule_name] + if rule == WasherRule: + rule.register_cleaning_conditions(rule_recipes) + elif issubclass(rule, RecipeRule): + for recipe in rule_recipes: + if "rule_name" in recipe: + recipe["name"] = recipe.pop("rule_name") + if "container" in recipe: + recipe["fillable_categories"] = set(recipe.pop("container").keys()) + if "heat_source" in recipe: + recipe["heatsource_categories"] = set(recipe.pop("heat_source").keys()) + if "machine" in recipe: + recipe["fillable_categories"] = set(recipe.pop("machine").keys()) + + satisfied = True + output_synsets = set(recipe["output_synsets"].keys()) + has_substance = any([s for s in output_synsets if is_substance_synset(s)]) + if (rule_name == "CookingObjectRule" and has_substance) or (rule_name == "CookingSystemRule" and not has_substance): + satisfied = False + if satisfied: + rule.add_recipe(**recipe) + log.info(f"All recipes of rule {rule_name} imported successfully.") import_recipes() \ No newline at end of file diff --git a/tests/test_transition_rules.py b/tests/test_transition_rules.py index de2258703..1e12a5365 100644 --- a/tests/test_transition_rules.py +++ b/tests/test_transition_rules.py @@ -16,6 +16,120 @@ import pytest import numpy as np +@pytest.mark.skip(reason="dryer is not fillable yet.") +@og_test +def test_dryer_rule(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + clothes_dryer = og.sim.scene.object_registry("name", "clothes_dryer") + remover_dishtowel = og.sim.scene.object_registry("name", "remover_dishtowel") + bowl = og.sim.scene.object_registry("name", "bowl") + water = get_system("water") + + place_obj_on_floor_plane(clothes_dryer) + og.sim.step() + + remover_dishtowel.set_position_orientation([0.0, 0.0, 0.4], [0, 0, 0, 1]) + bowl.set_position_orientation([0.0, 0.0, 0.5], [0, 0, 0, 1]) + og.sim.step() + + assert remover_dishtowel.states[Saturated].set_value(water, True) + assert bowl.states[Covered].set_value(water, True) + og.sim.step() + + assert remover_dishtowel.states[Saturated].get_value(water) + assert clothes_dryer.states[Contains].get_value(water) + + # The rule will not execute if Open is True + clothes_dryer.states[Open].set_value(True) + og.sim.step() + + assert remover_dishtowel.states[Saturated].get_value(water) + assert clothes_dryer.states[Contains].get_value(water) + + clothes_dryer.states[Open].set_value(False) + clothes_dryer.states[ToggledOn].set_value(True) + + # The rule will execute when Open is False and ToggledOn is True + og.sim.step() + + # Need to take one more step for the state setters to take effect + og.sim.step() + + assert not remover_dishtowel.states[Saturated].get_value(water) + assert not clothes_dryer.states[Contains].get_value(water) + + # Clean up + water.remove_all_particles() + og.sim.step() + +@og_test +def test_washer_rule(): + assert len(REGISTERED_RULES) > 0, "No rules registered!" + washer = og.sim.scene.object_registry("name", "washer") + remover_dishtowel = og.sim.scene.object_registry("name", "remover_dishtowel") + bowl = og.sim.scene.object_registry("name", "bowl") + water = get_system("water") + dust = get_system("dust") # always remove + salt = get_system("salt") # always remove (not explicitly specified) + rust = get_system("rust") # never remove + spray_paint = get_system("spray_paint") # requires acetone + acetone = get_system("acetone") # solvent for spray paint + cooking_oil = get_system("cooking_oil") # requires vinegar, lemon_juice, vinegar, etc. + + place_obj_on_floor_plane(washer) + og.sim.step() + + remover_dishtowel.set_position_orientation([0.0, 0.0, 0.4], [0, 0, 0, 1]) + bowl.set_position_orientation([0.0, 0.0, 0.5], [0, 0, 0, 1]) + og.sim.step() + + assert bowl.states[Covered].set_value(dust, True) + assert bowl.states[Covered].set_value(salt, True) + assert bowl.states[Covered].set_value(rust, True) + assert bowl.states[Covered].set_value(spray_paint, True) + assert bowl.states[Covered].set_value(acetone, True) + assert bowl.states[Covered].set_value(cooking_oil, True) + + assert not remover_dishtowel.states[Saturated].get_value(water) + assert not bowl.states[Covered].get_value(water) + + # The rule will not execute if Open is True + washer.states[Open].set_value(True) + og.sim.step() + + assert bowl.states[Covered].get_value(dust) + assert bowl.states[Covered].get_value(salt) + assert bowl.states[Covered].get_value(rust) + assert bowl.states[Covered].get_value(spray_paint) + assert bowl.states[Covered].get_value(acetone) + assert bowl.states[Covered].get_value(cooking_oil) + assert not remover_dishtowel.states[Saturated].get_value(water) + assert not bowl.states[Covered].get_value(water) + + washer.states[Open].set_value(False) + washer.states[ToggledOn].set_value(True) + + # The rule will execute when Open is False and ToggledOn is True + og.sim.step() + + # Need to take one more step for the state setters to take effect + og.sim.step() + + assert not bowl.states[Covered].get_value(dust) + assert not bowl.states[Covered].get_value(salt) + assert bowl.states[Covered].get_value(rust) + assert not bowl.states[Covered].get_value(spray_paint) + assert not bowl.states[Covered].get_value(acetone) + assert bowl.states[Covered].get_value(cooking_oil) + assert remover_dishtowel.states[Saturated].get_value(water) + assert bowl.states[Covered].get_value(water) + + # Clean up + water.remove_all_particles() + rust.remove_all_particles() + cooking_oil.remove_all_particles() + og.sim.step() + @og_test def test_slicing_rule(): assert len(REGISTERED_RULES) > 0, "No rules registered!" diff --git a/tests/utils.py b/tests/utils.py index 069cf91d4..fb08fdfc8 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -93,6 +93,8 @@ def assert_test_scene(): get_obj_cfg("apple", "apple", "agveuv"), get_obj_cfg("table_knife", "table_knife", "jxdfyy"), get_obj_cfg("half_apple", "half_apple", "sguztn"), + get_obj_cfg("washer", "washer", "dobgmu"), + get_obj_cfg("carpet_sweeper", "carpet_sweeper", "xboreo"), ], "robots": [ { From 8ce69050d200c5b4bc77dd87a41ba9e95d570e64 Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Wed, 7 Feb 2024 17:25:57 -0800 Subject: [PATCH 12/24] minor changes for the tests --- tests/test_transition_rules.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_transition_rules.py b/tests/test_transition_rules.py index 1e12a5365..d3c4e9404 100644 --- a/tests/test_transition_rules.py +++ b/tests/test_transition_rules.py @@ -28,6 +28,7 @@ def test_dryer_rule(): place_obj_on_floor_plane(clothes_dryer) og.sim.step() + # Place the two objects inside the dryer remover_dishtowel.set_position_orientation([0.0, 0.0, 0.4], [0, 0, 0, 1]) bowl.set_position_orientation([0.0, 0.0, 0.5], [0, 0, 0, 1]) og.sim.step() @@ -79,6 +80,7 @@ def test_washer_rule(): place_obj_on_floor_plane(washer) og.sim.step() + # Place the two objects inside the washer remover_dishtowel.set_position_orientation([0.0, 0.0, 0.4], [0, 0, 0, 1]) bowl.set_position_orientation([0.0, 0.0, 0.5], [0, 0, 0, 1]) og.sim.step() @@ -126,7 +128,11 @@ def test_washer_rule(): # Clean up water.remove_all_particles() + dust.remove_all_particles() + salt.remove_all_particles() rust.remove_all_particles() + spray_paint.remove_all_particles() + acetone.remove_all_particles() cooking_oil.remove_all_particles() og.sim.step() @@ -795,7 +801,7 @@ def test_cooking_object_rule_failure_wrong_container(): bagel_dough.set_position_orientation([0, 0, 0.45], [0, 0, 0, 1]) raw_egg.set_position_orientation([0.02, 0, 0.50], [0, 0, 0, 1]) og.sim.step() - assert bagel_dough.states[OnTop].get_value(stockpot) + assert bagel_dough.states[Inside].get_value(stockpot) assert raw_egg.states[OnTop].get_value(bagel_dough) assert bagel_dough.states[Cooked].set_value(False) From be438e9151816145d5f72ddd6ed0abd42df731dd Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Wed, 7 Feb 2024 20:31:09 -0800 Subject: [PATCH 13/24] fix minor issues with macro particle system --- omnigibson/objects/object_base.py | 3 --- omnigibson/objects/stateful_object.py | 4 ---- omnigibson/prims/prim_base.py | 1 - omnigibson/sensors/vision_sensor.py | 3 --- omnigibson/systems/macro_particle_system.py | 2 +- omnigibson/systems/system_base.py | 8 -------- 6 files changed, 1 insertion(+), 20 deletions(-) diff --git a/omnigibson/objects/object_base.py b/omnigibson/objects/object_base.py index 73fcb7651..a89219113 100644 --- a/omnigibson/objects/object_base.py +++ b/omnigibson/objects/object_base.py @@ -132,9 +132,6 @@ def load(self): return prim def remove(self): - """ - Do NOT call this function directly to remove a prim - call og.sim.remove_prim(prim) for proper cleanup - """ # Run super first super().remove() diff --git a/omnigibson/objects/stateful_object.py b/omnigibson/objects/stateful_object.py index 3c243f4d6..ca1e5e0e2 100644 --- a/omnigibson/objects/stateful_object.py +++ b/omnigibson/objects/stateful_object.py @@ -451,10 +451,6 @@ def _update_albedo_value(object_state, material): material.diffuse_tint = diffuse_tint def remove(self): - """ - Removes this prim from omniverse stage. - Do NOT call this function directly to remove a prim - call og.sim.remove_prim(prim) for proper cleanup - """ # Iterate over all states and run their remove call for state_instance in self._states.values(): state_instance.remove() diff --git a/omnigibson/prims/prim_base.py b/omnigibson/prims/prim_base.py index 7985870d5..b7ee7ed77 100644 --- a/omnigibson/prims/prim_base.py +++ b/omnigibson/prims/prim_base.py @@ -108,7 +108,6 @@ def _post_load(self): def remove(self): """ Removes this prim from omniverse stage. - Do NOT call this function directly to remove a prim - call og.sim.remove_prim(prim) for proper cleanup. """ if not self._loaded: raise ValueError("Cannot remove a prim that was never loaded.") diff --git a/omnigibson/sensors/vision_sensor.py b/omnigibson/sensors/vision_sensor.py index 74e8d05a1..ede4cb0d4 100644 --- a/omnigibson/sensors/vision_sensor.py +++ b/omnigibson/sensors/vision_sensor.py @@ -260,9 +260,6 @@ def add_modality(self, modality): self.initialize_sensors(names=modality) def remove(self): - """ - Do NOT call this function directly to remove a prim - call og.sim.remove_prim(prim) for proper cleanup - """ # Remove from global sensors dictionary self.SENSORS.pop(self._prim_path) diff --git a/omnigibson/systems/macro_particle_system.py b/omnigibson/systems/macro_particle_system.py index c03b62bb8..fea0ebe45 100644 --- a/omnigibson/systems/macro_particle_system.py +++ b/omnigibson/systems/macro_particle_system.py @@ -326,7 +326,7 @@ def color(cls): return np.array(cls._color) -class MacroVisualParticleSystem(MacroParticleSystem, VisualParticleSystem): +class MacroVisualParticleSystem(VisualParticleSystem, MacroParticleSystem): """ Particle system class that procedurally generates individual particles that are not subject to physics """ diff --git a/omnigibson/systems/system_base.py b/omnigibson/systems/system_base.py index 2b568001e..f32ef8627 100644 --- a/omnigibson/systems/system_base.py +++ b/omnigibson/systems/system_base.py @@ -543,15 +543,7 @@ def clear(cls): cls._group_objects = dict() cls._group_scales = dict() - @classmethod - def remove_particle_by_name(cls, name): - """ - Remove particle with name @name from both the simulator and internal state - Args: - name (str): Name of the particle to remove - """ - raise NotImplementedError() @classmethod def remove_all_group_particles(cls, group): From f17d5bb2eff74d11d2382eebd06247c416145e46 Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Thu, 8 Feb 2024 01:16:02 -0800 Subject: [PATCH 14/24] fixing tests: handle cases where an object with visual particles attached is removed between dump_state and load_state --- omnigibson/object_states/particle_modifier.py | 9 ++-- omnigibson/objects/stateful_object.py | 6 +-- omnigibson/prims/prim_base.py | 3 +- omnigibson/systems/macro_particle_system.py | 45 ++++++++++++++----- omnigibson/systems/system_base.py | 6 +-- omnigibson/utils/sim_utils.py | 4 +- 6 files changed, 50 insertions(+), 23 deletions(-) diff --git a/omnigibson/object_states/particle_modifier.py b/omnigibson/object_states/particle_modifier.py index 8e35290a7..d2828d69f 100644 --- a/omnigibson/object_states/particle_modifier.py +++ b/omnigibson/object_states/particle_modifier.py @@ -15,6 +15,7 @@ from omnigibson.object_states.saturated import ModifiedParticles, Saturated from omnigibson.object_states.toggle import ToggledOn from omnigibson.object_states.update_state_mixin import UpdateStateMixin +from omnigibson.prims.prim_base import BasePrim from omnigibson.systems.system_base import VisualParticleSystem, PhysicalParticleSystem, get_system, \ is_visual_particle_system, is_physical_particle_system, is_fluid_system, is_system_active, REGISTERED_SYSTEMS from omnigibson.utils.constants import ParticleModifyMethod, ParticleModifyCondition, PrimType @@ -884,6 +885,7 @@ def __init__( self._in_mesh_local_particle_directions = None self.projection_system = None + self.projection_system_prim = None self.projection_emitter = None # Run super @@ -929,7 +931,8 @@ def _initialize(self): parent_scale=self.link.scale, material=system.material, ) - + self.projection_system_prim = BasePrim(prim_path=self.projection_system.GetPrimPath().pathString, + name=projection_name) # Create the visual geom instance referencing the generated source mesh prim, and then hide it self.projection_source_sphere = VisualGeomPrim(prim_path=projection_visualization_path, name=f"{name_prefix}_projection_source_sphere") self.projection_source_sphere.initialize() @@ -1024,8 +1027,8 @@ def _update(self): def remove(self): # We need to remove the projection visualization if it exists - if self.projection_system is not None: - lazy.omni.isaac.core.utils.prims.delete_prim(self.projection_system.GetPrimPath().pathString) + if self.projection_system_prim is not None: + og.sim.remove_prim(self.projection_system_prim) def _modify_particles(self, system): if self._sample_with_raycast: diff --git a/omnigibson/objects/stateful_object.py b/omnigibson/objects/stateful_object.py index ca1e5e0e2..4c9a50f66 100644 --- a/omnigibson/objects/stateful_object.py +++ b/omnigibson/objects/stateful_object.py @@ -451,13 +451,13 @@ def _update_albedo_value(object_state, material): material.diffuse_tint = diffuse_tint def remove(self): + # Run super + super().remove() + # Iterate over all states and run their remove call for state_instance in self._states.values(): state_instance.remove() - # Run super - super().remove() - def _dump_state(self): # Grab state from super class state = super()._dump_state() diff --git a/omnigibson/prims/prim_base.py b/omnigibson/prims/prim_base.py index b7ee7ed77..a3565ee36 100644 --- a/omnigibson/prims/prim_base.py +++ b/omnigibson/prims/prim_base.py @@ -119,12 +119,11 @@ def remove(self): # Also clear the name so we can reuse this later self.remove_names() - @abstractmethod def _load(self): """ Loads the raw prim into the simulator. Any post-processing should be done in @self._post_load() """ - raise NotImplementedError() + pass @property def loaded(self): diff --git a/omnigibson/systems/macro_particle_system.py b/omnigibson/systems/macro_particle_system.py index fea0ebe45..299fd9f95 100644 --- a/omnigibson/systems/macro_particle_system.py +++ b/omnigibson/systems/macro_particle_system.py @@ -864,8 +864,8 @@ def _sync_particle_groups( Synchronizes the particle groups based on desired identification numbers @group_idns Args: - group_objects (list of None or BaseObject): Desired unique group objects that should be active for - this particle system. Any objects that aren't found will be skipped over + group_objects (list of BaseObject): Desired unique group objects that should be active for + this particle system. particle_idns (list of list of int): Per-group unique id numbers for the particles assigned to that group. List should be same length as @group_idns with sub-entries corresponding to the desired number of particles assigned to that group @@ -994,16 +994,18 @@ def cm_create_particle_template(cls): @classmethod def _dump_state(cls): state = super()._dump_state() - + particle_names = list(cls.particles.keys()) # Add in per-group information groups_dict = dict() for group_name, group_particles in cls._group_particles.items(): obj = cls._group_objects[group_name] is_cloth = cls._is_cloth_obj(obj=obj) + groups_dict[group_name] = dict( particle_attached_obj_uuid=obj.uuid, n_particles=cls.num_group_particles(group=group_name), particle_idns=[cls.particle_name2idn(name=name) for name in group_particles.keys()], + particle_indices=[particle_names.index(name) for name in group_particles.keys()], # If the attached object is a cloth, store the face_id, otherwise, store the link name particle_attached_references=[cls._particles_info[name]["face_id"] for name in group_particles.keys()] if is_cloth else [cls._particles_info[name]["link"].prim_path.split("/")[-1] for name in group_particles.keys()], @@ -1024,12 +1026,29 @@ def _load_state(cls, state): state (dict): Keyword-mapped states of this object to set """ # Synchronize particle groups + group_objects = [] + particle_idns = [] + particle_attached_references = [] + + indices_to_remove = np.array([], dtype=int) + for info in state["groups"].values(): + obj = og.sim.scene.object_registry("uuid", info["particle_attached_obj_uuid"]) + # obj will be None if an object with an attachment group is removed between dump_state() and load_state() + if obj is not None: + group_objects.append(obj) + particle_idns.append(info["particle_idns"]) + particle_attached_references.append(info["particle_attached_references"]) + else: + indices_to_remove = np.append(indices_to_remove, info["particle_indices"]) cls._sync_particle_groups( - group_objects=[og.sim.scene.object_registry("uuid", info["particle_attached_obj_uuid"]) - for info in state["groups"].values()], - particle_idns=[info["particle_idns"] for info in state["groups"].values()], - particle_attached_references=[info["particle_attached_references"] for info in state["groups"].values()], + group_objects=group_objects, + particle_idns=particle_idns, + particle_attached_references=particle_attached_references, ) + state["n_particles"] -= len(indices_to_remove) + state["positions"] = np.delete(state["positions"], indices_to_remove, axis=0) + state["orientations"] = np.delete(state["orientations"], indices_to_remove, axis=0) + state["scales"] = np.delete(state["scales"], indices_to_remove, axis=0) # Run super super()._load_state(state=state) @@ -1049,6 +1068,7 @@ def _serialize(cls, state): [group_dict["particle_attached_obj_uuid"]], [group_dict["n_particles"]], group_dict["particle_idns"], + group_dict["particle_indices"], (group_dict["particle_attached_references"] if is_cloth else [group_obj_link2id[reference] for reference in group_dict["particle_attached_references"]]), ] @@ -1063,9 +1083,11 @@ def _deserialize(cls, state): group_objs = [] # Index starts at 1 because index 0 is n_groups idx = 1 + indices_to_remove = np.array([], dtype=int) for i in range(n_groups): obj_uuid, n_particles = int(state[idx]), int(state[idx + 1]) obj = og.sim.scene.object_registry("uuid", obj_uuid) + assert obj is not None: is_cloth = cls._is_cloth_obj(obj=obj) group_obj_id2link = {i: link_name for i, link_name in enumerate(obj.links.keys())} group_objs.append(obj) @@ -1073,10 +1095,12 @@ def _deserialize(cls, state): particle_attached_obj_uuid=obj_uuid, n_particles=n_particles, particle_idns=[int(idn) for idn in state[idx + 2 : idx + 2 + n_particles]], # Idx + 2 because the first two are obj_uuid and n_particles - particle_attached_references=[int(idn) for idn in state[idx + 2 + n_particles : idx + 2 + n_particles * 2]] - if is_cloth else [group_obj_id2link[int(idn)] for idn in state[idx + 2 + n_particles : idx + 2 + n_particles * 2]], + particle_indices=[int(idn) for idn in state[idx + 2 + n_particles: idx + 2 + n_particles * 2]], + particle_attached_references=[int(idn) for idn in state[idx + 2 + n_particles * 2: idx + 2 + n_particles * 3]] + if is_cloth else [group_obj_id2link[int(idn)] for idn in state[idx + 2 + n_particles * 2: idx + 2 + n_particles * 3]], ) - idx += 2 + n_particles * 2 + idx += 2 + n_particles * 3 + log.debug(f"Syncing {cls.name} particles with {n_groups} groups..") cls._sync_particle_groups( group_objects=group_objs, @@ -1086,6 +1110,7 @@ def _deserialize(cls, state): # Get super method state_dict, idx_super = super()._deserialize(state=state[idx:]) + state_dict["n_groups"] = n_groups state_dict["groups"] = groups_dict return state_dict, idx + idx_super diff --git a/omnigibson/systems/system_base.py b/omnigibson/systems/system_base.py index f32ef8627..a2deb9982 100644 --- a/omnigibson/systems/system_base.py +++ b/omnigibson/systems/system_base.py @@ -529,9 +529,9 @@ def state_size(cls): state_size = super().state_size # Additionally, we have n_groups (1), with m_particles for each group (n), attached_obj_uuids (n), and - # particle ids and corresponding link info for each particle (m * 2) + # particle ids, particle indices, and corresponding link info for each particle (m * 3) return state_size + 1 + 2 * len(cls._group_particles) + \ - sum(2 * cls.num_group_particles(group) for group in cls.groups) + sum(3 * cls.num_group_particles(group) for group in cls.groups) @classmethod def clear(cls): @@ -543,8 +543,6 @@ def clear(cls): cls._group_objects = dict() cls._group_scales = dict() - - @classmethod def remove_all_group_particles(cls, group): """ diff --git a/omnigibson/utils/sim_utils.py b/omnigibson/utils/sim_utils.py index beed9f7ac..3619ca02f 100644 --- a/omnigibson/utils/sim_utils.py +++ b/omnigibson/utils/sim_utils.py @@ -64,6 +64,8 @@ def check_deletable_prim(prim_path): Returns: bool: Whether the prim can be deleted or not """ + if not lazy.omni.isaac.core.utils.prims.is_prim_path_valid(prim_path): + return False if lazy.omni.isaac.core.utils.prims.is_prim_no_delete(prim_path): return False if lazy.omni.isaac.core.utils.prims.is_prim_ancestral(prim_path): @@ -355,4 +357,4 @@ def land_object(obj, pos, quat=None, z_offset=None): def meets_minimum_isaac_version(minimum_version): - return python_utils.meets_minimum_version(lazy.omni.isaac.version.get_version()[0], minimum_version) \ No newline at end of file + return python_utils.meets_minimum_version(lazy.omni.isaac.version.get_version()[0], minimum_version) From bc74cfec9bfe51c83af8c973cdeac09065ab5026 Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Thu, 8 Feb 2024 02:04:53 -0800 Subject: [PATCH 15/24] fix deserialization issues --- omnigibson/controllers/ik_controller.py | 4 ++-- omnigibson/object_states/particle_modifier.py | 15 +++++++++------ omnigibson/objects/controllable_object.py | 7 ------- omnigibson/systems/macro_particle_system.py | 2 +- tests/test_transition_rules.py | 2 +- 5 files changed, 13 insertions(+), 17 deletions(-) diff --git a/omnigibson/controllers/ik_controller.py b/omnigibson/controllers/ik_controller.py index 9e743d0fc..38e0b7035 100644 --- a/omnigibson/controllers/ik_controller.py +++ b/omnigibson/controllers/ik_controller.py @@ -231,9 +231,9 @@ def _deserialize(self, state): state_dict, idx = super()._deserialize(state=state) # Deserialize state for this controller - state_dict["control_filter"] = self.control_filter.deserialize(state=state[idx + 4: idx + 4 + self.control_filter.state_size]) + state_dict["control_filter"] = self.control_filter.deserialize(state=state[idx: idx + self.control_filter.state_size]) - return state_dict, idx + 4 + self.control_filter.state_size + return state_dict, idx + self.control_filter.state_size def _update_goal(self, command, control_dict): # Grab important info from control dict diff --git a/omnigibson/object_states/particle_modifier.py b/omnigibson/object_states/particle_modifier.py index d2828d69f..432cfe89f 100644 --- a/omnigibson/object_states/particle_modifier.py +++ b/omnigibson/object_states/particle_modifier.py @@ -418,6 +418,15 @@ def check_overlap(): # Store check overlap function self._check_overlap = check_overlap + # Update the saturation limit for each system + for system_name in self.conditions.keys(): + system = get_system(system_name, force_active=False) + limit = self.visual_particle_modification_limit \ + if is_visual_particle_system(system_name=system.name) \ + else self.physical_particle_modification_limit + self.obj.states[Saturated].set_limit(system=system, limit=limit) + + def _generate_condition(self, condition_type, value): """ Generates a valid condition function given @condition_type and its corresponding @value @@ -580,12 +589,6 @@ def _update(self): # Check if all conditions are met if self.check_conditions_for_system(system_name): system = get_system(system_name) - # Update saturation limit if it's not specified yet - limit = self.visual_particle_modification_limit \ - if is_visual_particle_system(system_name=system.name) \ - else self.physical_particle_modification_limit - if system not in self.obj.states[Saturated].limits: - self.obj.states[Saturated].set_limit(system=system, limit=limit) # Sanity check for oversaturation if self.obj.states[Saturated].get_value(system=system): continue diff --git a/omnigibson/objects/controllable_object.py b/omnigibson/objects/controllable_object.py index b3acb427c..8d9d5a5dc 100644 --- a/omnigibson/objects/controllable_object.py +++ b/omnigibson/objects/controllable_object.py @@ -533,13 +533,6 @@ def set_joint_positions(self, positions, indices=None, normalized=False, drive=F for controller in self._controllers.values(): controller.reset() - @property - def state_size(self): - # Grab size from super and add in controller state sizes - size = super().state_size - - return size + sum([c.state_size for c in self._controllers.values()]) - def _dump_state(self): # Grab super state state = super()._dump_state() diff --git a/omnigibson/systems/macro_particle_system.py b/omnigibson/systems/macro_particle_system.py index 299fd9f95..c817ec3b8 100644 --- a/omnigibson/systems/macro_particle_system.py +++ b/omnigibson/systems/macro_particle_system.py @@ -1087,7 +1087,7 @@ def _deserialize(cls, state): for i in range(n_groups): obj_uuid, n_particles = int(state[idx]), int(state[idx + 1]) obj = og.sim.scene.object_registry("uuid", obj_uuid) - assert obj is not None: + assert obj is not None, f"Object with UUID {obj_uuid} not found in the scene" is_cloth = cls._is_cloth_obj(obj=obj) group_obj_id2link = {i: link_name for i, link_name in enumerate(obj.links.keys())} group_objs.append(obj) diff --git a/tests/test_transition_rules.py b/tests/test_transition_rules.py index d3c4e9404..5eeb0ce81 100644 --- a/tests/test_transition_rules.py +++ b/tests/test_transition_rules.py @@ -1128,7 +1128,7 @@ def test_single_toggleable_machine_rule_output_system_failure_wrong_container(): milk.generate_particles(positions=np.array([[0.02, 0, 0.25]])) chocolate_sauce.generate_particles(positions=np.array([[0, -0.02, 0.25]])) - ice_cream.set_position([-0.03, 0.02, 0.25]) + ice_cream.set_position([-0.04, 0.025, 0.3]) og.sim.step() From 28d9d1bd9e73f4626659deefb5d979c451999521 Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Thu, 8 Feb 2024 13:09:07 -0800 Subject: [PATCH 16/24] fix more transition rules tests --- omnigibson/systems/macro_particle_system.py | 3 +- tests/test_transition_rules.py | 52 ++++++++++----------- tests/utils.py | 2 +- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/omnigibson/systems/macro_particle_system.py b/omnigibson/systems/macro_particle_system.py index c817ec3b8..f30d3b923 100644 --- a/omnigibson/systems/macro_particle_system.py +++ b/omnigibson/systems/macro_particle_system.py @@ -1039,7 +1039,7 @@ def _load_state(cls, state): particle_idns.append(info["particle_idns"]) particle_attached_references.append(info["particle_attached_references"]) else: - indices_to_remove = np.append(indices_to_remove, info["particle_indices"]) + indices_to_remove = np.append(indices_to_remove, np.array(info["particle_indices"], dtype=int)) cls._sync_particle_groups( group_objects=group_objects, particle_idns=particle_idns, @@ -1083,7 +1083,6 @@ def _deserialize(cls, state): group_objs = [] # Index starts at 1 because index 0 is n_groups idx = 1 - indices_to_remove = np.array([], dtype=int) for i in range(n_groups): obj_uuid, n_particles = int(state[idx]), int(state[idx + 1]) obj = og.sim.scene.object_registry("uuid", obj_uuid) diff --git a/tests/test_transition_rules.py b/tests/test_transition_rules.py index 5eeb0ce81..449d1f9f1 100644 --- a/tests/test_transition_rules.py +++ b/tests/test_transition_rules.py @@ -416,10 +416,10 @@ def test_mixing_rule_failure_recipe_systems(): place_obj_on_floor_plane(bowl) og.sim.step() - water.generate_particles(positions=[[-0.02, 0, 0.03]]) - granulated_sugar.generate_particles(positions=[[0.0, 0.0, 0.03]]) + water.generate_particles(positions=[[-0.02, 0.0, 0.02]]) + granulated_sugar.generate_particles(positions=[[0.0, 0.0, 0.02]]) # This fails the recipe because lemon juice (recipe system) is not in the bowl - lemon_juice.generate_particles(positions=[[0.02, 0.0, 1.03]]) + lemon_juice.generate_particles(positions=[[0.02, 0.0, 1.02]]) assert bowl.states[Contains].get_value(water) assert bowl.states[Contains].get_value(granulated_sugar) @@ -459,11 +459,11 @@ def test_mixing_rule_failure_nonrecipe_systems(): place_obj_on_floor_plane(bowl) og.sim.step() - water.generate_particles(positions=[[-0.02, 0, 0.03]]) - granulated_sugar.generate_particles(positions=[[0.0, 0.0, 0.03]]) - lemon_juice.generate_particles(positions=[[0.02, 0.0, 0.03]]) + water.generate_particles(positions=[[-0.02, 0, 0.02]]) + granulated_sugar.generate_particles(positions=[[0.0, 0.0, 0.02]]) + lemon_juice.generate_particles(positions=[[0.02, 0.0, 0.02]]) # This fails the recipe because salt (nonrecipe system) is in the bowl - salt.generate_particles(positions=[[0.0, 0.02, 0.03]]) + salt.generate_particles(positions=[[0.0, 0.02, 0.02]]) assert bowl.states[Contains].get_value(water) assert bowl.states[Contains].get_value(granulated_sugar) @@ -503,9 +503,9 @@ def test_mixing_rule_success(): place_obj_on_floor_plane(bowl) og.sim.step() - water.generate_particles(positions=[[-0.02, 0, 0.03]]) - granulated_sugar.generate_particles(positions=[[0.0, 0.0, 0.03]]) - lemon_juice.generate_particles(positions=[[0.02, 0.0, 0.03]]) + water.generate_particles(positions=[[-0.02, 0.0, 0.02]]) + granulated_sugar.generate_particles(positions=[[0.0, 0.0, 0.02]]) + lemon_juice.generate_particles(positions=[[0.02, 0.0, 0.02]]) assert bowl.states[Contains].get_value(water) assert bowl.states[Contains].get_value(granulated_sugar) @@ -1421,14 +1421,14 @@ def test_single_toggleable_machine_rule_output_object_failure_unary_states(): place_obj_on_floor_plane(electric_mixer) og.sim.step() - another_raw_egg.set_position_orientation([0, 0.1, 0.2], [0, 0, 0, 1]) - raw_egg.set_position_orientation([0, 0.1, 0.17], [0, 0, 0, 1]) - flour.generate_particles(positions=np.array([[-0.02, 0.06, 0.15]])) - granulated_sugar.generate_particles(positions=np.array([[0.0, 0.06, 0.15]])) - vanilla.generate_particles(positions=np.array([[0.02, 0.06, 0.15]])) - melted_butter.generate_particles(positions=np.array([[-0.02, 0.08, 0.15]])) - baking_powder.generate_particles(positions=np.array([[0.0, 0.08, 0.15]])) - salt.generate_particles(positions=np.array([[0.02, 0.08, 0.15]])) + another_raw_egg.set_position_orientation([-0.01, -0.14, 0.40], [0, 0, 0, 1]) + raw_egg.set_position_orientation([-0.01, -0.14, 0.37], [0, 0, 0, 1]) + flour.generate_particles(positions=np.array([[-0.01, -0.15, 0.33]])) + granulated_sugar.generate_particles(positions=np.array([[0.01, -0.15, 0.33]])) + vanilla.generate_particles(positions=np.array([[0.03, -0.15, 0.33]])) + melted_butter.generate_particles(positions=np.array([[-0.01, -0.13, 0.33]])) + baking_powder.generate_particles(positions=np.array([[0.01, -0.13, 0.33]])) + salt.generate_particles(positions=np.array([[0.03, -0.13, 0.33]])) # This fails the recipe because the egg should not be cooked raw_egg.states[Cooked].set_value(True) og.sim.step() @@ -1495,14 +1495,14 @@ def test_single_toggleable_machine_rule_output_object_success(): place_obj_on_floor_plane(electric_mixer) og.sim.step() - another_raw_egg.set_position_orientation([0, 0.1, 0.2], [0, 0, 0, 1]) - raw_egg.set_position_orientation([0, 0.1, 0.17], [0, 0, 0, 1]) - flour.generate_particles(positions=np.array([[-0.02, 0.06, 0.15]])) - granulated_sugar.generate_particles(positions=np.array([[0.0, 0.06, 0.15]])) - vanilla.generate_particles(positions=np.array([[0.02, 0.06, 0.15]])) - melted_butter.generate_particles(positions=np.array([[-0.02, 0.08, 0.15]])) - baking_powder.generate_particles(positions=np.array([[0.0, 0.08, 0.15]])) - salt.generate_particles(positions=np.array([[0.02, 0.08, 0.15]])) + another_raw_egg.set_position_orientation([-0.01, -0.14, 0.40], [0, 0, 0, 1]) + raw_egg.set_position_orientation([-0.01, -0.14, 0.37], [0, 0, 0, 1]) + flour.generate_particles(positions=np.array([[-0.01, -0.15, 0.33]])) + granulated_sugar.generate_particles(positions=np.array([[0.01, -0.15, 0.33]])) + vanilla.generate_particles(positions=np.array([[0.03, -0.15, 0.33]])) + melted_butter.generate_particles(positions=np.array([[-0.01, -0.13, 0.33]])) + baking_powder.generate_particles(positions=np.array([[0.01, -0.13, 0.33]])) + salt.generate_particles(positions=np.array([[0.03, -0.13, 0.33]])) og.sim.step() diff --git a/tests/utils.py b/tests/utils.py index e36004d5b..72691dbd0 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -85,7 +85,7 @@ def assert_test_scene(): get_obj_cfg("raw_egg", "raw_egg", "ydgivr"), get_obj_cfg("scoop_of_ice_cream", "scoop_of_ice_cream", "dodndj", bounding_box=[0.076, 0.077, 0.065]), get_obj_cfg("food_processor", "food_processor", "gamkbo"), - get_obj_cfg("electric_mixer", "electric_mixer", "ceaeqf"), + get_obj_cfg("electric_mixer", "electric_mixer", "qornxa"), get_obj_cfg("another_raw_egg", "raw_egg", "ydgivr"), get_obj_cfg("chicken", "chicken", "nppsmz"), get_obj_cfg("tablespoon", "tablespoon", "huudhe"), From 53bc6348c892b22cf7f70902ab75af1e41f5a519 Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Thu, 8 Feb 2024 13:25:28 -0800 Subject: [PATCH 17/24] properly clean up particles for object state tests --- tests/test_object_states.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/test_object_states.py b/tests/test_object_states.py index ac6122522..104b8f06d 100644 --- a/tests/test_object_states.py +++ b/tests/test_object_states.py @@ -764,6 +764,8 @@ def test_particle_source(): with pytest.raises(NotImplementedError): sink.states[ParticleSource].set_value(True) + water_system.remove_all_particles() + @og_test def test_particle_sink(): @@ -791,6 +793,8 @@ def test_particle_sink(): with pytest.raises(NotImplementedError): sink.states[ParticleSink].set_value(True) + water_system.remove_all_particles() + @og_test def test_particle_applier(): @@ -850,6 +854,7 @@ def test_particle_applier(): with pytest.raises(NotImplementedError): spray_bottle.states[ParticleApplier].set_value(True) + water_system.remove_all_particles() @og_test def test_particle_remover(): @@ -909,6 +914,8 @@ def test_particle_remover(): with pytest.raises(NotImplementedError): vacuum.states[ParticleRemover].set_value(True) + water_system.remove_all_particles() + @og_test def test_saturated(): @@ -938,6 +945,8 @@ def test_saturated(): assert remover_dishtowel.states[Saturated].set_value(water_system, False) assert remover_dishtowel.states[Saturated].set_value(water_system, True) + water_system.remove_all_particles() + @og_test def test_open(): @@ -1079,14 +1088,13 @@ def test_filled(): # Cannot set Filled state False with pytest.raises(NotImplementedError): stockpot.states[Filled].set_value(system, False) + system.remove_all_particles() for _ in range(5): og.sim.step() - assert not stockpot.states[Filled].get_value(system) - - system.remove_all_particles() + assert not stockpot.states[Filled].get_value(system) @og_test def test_contains(): @@ -1131,6 +1139,7 @@ def test_contains(): with pytest.raises(NotImplementedError): stockpot.states[Contains].set_value(system, True) + system.remove_all_particles() @og_test def test_covered(): From 2eaebb974c433c60f9f4a6352d82c55fe74bc027 Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Thu, 8 Feb 2024 14:59:42 -0800 Subject: [PATCH 18/24] fix object removal tests, improve styles --- omnigibson/transition_rules.py | 295 ++++++++++++++++++++------------- 1 file changed, 178 insertions(+), 117 deletions(-) diff --git a/omnigibson/transition_rules.py b/omnigibson/transition_rules.py index 3588fff03..93b2f1554 100644 --- a/omnigibson/transition_rules.py +++ b/omnigibson/transition_rules.py @@ -504,7 +504,7 @@ def __init__(self, conditions): """ Args: conditions (list of RuleConditions): Conditions to take logical OR over. This will generate - the UNION of all pruning between the two sets + the UNION of all candidates. """ self._conditions = conditions @@ -544,8 +544,8 @@ class AndConditionWrapper(RuleCondition): def __init__(self, conditions): """ Args: - conditions (list of RuleConditions): Conditions to take logical OR over. This will generate - the UNION of all pruning between the two sets + conditions (list of RuleConditions): Conditions to take logical AND over. This will generate + the INTERSECTION of all candidates. """ self._conditions = conditions @@ -792,6 +792,11 @@ def _do_not_register_classes(cls): return classes class WasherRule(WasherDryerRule): + """ + Transition rule to apply to cloth washers. + 1. remove "dirty" particles from the washer if the necessary solvent is present. + 2. wet the objects inside by making them either Saturated with or Covered by water. + """ _CLEANING_CONDITONS = None @classmethod @@ -871,6 +876,11 @@ def transition(cls, object_candidates): class DryerRule(WasherDryerRule): + """ + Transition rule to apply to cloth dryers. + 1. dry the objects inside by making them not Saturated with water. + 2. remove all water from the dryer. + """ @classproperty def candidate_filters(cls): return { @@ -1097,7 +1107,7 @@ def add_recipe( "output_systems": list(), # Maps object categories to ["unary", "bianry_system", "binary_object"] to a list of states that must be satisfied for the recipe to execute "input_states": defaultdict(lambda: defaultdict(list)), - # Maps object categories to ["unary", "bianry_system", "binary_object"] to a list of states that should be set after the output objects are spawned + # Maps object categories to ["unary", "bianry_system"] to a list of states that should be set after the output objects are spawned "output_states": defaultdict(lambda: defaultdict(list)), # Set of fillable categories which are allowed for this recipe "fillable_categories": None, @@ -1107,6 +1117,13 @@ def add_recipe( **kwargs, } + cls._populate_input_output_objects_systems(name=name, input_synsets=input_synsets, output_synsets=output_synsets) + cls._populate_input_output_states(name=name, input_states=input_states, output_states=output_states) + cls._populate_input_object_tree(name=name) + cls._populate_fillable_categories(name=name, fillable_categories=fillable_categories) + + @classmethod + def _populate_input_output_objects_systems(cls, name, input_synsets, output_synsets): # Map input/output synsets into input/output objects and systems. for synsets, obj_key, system_key in zip((input_synsets, output_synsets), ("input_objects", "output_objects"), ("input_systems", "output_systems")): for synset, count in synsets.items(): @@ -1122,6 +1139,8 @@ def add_recipe( assert len(cls._RECIPES[name]["output_objects"]) == 0 or len(cls._RECIPES[name]["output_systems"]) == 0, \ "Recipe can only generate output objects or output systems, but not both!" + @classmethod + def _populate_input_output_states(cls, name, input_states, output_states): # Apply post-processing for input/output states if specified for synsets_to_states, states_key in zip((input_states, output_states), ("input_states", "output_states")): if synsets_to_states is None: @@ -1178,8 +1197,10 @@ def add_recipe( cls._RECIPES[name][states_key][first_obj_category]["binary_object"].append( (state_class, second_obj_category, state_value)) + @classmethod + def _populate_input_object_tree(cls, name): if cls.is_multi_instance and len(cls._RECIPES[name]["input_objects"]) > 0: - # Build a tree of input objects according to the kinematic binary states + # Build a tree of input object categories according to the kinematic binary states # Example: 'raw_egg': {'binary_object': [(OnTop, 'bagel_dough', True)]} results in an edge # from 'bagel_dough' to 'raw_egg', i.e. 'bagel_dough' is the parent of 'raw_egg'. input_object_tree = nx.DiGraph() @@ -1194,6 +1215,8 @@ def add_recipe( assert cls._RECIPES[name]["input_objects"][root_nodes[0]] == 1, f"Input object tree root node must have exactly one instance! Now: {cls._RECIPES[name]['input_objects'][root_nodes[0]]}." cls._RECIPES[name]["input_object_tree"] = input_object_tree + @classmethod + def _populate_fillable_categories(cls, name, fillable_categories): # Map fillable synsets to fillable object categories. if fillable_categories is not None: cls._RECIPES[name]["fillable_categories"] = set() @@ -1272,8 +1295,22 @@ def _validate_recipe_objects_are_contained_and_states_satisfied(cls, recipe, con """ in_volume = container_info["in_volume"] + # Store necessary information for execution container_info["execution_info"] = dict() + category_to_valid_indices = cls._filter_input_objects_by_unary_and_binary_system_states(recipe=recipe) + container_info["execution_info"]["category_to_valid_indices"] = category_to_valid_indices + if not cls.is_multi_instance: + return cls._validate_recipe_objects_non_multi_instance( + recipe=recipe, category_to_valid_indices=category_to_valid_indices, in_volume=in_volume, + ) + else: + return cls._validate_recipe_objects_multi_instance( + recipe=recipe, category_to_valid_indices=category_to_valid_indices, container_info=container_info, + ) + + @classmethod + def _filter_input_objects_by_unary_and_binary_system_states(cls, recipe): # Filter input objects based on a subset of input states (unary states and binary system states) # Map object categories (str) to valid indices (np.ndarray) category_to_valid_indices = dict() @@ -1307,118 +1344,144 @@ def _validate_recipe_objects_are_contained_and_states_satisfied(cls, recipe, con # Convert to numpy array for faster indexing category_to_valid_indices[obj_category] = np.array(category_to_valid_indices[obj_category], dtype=int) + return category_to_valid_indices - container_info["execution_info"]["category_to_valid_indices"] = category_to_valid_indices - if not cls.is_multi_instance: - # Check if sufficiently number of objects are contained + @classmethod + def _validate_recipe_objects_non_multi_instance(cls, recipe, category_to_valid_indices, in_volume): + # Check if sufficiently number of objects are contained + for obj_category, obj_quantity in recipe["input_objects"].items(): + if np.sum(in_volume[category_to_valid_indices[obj_category]]) < obj_quantity: + return False + return True + + @classmethod + def _validate_recipe_objects_multi_instance(cls, recipe, category_to_valid_indices, container_info): + in_volume = container_info["in_volume"] + input_object_tree = recipe["input_object_tree"] + + # Map object category to a set of objects that are used in this execution + relevant_objects = defaultdict(set) + + # Map system name to a set of particle indices that are used in this execution + relevant_systems = defaultdict(set) + + # Number of instances of this recipe that can be produced + num_instances = 0 + + # Define a recursive function to check the kinematic tree + def check_kinematic_tree(obj, should_check_in_volume=False): + """ + Recursively check if the kinematic tree is satisfied. + Return True/False, and a set of objects that belong to the subtree rooted at the current node + + Args: + obj (BaseObject): Subtree root node to check + should_check_in_volume (bool): Whether to check if the object is in the volume or not + Returns: + bool: True if the subtree rooted at the current node is satisfied + set: Set of objects that belong to the subtree rooted at the current node + """ + + # Check if obj is in volume + if should_check_in_volume and not in_volume[cls._OBJECTS_TO_IDX[obj]]: + return False, set() + + # If the object is a leaf node, return True and the set containing the object + if input_object_tree.out_degree(obj.category) == 0: + return True, set([obj]) + + children_categories = list(input_object_tree.successors(obj.category)) + + all_subtree_objs = set() + for child_cat in children_categories: + assert len(input_states[child_cat]["binary_object"]) == 1, \ + "Each child node should have exactly one binary object state, i.e. one parent in the input_object_tree" + state_class, _, state_value = input_states[child_cat]["binary_object"][0] + num_valid_children = 0 + children_objs = cls._OBJECTS[category_to_valid_indices[child_cat]] + for child_obj in children_objs: + # If the child doesn't satisfy the binary object state, skip + if child_obj.states[state_class].get_value(obj) != state_value: + continue + # Recursively check if the subtree rooted at the child is valid + subtree_valid, subtree_objs = check_kinematic_tree(child_obj) + # If the subtree is valid, increment the number of valid children and aggregate the objects + if subtree_valid: + num_valid_children += 1 + all_subtree_objs |= subtree_objs + + # If there are not enough valid children, return False + if num_valid_children < recipe["input_objects"][child_cat]: + return False, set() + + # If all children categories have sufficient number of objects that satisfy the binary object state, + # e.g. five pieces of pepperoni and two pieces of basil on the pizza, the subtree rooted at the + # current node is valid. Return True and the set of objects in the subtree (all descendants plus + # the current node) + return True, all_subtree_objs | {obj} + + # If multi-instance is True but doesn't require kinematic states between objects + if input_object_tree is None: + num_instances = np.inf + # Compute how many instances of this recipe can be produced. + # Example: if a recipe requires 1 apple and 2 bananas, and there are 3 apples and 4 bananas in the + # container, then 2 instance of the recipe can be produced. for obj_category, obj_quantity in recipe["input_objects"].items(): - if np.sum(in_volume[category_to_valid_indices[obj_category]]) < obj_quantity: + quantity_in_volume = np.sum(in_volume[category_to_valid_indices[obj_category]]) + num_inst = quantity_in_volume // obj_quantity + if num_inst < 1: return False - return True + num_instances = min(num_instances, num_inst) + + for obj_category, obj_quantity in recipe["input_objects"].items(): + quantity_used = num_instances * obj_quantity + # Pick the first quantity_used objects from the valid indices + relevant_objects[obj_category] = set( + cls._OBJECTS[category_to_valid_indices[obj_category][:quantity_used]]) + + # If multi-instance is True and requires kinematic states between objects else: - input_object_tree = recipe["input_object_tree"] - # If multi-instance is True but doesn't require kinematic states between objects - if input_object_tree is None: - num_instances = np.inf - # Compute how many instances of this recipe can be produced. - # Example: if a recipe requires 1 apple and 2 bananas, and there are 3 apples and 4 bananas in the - # container, then 2 instance of the recipe can be produced. - for obj_category, obj_quantity in recipe["input_objects"].items(): - quantity_in_volume = np.sum(in_volume[category_to_valid_indices[obj_category]]) - num_inst = quantity_in_volume // obj_quantity - if num_inst < 1: - return False - num_instances = min(num_instances, num_inst) - - # Map object category to a set of objects that are used in this execution - relevant_objects = defaultdict(set) - for obj_category, obj_quantity in recipe["input_objects"].items(): - quantity_used = num_instances * obj_quantity - relevant_objects[obj_category] = set(cls._OBJECTS[category_to_valid_indices[obj_category][:quantity_used]]) - - # If multi-instance is True and requires kinematic states between objects - else: - root_node_category = [node for node in input_object_tree.nodes() if input_object_tree.in_degree(node) == 0][0] - # A list of objects belonging to the root node category - root_nodes = cls._OBJECTS[category_to_valid_indices[root_node_category]] - input_states = recipe["input_states"] - - # Recursively check if the kinematic tree is satisfied. - # Return True/False, and a set of objects that belong to the subtree rooted at the current node - def check_kinematic_tree(obj, should_check_in_volume=False): - # Check if obj is in volume - if should_check_in_volume and not in_volume[cls._OBJECTS_TO_IDX[obj]]: - return False, set() - - # If the object is a leaf node, return True and the set containing the object - if input_object_tree.out_degree(obj.category) == 0: - return True, set([obj]) - - children_categories = list(input_object_tree.successors(obj.category)) - - all_subtree_objs = set() - for child_cat in children_categories: - assert len(input_states[child_cat]["binary_object"]) == 1, \ - "Each child node should have exactly one binary object state, i.e. one parent in the input_object_tree" - state_class, _, state_value = input_states[child_cat]["binary_object"][0] - num_valid_children = 0 - children_objs = cls._OBJECTS[category_to_valid_indices[child_cat]] - for child_obj in children_objs: - # If the child doesn't satisfy the binary object state, skip - if child_obj.states[state_class].get_value(obj) != state_value: - continue - # Recursively check if the subtree rooted at the child is valid - subtree_valid, subtree_objs = check_kinematic_tree(child_obj) - # If the subtree is valid, increment the number of valid children and aggregate the objects - if subtree_valid: - num_valid_children += 1 - all_subtree_objs |= subtree_objs - - # If there are not enough valid children, return False - if num_valid_children < recipe["input_objects"][child_cat]: - return False, set() - - # If all children categories have sufficient number of objects that satisfy the binary object state, - # e.g. five pieces of pepperoni and two pieces of basil on the pizza, the subtree rooted at the - # current node is valid. Return True and the set of objects in the subtree (all descendants plus - # the current node) - return True, all_subtree_objs | {obj} - - num_instances = 0 - relevant_objects = defaultdict(set) - for root_node in root_nodes: - # should_check_in_volume is True only for the root nodes. - # Example: the bagel dough needs to be in_volume of the container, but the raw egg on top doesn't. - tree_valid, relevant_object_set = check_kinematic_tree(root_node, should_check_in_volume=True) - if tree_valid: - # For each valid tree, increment the number of instances and aggregate the objects - num_instances += 1 - for obj in relevant_object_set: - relevant_objects[obj.category].add(obj) - - # If there are no valid trees, return False - if num_instances == 0: - return False + root_node_category = [node for node in input_object_tree.nodes() + if input_object_tree.in_degree(node) == 0][0] + # A list of objects belonging to the root node category + root_nodes = cls._OBJECTS[category_to_valid_indices[root_node_category]] + input_states = recipe["input_states"] + + for root_node in root_nodes: + # should_check_in_volume is True only for the root nodes. + # Example: the bagel dough needs to be in_volume of the container, but the raw egg on top doesn't. + tree_valid, relevant_object_set = check_kinematic_tree(obj=root_node, should_check_in_volume=True) + if tree_valid: + # For each valid tree, increment the number of instances and aggregate the objects + num_instances += 1 + for obj in relevant_object_set: + relevant_objects[obj.category].add(obj) + + # If there are no valid trees, return False + if num_instances == 0: + return False - # Map system name to a set of particle indices that are used in this execution - relevant_systems = defaultdict(set) - for obj_category, objs in relevant_objects.items(): - for state_class, system_name, state_value in recipe["input_states"][obj_category]["binary_system"]: + # Note that for multi instance recipes, the relevant system particles are NOT the ones in the container. + # Instead, they are the ones that are related to the relevant objects, e.g. salt covering the bagel dough. + for obj_category, objs in relevant_objects.items(): + for state_class, system_name, state_value in recipe["input_states"][obj_category]["binary_system"]: + # If the state value is False, skip + if not state_value: + continue + for obj in objs: if state_class in [Filled, Contains]: - for obj in objs: - contained_particle_idx = obj.states[ContainedParticles].get_value(get_system(system_name)).in_volume.nonzero()[0] - relevant_systems[system_name] |= contained_particle_idx + contained_particle_idx = obj.states[ContainedParticles].get_value(get_system(system_name)).in_volume.nonzero()[0] + relevant_systems[system_name] |= contained_particle_idx elif state_class in [Covered]: - for obj in objs: - covered_particle_idx = obj.states[ContactParticles].get_value(get_system(system_name)) - relevant_systems[system_name] |= covered_particle_idx - - # Now we populate the execution info with the relevant objects and systems as well as the number of - # instances of the recipe that can be produced. - container_info["execution_info"]["relevant_objects"] = relevant_objects - container_info["execution_info"]["relevant_systems"] = relevant_systems - container_info["execution_info"]["num_instances"] = num_instances - return True + covered_particle_idx = obj.states[ContactParticles].get_value(get_system(system_name)) + relevant_systems[system_name] |= covered_particle_idx + + # Now we populate the execution info with the relevant objects and systems as well as the number of + # instances of the recipe that can be produced. + container_info["execution_info"]["relevant_objects"] = relevant_objects + container_info["execution_info"]["relevant_systems"] = relevant_systems + container_info["execution_info"]["num_instances"] = num_instances + return True @classmethod def _validate_nonrecipe_objects_not_contained(cls, recipe, container_info): @@ -1542,27 +1605,22 @@ def _is_recipe_executable(cls, recipe, container, global_info, container_info): # Verify the container category is valid if not cls._validate_recipe_container_is_valid(recipe=recipe, container=container): - print("recipe container is not valid") return False # Verify all required systems are contained in the container if not cls.relax_recipe_systems and not cls._validate_recipe_systems_are_contained(recipe=recipe, container=container): - print("recipe systems are not contained") return False # Verify all required object quantities are contained in the container and their states are satisfied if not cls._validate_recipe_objects_are_contained_and_states_satisfied(recipe=recipe, container_info=container_info): - print("recipe objects are not contained or their states are not satisfied") return False # Verify no non-relevant system is contained if not cls.ignore_nonrecipe_systems and not cls._validate_nonrecipe_systems_not_contained(recipe=recipe, container=container): - print("non-recipe systems are contained") return False # Verify no non-relevant object is contained if we're not ignoring them if not cls.ignore_nonrecipe_objects and not cls._validate_nonrecipe_objects_not_contained(recipe=recipe, container_info=container_info): - print("non-recipe objects are contained") return False return True @@ -1807,7 +1865,8 @@ def _spawn_object_in_container(obj): out_system.generate_particles_from_link( obj=container, link=contained_particles_state.link, - # We don't necessarily have removed all objects in the container. + # When ignore_nonrecipe_objects is True, we don't necessarily remove all objects in the container. + # Therefore, we need to check for contact when generating output systems. check_contact=cls.ignore_nonrecipe_objects, max_samples=int(volume / (np.pi * (out_system.particle_radius ** 3) * 4 / 3)), ) @@ -1995,6 +2054,7 @@ def candidate_filters(cls): candidate_filters["container"] = AndFilter(filters=[ candidate_filters["container"], AbilityFilter(ability="toggleable"), + # Exclude washer and clothes dryer because they are handled by WasherRule and DryerRule NotFilter(CategoryFilter("washer")), NotFilter(CategoryFilter("clothes_dryer")), ]) @@ -2453,10 +2513,11 @@ def import_recipes(): if "machine" in recipe: recipe["fillable_categories"] = set(recipe.pop("machine").keys()) + # Route the recipe to the correct rule: CookingObjectRule or CookingSystemRule satisfied = True output_synsets = set(recipe["output_synsets"].keys()) has_substance = any([s for s in output_synsets if is_substance_synset(s)]) - if (rule_name == "CookingObjectRule" and has_substance) or (rule_name == "CookingSystemRule" and not has_substance): + if (rule == CookingObjectRule and has_substance) or (rule == CookingSystemRule and not has_substance): satisfied = False if satisfied: rule.add_recipe(**recipe) From 209b3246a904434906c2e31c5e77be4025cdbcc0 Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Thu, 8 Feb 2024 15:00:26 -0800 Subject: [PATCH 19/24] actually fix object removal tests --- tests/test_object_removal.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_object_removal.py b/tests/test_object_removal.py index 4c88a5e01..c15c6ed58 100644 --- a/tests/test_object_removal.py +++ b/tests/test_object_removal.py @@ -14,7 +14,7 @@ def test_removal_and_readdition(): # Add an apple apple = DatasetObject( - name="apple", + name="apple_unique", category="apple", model="agveuv", ) @@ -38,11 +38,12 @@ def test_removal_and_readdition(): # Importing should work now apple2 = DatasetObject( - name="apple", + name="apple_unique", category="apple", model="agveuv", ) og.sim.import_object(apple2) + og.sim.step() # Clear the stuff we added og.sim.remove_object(apple2) @@ -54,7 +55,7 @@ def test_readdition(): # Add an apple apple = DatasetObject( - name="apple", + name="apple_unique", category="apple", model="agveuv", ) @@ -73,7 +74,7 @@ def test_readdition(): # Creating and importing a new apple should fail with pytest.raises(AssertionError): apple2 = DatasetObject( - name="apple", + name="apple_unique", category="apple", model="agveuv", ) From 1c609f5ccac3539973322b376a820881a8eedf62 Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Thu, 8 Feb 2024 18:03:16 -0800 Subject: [PATCH 20/24] address comments other than transition rules --- omnigibson/object_states/particle_modifier.py | 20 ++++++-- omnigibson/prims/prim_base.py | 2 +- omnigibson/simulator.py | 46 ++++--------------- omnigibson/systems/macro_particle_system.py | 12 +---- omnigibson/transition_rules.py | 32 ++++++------- tests/test_transition_rules.py | 6 +-- 6 files changed, 47 insertions(+), 71 deletions(-) diff --git a/omnigibson/object_states/particle_modifier.py b/omnigibson/object_states/particle_modifier.py index 432cfe89f..2a7cb6768 100644 --- a/omnigibson/object_states/particle_modifier.py +++ b/omnigibson/object_states/particle_modifier.py @@ -42,7 +42,7 @@ m.N_STEPS_PER_APPLICATION = 5 m.N_STEPS_PER_REMOVAL = 1 -# Saturation thresholds -- maximum number of particles that can be applied by a ParticleApplier +# Application thresholds -- maximum number of particles that can be applied by a ParticleApplier m.VISUAL_PARTICLES_APPLICATION_LIMIT = 1000000 m.PHYSICAL_PARTICLES_APPLICATION_LIMIT = 1000000 @@ -221,11 +221,21 @@ def __init__(self, obj, conditions, method=ParticleModifyMethod.ADJACENCY, proje self._projection_mesh_params = projection_mesh_params # Parse conditions - self.conditions = self._parse_conditions(conditions=conditions) - + self._conditions = self._parse_conditions(conditions=conditions) # Run super method super().__init__(obj) + @property + def conditions(self): + """ + dict: Dictionary mapping the names of ParticleSystem (str) to a list of function calls that must evaluate to + True in order for this particle modifier to be able to modify particles belonging to @ParticleSystem. + The list of functions at least contains the limit condition, which is a function that checks whether the + applier has applied or the remover has removed the maximum number of particles allowed. If the systen name is + not in the dictionary, then the modifier cannot modify particles of that system. + """ + return self._conditions + @classmethod def is_compatible(cls, obj, **kwargs): # Run super first @@ -418,7 +428,7 @@ def check_overlap(): # Store check overlap function self._check_overlap = check_overlap - # Update the saturation limit for each system + # We abuse the Saturated state to store the limit for particle modifier (including both applier and remover) for system_name in self.conditions.keys(): system = get_system(system_name, force_active=False) limit = self.visual_particle_modification_limit \ @@ -589,7 +599,7 @@ def _update(self): # Check if all conditions are met if self.check_conditions_for_system(system_name): system = get_system(system_name) - # Sanity check for oversaturation + # Sanity check to see if the modifier has reached its limit for this system if self.obj.states[Saturated].get_value(system=system): continue # Potentially modify particles within the volume diff --git a/omnigibson/prims/prim_base.py b/omnigibson/prims/prim_base.py index a3565ee36..251808a23 100644 --- a/omnigibson/prims/prim_base.py +++ b/omnigibson/prims/prim_base.py @@ -123,7 +123,7 @@ def _load(self): """ Loads the raw prim into the simulator. Any post-processing should be done in @self._post_load() """ - pass + raise NotImplementedError() @property def loaded(self): diff --git a/omnigibson/simulator.py b/omnigibson/simulator.py index cb01ab8e7..400b30611 100644 --- a/omnigibson/simulator.py +++ b/omnigibson/simulator.py @@ -477,56 +477,30 @@ def import_object(self, obj, register=True): # Lastly, additionally add this object automatically to be initialized as soon as another simulator step occurs self.initialize_object_on_next_sim_step(obj=obj) - def remove_objects(self, objs): - """ - Remove a list of non-robot object from the simulator. - - Args: - objs (List[BaseObject]): list of non-robot objects to remove - """ - state = self.dump_state() - - # Omniverse has a strange bug where if GPU dynamics is on and the object to remove is in contact with - # with another object (in some specific configuration only, not always), the simulator crashes. Therefore, - # we first move the object to a safe location, then remove it. - pos = list(m.OBJECT_GRAVEYARD_POS) - for obj in objs: - obj.set_position_orientation(pos, [0, 0, 0, 1]) - pos[0] += max(obj.aabb_extent) - - # One timestep will elapse - self.app.update() - - for obj in objs: - self._remove_object(obj) - - # Update all handles that are now broken because objects have changed - self.update_handles() - - # Load the state back - self.load_state(state) - - # Refresh all current rules - TransitionRuleAPI.prune_active_rules() - def remove_object(self, obj): """ - Remove a non-robot object from the simulator. + Remove one or a list of non-robot object from the simulator. Args: - obj (BaseObject): a non-robot object to remove + obj (BaseObject or Iterable[BaseObject]): one or a list of non-robot objects to remove """ state = self.dump_state() + objs = [obj] if isinstance(obj, BaseObject) else obj + # Omniverse has a strange bug where if GPU dynamics is on and the object to remove is in contact with # with another object (in some specific configuration only, not always), the simulator crashes. Therefore, # we first move the object to a safe location, then remove it. - obj.set_position_orientation(m.OBJECT_GRAVEYARD_POS, [0, 0, 0, 1]) + pos = list(m.OBJECT_GRAVEYARD_POS) + for ob in objs: + ob.set_position_orientation(pos, [0, 0, 0, 1]) + pos[0] += max(ob.aabb_extent) # One timestep will elapse self.app.update() - self._remove_object(obj) + for ob in objs: + self._remove_object(ob) # Update all handles that are now broken because objects have changed self.update_handles() diff --git a/omnigibson/systems/macro_particle_system.py b/omnigibson/systems/macro_particle_system.py index f30d3b923..802679721 100644 --- a/omnigibson/systems/macro_particle_system.py +++ b/omnigibson/systems/macro_particle_system.py @@ -997,6 +997,7 @@ def _dump_state(cls): particle_names = list(cls.particles.keys()) # Add in per-group information groups_dict = dict() + name2idx = {name: idx for idx, name in enumerate(particle_names)} for group_name, group_particles in cls._group_particles.items(): obj = cls._group_objects[group_name] is_cloth = cls._is_cloth_obj(obj=obj) @@ -1005,7 +1006,7 @@ def _dump_state(cls): particle_attached_obj_uuid=obj.uuid, n_particles=cls.num_group_particles(group=group_name), particle_idns=[cls.particle_name2idn(name=name) for name in group_particles.keys()], - particle_indices=[particle_names.index(name) for name in group_particles.keys()], + particle_indices=[name2idx[name] for name in group_particles.keys()], # If the attached object is a cloth, store the face_id, otherwise, store the link name particle_attached_references=[cls._particles_info[name]["face_id"] for name in group_particles.keys()] if is_cloth else [cls._particles_info[name]["link"].prim_path.split("/")[-1] for name in group_particles.keys()], @@ -1126,9 +1127,6 @@ class MacroPhysicalParticleSystem(PhysicalParticleSystem, MacroParticleSystem): _particle_radius = None _particle_offset = None - # We need to manually call refresh_particles_view the first time when particle count goes from 0 to non-zero - _has_refreshed_particles_view = False - @classmethod def initialize(cls): # Run super method first @@ -1144,12 +1142,6 @@ def initialize(cls): if og.sim.is_playing(): cls.refresh_particles_view() - @classmethod - def update(cls): - if not cls._has_refreshed_particles_view and cls.n_particles > 0: - cls.refresh_particles_view() - cls._has_refreshed_particles_view = True - @classmethod def _load_new_particle(cls, prim_path, name): # We copy the template prim and generate the new object if the prim doesn't already exist, otherwise we diff --git a/omnigibson/transition_rules.py b/omnigibson/transition_rules.py index 93b2f1554..8ac50f4cc 100644 --- a/omnigibson/transition_rules.py +++ b/omnigibson/transition_rules.py @@ -56,12 +56,9 @@ # Mapping from transition rule json files to rule classe names _JSON_FILES_TO_RULES = { - "dicing.json": ["DicingRule"], "heat_cook.json": ["CookingObjectRule", "CookingSystemRule"], - "melting.json": ["MeltingRule"], "mixing_stick.json": ["MixingToolRule"], "single_toggleable_machine.json": ["ToggleableMachineRule"], - "slicing.json": ["SlicingRule"], "substance_cooking.json": ["CookingPhysicalParticleRule"], "substance_watercooking.json": ["CookingPhysicalParticleRule"], "washer.json": ["WasherRule"], @@ -185,7 +182,7 @@ def execute_transition(cls, added_obj_attrs, removed_objs): # Process all transition results if len(removed_objs) > 0: # First remove pre-existing objects - og.sim.remove_objects(removed_objs) + og.sim.remove_object(removed_objs) # Then add new objects if len(added_obj_attrs) > 0: @@ -923,10 +920,6 @@ def _generate_conditions(cls): def transition(cls, object_candidates): objs_to_add, objs_to_remove = [], [] - # Define callback for propagating non-kinematic state from whole objects to half objects - def _get_load_non_kin_state_callback(state): - return lambda obj: obj.load_non_kin_state(state) - for sliceable_obj in object_candidates["sliceable"]: # Object parts offset annotation are w.r.t the base link of the whole object. pos, orn = sliceable_obj.get_position_orientation() @@ -962,13 +955,14 @@ def _get_load_non_kin_state_callback(state): bounding_box=part["bb_size"] * scale, # equiv. to scale=(part["bb_size"] / self.native_bbox) * (scale) ) + sliceable_obj_state = sliceable_obj.dump_state() # Propagate non-physical states of the whole object to the half objects, e.g. cooked, saturated, etc. # Add the new object to the results. new_obj_attrs = ObjectAttrs( obj=part_obj, bb_pos=part_bb_pos, bb_orn=part_bb_orn, - callback=_get_load_non_kin_state_callback(sliceable_obj.dump_state()) + callback=lambda obj: obj.load_non_kin_state(sliceable_obj_state), ) objs_to_add.append(new_obj_attrs) @@ -1001,6 +995,7 @@ def transition(cls, object_candidates): for diceable_obj in object_candidates["diceable"]: obj_category = diceable_obj.category + # We expect all diced particle systems to follow the naming convention (cooked__)diced__ system_name = "diced__" + diceable_obj.category.removeprefix("half_") if Cooked in diceable_obj.states and diceable_obj.states[Cooked].get_value(): system_name = "cooked__" + system_name @@ -1078,6 +1073,7 @@ def add_recipe( fillable_categories=None, **kwargs, ): + # TODO: rename to fillable synsets and heatsource sysnets """ Adds a recipe to this recipe rule to check against. This defines a valid mapping of inputs that will transform into the outputs @@ -1433,11 +1429,10 @@ def check_kinematic_tree(obj, should_check_in_volume=False): return False num_instances = min(num_instances, num_inst) - for obj_category, obj_quantity in recipe["input_objects"].items(): - quantity_used = num_instances * obj_quantity - # Pick the first quantity_used objects from the valid indices - relevant_objects[obj_category] = set( - cls._OBJECTS[category_to_valid_indices[obj_category][:quantity_used]]) + # If at least one instance of the recipe can be executed, add all valid objects to be relevant_objects. + # This can be considered as a special case of below where there are no binary kinematic states required. + for obj_category in recipe["input_objects"]: + relevant_objects[obj_category] = set(cls._OBJECTS[category_to_valid_indices[obj_category]) # If multi-instance is True and requires kinematic states between objects else: @@ -1826,7 +1821,9 @@ def _spawn_object_in_container(obj): state = OnTop # TODO: What to do if setter fails? if not obj.states[state].set_value(container, True): - log.warning(f"Failed to spawn object {obj.name} in container {container.name}!") + log.warning(f"Failed to spawn object {obj.name} in container {container.name}! Directly placing on top instead.") + pos = np.array(container.aabb_center) + np.array([0, 0, container.aabb_extent[2] / 2.0 + obj.aabb_extent[2] / 2.0]) + obj.set_bbox_center_position_orientation(position=pos) # Spawn in new objects for category, n_instances in recipe["output_objects"].items(): @@ -1925,7 +1922,7 @@ def _do_not_register_classes(cls): class CookingPhysicalParticleRule(RecipeRule): """ - Transition rule to apply to "cook" physicl particles + Transition rule to apply to "cook" physical particles """ @classmethod def add_recipe(cls, name, input_synsets, output_synsets): @@ -2004,6 +2001,8 @@ def _execute_recipe(cls, container, recipe, container_info): return TransitionResults(add=[], remove=[]) +# TODO: add more rich doc for each class, add example, e.g. stew, bagel, etc + class ToggleableMachineRule(RecipeRule): """ Transition mixing rule that leverages a single toggleable machine (e.g. electric mixer, coffee machine, blender), @@ -2520,6 +2519,7 @@ def import_recipes(): if (rule == CookingObjectRule and has_substance) or (rule == CookingSystemRule and not has_substance): satisfied = False if satisfied: + # TODO: put translation from BDDL to OG into bddl_utils rule.add_recipe(**recipe) log.info(f"All recipes of rule {rule_name} imported successfully.") diff --git a/tests/test_transition_rules.py b/tests/test_transition_rules.py index 449d1f9f1..f1698ca81 100644 --- a/tests/test_transition_rules.py +++ b/tests/test_transition_rules.py @@ -176,7 +176,7 @@ def test_slicing_rule(): assert half_apple.states[Cooked].get_value() # Clean up - og.sim.remove_objects(new_half_apples) + og.sim.remove_object(new_half_apples) og.sim.step() for obj_cfg in deleted_objs_cfg: @@ -1101,7 +1101,7 @@ def test_cooking_object_rule_success(): sesame_seed.remove_all_particles() og.sim.step() - og.sim.remove_objects(new_bagels) + og.sim.remove_object(new_bagels) og.sim.step() for obj_cfg in deleted_objs_cfg: @@ -1546,7 +1546,7 @@ def test_single_toggleable_machine_rule_output_object_success(): assert dough.states[OnTop].get_value(electric_mixer) # Clean up - og.sim.remove_objects(new_doughs) + og.sim.remove_object(new_doughs) og.sim.step() for obj_cfg in deleted_objs_cfg: From 860194f23f064444b3d436d4f9a92869d775fe1f Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Thu, 8 Feb 2024 18:11:07 -0800 Subject: [PATCH 21/24] rename to default_non_fluid_conditions --- omnigibson/object_states/particle_modifier.py | 10 +++++----- omnigibson/object_states/particle_source_or_sink.py | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/omnigibson/object_states/particle_modifier.py b/omnigibson/object_states/particle_modifier.py index 2a7cb6768..abfaf055e 100644 --- a/omnigibson/object_states/particle_modifier.py +++ b/omnigibson/object_states/particle_modifier.py @@ -726,7 +726,7 @@ def condition(obj) --> bool specified in @conditions. If None, then it is assumed that no other physical particles can be removed. If not None, should be in same format as an entry in @conditions, i.e.: list of (ParticleModifyCondition, val) 2-tuples - default_physical_conditions (None or list): Condition(s) needed to remove any physical (excluding fluid) + default_non_fluid_conditions (None or list): Condition(s) needed to remove any physical (excluding fluid) particles not explicitly specified in @conditions. If None, then it is assumed that no other physical particles can be removed. If not None, should be in same format as an entry in @conditions, i.e.: list of (ParticleModifyCondition, val) 2-tuples @@ -742,14 +742,14 @@ def __init__( method=ParticleModifyMethod.ADJACENCY, projection_mesh_params=None, default_fluid_conditions=None, - default_physical_conditions=None, + default_non_fluid_conditions=None, default_visual_conditions=None, ): # Store values self._default_fluid_conditions = default_fluid_conditions if default_fluid_conditions is None else \ [self._generate_condition(cond_type, cond_val) for cond_type, cond_val in default_fluid_conditions] - self._default_physical_conditions = default_physical_conditions if default_physical_conditions is None else \ - [self._generate_condition(cond_type, cond_val) for cond_type, cond_val in default_physical_conditions] + self._default_non_fluid_conditions = default_non_fluid_conditions if default_non_fluid_conditions is None else \ + [self._generate_condition(cond_type, cond_val) for cond_type, cond_val in default_non_fluid_conditions] self._default_visual_conditions = default_visual_conditions if default_visual_conditions is None else \ [self._generate_condition(cond_type, cond_val) for cond_type, cond_val in default_visual_conditions] @@ -770,7 +770,7 @@ def _parse_conditions(self, conditions): elif is_fluid_system(system_name): default_system_conditions = self._default_fluid_conditions elif is_physical_particle_system(system_name): - default_system_conditions = self._default_physical_conditions + default_system_conditions = self._default_non_fluid_conditions elif is_visual_particle_system(system_name): default_system_conditions = self._default_visual_conditions else: diff --git a/omnigibson/object_states/particle_source_or_sink.py b/omnigibson/object_states/particle_source_or_sink.py index db11a2f87..8fb58aa09 100644 --- a/omnigibson/object_states/particle_source_or_sink.py +++ b/omnigibson/object_states/particle_source_or_sink.py @@ -174,7 +174,7 @@ def condition(obj) --> bool specified in @conditions. If None, then it is assumed that no other physical particles can be removed. If not None, should be in same format as an entry in @conditions, i.e.: list of (ParticleModifyCondition, val) 2-tuples - default_physical_conditions (None or list): Condition(s) needed to remove any physical (excluding fluid) + default_non_fluid_conditions (None or list): Condition(s) needed to remove any physical (excluding fluid) particles not explicitly specified in @conditions. If None, then it is assumed that no other physical particles can be removed. If not None, should be in same format as an entry in @conditions, i.e.: list of (ParticleModifyCondition, val) 2-tuples @@ -190,7 +190,7 @@ def __init__( sink_radius=None, sink_height=None, default_fluid_conditions=None, - default_physical_conditions=None, + default_non_fluid_conditions=None, default_visual_conditions=None, ): # Initialize variables that will be filled in at runtime @@ -214,7 +214,7 @@ def __init__( method=ParticleModifyMethod.PROJECTION, projection_mesh_params=projection_mesh_params, default_fluid_conditions=default_fluid_conditions, - default_physical_conditions=default_physical_conditions, + default_non_fluid_conditions=default_non_fluid_conditions, default_visual_conditions=default_visual_conditions, ) From 63ef51a7f3895d276964bda287790332305e61fb Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Thu, 8 Feb 2024 18:26:28 -0800 Subject: [PATCH 22/24] add more comments to transition rules --- omnigibson/transition_rules.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/omnigibson/transition_rules.py b/omnigibson/transition_rules.py index 8ac50f4cc..d8d278529 100644 --- a/omnigibson/transition_rules.py +++ b/omnigibson/transition_rules.py @@ -788,6 +788,7 @@ def _do_not_register_classes(cls): classes.add("WasherDryerRule") return classes + class WasherRule(WasherDryerRule): """ Transition rule to apply to cloth washers. @@ -1011,6 +1012,7 @@ def transition(cls, object_candidates): class MeltingRule(BaseTransitionRule): """ Transition rule to apply to meltable objects to simulate melting + Once the object reaches the melting temperature, remove the object and spawn the melted substance in its place. """ @classproperty def candidate_filters(cls): @@ -1073,7 +1075,6 @@ def add_recipe( fillable_categories=None, **kwargs, ): - # TODO: rename to fillable synsets and heatsource sysnets """ Adds a recipe to this recipe rule to check against. This defines a valid mapping of inputs that will transform into the outputs @@ -1432,7 +1433,7 @@ def check_kinematic_tree(obj, should_check_in_volume=False): # If at least one instance of the recipe can be executed, add all valid objects to be relevant_objects. # This can be considered as a special case of below where there are no binary kinematic states required. for obj_category in recipe["input_objects"]: - relevant_objects[obj_category] = set(cls._OBJECTS[category_to_valid_indices[obj_category]) + relevant_objects[obj_category] = set(cls._OBJECTS[category_to_valid_indices[obj_category]]) # If multi-instance is True and requires kinematic states between objects else: @@ -1922,7 +1923,12 @@ def _do_not_register_classes(cls): class CookingPhysicalParticleRule(RecipeRule): """ - Transition rule to apply to "cook" physical particles + Transition rule to apply to "cook" physical particles. + It comes with two forms of recipes: + 1. xyz -> cooked__xyz, e.g. diced__chicken -> cooked__diced__chicken + 2. xyz + cooked__water -> cooked__xyz, e.g. rice + cooked__water -> cooked__rice + During execution, we replace the input particles (xyz) with the output particles (cooked__xyz), and remove the + cooked__water if it was used as an input. """ @classmethod def add_recipe(cls, name, input_synsets, output_synsets): @@ -2001,12 +2007,13 @@ def _execute_recipe(cls, container, recipe, container_info): return TransitionResults(add=[], remove=[]) -# TODO: add more rich doc for each class, add example, e.g. stew, bagel, etc - class ToggleableMachineRule(RecipeRule): """ Transition mixing rule that leverages a single toggleable machine (e.g. electric mixer, coffee machine, blender), - which require toggledOn in order to trigger the recipe event + which require toggledOn in order to trigger the recipe event. + It comes with two forms of recipes: + 1. output is a single object, e.g. flour + butter + sugar -> dough, machine is electric mixer + 2. output is a system, e.g. strawberry + milk -> smoothie, machine is blender """ @classmethod @@ -2086,7 +2093,8 @@ def use_garbage_fallback_recipe(cls): class MixingToolRule(RecipeRule): """ Transition mixing rule that leverages "mixingTool" ability objects, which require touching between a mixing tool - and a container in order to trigger the recipe event + and a container in order to trigger the recipe event. + Example: water + lemon_juice + sugar -> lemonade, mixing tool is spoon """ @classmethod def add_recipe(cls, name, input_synsets, output_synsets, input_states=None, output_states=None): @@ -2141,7 +2149,8 @@ def use_garbage_fallback_recipe(cls): class CookingRule(RecipeRule): """ - Transition mixing rule that approximates cooking recipes via a container and heatsource + Transition mixing rule that approximates cooking recipes via a container and heatsource. + It is subclassed by CookingObjectRule and CookingSystemRule. """ # Counter that increments monotonically COUNTER = 0 @@ -2368,6 +2377,11 @@ def _do_not_register_classes(cls): class CookingObjectRule(CookingRule): + """ + Cooking rule when output is objects (e.g. one dough can produce many bagels as output). + Example: bagel_dough + egg + sesame_seed -> bagel, heat source is oven, fillable is baking_sheet. + This is the only rule where is_multi_instance is True, where multiple copies of the recipe can be executed. + """ @classmethod def add_recipe( cls, @@ -2431,6 +2445,10 @@ def is_multi_instance(cls): class CookingSystemRule(CookingRule): + """ + Cooking rule when output is a system. + Example: beef + tomato + chicken_stock -> stew, heat source is stove, fillable is stockpot. + """ @classmethod def add_recipe( cls, From e78a7e147cca3693c5ca8c0f3eaf84419af8a721 Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Thu, 8 Feb 2024 19:39:25 -0800 Subject: [PATCH 23/24] migrate all synset related code from transition rules to bddl utils --- omnigibson/transition_rules.py | 504 +++++++++++++++------------------ omnigibson/utils/bddl_utils.py | 175 +++++++++++- 2 files changed, 398 insertions(+), 281 deletions(-) diff --git a/omnigibson/transition_rules.py b/omnigibson/transition_rules.py index d8d278529..188506e9f 100644 --- a/omnigibson/transition_rules.py +++ b/omnigibson/transition_rules.py @@ -23,16 +23,12 @@ import omnigibson.utils.transform_utils as T from omnigibson.utils.ui_utils import disclaimer, create_module_logger from omnigibson.utils.usd_utils import RigidContactAPI +from omnigibson.utils.bddl_utils import translate_bddl_recipe_to_og_recipe, translate_bddl_washer_rule_to_og_washer_rule import bddl -from bddl.object_taxonomy import ObjectTaxonomy -from omnigibson.utils.bddl_utils import is_substance_synset, get_system_name_by_synset, SUPPORTED_PREDICATES # Create module logger log = create_module_logger(module_name=__name__) -# Create object taxonomy -OBJECT_TAXONOMY = ObjectTaxonomy() - # Create settings for this module m = create_module_macros(module_path=__file__) @@ -795,7 +791,7 @@ class WasherRule(WasherDryerRule): 1. remove "dirty" particles from the washer if the necessary solvent is present. 2. wet the objects inside by making them either Saturated with or Covered by water. """ - _CLEANING_CONDITONS = None + cleaning_conditions = None @classmethod def register_cleaning_conditions(cls, conditions): @@ -803,30 +799,16 @@ def register_cleaning_conditions(cls, conditions): Register cleaning conditions for this rule. Args: - conditions (dict): Dictionary mapping the synset of ParticleSystem (str) to None or list of synsets of - ParticleSystem (str). None represents "never", empty list represents "always", or non-empty list represents - at least one of the systems in the list needs to be present in the washer for the key system to be removed. - E.g. "rust.n.01" -> None: "never remove rust.n.01 from the washer" - E.g. "dust.n.01" -> []: "always remove dust.n.01 from the washer" - E.g. "cooking_oil.n.01" -> ["sodium_carbonate.n.01", "vinegar.n.01"]: "remove cooking_oil.n.01 from the - washer if either sodium_carbonate.n.01 or vinegar.n.01 is present" - For keys not present in the dictionary, the default is []: "always remove" - """ - cls._CLEANING_CONDITONS = dict() - for solute, solvents in conditions.items(): - assert OBJECT_TAXONOMY.is_leaf(solute), f"Synset {solute} must be a leaf node in the taxonomy!" - assert is_substance_synset(solute), f"Synset {solute} must be a substance synset!" - solute_name = get_system_name_by_synset(solute) - if solvents is None: - cls._CLEANING_CONDITONS[solute_name] = None - else: - solvent_names = [] - for solvent in solvents: - assert OBJECT_TAXONOMY.is_leaf(solvent), f"Synset {solvent} must be a leaf node in the taxonomy!" - assert is_substance_synset(solvent), f"Synset {solvent} must be a substance synset!" - solvent_name = get_system_name_by_synset(solvent) - solvent_names.append(solvent_name) - cls._CLEANING_CONDITONS[solute_name] = solvent_names + conditions (dict): ictionary mapping the system name (str) to None or list of system names (str). None + represents "never", empty list represents "always", or non-empty list represents at least one of the + systems in the list needs to be present in the washer for the key system to be removed. + E.g. "rust" -> None: "never remove rust from the washer" + E.g. "dust" -> []: "always remove dust from the washer" + E.g. "cooking_oil" -> ["sodium_carbonate", "vinegar"]: "remove cooking_oil from the washer if either + sodium_carbonate or vinegar is present" + For keys not present in the dictionary, the default is []: "always remove" + """ + cls.cleaning_conditions = conditions @classproperty def candidate_filters(cls): @@ -843,12 +825,12 @@ def transition(cls, object_candidates): systems_to_remove = [] for system in ParticleRemover.supported_active_systems: # Never remove - if system.name in cls._CLEANING_CONDITONS and cls._CLEANING_CONDITONS[system.name] is None: + if system.name in cls.cleaning_conditions and cls.cleaning_conditions[system.name] is None: continue if not washer.states[Contains].get_value(system): continue - solvents = cls._CLEANING_CONDITONS.get(system.name, []) + solvents = cls.cleaning_conditions.get(system.name, []) # Always remove if len(solvents) == 0: systems_to_remove.append(system) @@ -1068,8 +1050,10 @@ def __init_subclass__(cls, **kwargs): def add_recipe( cls, name, - input_synsets, - output_synsets, + input_objects, + input_systems, + output_objects, + output_systems, input_states=None, output_states=None, fillable_categories=None, @@ -1081,147 +1065,53 @@ def add_recipe( Args: name (str): Name of the recipe - input_synsets (dict): Maps synsets to number of instances required for the recipe - output_synsets (dict): Maps synsets to number of instances to be spawned in the container when the recipe executes - input_states (dict or None): Maps input synsets to states that must be satisfied for the recipe to execute, - or None if no states are required - otuput_states (dict or None): Map output synsets to states that should be set when spawned when the recipe executes, - or None if no states are required + input_objects (dict): Maps object categories to number of instances required for the recipe + input_systems (list): List of system names required for the recipe + output_objects (dict): Maps object categories to number of instances to be spawned in the container when the recipe executes + output_systems (list): List of system names to be spawned in the container when the recipe executes. Currently the length is 1. + input_states (None or defaultdict(lambda: defaultdict(list))): Maps object categories to + ["unary", "bianry_system", "binary_object"] to a list of states that must be satisfied for the recipe to execute + output_states (None or defaultdict(lambda: defaultdict(list))): Maps object categories to + ["unary", "bianry_system"] to a list of states that should be set after the output objects are spawned fillable_categories (None or set of str): If specified, set of fillable categories which are allowed for this recipe. If None, any fillable is allowed kwargs (dict): Any additional keyword-arguments to be stored as part of this recipe """ - # Store information for this recipe - cls._RECIPES[name] = { - "name": name, - # Maps object categories to number of instances required for the recipe - "input_objects": dict(), - # List of system names required for the recipe - "input_systems": list(), - # Maps object categories to number of instances to be spawned in the container when the recipe executes - "output_objects": dict(), - # List of system names to be spawned in the container when the recipe executes. Currently the length is 1. - "output_systems": list(), - # Maps object categories to ["unary", "bianry_system", "binary_object"] to a list of states that must be satisfied for the recipe to execute - "input_states": defaultdict(lambda: defaultdict(list)), - # Maps object categories to ["unary", "bianry_system"] to a list of states that should be set after the output objects are spawned - "output_states": defaultdict(lambda: defaultdict(list)), - # Set of fillable categories which are allowed for this recipe - "fillable_categories": None, - # networkx DiGraph that represents the kinematic dependency graph of the input objects - # If input_states has no kinematic states between pairs of objects, this will be None. - "input_object_tree": None, - **kwargs, - } - - cls._populate_input_output_objects_systems(name=name, input_synsets=input_synsets, output_synsets=output_synsets) - cls._populate_input_output_states(name=name, input_states=input_states, output_states=output_states) - cls._populate_input_object_tree(name=name) - cls._populate_fillable_categories(name=name, fillable_categories=fillable_categories) - @classmethod - def _populate_input_output_objects_systems(cls, name, input_synsets, output_synsets): - # Map input/output synsets into input/output objects and systems. - for synsets, obj_key, system_key in zip((input_synsets, output_synsets), ("input_objects", "output_objects"), ("input_systems", "output_systems")): - for synset, count in synsets.items(): - assert OBJECT_TAXONOMY.is_leaf(synset), f"Synset {synset} must be a leaf node in the taxonomy!" - if is_substance_synset(synset): - cls._RECIPES[name][system_key].append(get_system_name_by_synset(synset)) - else: - obj_categories = OBJECT_TAXONOMY.get_categories(synset) - assert len(obj_categories) == 1, f"Object synset {synset} must map to exactly one object category! Now: {obj_categories}." - cls._RECIPES[name][obj_key][obj_categories[0]] = count - - # Assert only one of output_objects or output_systems is not None - assert len(cls._RECIPES[name]["output_objects"]) == 0 or len(cls._RECIPES[name]["output_systems"]) == 0, \ - "Recipe can only generate output objects or output systems, but not both!" - - @classmethod - def _populate_input_output_states(cls, name, input_states, output_states): - # Apply post-processing for input/output states if specified - for synsets_to_states, states_key in zip((input_states, output_states), ("input_states", "output_states")): - if synsets_to_states is None: - continue - for synsets, states in synsets_to_states.items(): - # For unary/binary states, synsets is a single synset or a comma-separated pair of synsets, respectively - synset_split = synsets.split(",") - if len(synset_split) == 1: - first_synset = synset_split[0] - second_synset = None - else: - first_synset, second_synset = synset_split - - # Assert the first synset is an object because the systems don't have any states. - assert OBJECT_TAXONOMY.is_leaf(first_synset), f"Input/output state synset {first_synset} must be a leaf node in the taxonomy!" - assert not is_substance_synset(first_synset), f"Input/output state synset {first_synset} must be applied to an object, not a substance!" - obj_categories = OBJECT_TAXONOMY.get_categories(first_synset) - assert len(obj_categories) == 1, f"Input/output state synset {first_synset} must map to exactly one object category! Now: {obj_categories}." - first_obj_category = obj_categories[0] - - if second_synset is None: - # Unary states for the first synset - for state_type, state_value in states: - state_class = SUPPORTED_PREDICATES[state_type].STATE_CLASS - assert issubclass(state_class, AbsoluteObjectState), f"Input/output state type {state_type} must be a unary state!" - # Example: (Cooked, True) - cls._RECIPES[name][states_key][first_obj_category]["unary"].append((state_class, state_value)) - else: - assert OBJECT_TAXONOMY.is_leaf(second_synset), f"Input/output state synset {second_synset} must be a leaf node in the taxonomy!" - obj_categories = OBJECT_TAXONOMY.get_categories(second_synset) - if is_substance_synset(second_synset): - second_obj_category = get_system_name_by_synset(second_synset) - is_substance = True - else: - obj_categories = OBJECT_TAXONOMY.get_categories(second_synset) - assert len(obj_categories) == 1, f"Input/output state synset {second_synset} must map to exactly one object category! Now: {obj_categories}." - second_obj_category = obj_categories[0] - is_substance = False - - for state_type, state_value in states: - state_class = SUPPORTED_PREDICATES[state_type].STATE_CLASS - assert issubclass(state_class, RelativeObjectState), f"Input/output state type {state_type} must be a binary state!" - assert is_substance == (state_class in get_system_states()), f"Input/output state type {state_type} system state inconsistency found!" - if is_substance: - # Non-kinematic binary states, e.g. Covered, Saturated, Filled, Contains. - # Example: (Covered, "sesame_seed", True) - cls._RECIPES[name][states_key][first_obj_category]["binary_system"].append( - (state_class, second_obj_category, state_value)) - else: - # Kinematic binary states w.r.t. the second object. - # Example: (OnTop, "raw_egg", True) - assert cls.is_multi_instance, f"Input/output state type {state_type} can only be used in multi-instance recipes!" - assert states_key != "output_states", f"Output state type {state_type} can only be used in input states!" - cls._RECIPES[name][states_key][first_obj_category]["binary_object"].append( - (state_class, second_obj_category, state_value)) + input_states = input_states if input_states is not None else defaultdict(lambda: defaultdict(list)) + output_states = output_states if output_states is not None else defaultdict(lambda: defaultdict(list)) - @classmethod - def _populate_input_object_tree(cls, name): - if cls.is_multi_instance and len(cls._RECIPES[name]["input_objects"]) > 0: + input_object_tree = None + if cls.is_multi_instance and len(input_objects) > 0: # Build a tree of input object categories according to the kinematic binary states # Example: 'raw_egg': {'binary_object': [(OnTop, 'bagel_dough', True)]} results in an edge # from 'bagel_dough' to 'raw_egg', i.e. 'bagel_dough' is the parent of 'raw_egg'. input_object_tree = nx.DiGraph() - for obj_category, state_checks in cls._RECIPES[name]["input_states"].items(): + for obj_category, state_checks in input_states.items(): for state_class, second_obj_category, state_value in state_checks["binary_object"]: input_object_tree.add_edge(second_obj_category, obj_category) - if not nx.is_empty(input_object_tree): + if nx.is_empty(input_object_tree): + input_object_tree = None + else: assert nx.is_tree(input_object_tree), f"Input object tree must be a tree! Now: {input_object_tree}." root_nodes = [node for node in input_object_tree.nodes() if input_object_tree.in_degree(node) == 0] assert len(root_nodes) == 1, f"Input object tree must have exactly one root node! Now: {root_nodes}." - assert cls._RECIPES[name]["input_objects"][root_nodes[0]] == 1, f"Input object tree root node must have exactly one instance! Now: {cls._RECIPES[name]['input_objects'][root_nodes[0]]}." - cls._RECIPES[name]["input_object_tree"] = input_object_tree + assert input_objects[root_nodes[0]] == 1, f"Input object tree root node must have exactly one instance! Now: {cls._RECIPES[name]['input_objects'][root_nodes[0]]}." - @classmethod - def _populate_fillable_categories(cls, name, fillable_categories): - # Map fillable synsets to fillable object categories. - if fillable_categories is not None: - cls._RECIPES[name]["fillable_categories"] = set() - for synset in fillable_categories: - assert OBJECT_TAXONOMY.is_leaf(synset), f"Synset {synset} must be a leaf node in the taxonomy!" - assert not is_substance_synset(synset), f"Synset {synset} must be applied to an object, not a substance!" - for category in OBJECT_TAXONOMY.get_categories(synset): - cls._RECIPES[name]["fillable_categories"].add(category) + # Store information for this recipe + cls._RECIPES[name] = { + "name": name, + "input_objects": input_objects, + "input_systems": input_systems, + "output_objects": output_objects, + "output_systems": output_systems, + "input_states": input_states, + "output_states": output_states, + "fillable_categories": fillable_categories, + "input_object_tree": input_object_tree, + **kwargs, + } @classmethod def _validate_recipe_container_is_valid(cls, recipe, container): @@ -1931,23 +1821,29 @@ class CookingPhysicalParticleRule(RecipeRule): cooked__water if it was used as an input. """ @classmethod - def add_recipe(cls, name, input_synsets, output_synsets): - super().add_recipe( - name=name, - input_synsets=input_synsets, - output_synsets=output_synsets, - input_states=None, - output_states=None, - fillable_categories=None, - ) + def add_recipe( + cls, + name, + input_objects, + input_systems, + output_objects, + output_systems, + **kwargs, + ): + """ + Adds a recipe to this recipe rule to check against. This defines a valid mapping of inputs that will transform + into the outputs - input_objects = cls._RECIPES[name]["input_objects"] - output_objects = cls._RECIPES[name]["output_objects"] + Args: + name (str): Name of the recipe + input_objects (dict): Maps object categories to number of instances required for the recipe + input_systems (list): List of system names required for the recipe + output_objects (dict): Maps object categories to number of instances to be spawned in the container when the recipe executes + output_systems (list): List of system names to be spawned in the container when the recipe executes. Currently the length is 1. + """ assert len(input_objects) == 0, f"No input objects can be specified for {cls.__name__}, recipe: {name}!" assert len(output_objects) == 0, f"No output objects can be specified for {cls.__name__}, recipe: {name}!" - input_systems = cls._RECIPES[name]["input_systems"] - output_systems = cls._RECIPES[name]["output_systems"] assert len(input_systems) == 1 or len(input_systems) == 2, \ f"Only one or two input systems can be specified for {cls.__name__}, recipe: {name}!" if len(input_systems) == 2: @@ -1956,6 +1852,15 @@ def add_recipe(cls, name, input_synsets, output_synsets): assert len(output_systems) == 1, \ f"Exactly one output system needs to be specified for {cls.__name__}, recipe: {name}!" + super().add_recipe( + name=name, + input_objects=input_objects, + input_systems=input_systems, + output_objects=output_objects, + output_systems=output_systems, + **kwargs, + ) + @classproperty def candidate_filters(cls): # Modify the container filter to include the heatable ability as well @@ -2018,40 +1923,49 @@ class ToggleableMachineRule(RecipeRule): @classmethod def add_recipe( - cls, - name, - input_synsets, - output_synsets, - fillable_categories, - input_states=None, - output_states=None, + cls, + name, + input_objects, + input_systems, + output_objects, + output_systems, + input_states=None, + output_states=None, + fillable_categories=None, + **kwargs, ): """ - Adds a recipe to this cooking recipe rule to check against. This defines a valid mapping of inputs that - will transform into the outputs + Adds a recipe to this recipe rule to check against. This defines a valid mapping of inputs that will transform + into the outputs Args: name (str): Name of the recipe - input_synsets (dict): Maps synsets to number of instances required for the recipe - output_synsets (dict): Maps synsets to number of instances to be spawned in the container when the recipe executes - fillable_categories (set of str): Set of toggleable machine categories which are allowed for this recipe - input_states (dict or None): Maps input synsets to states that must be satisfied for the recipe to execute, - or None if no states are required - otuput_states (dict or None): Map output synsets to states that should be set when spawned when the recipe executes, - or None if no states are required + input_objects (dict): Maps object categories to number of instances required for the recipe + input_systems (list): List of system names required for the recipe + output_objects (dict): Maps object categories to number of instances to be spawned in the container when the recipe executes + output_systems (list): List of system names to be spawned in the container when the recipe executes. Currently the length is 1. + input_states (None or defaultdict(lambda: defaultdict(list))): Maps object categories to + ["unary", "bianry_system", "binary_object"] to a list of states that must be satisfied for the recipe to execute + output_states (None or defaultdict(lambda: defaultdict(list))): Maps object categories to + ["unary", "bianry_system"] to a list of states that should be set after the output objects are spawned + fillable_categories (None or set of str): If specified, set of fillable categories which are allowed + for this recipe. If None, any fillable is allowed """ + if len(output_objects) > 0: + assert len(output_objects) == 1, f"Only one category of output object can be specified for {cls.__name__}, recipe: {name}!" + assert output_objects[list(output_objects.keys())[0]] == 1, f"Only one instance of output object can be specified for {cls.__name__}, recipe: {name}!" + super().add_recipe( name=name, - input_synsets=input_synsets, - output_synsets=output_synsets, + input_objects=input_objects, + input_systems=input_systems, + output_objects=output_objects, + output_systems=output_systems, input_states=input_states, output_states=output_states, - fillable_categories=fillable_categories + fillable_categories=fillable_categories, + **kwargs, ) - output_objects = cls._RECIPES[name]["output_objects"] - if len(output_objects) > 0: - assert len(output_objects) == 1, f"Only one category of output object can be specified for {cls.__name__}, recipe: {name}!" - assert output_objects[list(output_objects.keys())[0]] == 1, f"Only one instance of output object can be specified for {cls.__name__}, recipe: {name}!" @classproperty def candidate_filters(cls): @@ -2097,25 +2011,48 @@ class MixingToolRule(RecipeRule): Example: water + lemon_juice + sugar -> lemonade, mixing tool is spoon """ @classmethod - def add_recipe(cls, name, input_synsets, output_synsets, input_states=None, output_states=None): - super().add_recipe( - name=name, - input_synsets=input_synsets, - output_synsets=output_synsets, - input_states=input_states, - output_states=output_states, - fillable_categories=None, - ) + def add_recipe( + cls, + name, + input_objects, + input_systems, + output_objects, + output_systems, + input_states=None, + output_states=None, + **kwargs, + ): + """ + Adds a recipe to this recipe rule to check against. This defines a valid mapping of inputs that will transform + into the outputs - output_objects = cls._RECIPES[name]["output_objects"] + Args: + name (str): Name of the recipe + input_objects (dict): Maps object categories to number of instances required for the recipe + input_systems (list): List of system names required for the recipe + output_objects (dict): Maps object categories to number of instances to be spawned in the container when the recipe executes + output_systems (list): List of system names to be spawned in the container when the recipe executes. Currently the length is 1. + input_states (None or defaultdict(lambda: defaultdict(list))): Maps object categories to + ["unary", "bianry_system", "binary_object"] to a list of states that must be satisfied for the recipe to execute + output_states (None or defaultdict(lambda: defaultdict(list))): Maps object categories to + ["unary", "bianry_system"] to a list of states that should be set after the output objects are spawned + """ assert len(output_objects) == 0, f"No output objects can be specified for {cls.__name__}, recipe: {name}!" - - input_systems = cls._RECIPES[name]["input_systems"] - output_systems = cls._RECIPES[name]["output_systems"] assert len(input_systems) > 0, f"Some input systems need to be specified for {cls.__name__}, recipe: {name}!" assert len(output_systems) == 1, \ f"Exactly one output system needs to be specified for {cls.__name__}, recipe: {name}!" + super().add_recipe( + name=name, + input_objects=input_objects, + input_systems=input_systems, + output_objects=output_objects, + output_systems=output_systems, + input_states=input_states, + output_states=output_states, + **kwargs, + ) + @classproperty def candidate_filters(cls): # Add mixing tool filter as well @@ -2287,15 +2224,17 @@ def _is_recipe_executable(cls, recipe, container, global_info, container_info): @classmethod def add_recipe( - cls, - name, - input_synsets, - output_synsets, - input_states=None, - output_states=None, - fillable_categories=None, - heatsource_categories=None, - timesteps=None, + cls, + name, + input_objects, + input_systems, + output_objects, + output_systems, + input_states=None, + output_states=None, + fillable_categories=None, + heatsource_categories=None, + timesteps=None, ): """ Adds a recipe to this cooking recipe rule to check against. This defines a valid mapping of inputs that @@ -2303,12 +2242,14 @@ def add_recipe( Args: name (str): Name of the recipe - input_synsets (dict): Maps synsets to number of instances required for the recipe - output_synsets (dict): Maps synsets to number of instances to be spawned in the container when the recipe executes - input_states (dict or None): Maps input synsets to states that must be satisfied for the recipe to execute, - or None if no states are required - otuput_states (dict or None): Map output synsets to states that should be set when spawned when the recipe executes, - or None if no states are required + input_objects (dict): Maps object categories to number of instances required for the recipe + input_systems (list): List of system names required for the recipe + output_objects (dict): Maps object categories to number of instances to be spawned in the container when the recipe executes + output_systems (list): List of system names to be spawned in the container when the recipe executes. Currently the length is 1. + input_states (None or defaultdict(lambda: defaultdict(list))): Maps object categories to + ["unary", "bianry_system", "binary_object"] to a list of states that must be satisfied for the recipe to execute + output_states (None or defaultdict(lambda: defaultdict(list))): Maps object categories to + ["unary", "bianry_system"] to a list of states that should be set after the output objects are spawned fillable_categories (None or set of str): If specified, set of fillable categories which are allowed for this recipe. If None, any fillable is allowed heatsource_categories (None or set of str): If specified, set of heatsource categories which are allowed @@ -2316,19 +2257,12 @@ def add_recipe( timesteps (None or int): Number of subsequent heating steps required for the recipe to execute. If None, it will be set to be 1, i.e.: instantaneous execution """ - if heatsource_categories is not None: - heatsource_categories_postprocessed = set() - for synset in heatsource_categories: - assert OBJECT_TAXONOMY.is_leaf(synset), f"Synset {synset} must be a leaf node in the taxonomy!" - assert not is_substance_synset(synset), f"Synset {synset} must be applied to an object, not a substance!" - for category in OBJECT_TAXONOMY.get_categories(synset): - heatsource_categories_postprocessed.add(category) - heatsource_categories = heatsource_categories_postprocessed - super().add_recipe( name=name, - input_synsets=input_synsets, - output_synsets=output_synsets, + input_objects=input_objects, + input_systems=input_systems, + output_objects=output_objects, + output_systems=output_systems, input_states=input_states, output_states=output_states, fillable_categories=fillable_categories, @@ -2384,15 +2318,17 @@ class CookingObjectRule(CookingRule): """ @classmethod def add_recipe( - cls, - name, - input_synsets, - output_synsets, - input_states=None, - output_states=None, - fillable_categories=None, - heatsource_categories=None, - timesteps=None, + cls, + name, + input_objects, + input_systems, + output_objects, + output_systems, + input_states=None, + output_states=None, + fillable_categories=None, + heatsource_categories=None, + timesteps=None, ): """ Adds a recipe to this cooking recipe rule to check against. This defines a valid mapping of inputs that @@ -2400,12 +2336,14 @@ def add_recipe( Args: name (str): Name of the recipe - input_synsets (dict): Maps synsets to number of instances required for the recipe - output_synsets (dict): Maps synsets to number of instances to be spawned in the container when the recipe executes - input_states (dict or None): Maps input synsets to states that must be satisfied for the recipe to execute, - or None if no states are required - otuput_states (dict or None): Map output synsets to states that should be set when spawned when the recipe executes, - or None if no states are required + input_objects (dict): Maps object categories to number of instances required for the recipe + input_systems (list): List of system names required for the recipe + output_objects (dict): Maps object categories to number of instances to be spawned in the container when the recipe executes + output_systems (list): List of system names to be spawned in the container when the recipe executes. Currently the length is 1. + input_states (None or defaultdict(lambda: defaultdict(list))): Maps object categories to + ["unary", "bianry_system", "binary_object"] to a list of states that must be satisfied for the recipe to execute + output_states (None or defaultdict(lambda: defaultdict(list))): Maps object categories to + ["unary", "bianry_system"] to a list of states that should be set after the output objects are spawned fillable_categories (None or set of str): If specified, set of fillable categories which are allowed for this recipe. If None, any fillable is allowed heatsource_categories (None or set of str): If specified, set of heatsource categories which are allowed @@ -2413,18 +2351,19 @@ def add_recipe( timesteps (None or int): Number of subsequent heating steps required for the recipe to execute. If None, it will be set to be 1, i.e.: instantaneous execution """ + assert len(output_systems) == 0, f"No output systems can be specified for {cls.__name__}, recipe: {name}!" super().add_recipe( name=name, - input_synsets=input_synsets, - output_synsets=output_synsets, + input_objects=input_objects, + input_systems=input_systems, + output_objects=output_objects, + output_systems=output_systems, input_states=input_states, output_states=output_states, fillable_categories=fillable_categories, heatsource_categories=heatsource_categories, timesteps=timesteps, ) - output_systems = cls._RECIPES[name]["output_systems"] - assert len(output_systems) == 0, f"No output systems can be specified for {cls.__name__}, recipe: {name}!" @classproperty def relax_recipe_systems(cls): @@ -2451,15 +2390,17 @@ class CookingSystemRule(CookingRule): """ @classmethod def add_recipe( - cls, - name, - input_synsets, - output_synsets, - input_states=None, - output_states=None, - fillable_categories=None, - heatsource_categories=None, - timesteps=None, + cls, + name, + input_objects, + input_systems, + output_objects, + output_systems, + input_states=None, + output_states=None, + fillable_categories=None, + heatsource_categories=None, + timesteps=None, ): """ Adds a recipe to this cooking recipe rule to check against. This defines a valid mapping of inputs that @@ -2467,12 +2408,14 @@ def add_recipe( Args: name (str): Name of the recipe - input_synsets (dict): Maps synsets to number of instances required for the recipe - output_synsets (dict): Maps synsets to number of instances to be spawned in the container when the recipe executes - input_states (dict or None): Maps input synsets to states that must be satisfied for the recipe to execute, - or None if no states are required - otuput_states (dict or None): Map output synsets to states that should be set when spawned when the recipe executes, - or None if no states are required + input_objects (dict): Maps object categories to number of instances required for the recipe + input_systems (list): List of system names required for the recipe + output_objects (dict): Maps object categories to number of instances to be spawned in the container when the recipe executes + output_systems (list): List of system names to be spawned in the container when the recipe executes. Currently the length is 1. + input_states (None or defaultdict(lambda: defaultdict(list))): Maps object categories to + ["unary", "bianry_system", "binary_object"] to a list of states that must be satisfied for the recipe to execute + output_states (None or defaultdict(lambda: defaultdict(list))): Maps object categories to + ["unary", "bianry_system"] to a list of states that should be set after the output objects are spawned fillable_categories (None or set of str): If specified, set of fillable categories which are allowed for this recipe. If None, any fillable is allowed heatsource_categories (None or set of str): If specified, set of heatsource categories which are allowed @@ -2480,18 +2423,19 @@ def add_recipe( timesteps (None or int): Number of subsequent heating steps required for the recipe to execute. If None, it will be set to be 1, i.e.: instantaneous execution """ + assert len(output_objects) == 0, f"No output objects can be specified for {cls.__name__}, recipe: {name}!" super().add_recipe( name=name, - input_synsets=input_synsets, - output_synsets=output_synsets, + input_objects=input_objects, + input_systems=input_systems, + output_objects=output_objects, + output_systems=output_systems, input_states=input_states, output_states=output_states, fillable_categories=fillable_categories, heatsource_categories=heatsource_categories, timesteps=timesteps, ) - output_objects = cls._RECIPES[name]["output_objects"] - assert len(output_objects) == 0, f"No output objects can be specified for {cls.__name__}, recipe: {name}!" @classproperty def relax_recipe_systems(cls): @@ -2518,27 +2462,27 @@ def import_recipes(): for rule_name in rule_names: rule = REGISTERED_RULES[rule_name] if rule == WasherRule: - rule.register_cleaning_conditions(rule_recipes) + rule.register_cleaning_conditions(translate_bddl_washer_rule_to_og_washer_rule(rule_recipes)) elif issubclass(rule, RecipeRule): + log.info(f"Adding recipes of rule {rule_name}...") for recipe in rule_recipes: if "rule_name" in recipe: recipe["name"] = recipe.pop("rule_name") if "container" in recipe: - recipe["fillable_categories"] = set(recipe.pop("container").keys()) + recipe["fillable_synsets"] = set(recipe.pop("container").keys()) if "heat_source" in recipe: - recipe["heatsource_categories"] = set(recipe.pop("heat_source").keys()) + recipe["heatsource_synsets"] = set(recipe.pop("heat_source").keys()) if "machine" in recipe: - recipe["fillable_categories"] = set(recipe.pop("machine").keys()) + recipe["fillable_synsets"] = set(recipe.pop("machine").keys()) # Route the recipe to the correct rule: CookingObjectRule or CookingSystemRule satisfied = True - output_synsets = set(recipe["output_synsets"].keys()) - has_substance = any([s for s in output_synsets if is_substance_synset(s)]) - if (rule == CookingObjectRule and has_substance) or (rule == CookingSystemRule and not has_substance): + og_recipe = translate_bddl_recipe_to_og_recipe(**recipe) + has_output_system = len(og_recipe["output_systems"]) > 0 + if (rule == CookingObjectRule and has_output_system) or (rule == CookingSystemRule and not has_output_system): satisfied = False if satisfied: - # TODO: put translation from BDDL to OG into bddl_utils - rule.add_recipe(**recipe) + rule.add_recipe(**og_recipe) log.info(f"All recipes of rule {rule_name} imported successfully.") import_recipes() \ No newline at end of file diff --git a/omnigibson/utils/bddl_utils.py b/omnigibson/utils/bddl_utils.py index 2288aa438..cba82ab4a 100644 --- a/omnigibson/utils/bddl_utils.py +++ b/omnigibson/utils/bddl_utils.py @@ -22,7 +22,8 @@ from omnigibson.objects.dataset_object import DatasetObject from omnigibson.robots import BaseRobot from omnigibson import object_states -from omnigibson.object_states.factory import _KINEMATIC_STATE_SET +from omnigibson.object_states.object_state_base import AbsoluteObjectState, RelativeObjectState +from omnigibson.object_states.factory import _KINEMATIC_STATE_SET, get_system_states from omnigibson.systems.system_base import is_system_active, get_system from omnigibson.scenes.interactive_traversable_scene import InteractiveTraversableScene @@ -168,6 +169,178 @@ def process_single_condition(condition): OBJECT_TAXONOMY = ObjectTaxonomy() BEHAVIOR_ACTIVITIES = sorted(os.listdir(os.path.join(os.path.dirname(bddl.__file__), "activity_definitions"))) +def _populate_input_output_objects_systems(og_recipe, input_synsets, output_synsets): + # Map input/output synsets into input/output objects and systems. + for synsets, obj_key, system_key in zip((input_synsets, output_synsets), ("input_objects", "output_objects"), ("input_systems", "output_systems")): + for synset, count in synsets.items(): + assert OBJECT_TAXONOMY.is_leaf(synset), f"Synset {synset} must be a leaf node in the taxonomy!" + if is_substance_synset(synset): + og_recipe[system_key].append(get_system_name_by_synset(synset)) + else: + obj_categories = OBJECT_TAXONOMY.get_categories(synset) + assert len(obj_categories) == 1, f"Object synset {synset} must map to exactly one object category! Now: {obj_categories}." + og_recipe[obj_key][obj_categories[0]] = count + + # Assert only one of output_objects or output_systems is not None + assert len(og_recipe["output_objects"]) == 0 or len(og_recipe["output_systems"]) == 0, \ + "Recipe can only generate output objects or output systems, but not both!" + +def _populate_input_output_states(og_recipe, input_states, output_states): + # Apply post-processing for input/output states if specified + for synsets_to_states, states_key in zip((input_states, output_states), ("input_states", "output_states")): + if synsets_to_states is None: + continue + for synsets, states in synsets_to_states.items(): + # For unary/binary states, synsets is a single synset or a comma-separated pair of synsets, respectively + synset_split = synsets.split(",") + if len(synset_split) == 1: + first_synset = synset_split[0] + second_synset = None + else: + first_synset, second_synset = synset_split + + # Assert the first synset is an object because the systems don't have any states. + assert OBJECT_TAXONOMY.is_leaf(first_synset), f"Input/output state synset {first_synset} must be a leaf node in the taxonomy!" + assert not is_substance_synset(first_synset), f"Input/output state synset {first_synset} must be applied to an object, not a substance!" + obj_categories = OBJECT_TAXONOMY.get_categories(first_synset) + assert len(obj_categories) == 1, f"Input/output state synset {first_synset} must map to exactly one object category! Now: {obj_categories}." + first_obj_category = obj_categories[0] + + if second_synset is None: + # Unary states for the first synset + for state_type, state_value in states: + state_class = SUPPORTED_PREDICATES[state_type].STATE_CLASS + assert issubclass(state_class, AbsoluteObjectState), f"Input/output state type {state_type} must be a unary state!" + # Example: (Cooked, True) + og_recipe[states_key][first_obj_category]["unary"].append((state_class, state_value)) + else: + assert OBJECT_TAXONOMY.is_leaf(second_synset), f"Input/output state synset {second_synset} must be a leaf node in the taxonomy!" + obj_categories = OBJECT_TAXONOMY.get_categories(second_synset) + if is_substance_synset(second_synset): + second_obj_category = get_system_name_by_synset(second_synset) + is_substance = True + else: + obj_categories = OBJECT_TAXONOMY.get_categories(second_synset) + assert len(obj_categories) == 1, f"Input/output state synset {second_synset} must map to exactly one object category! Now: {obj_categories}." + second_obj_category = obj_categories[0] + is_substance = False + + for state_type, state_value in states: + state_class = SUPPORTED_PREDICATES[state_type].STATE_CLASS + assert issubclass(state_class, RelativeObjectState), f"Input/output state type {state_type} must be a binary state!" + assert is_substance == (state_class in get_system_states()), f"Input/output state type {state_type} system state inconsistency found!" + if is_substance: + # Non-kinematic binary states, e.g. Covered, Saturated, Filled, Contains. + # Example: (Covered, "sesame_seed", True) + og_recipe[states_key][first_obj_category]["binary_system"].append( + (state_class, second_obj_category, state_value)) + else: + # Kinematic binary states w.r.t. the second object. + # Example: (OnTop, "raw_egg", True) + assert states_key != "output_states", f"Output state type {state_type} can only be used in input states!" + og_recipe[states_key][first_obj_category]["binary_object"].append( + (state_class, second_obj_category, state_value)) + +def _populate_filter_categories(og_recipe, filter_name, synsets): + # Map synsets to categories. + if synsets is not None: + og_recipe[f"{filter_name}_categories"] = set() + for synset in synsets: + assert OBJECT_TAXONOMY.is_leaf(synset), f"Synset {synset} must be a leaf node in the taxonomy!" + assert not is_substance_synset(synset), f"Synset {synset} must be applied to an object, not a substance!" + for category in OBJECT_TAXONOMY.get_categories(synset): + og_recipe[f"{filter_name}_categories"].add(category) + +def translate_bddl_recipe_to_og_recipe( + name, + input_synsets, + output_synsets, + input_states=None, + output_states=None, + fillable_synsets=None, + heatsource_synsets=None, + timesteps=None, +): + """ + Translate a BDDL recipe to an OG recipe. + Args: + name (str): Name of the recipe + input_synsets (dict): Maps synsets to number of instances required for the recipe + output_synsets (dict): Maps synsets to number of instances to be spawned in the container when the recipe executes + input_states (dict or None): Maps input synsets to states that must be satisfied for the recipe to execute, + or None if no states are required + otuput_states (dict or None): Map output synsets to states that should be set when spawned when the recipe executes, + or None if no states are required + fillable_synsets (None or set of str): If specified, set of fillable synsets which are allowed for this recipe. + If None, any fillable is allowed + heatsource_synsets (None or set of str): If specified, set of heatsource synsets which are allowed for this recipe. + If None, any heatsource is allowed + timesteps (None or int): Number of subsequent heating steps required for the recipe to execute. If None, + it will be set to be 1, i.e.: instantaneous execution + """ + og_recipe = { + "name": name, + # Maps object categories to number of instances required for the recipe + "input_objects": dict(), + # List of system names required for the recipe + "input_systems": list(), + # Maps object categories to number of instances to be spawned in the container when the recipe executes + "output_objects": dict(), + # List of system names to be spawned in the container when the recipe executes. Currently the length is 1. + "output_systems": list(), + # Maps object categories to ["unary", "bianry_system", "binary_object"] to a list of states that must be satisfied for the recipe to execute + "input_states": defaultdict(lambda: defaultdict(list)), + # Maps object categories to ["unary", "bianry_system"] to a list of states that should be set after the output objects are spawned + "output_states": defaultdict(lambda: defaultdict(list)), + # Set of fillable categories which are allowed for this recipe + "fillable_categories": None, + # Set of heatsource categories which are allowed for this recipe + "heatsource_categories": None, + # Number of subsequent heating steps required for the recipe to execute + "timesteps": timesteps if timesteps is not None else 1, + } + + _populate_input_output_objects_systems(og_recipe=og_recipe, input_synsets=input_synsets, output_synsets=output_synsets) + _populate_input_output_states(og_recipe=og_recipe, input_states=input_states, output_states=output_states) + _populate_filter_categories(og_recipe=og_recipe, filter_name="fillable", synsets=fillable_synsets) + _populate_filter_categories(og_recipe=og_recipe, filter_name="heatsource", synsets=heatsource_synsets) + + return og_recipe + +def translate_bddl_washer_rule_to_og_washer_rule(conditions): + """ + Translate BDDL washer rule to OG washer rule. + + Args: + conditions (dict): Dictionary mapping the synset of ParticleSystem (str) to None or list of synsets of + ParticleSystem (str). None represents "never", empty list represents "always", or non-empty list represents + at least one of the systems in the list needs to be present in the washer for the key system to be removed. + E.g. "rust.n.01" -> None: "never remove rust.n.01 from the washer" + E.g. "dust.n.01" -> []: "always remove dust.n.01 from the washer" + E.g. "cooking_oil.n.01" -> ["sodium_carbonate.n.01", "vinegar.n.01"]: "remove cooking_oil.n.01 from the + washer if either sodium_carbonate.n.01 or vinegar.n.01 is present" + For keys not present in the dictionary, the default is []: "always remove" + Returns: + dict: Dictionary mapping the system name (str) to None or list of system names (str). None represents "never", + empty list represents "always", or non-empty list represents at least one of the systems in the list needs + to be present in the washer for the key system to be removed. + """ + og_washer_rule = dict() + for solute, solvents in conditions.items(): + assert OBJECT_TAXONOMY.is_leaf(solute), f"Synset {solute} must be a leaf node in the taxonomy!" + assert is_substance_synset(solute), f"Synset {solute} must be a substance synset!" + solute_name = get_system_name_by_synset(solute) + if solvents is None: + og_washer_rule[solute_name] = None + else: + solvent_names = [] + for solvent in solvents: + assert OBJECT_TAXONOMY.is_leaf(solvent), f"Synset {solvent} must be a leaf node in the taxonomy!" + assert is_substance_synset(solvent), f"Synset {solvent} must be a substance synset!" + solvent_name = get_system_name_by_synset(solvent) + solvent_names.append(solvent_name) + og_washer_rule[solute_name] = solvent_names + return og_washer_rule class OmniGibsonBDDLBackend(BDDLBackend): def get_predicate_class(self, predicate_name): From 21b5f85f372b634af8abf0af4f5ac9915f708342 Mon Sep 17 00:00:00 2001 From: Chengshu Li Date: Thu, 8 Feb 2024 23:04:56 -0800 Subject: [PATCH 24/24] update bddl version to 3.4.0b3 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 99b677996..a74d5579a 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ "trimesh~=4.0.8", "h5py~=3.10.0", "cryptography~=41.0.7", - "bddl~=3.3.0b3", + "bddl~=3.4.0b3", "opencv-python~=4.8.1", "nest_asyncio~=1.5.8", "imageio~=2.33.1",