Repository: ontodev/robot (java)

Issue:
new command: subset (filter option with ability to traverse all edges when preserve-structure is set)
Carried on from #263 

Given:

```
ontology: test
subsetdef: foo "foo"

[Term]
id: X:1
subset: foo

[Term]
id: X:2
relationship: part_of X:1

[Term]
id: X:3
relationship: part_of X:2

[Term]
id: X:4
relationship: part_of X:3

[Term]
id: X:5
is_a: X:4
subset: foo

[Typedef]
id: part_of
is_transitive: true
xref: BFO:0000050
```

Currently we have:

```
$ robot filter -i robot-core/src/test/resources/subset_test.obo --select annotations --prefix "subset: http://purl.obolibrary.org/obo/test#" --select "oboInOwl:inSubset=subset:foo" -o z.obo && cat z.obo
format-version: 1.2
ontology: test

[Term]
id: X:1
subset: foo

[Term]
id: X:5
subset: foo
```

We would like to see a part_of between X:5 and X:1.

This is pretty fundamental for all subset use cases involving GO, any anatomy ontology, CL, ENVO, any ontology that has part-of.

There are two ways this could be implemented. One is an ad-hoc graph walking procedure with rules for edge label composition (this is how the equivalent command in owltools worked).

The other is to use `materialize`. A special purpose case of this code could be written than will be more efficient for this use case, where for every selected class `C` we test for all inferred subclasses `R some C` for all structure preserving relations R.

To preserve backwards compatibility we should have the user specify the list of OPs they want to include. However, this is such a common use case it would be nice to have a command `subset` as originally specified in #263 that is a shorthand for `filter --preserve-structure true --use-all-relations true --select annotations -select "oboInOwl:inSubset=subset:$SUBSET"`



Pull request:
new extract command method: subset
Resolves [#497, resolves #497]

- [x] `docs/` have been added/updated
- [x] tests have been added/updated
- [x] `mvn verify` says all tests pass
- [ ] `mvn site` says all JavaDocs correct
- [x] `CHANGELOG.md` has been updated

Adds new `subset` method to the extract operation: `robot extract --method subset`
Logic of the method is as follows:
1. Materialized Axioms= Relation Graph(O)
2. OM = ADD(O, Materialized Axioms)
3. OF = RobotFilter(T, OM)
4. OR = RobotReduce(OF)

To achieve this [relation-graph](https://github.com/balhoff/relation-graph) dependency added to the ROBOT. Owl-api version increased to `4.5.9`. Following files updated to fix the errors raised after the owl-api version upgrade:
- docs/examples/example.obo
- docs/examples/metrics_all.json
- robot-core/src/main/java/org/obolibrary/robot/metrics/OntologyMetrics.java
- robot-core/src/test/java/org/obolibrary/robot/MeasureOperationTest.java



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b14776d3..d8364cd4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+- Subset method added to [`extract`] [#1000]
+
 ## [1.9.2] - 2023-02-09
 
 ### Changed
@@ -341,6 +344,7 @@ First official release of ROBOT!
 [`template`]: http://robot.obolibrary.org/template
 [`validate`]: http://robot.obolibrary.org/validate
 
+[#1000]: https://github.com/ontodev/robot/pull/1000
 [#1086]: https://github.com/ontodev/robot/pull/1086
 [#1084]: https://github.com/ontodev/robot/issues/1084
 [#1071]: https://github.com/ontodev/robot/pull/1071
diff --git a/docs/examples/subset.obo b/docs/examples/subset.obo
new file mode 100644
index 00000000..57965a01
--- /dev/null
+++ b/docs/examples/subset.obo
@@ -0,0 +1,28 @@
+ontology: test
+subsetdef: foo "foo"
+
+[Term]
+id: ONT:1
+subset: foo
+
+[Term]
+id: ONT:2
+relationship: part_of ONT:1
+
+[Term]
+id: ONT:3
+relationship: part_of ONT:2
+
+[Term]
+id: ONT:4
+relationship: part_of ONT:3
+
+[Term]
+id: ONT:5
+is_a: ONT:4
+subset: foo
+
+[Typedef]
+id: part_of
+is_transitive: true
+xref: BFO:0000050
\ No newline at end of file
diff --git a/docs/examples/subset_result.owl b/docs/examples/subset_result.owl
new file mode 100644
index 00000000..d79a8139
--- /dev/null
+++ b/docs/examples/subset_result.owl
@@ -0,0 +1,113 @@
+<?xml version="1.0"?>
+<rdf:RDF xmlns="http://purl.obolibrary.org/obo/test.owl#"
+     xml:base="http://purl.obolibrary.org/obo/test.owl"
+     xmlns:owl="http://www.w3.org/2002/07/owl#"
+     xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:xml="http://www.w3.org/XML/1998/namespace"
+     xmlns:xsd="http://www.w3.org/2001/XMLSchema#"
+     xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"
+     xmlns:oboInOwl="http://www.geneontology.org/formats/oboInOwl#">
+    <owl:Ontology rdf:about="http://purl.obolibrary.org/obo/test.owl"/>
+    
+
+
+    <!-- 
+    ///////////////////////////////////////////////////////////////////////////////////////
+    //
+    // Annotation properties
+    //
+    ///////////////////////////////////////////////////////////////////////////////////////
+     -->
+
+    
+
+
+    <!-- http://www.geneontology.org/formats/oboInOwl#hasDbXref -->
+
+    <owl:AnnotationProperty rdf:about="http://www.geneontology.org/formats/oboInOwl#hasDbXref">
+        <rdfs:label rdf:datatype="http://www.w3.org/2001/XMLSchema#string">database_cross_reference</rdfs:label>
+    </owl:AnnotationProperty>
+    
+
+
+    <!-- http://www.geneontology.org/formats/oboInOwl#id -->
+
+    <owl:AnnotationProperty rdf:about="http://www.geneontology.org/formats/oboInOwl#id"/>
+    
+
+
+    <!-- http://www.geneontology.org/formats/oboInOwl#inSubset -->
+
+    <owl:AnnotationProperty rdf:about="http://www.geneontology.org/formats/oboInOwl#inSubset">
+        <rdfs:label rdf:datatype="http://www.w3.org/2001/XMLSchema#string">in_subset</rdfs:label>
+    </owl:AnnotationProperty>
+    
+
+
+    <!-- http://www.geneontology.org/formats/oboInOwl#shorthand -->
+
+    <owl:AnnotationProperty rdf:about="http://www.geneontology.org/formats/oboInOwl#shorthand">
+        <rdfs:label rdf:datatype="http://www.w3.org/2001/XMLSchema#string">shorthand</rdfs:label>
+    </owl:AnnotationProperty>
+    
+
+
+    <!-- 
+    ///////////////////////////////////////////////////////////////////////////////////////
+    //
+    // Object Properties
+    //
+    ///////////////////////////////////////////////////////////////////////////////////////
+     -->
+
+    
+
+
+    <!-- http://purl.obolibrary.org/obo/BFO_0000050 -->
+
+    <owl:ObjectProperty rdf:about="http://purl.obolibrary.org/obo/BFO_0000050">
+    	<rdf:type rdf:resource="http://www.w3.org/2002/07/owl#TransitiveProperty"/>
+        <oboInOwl:hasDbXref rdf:datatype="http://www.w3.org/2001/XMLSchema#string">BFO:0000050</oboInOwl:hasDbXref>
+        <oboInOwl:id rdf:datatype="http://www.w3.org/2001/XMLSchema#string">part_of</oboInOwl:id>
+        <oboInOwl:shorthand rdf:datatype="http://www.w3.org/2001/XMLSchema#string">part_of</oboInOwl:shorthand>
+    </owl:ObjectProperty>
+    
+
+
+    <!-- 
+    ///////////////////////////////////////////////////////////////////////////////////////
+    //
+    // Classes
+    //
+    ///////////////////////////////////////////////////////////////////////////////////////
+     -->
+
+    
+
+
+    <!-- http://purl.obolibrary.org/obo/ONT_1 -->
+
+    <owl:Class rdf:about="http://purl.obolibrary.org/obo/ONT_1">
+        <oboInOwl:id rdf:datatype="http://www.w3.org/2001/XMLSchema#string">ONT:1</oboInOwl:id>
+        <oboInOwl:inSubset rdf:resource="http://purl.obolibrary.org/obo/test#foo"/>
+    </owl:Class>
+    
+
+
+    <!-- http://purl.obolibrary.org/obo/ONT_5 -->
+
+    <owl:Class rdf:about="http://purl.obolibrary.org/obo/ONT_5">
+        <rdfs:subClassOf>
+            <owl:Restriction>
+                <owl:onProperty rdf:resource="http://purl.obolibrary.org/obo/BFO_0000050"/>
+                <owl:someValuesFrom rdf:resource="http://purl.obolibrary.org/obo/ONT_1"/>
+            </owl:Restriction>
+        </rdfs:subClassOf>
+        <oboInOwl:id rdf:datatype="http://www.w3.org/2001/XMLSchema#string">ONT:5</oboInOwl:id>
+        <oboInOwl:inSubset rdf:resource="http://purl.obolibrary.org/obo/test#foo"/>
+    </owl:Class>
+</rdf:RDF>
+
+
+
+<!-- Generated by the OWL API (version 4.5.9) https://github.com/owlcs/owlapi -->
\ No newline at end of file
diff --git a/docs/extract.md b/docs/extract.md
index df525854..da832212 100644
--- a/docs/extract.md
+++ b/docs/extract.md
@@ -5,9 +5,10 @@
 1. [Overview](#overview)
 2. [Extraction method: SLME](#syntactic-locality-module-extractor-slme)
 3. [Extraction method: MIREOT](#mireot)
-4. [Handling Imports (`--imports`)](#handling-imports)
-5. [Extracting Ontology Annotations (`--copy-ontology-annotations`)](#extracting-ontology-annotations)
-6. [Adding Source Annotations (`--annotate-with-source`)](#adding-source-annotations)
+4. [Extraction method: Subset](#subset)
+5. [Handling Imports (`--imports`)](#handling-imports)
+6. [Extracting Ontology Annotations (`--copy-ontology-annotations`)](#extracting-ontology-annotations)
+7. [Adding Source Annotations (`--annotate-with-source`)](#adding-source-annotations)
 
 ## Overview
 
@@ -74,6 +75,19 @@ Note that if the same IRI is used for both a class and an individual, MIREOT wil
 
 For more details see the [MIREOT paper](http://dx.doi.org/10.3233/AO-2011-0087).
 
+## Subset
+
+The subset method extracts a sub-ontology that contains only the seed terms (that you specify with `--term` and `--term-file` options) and the relations between them. This method uses the [relation-graph](https://github.com/balhoff/relation-graph) to materialize the existential relations among the seed terms. Procedurally, the subset method materializes the input ontology and adds the inferred axioms to the input ontology. Then filters the ontology with the given seed terms. Finally, it reduces the filtered ontology to remove redundant subClassOf axioms.
+
+    robot extract --method subset \
+        --input subset.obo \
+        --term "obo:ONT_1" \
+        --term "obo:ONT_5" \
+        --term "BFO:0000050" \
+        --output results/subset_result.owl
+        
+ROBOT expects any `--term` or IRI in the `--term-file` to exist in the input ontology. If none of the input terms exist, the command will fail with an [empty terms error](errors#empty-terms-error). This can be overridden by including `--force true`. 
+
 ### Intermediates
 
 When extracting (especially with MIREOT), sometimes the hierarchy can have too many intermediate classes, making it difficult to identify relevant relationships. For example, you may end up with this after extracting `adrenal gland`:
diff --git a/robot-command/src/main/java/org/obolibrary/robot/ExtractCommand.java b/robot-command/src/main/java/org/obolibrary/robot/ExtractCommand.java
index 3a2a04ad..3c790124 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/ExtractCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/ExtractCommand.java
@@ -44,14 +44,14 @@ public class ExtractCommand implements Command {
 
   /** Error message when user provides invalid extraction method. */
   private static final String invalidMethodError =
-      NS + "INVALID METHOD ERROR method must be: MIREOT, STAR, TOP, BOT";
+      NS + "INVALID METHOD ERROR method must be: star, top, bot, mireot, subset";
 
   /** Error message when a MIREOT option is used for SLME. */
   private static final String invalidOptionError =
       NS
           + "INVALID OPTION ERROR "
           + "only --term or --term-file can be used to specify extract term(s) "
-          + "for STAR, TOP, or BOT";
+          + "for methods: star, top, bot, subset";
 
   /** Error message when the source map is not TSV or CSV. */
   private static final String invalidSourceMapError =
@@ -67,7 +67,7 @@ public class ExtractCommand implements Command {
     o.addOption("I", "input-iri", true, "load ontology from an IRI");
     o.addOption("o", "output", true, "save ontology to a file");
     o.addOption("O", "output-iri", true, "set OntologyIRI for output");
-    o.addOption("m", "method", true, "extract method to use");
+    o.addOption("m", "method", true, "extract method to use: star, top, bot, mireot, subset");
     o.addOption("t", "term", true, "term to extract");
     o.addOption("T", "term-file", true, "load terms from a file");
     o.addOption("u", "upper-term", true, "upper level term to extract");
@@ -111,6 +111,7 @@ public class ExtractCommand implements Command {
    */
   public String getUsage() {
     return "robot extract --input <file> "
+        + "--method <method> "
         + "--term-file <file> "
         + "--output <file> "
         + "--output-iri <iri>";
@@ -188,6 +189,8 @@ public class ExtractCommand implements Command {
 
     if (method.equals("mireot")) {
       outputOntology = mireotExtract(ioHelper, inputOntology, line, extractOptions);
+    } else if (method.equals("subset")) {
+      outputOntology = subsetExtract(ioHelper, inputOntology, line, extractOptions);
     } else if (moduleType != null) {
       outputOntology = slmeExtract(ioHelper, inputOntology, moduleType, line, extractOptions);
     } else {
@@ -348,6 +351,59 @@ public class ExtractCommand implements Command {
         inputOntology, terms, outputIRI, moduleType, extractOptions, sourceMap);
   }
 
+  /**
+   * Perform a Subset extraction after validating command line options.
+   *
+   * @param inputOntology OWLOntology to extract from
+   * @param line CommandLine with options
+   * @param extractOptions Map of extract options
+   * @return a new ontology containing extracted subset
+   * @throws IOException on issue parsing terms
+   * @throws OWLOntologyCreationException on OWLAPI issue
+   */
+  private static OWLOntology subsetExtract(
+      IOHelper ioHelper,
+      OWLOntology inputOntology,
+      CommandLine line,
+      Map<String, String> extractOptions)
+      throws Exception {
+    Imports imports = getImportsOption(extractOptions);
+    // upper-term, lower-term, and branch-from term should not be used
+    List<String> mireotTerms =
+        Arrays.asList(
+            CommandLineHelper.getOptionalValue(line, "upper-term"),
+            CommandLineHelper.getOptionalValue(line, "upper-terms"),
+            CommandLineHelper.getOptionalValue(line, "lower-term"),
+            CommandLineHelper.getOptionalValue(line, "lower-terms"),
+            CommandLineHelper.getOptionalValue(line, "branch-from-term"),
+            CommandLineHelper.getOptionalValue(line, "branch-from-terms"));
+    for (String mt : mireotTerms) {
+      if (mt != null) {
+        throw new IllegalArgumentException(invalidOptionError);
+      }
+    }
+
+    // Make sure the terms exist in the input ontology
+    Set<IRI> terms =
+        OntologyHelper.filterExistingTerms(
+            inputOntology,
+            CommandLineHelper.getTerms(ioHelper, line),
+            OptionsHelper.optionIsTrue(extractOptions, "force"),
+            imports);
+
+    // Determine what to do with sources
+    Map<IRI, IRI> sourceMap =
+        getSourceMap(ioHelper, CommandLineHelper.getOptionalValue(line, "sources"));
+    // Get the output IRI
+    IRI outputIRI = CommandLineHelper.getOutputIRI(line);
+    if (outputIRI == null) {
+      outputIRI = inputOntology.getOntologyID().getOntologyIRI().orNull();
+    }
+
+    return ExtractOperation.extractSubset(
+        inputOntology, terms, outputIRI, extractOptions, sourceMap, imports);
+  }
+
   /**
    * Given a map of options, return the imports option as Imports.
    *
diff --git a/robot-core/pom.xml b/robot-core/pom.xml
index cc589c9b..08123ceb 100644
--- a/robot-core/pom.xml
+++ b/robot-core/pom.xml
@@ -298,5 +298,16 @@
       <artifactId>py4j</artifactId>
       <version>0.10.9.2</version>
     </dependency>
+    <dependency>
+	  <groupId>org.geneontology</groupId>
+	  <artifactId>relation-graph_${scala.version}</artifactId>
+	  <version>2.2.1</version>
+	  <exclusions>
+        <exclusion>
+          <groupId>org.apache.jena</groupId>
+          <artifactId>apache-jena-libs</artifactId>
+        </exclusion>
+      </exclusions>
+	</dependency>
   </dependencies>
 </project>
diff --git a/robot-core/src/main/java/org/obolibrary/robot/ExtractOperation.java b/robot-core/src/main/java/org/obolibrary/robot/ExtractOperation.java
index b7bb64e3..0326e89d 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/ExtractOperation.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/ExtractOperation.java
@@ -1,9 +1,14 @@
 package org.obolibrary.robot;
 
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.stream.Collectors;
+import org.renci.relationgraph.RelationGraph.Config;
+import org.renci.relationgraph.RelationGraphUtil;
 import org.semanticweb.owlapi.apibinding.OWLManager;
 import org.semanticweb.owlapi.model.*;
 import org.semanticweb.owlapi.model.parameters.Imports;
@@ -214,6 +219,150 @@ public class ExtractOperation {
     }
   }
 
+  /**
+   * Extracts a materialized sub-ontology from the given ontology that only contains the given terms
+   * and the relations between them. The input ontology is not changed.
+   *
+   * @param inputOntology the ontology to extract from
+   * @param terms a set of IRIs for terms to extract
+   * @param outputIRI the OntologyIRI of the new ontology
+   * @param options map of extract options
+   * @param sourceMap map of term IRI to source IRI, or null (only used with annotate-with-source)
+   * @param imports handle imports option (default: include)
+   * @return a new ontology (with a new manager)
+   * @throws OWLOntologyCreationException on any OWLAPI problem
+   */
+  public static OWLOntology extractSubset(
+      OWLOntology inputOntology,
+      Set<IRI> terms,
+      IRI outputIRI,
+      Map<String, String> options,
+      Map<IRI, IRI> sourceMap,
+      Imports imports)
+      throws OWLOntologyCreationException {
+    Set<IRI> relations =
+        terms.stream()
+            .filter(t -> inputOntology.containsObjectPropertyInSignature(t, imports))
+            .collect(Collectors.toSet());
+    OWLOntology materializedOnt = materialize(inputOntology, relations, outputIRI);
+    OWLOntology filteredOnt = filter(materializedOnt, terms, outputIRI);
+    copyPropertyAnnotations(inputOntology, filteredOnt);
+    ReduceOperation.reduce(filteredOnt, new org.semanticweb.elk.owlapi.ElkReasonerFactory());
+
+    return filteredOnt;
+  }
+
+  /**
+   * Materializes the given ontology using the relation-graph.Creates a new ontology that contains
+   * the input ontology axioms and the materialized axioms. The input ontology is not changed.
+   *
+   * @param ontology ontology to materialize.
+   * @param relations a set of IRIs for the relations to materialize
+   * @param outputIRI IRI of the output ontology.
+   * @return materialized ontology.
+   * @throws OWLOntologyCreationException
+   */
+  private static OWLOntology materialize(OWLOntology ontology, Set<IRI> relations, IRI outputIRI)
+      throws OWLOntologyCreationException {
+    Config config = new Config(null, true, false, true, true, true, false);
+    Set<OWLClassAxiom> materializedAxioms =
+        RelationGraphUtil.computeRelationGraph(ontology, relations, config);
+
+    if (outputIRI == null) {
+      outputIRI = ontology.getOntologyID().getOntologyIRI().orNull();
+    }
+    OWLOntologyManager manager = OWLManager.createOWLOntologyManager();
+    OWLOntology materializedOntology = manager.createOntology(outputIRI);
+    manager.addAxioms(materializedOntology, ontology.getAxioms());
+    manager.addAxioms(materializedOntology, materializedAxioms);
+
+    return materializedOntology;
+  }
+
+  /**
+   * Filters the terms from the given ontology and returns a new filtered ontology. Copies ontology
+   * annotations, import declarations and the term annotations from the input ontology to the new
+   * ontology.
+   *
+   * @param inputOntology ontology to filter
+   * @param terms list of term IRIs to filter.
+   * @param outputIRI IRI of the output ontology.
+   * @return a filtered new ontology object
+   * @throws OWLOntologyCreationException
+   */
+  private static OWLOntology filter(OWLOntology inputOntology, Set<IRI> terms, IRI outputIRI)
+      throws OWLOntologyCreationException {
+    Set<OWLObject> relatedObjects = new HashSet<>();
+    relatedObjects.addAll(OntologyHelper.getEntities(inputOntology, terms));
+
+    if (outputIRI == null) {
+      outputIRI = inputOntology.getOntologyID().getOntologyIRI().orNull();
+    }
+    OWLOntologyManager manager = OWLManager.createOWLOntologyManager();
+    OWLOntology outputOntology = manager.createOntology(outputIRI);
+
+    List<String> axiomSelectors = new ArrayList<>();
+    axiomSelectors.add("all");
+
+    OWLOntologyManager mng = OWLManager.createOWLOntologyManager();
+    mng.addAxioms(
+        outputOntology,
+        RelatedObjectsHelper.filterAxioms(
+            inputOntology.getAxioms(),
+            relatedObjects,
+            axiomSelectors,
+            new ArrayList<>(),
+            false,
+            true));
+
+    // Add annotations on the related objects
+    manager.addAxioms(
+        outputOntology, RelatedObjectsHelper.getAnnotationAxioms(inputOntology, relatedObjects));
+
+    // Copy the import declarations to the output ontology
+    for (OWLImportsDeclaration imp : inputOntology.getImportsDeclarations()) {
+      manager.applyChange(new AddImport(outputOntology, imp));
+    }
+
+    return outputOntology;
+  }
+
+  /**
+   * Copies annotations of the ObjectProperties, DataProperties, AnnotationProperties and
+   * NamedIndividuals from source ontology to the target ontology.
+   *
+   * @param sourceOntology ontology that contains the annotations.
+   * @param targetOntology ontology to add annotations.
+   */
+  private static void copyPropertyAnnotations(
+      OWLOntology sourceOntology, OWLOntology targetOntology) {
+    OWLOntologyManager targetOntManager = targetOntology.getOWLOntologyManager();
+
+    targetOntManager.addAxioms(
+        targetOntology,
+        RelatedObjectsHelper.getAnnotationAxioms(
+            sourceOntology,
+            targetOntology.getObjectPropertiesInSignature().stream()
+                .map(prop -> (OWLObject) prop)
+                .collect(Collectors.toSet())));
+
+    targetOntManager.addAxioms(
+        targetOntology,
+        RelatedObjectsHelper.getAnnotationAxioms(
+            sourceOntology,
+            targetOntology.getAnnotationPropertiesInSignature().stream()
+                .map(prop -> (OWLObject) prop)
+                .collect(Collectors.toSet())));
+
+    targetOntManager.addAxioms(
+        targetOntology,
+        RelatedObjectsHelper.getAnnotationAxioms(
+            sourceOntology,
+            targetOntology.getDataPropertiesInSignature().stream()
+                .map(prop -> (OWLObject) prop)
+                .collect(Collectors.toSet())));
+  }
+
   /**
    * Given an input ontology, an output ontology, and an Imports specification, copy individuals
    * used in logical definitions from the input to the output ontology.


Test patch:
diff --git a/robot-core/src/test/java/org/obolibrary/robot/ExtractOperationTest.java b/robot-core/src/test/java/org/obolibrary/robot/ExtractOperationTest.java
index 9ae2b440..61e7ae4e 100644
--- a/robot-core/src/test/java/org/obolibrary/robot/ExtractOperationTest.java
+++ b/robot-core/src/test/java/org/obolibrary/robot/ExtractOperationTest.java
@@ -5,10 +5,12 @@ import static org.junit.Assert.assertEquals;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import org.junit.Test;
 import org.semanticweb.owlapi.model.*;
+import org.semanticweb.owlapi.model.parameters.Imports;
 import uk.ac.manchester.cs.owlapi.modularity.ModuleType;
 
 /**
@@ -151,6 +153,33 @@ public class ExtractOperationTest extends CoreTest {
     testExtract(ModuleType.STAR, "/none.owl", options);
   }
 
+  /**
+   * Tests subset extraction operation using a pre-generated OWL file to compare against.
+   *
+   * @throws IOException on IO error
+   * @throws OWLOntologyCreationException on ontology error
+   */
+  @Test
+  public void testExtractSubset() throws IOException, OWLOntologyCreationException {
+    OWLOntology simple = loadOntology("/subset.obo");
+
+    Map<String, String> options = ExtractOperation.getDefaultOptions();
+    IRI outputIRI = IRI.create("http://purl.obolibrary.org/obo/subset_result.owl");
+
+    Set<IRI> terms = new HashSet<IRI>();
+    terms.add(IRI.create("http://purl.obolibrary.org/obo/ONT_1"));
+    terms.add(IRI.create("http://purl.obolibrary.org/obo/ONT_5"));
+    terms.add(IRI.create("http://purl.obolibrary.org/obo/BFO_0000050"));
+
+    OWLOntology module =
+        ExtractOperation.extractSubset(simple, terms, outputIRI, options, null, Imports.INCLUDED);
+
+    OWLOntology expected = loadOntology("/subset_result.owl");
+    removeDeclarations(expected);
+    removeDeclarations(module);
+    assertIdentical(expected, module);
+  }
+
   /**
    * Tests a generic non-MIREOT (i.e. SLME) extraction operation using a custom module type and a
    * pre-generated OWL file to compare against.
diff --git a/robot-core/src/test/resources/subset.obo b/robot-core/src/test/resources/subset.obo
new file mode 100644
index 00000000..57965a01
--- /dev/null
+++ b/robot-core/src/test/resources/subset.obo
@@ -0,0 +1,28 @@
+ontology: test
+subsetdef: foo "foo"
+
+[Term]
+id: ONT:1
+subset: foo
+
+[Term]
+id: ONT:2
+relationship: part_of ONT:1
+
+[Term]
+id: ONT:3
+relationship: part_of ONT:2
+
+[Term]
+id: ONT:4
+relationship: part_of ONT:3
+
+[Term]
+id: ONT:5
+is_a: ONT:4
+subset: foo
+
+[Typedef]
+id: part_of
+is_transitive: true
+xref: BFO:0000050
\ No newline at end of file
diff --git a/robot-core/src/test/resources/subset_result.owl b/robot-core/src/test/resources/subset_result.owl
new file mode 100644
index 00000000..480b49d7
--- /dev/null
+++ b/robot-core/src/test/resources/subset_result.owl
@@ -0,0 +1,113 @@
+<?xml version="1.0"?>
+<rdf:RDF xmlns="http://purl.obolibrary.org/obo/subset_result.owl#"
+     xml:base="http://purl.obolibrary.org/obo/subset_result.owl"
+     xmlns:owl="http://www.w3.org/2002/07/owl#"
+     xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:xml="http://www.w3.org/XML/1998/namespace"
+     xmlns:xsd="http://www.w3.org/2001/XMLSchema#"
+     xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"
+     xmlns:oboInOwl="http://www.geneontology.org/formats/oboInOwl#">
+    <owl:Ontology rdf:about="http://purl.obolibrary.org/obo/subset_result.owl"/>
+    
+
+
+    <!-- 
+    ///////////////////////////////////////////////////////////////////////////////////////
+    //
+    // Annotation properties
+    //
+    ///////////////////////////////////////////////////////////////////////////////////////
+     -->
+
+    
+
+
+    <!-- http://www.geneontology.org/formats/oboInOwl#hasDbXref -->
+
+    <owl:AnnotationProperty rdf:about="http://www.geneontology.org/formats/oboInOwl#hasDbXref">
+        <rdfs:label rdf:datatype="http://www.w3.org/2001/XMLSchema#string">database_cross_reference</rdfs:label>
+    </owl:AnnotationProperty>
+    
+
+
+    <!-- http://www.geneontology.org/formats/oboInOwl#id -->
+
+    <owl:AnnotationProperty rdf:about="http://www.geneontology.org/formats/oboInOwl#id"/>
+    
+
+
+    <!-- http://www.geneontology.org/formats/oboInOwl#inSubset -->
+
+    <owl:AnnotationProperty rdf:about="http://www.geneontology.org/formats/oboInOwl#inSubset">
+        <rdfs:label rdf:datatype="http://www.w3.org/2001/XMLSchema#string">in_subset</rdfs:label>
+    </owl:AnnotationProperty>
+    
+
+
+    <!-- http://www.geneontology.org/formats/oboInOwl#shorthand -->
+
+    <owl:AnnotationProperty rdf:about="http://www.geneontology.org/formats/oboInOwl#shorthand">
+        <rdfs:label rdf:datatype="http://www.w3.org/2001/XMLSchema#string">shorthand</rdfs:label>
+    </owl:AnnotationProperty>
+    
+
+
+    <!-- 
+    ///////////////////////////////////////////////////////////////////////////////////////
+    //
+    // Object Properties
+    //
+    ///////////////////////////////////////////////////////////////////////////////////////
+     -->
+
+    
+
+
+    <!-- http://purl.obolibrary.org/obo/BFO_0000050 -->
+
+    <owl:ObjectProperty rdf:about="http://purl.obolibrary.org/obo/BFO_0000050">
+        <rdf:type rdf:resource="http://www.w3.org/2002/07/owl#TransitiveProperty"/>
+        <oboInOwl:hasDbXref rdf:datatype="http://www.w3.org/2001/XMLSchema#string">BFO:0000050</oboInOwl:hasDbXref>
+        <oboInOwl:id rdf:datatype="http://www.w3.org/2001/XMLSchema#string">part_of</oboInOwl:id>
+        <oboInOwl:shorthand rdf:datatype="http://www.w3.org/2001/XMLSchema#string">part_of</oboInOwl:shorthand>
+    </owl:ObjectProperty>
+    
+
+
+    <!-- 
+    ///////////////////////////////////////////////////////////////////////////////////////
+    //
+    // Classes
+    //
+    ///////////////////////////////////////////////////////////////////////////////////////
+     -->
+
+    
+
+
+    <!-- http://purl.obolibrary.org/obo/ONT_1 -->
+
+    <owl:Class rdf:about="http://purl.obolibrary.org/obo/ONT_1">
+        <oboInOwl:id rdf:datatype="http://www.w3.org/2001/XMLSchema#string">ONT:1</oboInOwl:id>
+        <oboInOwl:inSubset rdf:resource="http://purl.obolibrary.org/obo/test#foo"/>
+    </owl:Class>
+    
+
+
+    <!-- http://purl.obolibrary.org/obo/ONT_5 -->
+
+    <owl:Class rdf:about="http://purl.obolibrary.org/obo/ONT_5">
+        <rdfs:subClassOf>
+            <owl:Restriction>
+                <owl:onProperty rdf:resource="http://purl.obolibrary.org/obo/BFO_0000050"/>
+                <owl:someValuesFrom rdf:resource="http://purl.obolibrary.org/obo/ONT_1"/>
+            </owl:Restriction>
+        </rdfs:subClassOf>
+        <oboInOwl:id rdf:datatype="http://www.w3.org/2001/XMLSchema#string">ONT:5</oboInOwl:id>
+        <oboInOwl:inSubset rdf:resource="http://purl.obolibrary.org/obo/test#foo"/>
+    </owl:Class>
+</rdf:RDF>
+
+
+
+<!-- Generated by the OWL API (version 4.5.9) https://github.com/owlcs/owlapi -->
\ No newline at end of file