Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ontology to pycram objects #191

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 119 additions & 2 deletions src/pycram/ontology/ontology.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,69 @@
from ..ontology.ontology_common import (OntologyConceptHolderStore, OntologyConceptHolder,
ONTOLOGY_SQL_BACKEND_FILE_EXTENSION)

from enum import Enum

# TODO: assumes SOMA_DFL, and specifically a "module" resulting from selecting some of its concepts,
# is the default main ontology.
SOMA_DFL_ONTOLOGY_IRI = "https://raw.githubusercontent.com/ease-crc/ease_lexical_resources/master/src/dfl/owl/SOMA_DFL_module_merged.owl"
SOMA_HOME_ONTOLOGY_IRI = "http://www.ease-crc.org/ont/SOMA-HOME.owl"
SOMA_ONTOLOGY_IRI = "http://www.ease-crc.org/ont/SOMA.owl"
SOMA_ONTOLOGY_NAMESPACE = "SOMA"
DUL_ONTOLOGY_NAMESPACE = "DUL"

# TODO: complex concept queries, e.g. containers made of metal, things with a handle, stuff to roast meat in etc.

class ConceptShortcut(str, Enum):
Container = "dfl:container.n.wn.artifact"
CookedDish = "dfl:dish.n.wn.food" # i.e. some kind of cooked food
Crockery = "dfl:crockery.n.wn.artifact" # i.e. dishes like plates, eggcups, oven trays etc.
Cutlery = "dfl:cutlery.n.wn.artifact"
Drink = "dfl:beverage.n.wn.food"
Food = "dfl:food.n.wn.food..servable"
Fruit = "dfl:edible_fruit.n.wn.food"
Furniture = "dfl:furniture.n.wn.artifact"
Meat = "dfl:meat.n.wn.food"
Perishable = "dfl:perishables.n.wn.food"
SolidFood = "dfl:food.n.wn.food..solid"
Tableware = "dfl:tableware.n.wn.artifact"
Vegetable = "dfl:vegetable.n.wn.food"

class OntologyManager(object, metaclass=Singleton):
"""
Singleton class as the adapter accessing data of an OWL ontology, largely based on owlready2.
"""

# A map of useful namespaces, will allow writing shorter concept IRIs. First few are standard.
namespaceMap = {"owl": "http://www.w3.org/2002/07/owl#",
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
"xml": "http://www.w3.org/XML/1998/namespace",
"xsd": "http://www.w3.org/2001/XMLSchema#",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
# Our stuff begins here.
"dfl": "http://www.ease-crc.org/ont/SOMA_DFL.owl#",
# Assumes SOMA_DFL will eventually be the "default" main.
"": "http://www.ease-crc.org/ont/SOMA_DFL.owl#",
"home": "http://www.ease-crc.org/ont/SOMA-HOME.owl",
"dul": "http://www.ontologydesignpatterns.org/ont/dul/DUL.owl#",
"USD": "https://ease-crc.org/ont/USD.owl#",
"soma": "http://www.ease-crc.org/ont/SOMA.owl#"
}

# Dirty hack to map concept IRIs to pycram object types
iri2PyCramObjectType = {
"http://www.ease-crc.org/ont/SOMA_DFL.owl#mug.n.wn.artifact": ObjectType.METALMUG,
"http://www.ease-crc.org/ont/SOMA_DFL.owl#can_of_chips.n.wn.artifact": ObjectType.PRINGLES,
"http://www.ease-crc.org/ont/SOMA_DFL.owl#milk_carton.n.wn.artifact": ObjectType.MILK,
"http://www.ease-crc.org/ont/SOMA_DFL.owl#spoon.n.wn.artifact..cutlery": ObjectType.SPOON,
"http://www.ease-crc.org/ont/SOMA_DFL.owl#bowl.n.wn.artifact..dish": ObjectType.BOWL,
"http://www.ease-crc.org/ont/SOMA_DFL.owl#cereal_box.n.wn.artifact": ObjectType.BREAKFAST_CEREAL,
"http://www.ease-crc.org/ont/SOMA_DFL.owl#cup.n.wn.artifact..container": ObjectType.JEROEN_CUP,
#"": ROBOT,
#"": ENVIRONMENT,
"http://www.ontologydesignpatterns.org/ont/dul/DUL.owl#DesignedArtifact": ObjectType.GENERIC_OBJECT,
"http://www.ease-crc.org/ont/SOMA_DFL.owl#person.n.wn.body": ObjectType.HUMAN
}

def __init__(self, main_ontology_iri: Optional[str] = None, ontology_search_path: Optional[str] = None,
use_global_default_world: bool = True):
"""
Expand All @@ -48,6 +100,8 @@ def __init__(self, main_ontology_iri: Optional[str] = None, ontology_search_path
ontology_search_path = f"{Path.home()}/ontologies"
Path(ontology_search_path).mkdir(parents=True, exist_ok=True)
onto_path.append(ontology_search_path)

self.iri2Concept = {}

#: A dictionary of OWL ontologies, keyed by ontology name (same as its namespace name), eg. 'SOMA'
self.ontologies: Dict[str, Ontology] = {}
Expand All @@ -69,7 +123,7 @@ def __init__(self, main_ontology_iri: Optional[str] = None, ontology_search_path

#: Ontology IRI (Internationalized Resource Identifier), either a URL to a remote OWL file or the full name path of a local one
# Ref: https://owlready2.readthedocs.io/en/latest/onto.html
self.main_ontology_iri: str = main_ontology_iri if main_ontology_iri else SOMA_HOME_ONTOLOGY_IRI
self.main_ontology_iri: str = main_ontology_iri if main_ontology_iri else SOMA_DFL_ONTOLOGY_IRI

#: Namespace of the main ontology
self.main_ontology_namespace: Optional[Namespace] = None
Expand Down Expand Up @@ -256,6 +310,10 @@ def fetch_ontology(ontology__):
self.browse_ontologies(ontology, condition=None, func=lambda ontology__: fetch_ontology(ontology__))
else:
rospy.logerr(f"Ontology [{ontology.base_iri}]\'s name: {ontology.name} failed being loaded")

for concept in ontology.classes():
self.iri2Concept[concept.iri] = concept

return ontology, ontology_namespace

def initialized(self) -> bool:
Expand Down Expand Up @@ -383,7 +441,9 @@ def create_ontology_property_class(self, class_name: str,
return None

with ontology:
return types.new_class(class_name, (parent_class,) if parent_class else (Property,))
concept = types.new_class(class_name, (parent_class,) if parent_class else (Property,))
self.iri2Concept[concept.iri] = concept
return concept

def get_ontology_classes_by_condition(self, condition: Callable, first_match_only=False, **kwargs) \
-> List[Type[Thing]]:
Expand Down Expand Up @@ -710,6 +770,8 @@ def destroy_ontology_class(ontology_class, destroy_instances: bool = True):
:param ontology_class: The ontology class to be destroyed
:param destroy_instances: Whether to destroy instances of those ontology classes
"""
if ontology_class.iri in self.iri2Concept:
self.iri2Concept.pop(ontology_class.iri)
if destroy_instances:
for ontology_individual in ontology_class.instances():
destroy_entity(ontology_individual)
Expand Down Expand Up @@ -813,3 +875,58 @@ def reason(self, world: OntologyWorld = None, use_pellet_reasoner: bool = True)
return False
rospy.loginfo(f"{reasoner_name} reasoning finishes!")
return True

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also provide tests for such methods. Furthermore stick to the RST docstrings.

def ensure_concept_name_is_IRI(self, conceptName: str, namespaceMap: Optional[dict] = None) -> str:
"""
A function to convert a concept name -- which may be shortened by using a namespace prefix -- into an IRI.
For example, "dul:Object" becomes "http://www.ontologydesignpatterns.org/ont/dul/DUL.owl#Object".

:param conceptName: a string containing a possibly shortened concept name
:param namespaceMap: a dictionary of namespace names mapped to IRI prefixes. If left None, the default namespace map of the ontology manager object is used. It is often a good idea to just use the default map.
:return conceptIRI: an IRI string. If the input conceptName is already an IRI form, conceptIRI = conceptName.
"""
if namespaceMap is None:
namespaceMap = self.namespaceMap
conceptIRI = conceptName
# ':' must occur exactly once in an IRI or short concept name.
idx = conceptName.find(':')
if -1 == idx:
raise ValueError('Concept name is neither a valid IRI nor a valid short name: the namespace seems missing.')
# "//" after a ":" indicates a protocol has been specified to the left of the ":", i.e. we have an IRI already.
recStr = "//"
if recStr != conceptName[idx+1:idx+len(recStr)+1]
namespace, name = conceptName[:idx], conceptName[idx+1:]
conceptIRI = namespaceMap[namespace] + name
return conceptIRI

def concept2PycramConcepts(self, concept, concept2Enum: Optional[dict] = None, iri2Concept: Optional[dict] = None, namespaceMap: Optional[dict] = None) -> list:
"""
Returns a list of pycram object types that correspond to subconcepts of the given concept.

Not all subconcepts of the given concept may have pycram equivalents. Such unmapped subconcepts are ignored and not
represented in the output in any way.

:param concept: an OWLReady2 class, a string, or an enum with values strings or OWLReady2 classes.
If a string (or string-valued enum), can be an IRI or short name.
:param concept2Enum: a dictionary mapping concept IRIs to pycram object types. Can be often left to None, in which case the default mapping defined in the ontology manager is used.
:param iri2Concept: a mapping from IRIs to OWLReady2 classes. Can and should be left None, in which case the mapping maintained by the ontology mapper is used.
:param namespaceMap: a mapping of namespace names to IRI prefixes. Can be left None, in which case the default mapping definedin the ontology manager is used.
:return objectTypes: a list of pycram object types that are subconcepts of the input concept.
"""
if iri2Concept is None:
iri2Concept = self.iri2Concept
if concept2ObjectType is None:
concept2ObjectType = self.iri2PyCramObjectType
if isinstance(concept, Enum):
concept = concept.value
if isinstance(concept, str):
concept = iri2Concept[self.ensure_concept_name_is_IRI(concept, namespaceMap=namespaceMap)]
subconcepts = set()
todo = set([concept])
while todo:
cr = todo.pop()
subconcepts.add(cr)
for s in cr.subclasses():
if s not in subconcepts:
todo.add(s)
return [concept2Enum[x.iri] for x in subconcepts if x.iri in concept2Enum]
58 changes: 53 additions & 5 deletions test/test_ontology.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@

from pycram.designator import ObjectDesignatorDescription

from pycram.datastructures.enums import ObjectType

import rospy

# Owlready2
try:
from owlready2 import *
import owlready2
except ImportError:
owlready2 = None
rospy.logwarn("Could not import owlready2, Ontology unit-tests could not run!")
Expand All @@ -26,7 +28,7 @@
java_runtime_installed = False
rospy.logwarn("Java runtime is not installed, Ontology reasoning unit-test could not run!")

from pycram.ontology.ontology import OntologyManager, SOMA_HOME_ONTOLOGY_IRI, SOMA_ONTOLOGY_IRI
from pycram.ontology.ontology import OntologyManager, ConceptShortcut, SOMA_DFL_ONTOLOGY_IRI, SOMA_HOME_ONTOLOGY_IRI, SOMA_ONTOLOGY_IRI
from pycram.ontology.ontology_common import (OntologyConceptHolderStore, OntologyConceptHolder,
ONTOLOGY_SQL_BACKEND_FILE_EXTENSION, ONTOLOGY_OWL_FILE_EXTENSION)

Expand All @@ -39,8 +41,8 @@ class TestOntologyManager(unittest.TestCase):

@classmethod
def setUpClass(cls):
# Try loading from remote `SOMA_ONTOLOGY_IRI`, which will fail given no internet access
cls.ontology_manager = OntologyManager(SOMA_ONTOLOGY_IRI)
# Try loading from remote `SOMA_DFL_ONTOLOGY_IRI`, which will fail given no internet access
cls.ontology_manager = OntologyManager(SOMA_DFL_ONTOLOGY_IRI)
if cls.ontology_manager.initialized():
cls.soma = cls.ontology_manager.soma
cls.dul = cls.ontology_manager.dul
Expand Down Expand Up @@ -70,6 +72,7 @@ def remove_sql_file(cls, sql_filepath: str):
os.remove(sql_journal_filepath)

def test_ontology_manager(self):
# This works because OntologyManager is a singleton.
self.assertIs(self.ontology_manager, OntologyManager())
if owlready2:
self.assertTrue(self.ontology_manager.initialized())
Expand Down Expand Up @@ -116,7 +119,7 @@ def test_loaded_ontologies(self):
self.assertIsNotNone(self.main_ontology)
self.assertTrue(self.main_ontology.loaded)
if self.ontology_manager.main_ontology_iri is SOMA_ONTOLOGY_IRI or \
self.ontology_manager.main_ontology_iri is SOMA_HOME_ONTOLOGY_IRI:
self.ontology_manager.main_ontology_iri is SOMA_DFL_ONTOLOGY_IRI:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.ontology_manager.main_ontology_iri is SOMA_DFL_ONTOLOGY_IRI:
self.ontology_manager.main_ontology_iri is SOMA_HOME_ONTOLOGY_IRI or \
self.ontology_manager.main_ontology_iri is SOMA_DFL_ONTOLOGY_IRI:

self.assertIsNotNone(self.soma)
self.assertTrue(self.soma.loaded)
self.assertIsNotNone(self.dul)
Expand Down Expand Up @@ -303,6 +306,51 @@ def test_ontology_save(self):
self.assertTrue(self.ontology_manager.save(owl_filepath))
self.assertTrue(Path(owl_filepath).is_file())
self.assertTrue(Path(sql_filepath).is_file())

def test_ensure_concept_name_is_IRI(self):
defaultNamespaceMap = None
variantNamespaceMap = {"owl": "http://ornithology.org/birds/OWL#", "xyz": "http://nowhere.org/nothing#"}
testCases = [
("http://ornithology.org/birds/OWL#Kestrel", defaultNamespaceMap, "http://ornithology.org/birds/OWL#Kestrel"),
("http://ornithology.org/birds/OWL#Kestrel", variantNamespaceMap, "http://ornithology.org/birds/OWL#Kestrel"),
("owl:Kestrel", defaultNamespaceMap, "http://www.w3.org/2002/07/owl#Kestrel"),
("owl:Kestrel", variantNamespaceMap, "http://ornithology.org/birds/OWL#Kestrel"),
("xyz:Kestrel", defaultNamespaceMap, None),
("xyz:Kestrel", variantNamespaceMap, "http://nowhere.org/nothing#Kestrel")
]
for conceptName, namespaceMap, expected in testCases:
try:
actual = self.ontology_manager.ensure_concept_name_is_IRI(conceptName, namespaceMap=namespaceMap)
self.assertEqual(expected, actual)
except Exception as e:
if namespaceMap is None:
namespaceMap = self.ontology_manager.namespaceMap
if isinstance(e, ValueError):
self.assertTrue(':' not in conceptName)
elif isinstance(e, KeyError):
# If we made it here then there should be a prefix to replace, but was not found in namespaceMap
idxDoubleSlash = conceptName.find('://')
idxColon = conceptName.find(':')
self.assertEqual(idxDoubleSlash, -1) # conceptName was not already an IRI
self.assertTrue(conceptName[:idxColon] not in namespaceMap)
else:
raise e

@unittest.skipUnless(owlready2, 'Owlready2 is required')
def test_concept2PycramConcepts(self):
pycramContainers = set([ObjectType.METALMUG, ObjectType.BOWL, ObjectType.JEROEN_CUP, ObjectType.MILK, ObjectType.PRINGLES, ObjectType.BREAKFAST_CEREAL])
pycramCrockery = set([ObjectType.METALMUG, ObjectType.BOWL, ObjectType.JEROEN_CUP])
testCases = [
(ConceptShortcut.Crockery, pycramCrockery),
(ConceptShortcut.Crockery.value, pycramCrockery),
(self.ontology_manager.iri2Concept(self.ontology_manager.ensure_concept_name_is_IRI("dfl:crockery.n.wn.artifact")), pycramCrockery),
(ConceptShortcut.Container, pycramContainers),
(ConceptShortcut.Container.value, pycramContainers),
(self.ontology_manager.iri2Concept(self.ontology_manager.ensure_concept_name_is_IRI("dfl:container.n.wn.artifact")), pycramContainers)
]
for concept, expected in testCases:
actual = set(self.ontology_manager.concept2PycramConcepts(concept))
self.assertEqual(expected, actual)

if __name__ == '__main__':
unittest.main()
Loading