Repository: monarch-initiative/pyphetools (language: python)

Issue:
Allinone



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/generate_phenopackets.yml b/.github/workflows/generate_phenopackets.yml
new file mode 100644
index 00000000..81e2baf6
--- /dev/null
+++ b/.github/workflows/generate_phenopackets.yml
@@ -0,0 +1,65 @@
+name: Generate phenopackets
+
+env:
+  # We generate the phenopackets from this state
+  # of the `phenopacket-store` repository.
+  PHENOPACKET_STORE_TAG: pyphe-ci-wip
+#  PHENOPACKET_STORE_TAG: c06a52e0  # State from Mar 5th, 2024
+
+on:
+    push:
+      branches: [ develop ]
+    pull_request:
+      branches: [ main, develop ]
+
+jobs:
+    build:
+      runs-on: ubuntu-latest
+
+      steps:
+        - uses: actions/checkout@v2
+
+        - name: Initialize Python 3.8
+          uses: actions/setup-python@v4.3.1
+          with:
+            python-version: "3.8"
+
+        - name: Install pyphetools & dependencies
+          run: |
+            python3 -m pip install --editable .
+
+        - name: Install Jupyter and register current Python as kernel
+          run: |
+            python3 -m pip install ipykernel notebook
+            python3 -m ipykernel install --user --name pp_env --display-name "Pyphetools (Python 3)"
+
+        - name: Check out phenopacket-store
+          run: |
+            cd ..
+            git clone https://github.com/monarch-initiative/phenopacket-store.git
+            cd phenopacket-store
+            git checkout $PHENOPACKET_STORE_TAG
+
+        - name: Generate phenopackets
+          run: |
+            # phenopacket-store folder
+            pps_dir=$(dirname $(pwd))/phenopacket-store
+            
+            # Prepare the notebooks to run
+            declare -a fbn1_notebooks=(
+              "FBN1/Comeglio_isolated_ectopia_lentis.ipynb" 
+              "FBN1/FBN1_lipodystrophy_Lin_2020.ipynb"
+              "FBN1/Katzke_2002_Marfan.ipynb"
+              "FBN1/LeGoff_FBN1_acromicric_and_geleophysic_dysplasia.ipynb"
+              "FBN1/Loeys2010_stiff_skin_syndrome.ipynb"
+              "FBN1/Palz_Marfan_2000.ipynb"
+              "FBN1/Tiecke_2001_Marfan.ipynb"
+            )
+            
+            # Run the notebooks
+            for nb in ${fbn1_notebooks[@]} 
+            do
+              nb_path=${pps_dir}/notebooks/${nb}
+              printf "Running %s\n" ${nb_path} 
+              jupyter execute --kernel_name=pp_env ${nb_path}
+            done
diff --git a/.github/workflows/mkdocs-deploy.yml b/.github/workflows/mkdocs-deploy.yml
index f4d4274f..f5c6a11e 100644
--- a/.github/workflows/mkdocs-deploy.yml
+++ b/.github/workflows/mkdocs-deploy.yml
@@ -2,7 +2,6 @@ name: mkdocs-generation
 on:
   push:
     branches:
-      - master 
       - main
 permissions:
   contents: write
@@ -11,16 +10,16 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v3
-      - uses: actions/setup-python@v4
+
+      - uses: actions/setup-python@v5
         with:
           python-version: 3.x
+
       - uses: actions/cache@v2
         with:
           key: ${{ github.ref }}
           path: .cache
-      - run: pip install mkdocs-material
-      - run: pip install mkdocs-material[imaging]
-      - run: pip install pillow cairosvg
-      - run: pip install mkdocs-material-extensions
-      - run: pip install mkdocstrings[python]
+
+      - run: python3 -m pip install .[docs]
+
       - run: mkdocs gh-deploy --force
\ No newline at end of file
diff --git a/.github/workflows/python_ci.yml b/.github/workflows/python_ci.yml
index ac630058..b638df38 100644
--- a/.github/workflows/python_ci.yml
+++ b/.github/workflows/python_ci.yml
@@ -1,7 +1,4 @@
 name: CI
-##
-# Add the line below to README.md and the badge will come alive when we make the repo public.
-# [![CI](https://github.com/monarch-initiative/genophenocorr/workflows/CI/badge.svg)](https://github.com/monarch-initiative/genophenocorr/actions/workflows/python_ci.yml)
 
 on:
     push:
@@ -19,16 +16,15 @@ jobs:
           os: [ windows-latest, macOS-latest, ubuntu-latest ]
 
       steps:
-        - uses: actions/checkout@v2
+        - uses: actions/checkout@v4
+
         - name: Initialize Python ${{ matrix.python }}
-          uses: actions/setup-python@v4.3.1
+          uses: actions/setup-python@v5
           with:
             python-version: "3.11"
-        - name: Install package
-          run: |
-            # python -m pip install --upgrade pip
-            pip install .
-            pip install ".[test]"
-        - name: Run pytest tests
-          run: |
-            python3 -m pytest
+
+        - name: Install pyphetools
+          run: python3 -m pip install .[test]
+
+        - name: Run all tests
+          run: python3 -m pytest
diff --git a/.gitignore b/.gitignore
index fd060a08..b234cea6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -120,6 +120,8 @@ venv.bak/
 
 # mkdocs documentation
 /site
+docs/_build
+docs/apidocs
 
 # mypy
 .mypy_cache/
diff --git a/MANIFEST.in b/MANIFEST.in
index e69de29b..c915675a 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -0,0 +1,1 @@
+include src/pyphetools/creation/data/*
diff --git a/README.md b/README.md
index c6c433b9..9c4ce1df 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-[![CI](https://github.com/monarch-initiative/genophenocorr/workflows/CI/badge.svg)](https://github.com/monarch-initiative/pyphetools/actions/workflows/python_ci.yml)
+[![CI](https://github.com/monarch-initiative/pyphetools/workflows/CI/badge.svg)](https://github.com/monarch-initiative/pyphetools/actions/workflows/python_ci.yml)
 
 # pyphetools
 Python Phenopacket Tools
diff --git a/docs/api/creation/age_column_mapper.md b/docs/api/creation/age_column_mapper.md
index 080da68e..848d563a 100644
--- a/docs/api/creation/age_column_mapper.md
+++ b/docs/api/creation/age_column_mapper.md
@@ -1,4 +1,4 @@
 # AgeColumnMapper
 
 
-::: src.pyphetools.creation.AgeColumnMapper
\ No newline at end of file
+::: pyphetools.creation.AgeColumnMapper
\ No newline at end of file
diff --git a/docs/api/creation/case_encoder.md b/docs/api/creation/case_encoder.md
index e7cab3f2..a1c8b856 100644
--- a/docs/api/creation/case_encoder.md
+++ b/docs/api/creation/case_encoder.md
@@ -1,3 +1,3 @@
 # CaseEncoder
 
-::: src.pyphetools.creation.CaseEncoder
\ No newline at end of file
+::: pyphetools.creation.CaseEncoder
\ No newline at end of file
diff --git a/docs/api/creation/case_template_encoder.md b/docs/api/creation/case_template_encoder.md
index c963c3d3..b8520824 100644
--- a/docs/api/creation/case_template_encoder.md
+++ b/docs/api/creation/case_template_encoder.md
@@ -1,3 +1,3 @@
 # CaseTemplateEncoder
 
-::: src.pyphetools.creation.CaseTemplateEncoder
\ No newline at end of file
+::: pyphetools.creation.CaseTemplateEncoder
\ No newline at end of file
diff --git a/docs/api/creation/citation.md b/docs/api/creation/citation.md
index 58fca3bc..22d1bb09 100644
--- a/docs/api/creation/citation.md
+++ b/docs/api/creation/citation.md
@@ -1,3 +1,3 @@
 # CohortEncoder
 
-::: src.pyphetools.creation.Citation
+::: pyphetools.creation.Citation
diff --git a/docs/api/creation/cohort_encoder.md b/docs/api/creation/cohort_encoder.md
index 68b67a34..6f4f7187 100644
--- a/docs/api/creation/cohort_encoder.md
+++ b/docs/api/creation/cohort_encoder.md
@@ -1,3 +1,3 @@
 # CohortEncoder
 
-::: src.pyphetools.creation.CohortEncoder
\ No newline at end of file
+::: pyphetools.creation.CohortEncoder
\ No newline at end of file
diff --git a/docs/api/creation/column_mapper.md b/docs/api/creation/column_mapper.md
index 53de1606..37a18183 100644
--- a/docs/api/creation/column_mapper.md
+++ b/docs/api/creation/column_mapper.md
@@ -1,3 +1,3 @@
 # ColumnMapper
 
-::: src.pyphetools.creation.ColumnMapper
\ No newline at end of file
+::: pyphetools.creation.ColumnMapper
\ No newline at end of file
diff --git a/docs/api/creation/constant_column_mapper.md b/docs/api/creation/constant_column_mapper.md
index 41723dc5..e2b3e95c 100644
--- a/docs/api/creation/constant_column_mapper.md
+++ b/docs/api/creation/constant_column_mapper.md
@@ -1,4 +1,4 @@
 # ConstantColumnMapper
 
-::: src.pyphetools.creation.ConstantColumnMapper
+::: pyphetools.creation.ConstantColumnMapper
 
diff --git a/docs/api/creation/create_template.md b/docs/api/creation/create_template.md
new file mode 100644
index 00000000..9243d28c
--- /dev/null
+++ b/docs/api/creation/create_template.md
@@ -0,0 +1,3 @@
+# TemplateCreator
+
+::: pyphetools.creation.ConstantColumnMapper
\ No newline at end of file
diff --git a/docs/api/creation/disease.md b/docs/api/creation/disease.md
index 98752f2b..b46e1456 100644
--- a/docs/api/creation/disease.md
+++ b/docs/api/creation/disease.md
@@ -1,3 +1,3 @@
 # Disease
 
-::: src.pyphetools.creation.Disease
\ No newline at end of file
+::: pyphetools.creation.Disease
\ No newline at end of file
diff --git a/docs/api/creation/disease_id_column_mapper.md b/docs/api/creation/disease_id_column_mapper.md
index e1312f1c..42f2e811 100644
--- a/docs/api/creation/disease_id_column_mapper.md
+++ b/docs/api/creation/disease_id_column_mapper.md
@@ -1,5 +1,4 @@
 # DiseaseIdColumnMapper
 
-
-::: src.pyphetools.creation.DiseaseIdColumnMapper
+::: pyphetools.creation.DiseaseIdColumnMapper
 
diff --git a/docs/api/creation/hgvs_variant.md b/docs/api/creation/hgvs_variant.md
index b828811d..0b16d7ab 100644
--- a/docs/api/creation/hgvs_variant.md
+++ b/docs/api/creation/hgvs_variant.md
@@ -1,3 +1,3 @@
 # HgvsVariant
 
-::: src.pyphetools.creation.HgvsVariant
\ No newline at end of file
+::: pyphetools.creation.HgvsVariant
\ No newline at end of file
diff --git a/docs/api/creation/hp_term.md b/docs/api/creation/hp_term.md
index 6c983b9f..c1ec3485 100644
--- a/docs/api/creation/hp_term.md
+++ b/docs/api/creation/hp_term.md
@@ -1,3 +1,3 @@
 # HpTerm
 
-::: src.pyphetools.creation.HpTerm
+::: pyphetools.creation.HpTerm
diff --git a/docs/api/creation/hpo_cr.md b/docs/api/creation/hpo_cr.md
index fc47b299..b6c605d7 100644
--- a/docs/api/creation/hpo_cr.md
+++ b/docs/api/creation/hpo_cr.md
@@ -1,3 +1,3 @@
 # HpoConceptRecognizer
 
-::: src.pyphetools.creation.HpoConceptRecognizer
\ No newline at end of file
+::: pyphetools.creation.HpoConceptRecognizer
\ No newline at end of file
diff --git a/docs/api/creation/hpo_exact_cr.md b/docs/api/creation/hpo_exact_cr.md
index fdeb07e6..e1efd6ff 100644
--- a/docs/api/creation/hpo_exact_cr.md
+++ b/docs/api/creation/hpo_exact_cr.md
@@ -1,3 +1,3 @@
 # HpoExactConceptRecognizer
 
-::: src.pyphetools.creation.HpoExactConceptRecognizer
+::: pyphetools.creation.HpoExactConceptRecognizer
diff --git a/docs/api/creation/hpo_parser.md b/docs/api/creation/hpo_parser.md
index e80d09c1..9a10274b 100644
--- a/docs/api/creation/hpo_parser.md
+++ b/docs/api/creation/hpo_parser.md
@@ -1,3 +1,3 @@
 # HpoParser
 
-::: src.pyphetools.creation.HpoParser
+::: pyphetools.creation.HpoParser
diff --git a/docs/api/creation/individual.md b/docs/api/creation/individual.md
index 02c8882a..16115e9f 100644
--- a/docs/api/creation/individual.md
+++ b/docs/api/creation/individual.md
@@ -1,4 +1,4 @@
 # Individual
 
-::: src.pyphetools.creation.Individual
+::: pyphetools.creation.Individual
 
diff --git a/docs/api/creation/iso_age.md b/docs/api/creation/iso_age.md
index f7d02fe6..925b330c 100644
--- a/docs/api/creation/iso_age.md
+++ b/docs/api/creation/iso_age.md
@@ -1,3 +1,3 @@
 # IsoAge
 
-::: src.pyphetools.creation.IsoAge
\ No newline at end of file
+::: pyphetools.creation.IsoAge
\ No newline at end of file
diff --git a/docs/api/creation/metadata.md b/docs/api/creation/metadata.md
index 85201126..bac4a3c5 100644
--- a/docs/api/creation/metadata.md
+++ b/docs/api/creation/metadata.md
@@ -1,5 +1,5 @@
 # MetaData
 
-::: src.pyphetools.creation.MetaData
+::: pyphetools.creation.MetaData
 
 
diff --git a/docs/api/creation/mixed_cohort_encoder.md b/docs/api/creation/mixed_cohort_encoder.md
index b9349d86..9e6cb9e1 100644
--- a/docs/api/creation/mixed_cohort_encoder.md
+++ b/docs/api/creation/mixed_cohort_encoder.md
@@ -1,5 +1,3 @@
 # MixedCohortEncoder
 
-
-
-::: src.pyphetools.creation.MixedCohortEncoder
+::: pyphetools.creation.MixedCohortEncoder
diff --git a/docs/api/creation/option_column_mapper.md b/docs/api/creation/option_column_mapper.md
index 71abb2aa..6cbfd24d 100644
--- a/docs/api/creation/option_column_mapper.md
+++ b/docs/api/creation/option_column_mapper.md
@@ -1,3 +1,3 @@
 # OptionColumnMapper
 
-::: src.pyphetools.creation.OptionColumnMapper
+::: pyphetools.creation.OptionColumnMapper
diff --git a/docs/api/creation/sex_column_mapper.md b/docs/api/creation/sex_column_mapper.md
index 18f1339c..a1384c29 100644
--- a/docs/api/creation/sex_column_mapper.md
+++ b/docs/api/creation/sex_column_mapper.md
@@ -1,3 +1,3 @@
 # SexColumnMapper
 
-::: src.pyphetools.creation.SexColumnMapper
+::: pyphetools.creation.SexColumnMapper
diff --git a/docs/api/creation/simple_column_mapper.md b/docs/api/creation/simple_column_mapper.md
index 772b81da..3e56076b 100644
--- a/docs/api/creation/simple_column_mapper.md
+++ b/docs/api/creation/simple_column_mapper.md
@@ -1,3 +1,3 @@
 # SimpleColumnMapper
 
-::: src.pyphetools.creation.SimpleColumnMapper
+::: pyphetools.creation.SimpleColumnMapper
diff --git a/docs/api/creation/simple_column_mapper_generator.md b/docs/api/creation/simple_column_mapper_generator.md
index 9e4b8cb6..6ec49c62 100644
--- a/docs/api/creation/simple_column_mapper_generator.md
+++ b/docs/api/creation/simple_column_mapper_generator.md
@@ -1,3 +1,3 @@
 # SimpleColumnMapperGenerator
 
-::: src.pyphetools.creation.SimpleColumnMapperGenerator
\ No newline at end of file
+::: pyphetools.creation.SimpleColumnMapperGenerator
\ No newline at end of file
diff --git a/docs/api/creation/structural_variant.md b/docs/api/creation/structural_variant.md
index f999d49d..9a01e198 100644
--- a/docs/api/creation/structural_variant.md
+++ b/docs/api/creation/structural_variant.md
@@ -1,4 +1,3 @@
 # StructuralVariant
 
-
-::: src.pyphetools.creation.StructuralVariant
+::: pyphetools.creation.StructuralVariant
diff --git a/docs/api/creation/thresholded_column_mapper.md b/docs/api/creation/thresholded_column_mapper.md
index 177ef84c..5f778df9 100644
--- a/docs/api/creation/thresholded_column_mapper.md
+++ b/docs/api/creation/thresholded_column_mapper.md
@@ -1,4 +1,3 @@
 # ThresholdedColumnMapper
 
-
-::: src.pyphetools.creation.ThresholdedColumnMapper
+::: pyphetools.creation.ThresholdedColumnMapper
diff --git a/docs/api/creation/thresholder.md b/docs/api/creation/thresholder.md
index d66ecd6f..1f4a4cc6 100644
--- a/docs/api/creation/thresholder.md
+++ b/docs/api/creation/thresholder.md
@@ -1,4 +1,3 @@
 # Thresholder
 
-
-::: src.pyphetools.creation.Thresholder
\ No newline at end of file
+::: pyphetools.creation.Thresholder
\ No newline at end of file
diff --git a/docs/api/creation/variant.md b/docs/api/creation/variant.md
index 5c923db7..29c81f0b 100644
--- a/docs/api/creation/variant.md
+++ b/docs/api/creation/variant.md
@@ -1,3 +1,3 @@
 # Variant
 
-::: src.pyphetools.creation.Variant
+::: pyphetools.creation.Variant
diff --git a/docs/api/creation/variant_column_mapper.md b/docs/api/creation/variant_column_mapper.md
index 29c1afd4..e79f0405 100644
--- a/docs/api/creation/variant_column_mapper.md
+++ b/docs/api/creation/variant_column_mapper.md
@@ -1,4 +1,4 @@
 # VariantColumnMapper
 
-::: src.pyphetools.creation.VariantColumnMapper
+::: pyphetools.creation.VariantColumnMapper
 
diff --git a/docs/api/creation/variant_manager.md b/docs/api/creation/variant_manager.md
index d6cc9564..fc1d7708 100644
--- a/docs/api/creation/variant_manager.md
+++ b/docs/api/creation/variant_manager.md
@@ -1,2 +1,3 @@
 # VariantManager
-::: src.pyphetools.creation.VariantManager
\ No newline at end of file
+
+::: pyphetools.creation.VariantManager
\ No newline at end of file
diff --git a/docs/api/creation/variant_validator.md b/docs/api/creation/variant_validator.md
index a19c9e3f..b6152c6e 100644
--- a/docs/api/creation/variant_validator.md
+++ b/docs/api/creation/variant_validator.md
@@ -1,3 +1,3 @@
 # VariantValidator
 
-::: src.pyphetools.creation.VariantValidator
+::: pyphetools.creation.VariantValidator
diff --git a/docs/api/overview.md b/docs/api/overview.md
index b00abc52..399cb882 100644
--- a/docs/api/overview.md
+++ b/docs/api/overview.md
@@ -1,6 +1,6 @@
 # API Documentation
 
-pyphetools has four modules.
+pyphetools has three modules.
 
 
 - [creation](creation.md)
diff --git a/docs/api/validation.md b/docs/api/validation.md
index 3950bcbf..8827636b 100644
--- a/docs/api/validation.md
+++ b/docs/api/validation.md
@@ -4,4 +4,4 @@ This module contains code for validating phenopackets created by pyphetools. The
 are provided for convenience. We recommend using
 [phenopacket-tools](https://github.com/monarch-initiative/pyphetools) to validate phenopackets.
 
-::: src.pyphetools.validation
\ No newline at end of file
+::: pyphetools.validation
\ No newline at end of file
diff --git a/docs/api/validation/cohort_validator.md b/docs/api/validation/cohort_validator.md
index b680e74c..97dfeb82 100644
--- a/docs/api/validation/cohort_validator.md
+++ b/docs/api/validation/cohort_validator.md
@@ -1,6 +1,4 @@
-
 # CohortValidator
 
-
-::: src.pyphetools.validation.CohortValidator
+::: pyphetools.validation.CohortValidator
 
diff --git a/docs/api/validation/content_validator.md b/docs/api/validation/content_validator.md
index 5abe3216..ed5e1080 100644
--- a/docs/api/validation/content_validator.md
+++ b/docs/api/validation/content_validator.md
@@ -1,4 +1,3 @@
 # ContentValidator
 
-
-::: src.pyphetools.validation.ContentValidator
+::: pyphetools.validation.ContentValidator
diff --git a/docs/api/validation/ontology_qc.md b/docs/api/validation/ontology_qc.md
index 56e5161a..e74f45a5 100644
--- a/docs/api/validation/ontology_qc.md
+++ b/docs/api/validation/ontology_qc.md
@@ -1,6 +1,3 @@
 # OntologyQC
 
-::: src.pyphetools.validation.OntologyQC
-
-
-
+::: pyphetools.validation.OntologyQC
diff --git a/docs/api/validation/validation_result.md b/docs/api/validation/validation_result.md
index 4a154002..e969a501 100644
--- a/docs/api/validation/validation_result.md
+++ b/docs/api/validation/validation_result.md
@@ -1,5 +1,3 @@
-
 # ValidationResult
 
-
-::: src.pyphetools.validation.ValidationResult
+::: pyphetools.validation.ValidationResult
diff --git a/docs/api/visualization.md b/docs/api/visualization.md
index 068f433f..90c7d7e0 100644
--- a/docs/api/visualization.md
+++ b/docs/api/visualization.md
@@ -2,4 +2,4 @@
 
 This module contains code for visualizing phenopackets in Jupyter notebooks.
 
-::: src.pyphetools.visualization
\ No newline at end of file
+::: pyphetools.visualization
diff --git a/docs/api/visualization/detailed_suppl_table.md b/docs/api/visualization/detailed_suppl_table.md
index d34f93f6..42182305 100644
--- a/docs/api/visualization/detailed_suppl_table.md
+++ b/docs/api/visualization/detailed_suppl_table.md
@@ -1,4 +1,3 @@
 # DetailedSupplTable
 
-::: src.pyphetools.visualization.DetailedSupplTable
-
+::: pyphetools.visualization.DetailedSupplTable
diff --git a/docs/api/visualization/hpoa_table_creator.md b/docs/api/visualization/hpoa_table_creator.md
index 1a0831d6..5226e449 100644
--- a/docs/api/visualization/hpoa_table_creator.md
+++ b/docs/api/visualization/hpoa_table_creator.md
@@ -1,4 +1,4 @@
 # HpoaTableCreator
 
-::: src.pyphetools.visualization.HpoaTableCreator
+::: pyphetools.visualization.HpoaTableCreator
 
diff --git a/docs/api/visualization/onset_calculator.md b/docs/api/visualization/onset_calculator.md
index 1910d75f..863d6312 100644
--- a/docs/api/visualization/onset_calculator.md
+++ b/docs/api/visualization/onset_calculator.md
@@ -1,3 +1,3 @@
 # OnsetCalculator
 
-::: src.pyphetools.visualization.onset_calculator.OnsetCalculator
\ No newline at end of file
+::: pyphetools.visualization.onset_calculator.OnsetCalculator
\ No newline at end of file
diff --git a/docs/api/visualization/phenopacket_ingestor.md b/docs/api/visualization/phenopacket_ingestor.md
index 4eb85dba..3d3530f9 100644
--- a/docs/api/visualization/phenopacket_ingestor.md
+++ b/docs/api/visualization/phenopacket_ingestor.md
@@ -1,3 +1,3 @@
 # PhenopacketIngestor
 
-::: src.pyphetools.visualization.PhenopacketIngestor
+::: pyphetools.visualization.PhenopacketIngestor
diff --git a/docs/api/visualization/phenopacket_table.md b/docs/api/visualization/phenopacket_table.md
index 332772ea..845a9422 100644
--- a/docs/api/visualization/phenopacket_table.md
+++ b/docs/api/visualization/phenopacket_table.md
@@ -1,4 +1,3 @@
 # PhenopacketTable
 
-
-::: src.pyphetools.visualization.PhenopacketTable
+::: pyphetools.visualization.PhenopacketTable
diff --git a/docs/api/visualization/qc_visualizer.md b/docs/api/visualization/qc_visualizer.md
index 84e14d55..6cc32b55 100644
--- a/docs/api/visualization/qc_visualizer.md
+++ b/docs/api/visualization/qc_visualizer.md
@@ -1,3 +1,3 @@
 # QcVisualizer
 
-::: src.pyphetools.visualization.QcVisualizer
+::: pyphetools.visualization.QcVisualizer
diff --git a/docs/developers/developers.md b/docs/developers/developers.md
new file mode 100644
index 00000000..ba96e3fb
--- /dev/null
+++ b/docs/developers/developers.md
@@ -0,0 +1,13 @@
+# For developers
+
+
+pyphetools provides two main ways of creating phenopackets.
+
+- Using an Excel template. This is the recommended approach for most users.
+    - Details are provided in the [Phenopacket Store](https://monarch-initiative.github.io/phenopacket-store/contributing/){:target="\_blank"} documentation, along with many examples.
+
+- Creating custom Python scripts. This approach can be used by those who are comfortable with writing Python and using Jupyter notebooks. This approach is mainly intended to ingest tabular data from published articles with data from numerous individuals in tabular form (e.g., a typical Supplemental Table). The formats that
+one encounters in publications are so varied, that pyphetools provides a variety of approaches to capturing the data.
+    - See the section on [Custom Python scripts](../user-guide/jupyter.md) to get started
+    - See the [API documentation](../api/overview.md) for details
+
diff --git a/docs/developers/hpoa_editing.md b/docs/developers/hpoa_editing.md
index ef3e1767..71debd75 100644
--- a/docs/developers/hpoa_editing.md
+++ b/docs/developers/hpoa_editing.md
@@ -39,30 +39,12 @@ It is also possible to create a builder object by providing the path to the dire
 builder = HpoaTableBuilder(indir="phenopackets")
 ```
 
-### Age of onset
-In many cases, publications do not provide precise descriptions of the age of onset of disease, or the onset is described globally. For this reason, the builder object allows users to provide the age of onset separetely.
-
-For instance, one publication that describes 9 individuals states: 'Age at symptom recognition ranged from 4 months to 10 years,' but does not provide the ages of onset for each patient. Additionally, the ages of onset in the HPOA file are provided as HPO terms rather than specific numerical values. For this case, it seems that we can conclude there was at least one individual with [Infantile onset](https://hpo.jax.org/app/browse/term/HP:0003593), one with [Childhood onset](https://hpo.jax.org/app/browse/term/HP:0011463), and one with [Juvenile onset](https://hpo.jax.org/app/browse/term/HP:0003621), but we do not know the counts. The best way of annotating seems to be to add one count for each age range as follows. Note that the PMID, the numerator, and the denominator are required to be specified.
-
-```python title="specifying age of onset"
-PMID = "PMID:37167966"
-builder.infantile_onset(PMID, 1, 1).childhood_onset(PMID, 1,1).juvenile_onset(PMID,1,1)
-```
-In this particular example, a second publication is available that states:
-
-    Six individuals of a single consanguineous Bedouin kindred (Fig. 1A) were affected with apparently autosomal recessive progressive limb girdle muscle disease. The disease initially manifested during the fourth decade of life with pain on exertion, followed by muscle fatigue and weakness, affecting mostly the proximal and axial muscles, and culminating with involvement of respiratory muscles.
-
-We code this as
-
-```python title="specifying age of onset"
-PMID2 = "PMID:36745799"
-builder.middle_age_onset(PMID2, 6, 6)
-```
 
 ### Mode of inheritance
 In the HPOA annotation model, we regard the mode of inheritance as certain knoweldge that does not receive a frequency or does not need to be specified for each publication (exception: rare diseases with multiple modes of inheritance). In this case, the disease is listed as autosomal dominant in OMIM and in both publications. We curate this using one of the PMIDs (generally, the first to report the disease) as follows.
 
 ```python title="specifying mode of inheritance"
+PMID = "PMID:123" # enter the PMID of an article that specificies the mode of inheritance.
 builder.autosomal_recessive(PMID)
 ```
 
diff --git a/docs/installation.md b/docs/developers/installation.md
similarity index 100%
rename from docs/installation.md
rename to docs/developers/installation.md
diff --git a/docs/developers.md b/docs/developers/internal.md
similarity index 88%
rename from docs/developers.md
rename to docs/developers/internal.md
index 620c868f..091abf9d 100644
--- a/docs/developers.md
+++ b/docs/developers/internal.md
@@ -1,4 +1,9 @@
-# For developers
+# Internal
+
+The following commands are used by the development team.
+
+
+
 
 ## Updating package in PyPI
 
@@ -12,7 +17,7 @@ python3 -m pip install --upgrade build
 python3 -m pip install --upgrade twine
 ```
 
-To update the version in PyPI, update the version number in the pyproject.toml file, and 
+To update the version in PyPI, update the version number in the pyproject.toml file, and
 execute the following commands to build and install.
 
 ```bash title="updating package in PyPI"
diff --git a/docs/index.md b/docs/index.md
index 761c630f..aa5c0e6b 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,15 +1,15 @@
 # pyphetools
 
 
-pyphetools is a Python package for creating  
-[GA4GH phenopackets](https://phenopacket-schema.readthedocs.io/en/latest/){:target="\_blank"}
-from tabular data such as databases or supplemental files found in
-the medical literature.
+pyphetools is a Python package for creating [GA4GH phenopackets](https://phenopacket-schema.readthedocs.io/en/latest/){:target="\_blank"}
+from tabular data such as databases or supplemental files found in the medical literature.
 
 
-### Feedback
-
-
-The best place to leave feedback, ask questions, and report bugs is the 
+- **GitHub**:
+Source code is available at the [pyphetools GitHub repository](https://github.com/monarch-initiative/pyphetools){:target="\_blank"}.
+- **PyPI**:
+pyphetools has been released as a Python package as. See its [PyPI project page](https://pypi.org/project/pyphetools/).
+- **Feedback**:
+The best place to leave feedback, ask questions, and report bugs is the
 [Pyphetools Issue Tracker](https://github.com/monarch-initiative/pyphetools/issues){:target="\_blank"}.
 
diff --git a/docs/user-guide/excel.md b/docs/user-guide/excel.md
new file mode 100644
index 00000000..cd597b27
--- /dev/null
+++ b/docs/user-guide/excel.md
@@ -0,0 +1,136 @@
+# Excel Template
+
+We have designed a format for Excel templates that can be used to quickly and efficiently generate collections of Phenopackets. This is currently the prefered way for clinicians and translational researchers to contribute to this project. The [pyphetools](https://github.com/monarch-initiative/pyphetools){:target="_blank"}  library provides other means for bioinformaticians (please ask us).
+
+The template can be downloaded [here](../_static/template.xlsx){:target="_blank"}.
+
+# A format for cohort descriptions in excel
+
+The schema of the template consists in two rows that specify the nature of the data. There is a fixed set of columns that capture basic demographic data together with the disease, the source publication, and the variants. The second half of the template should be used to record information about
+HPO terms curated from the publications.
+
+Note that the format specifier "CURIE" means "Compact uniform resource identifier", which means the entry should have a prefix (e.g., PMID, a colon, and the identifier, e.g., 3021034, altogether "PMID:3021034"). "str" refers to an arbitrary text (string). "optional" means the cell can
+be left empty.
+
+## Fixed columns
+The first (leftmost) 11 or 12 columns specify basic demographic data together with the disease, the source publication, and the variants.
+The first two rows are used to specify the datatypes and should not be changed. The following tables show the first two rows together with
+one example row with data extracted from a publication (We show two tables for better legibility)
+
+
+|  PMID	| title	| individual_id	| Comment | disease_id	 | disease_label|
+|:------|:------|:--------------|:--------|:-------------|:-------------|
+| CURIE	| str	| str       	| optional|  CURIE       | 	        str |
+| PMID:33087723| 	Early-onset autoimmunity associated with SOCS1 haploinsufficiency| 	A1|	|	OMIM:603597|	Autoinflammatory syndrome, familial, with or without immunodeficiency|
+
+
+1. **PMID** (CURIE: The PubMed identifier of the publication being curated.
+2. **title** (str): The title of the publication being curated.
+3. **individual_id** (str): The identifier of the individual being described in the original publication. This field is required. Please add ‘individual’ if the original article does not provide an identifier (if needed, individual 1, individual 2,...).
+4. **comment** (str): This field is provided to record additional information that will not be used for creating phenopackets but may be helpful for future reference. It can be left empty.
+5. **disease_id** (CURIE). The disease identifer (e.g., ``OMIM:154700`` or  ``MONDO:0007947``).
+6. **disease_label** (str). The name of the disease (e.g. ``Marfan syndrome``).
+
+
+
+
+|  HGNC_id   |  gene_symbol|  transcript| 	allele_1| allele_2 | variant.comment|
+|:-----------|:------------|:-----------|:----------|:---------|:---------------|
+|  CURIE     |  str        |      str   | str       |str       |  str        	|
+| HGNC:19383 | SOCS1       | NM_003745.2|c.368C>G   |	    na |p.P123R         |
+
+
+
+7. **HGNC_id** (CURIE): Identifier of the [HUGO Gene Nomenclature Committee](https://www.genenames.org/){target="_blank"}.
+8. **gene_symbol** (str):: Gene symbol, e.g., SOCS1.
+9. **transcript** (str): The identifier of the transcript. NCBI RefSeq or ENSEMBL identifiers are preferred.
+10. **allele_1** (str): A string representing the first pathogenic allele (variant) according to [HGVS](https://hgvs-nomenclature.org/stable/background/simple/) nomenclature.
+11. **allele_2** (str): This field should not be used for monoallelic diseases (e.g. autosomal dominant, XLR). The column can eigther be omitted or can be filled with "na" to denote "not applicable". It the column is present and is left empty, this will be flagged as an error. For biallelic diseases (autosomal recessive), specific the second allele (which will be the same as the first for homozygous genotypes).
+12. **variant.comment** (str): This field is provided to record additional information that will not be used for creating phenopackets but may be helpful for future reference.
+
+
+
+| age_of_onset   | age_at_last_encounter |sex	  | HPO	                 |
+:----------------|:----------------------|:-------|:-------------------------|
+| age      	     | age                   | M:F:O:U| na	                     |
+|Infantile onset |    	P21Y	         | F      | na     |
+
+11. **age_of_onset**: The age of onset of disease, recorded using [iso8601 convention](https://en.wikipedia.org/wiki/ISO_8601#Durations) or an HPO [Onset](https://hpo.jax.org/app/browse/term/HP:0003674){:target="_blank"} term.
+12. **sex** (M:F:U:O): one of M (male), female (F), other(O), or unknown (U)
+13. **HPO** (str): The column marks the end of the data columns and should contain "na".
+
+### HPO Term Columns
+All of the following columns denote HPO terms. The first row has the HPO term label. Be sure to use the same label as is shown on the HPO
+webpage and do not chance the capitalization. The second row has the corresponding HPO id. The following table shows several examples, whereby
+the individual_id column from above is shown for ease of exposition.
+
+
+
+
+|individual_id |  Hepatitis  | 	Pancreatitis| 	Lymphadenopathy| 	Splenomegaly   |
+|:-------------|:------------|:-------------|:-----------------|:------------------|
+|              | HP:0012115  |HP:0001733    |  HP:0002716      |   HP:0001744      |
+| A            | observed    | excluded     |                  | P4Y2M             |
+| B            |P3Y          |    na        | observed         | excluded          |
+
+Each table cell can contain either
+1. observed: The phenotypic abnormality denoted by the HPO term was present
+2. excluded: The phenotypic abnormality denoted by the HPO term was investigated and ruled out.
+3. An [iso8601](https://en.wikipedia.org/wiki/ISO_8601#Durations) string denoting the age of onset.
+4. na or empty (blank): Information not available or phenotypic feature not measured.
+
+In this example, individual A was observed to have hepatitis (but age of onset is unknown or not available), pancreatitis was ruled out, no information is available about lymphadenopathy, and splenomegaly was first observed at age 4 years and 2 months.
+
+Individual B was found to have hepatitis first observed at age 3 years, no information was available about pancreatitis, lymphadenopathy was observed (but age of onset is unknown or not available), and splenomegaly  was ruled out.
+
+
+
+
+
+
+
+
+
+
+
+The file should contain at least the following information; see explanations below.
+
+
+
+
+|row_type| id | age | sex | allele |  Tall stature | Abnormal sternum morphology | Potassium |
+|:---- |:----|:----|:----|:-----|:---------|:----|:------------- |
+| header1 | str | ISO8601 | str | NM_000138.5 | simple  | option| threshold |
+| header2 |  |  |  |  | HP:0000098 |  HP:0000767; HP:0000768| 3.5-5.2 mEq/L: High->Hyperkalemia{HP:0002153); Low->Hypokalemia(HP:0002900) |
+| individual| patient A | P6Y | male | c.8326C>T | + | Pectus carinatum | n/a |
+| individual| patient B | P9Y | female | c.7988G>C | - | Pectus excavatum | 5.8 |
+
+
+
+## row_type
+Each row must begin with one of the words "header1", "header2" or "individual". There should be one row for each individual in the cohort.
+
+## id
+This is an cohort-specific identifier that **must** be anonymized.
+
+## age
+This is the age of the individual at the time of the medical encounter at which the phenotypic features were recorded. The format of the column is recorded in the header1 line. Valid options are *ISO8601* for strings such as "P4Y" (four years of age) and "P71Y6M2D" for 71 years, 6 months, and 2 days; *Years* for 5 (5 years of age) or 7.5 (7 years and 6 months).
+
+## sex
+Use male, female, other, or unknown.
+
+# HPO columns
+The remaining columns contain information about HPO terms observed in the individuals. There are three types of column.
+
+# Simple.
+The top row contains the label of the term. The header1 row contains the word "simple". The header2 row contains the HPO id; in the example table, we see [Tall stature; HP:0000098](https://hpo.jax.org/app/browse/term/HP:0000098). If the feature is observed in an indivual, use "+"; if the feature was explicitly excluded, use "-". If the feature was not measured or no information is available, use "n/a".
+
+# Option
+This type of column is used for sets of related terms. The top row contains the label of the HPO superclass (herere [Abnormal sternum morphology HP:0000766](https://hpo.jax.org/app/browse/term/HP:0000766)). The header1 row contains the word "option". The header2 row contains the HPO ids of the terms that can be recorded in the column. In this example, it is the terms [Pectus carinatum; HP:0000768](https://hpo.jax.org/app/browse/term/HP:0000768) and
+[Pectus excavatum HP:0000767](https://hpo.jax.org/app/browse/term/HP:0000767). In the individual rows, the corresponding label of the HPO term is written. Any number of terms can be put into one column in this way.
+
+# Threshold
+This type of column is used for numerical data with a normal range. Indicate the normal range and the HPO terms that are used for low or high values as shown.
+
+# Text mining
+This type of column is not preferred but if necessary can be used. An example is not shown here, but but "text mining" in the header1 row and nothing in the header2 row. We will use text mining to attempt to extract HPO terms from the texts in the column.
\ No newline at end of file
diff --git a/docs/user-guide/jupyter.md b/docs/user-guide/jupyter.md
index 6b197e47..b060b41f 100644
--- a/docs/user-guide/jupyter.md
+++ b/docs/user-guide/jupyter.md
@@ -34,49 +34,49 @@ used (see the last two lines) in case of errors or feature requests. Sometimes,
 to be imported to support special cases.
 
 
-```python title="imports"
+```python title="Imports"
+import hpotk
+import pyphetools
+
 import pandas as pd
+
+print(f"Using pyphetools version {pyphetools.__version__}")
+
 pd.set_option('display.max_colwidth', None) # show entire column contents, important!
 from IPython.display import display, HTML
-from pyphetools.creation import *
-from pyphetools.visualization import *
-from pyphetools.validation import *
-import pyphetools
-print(f"Using pyphetools version {pyphetools.__version__}")
 ```
 
+### Import the Human Phenotype Ontology (HPO) file
 
+It is useful to import the HPO file and create the `MetaData` object 
+(which records your `ORCID <https://orcid.org/>`_ id and the version of the HPO used) in one step.
 
+First, we load the latest HPO file using HPO toolkit:
 
-### Import the Human Phenotype Ontology (HPO) file
-
+```python title="Load the latest HPO"
+ontostore = hpotk.configure_ontology_store()
+hpo = ontostore.load_hpo()
+```
 
-It is useful to import the HPO file and create the MetaData object (which records your `ORCID <https://orcid.org/>`_ id and the version of the HPO used) in one step.
+Now, we can create the `MetaData`: 
 
-```python title="HPO and MetaData"
-parser = HpoParser()
-hpo_cr = parser.get_hpo_concept_recognizer()
-hpo_version = parser.get_version()
-hpo_ontology = parser.get_ontology()
+```python title="Configure MetaData"
 PMID = "PMID:16783569"
 title = "A novel X-linked recessive mental retardation syndrome comprising macrocephaly and ciliary dysfunction is allelic to oral-facial-digital type I syndrome"
 citation = TODO
 metadata = MetaData(created_by="ORCID:0000-0002-5648-2155", pmid=PMID, pubmed_title=title)
-metadata.default_versions_with_hpo(version=hpo_version)
-print(f"HPO version {hpo_version}")
+metadata.default_versions_with_hpo(version=hpo.version)
+print(f"HPO version {hpo.version}")
 ```
 
 It is often useful to display the title and PubMed identifier of the publication from which the data come.
 Simply replace the above code with the following.
 
-```python title="HPO and MetaData (with title and PMID)"
-parser = HpoParser()
-hpo_cr = parser.get_hpo_concept_recognizer()
-hpo_version = parser.get_version()
+```python title="Configure MetaData with publication title and its PMID"
 PMID = "PMID:24736735"
 title = "New insights into genotype-phenotype correlation for GLI3 mutations"
 metadata = MetaData(created_by="", pmid=PMID, pubmed_title=title)
-metadata.default_versions_with_hpo(version=hpo_version)
+metadata.default_versions_with_hpo(version=hpo.version)
 ```
 
 
diff --git a/docs/user-guide/python_notebook.md b/docs/user-guide/python_notebook.md
new file mode 100644
index 00000000..b44d0931
--- /dev/null
+++ b/docs/user-guide/python_notebook.md
@@ -0,0 +1,91 @@
+# Python (Jupyter) notebook for the Excel template
+
+The following sections explain how to use Python code to create phenopackets from data stored using the [Excel template](excel.md).
+
+
+## Preparing the notebook.
+
+We first import the necessary libraries. Note that for conciseness, we import all classes from the pyphetools modules below. It is often better
+to import only the specifically needed classes.
+
+```python
+import pandas as pd
+from IPython.display import display, HTML
+pd.set_option('display.max_colwidth', None) # show entire column contents, important!
+from collections import defaultdict
+from pyphetools.creation import *
+from pyphetools.visualization import *
+from pyphetools.validation import *
+import pyphetools
+print(f"Using pyphetools version {pyphetools.__version__}")
+```
+
+Import the [Human Phenotype Ontology (HPO)](https://hpo.jax.org/app/) hp.json file. Note that here we show code that assumes that the file is available in the encloding directory. Update the ORCID identifier to your own [ORCID](https://orcid.org/){:target="_blank"}  id.
+
+```python
+parser = HpoParser(hpo_json_file="../hp.json")
+hpo_cr = parser.get_hpo_concept_recognizer()
+hpo_version = parser.get_version()
+hpo_ontology = parser.get_ontology()
+created_by="ORCID:0000-0002-0736-9199"
+print(f"HPO version {hpo_version}")
+```
+
+import the template file and show the contents (Adjust the head command to show as many lines as needed to check the data).
+
+```
+df = pd.read_excel("input/ATP13A2_Kufor_Rakeb_individuals.xlsx")
+df.head(2)
+```
+Create the encoder.
+
+```
+encoder = CaseTemplateEncoder(df=df, hpo_cr=hpo_cr, created_by=created_by)
+individuals = encoder.get_individuals()
+```
+Create the VariantManager object. Supply it with the corresponding gene symbol and transcript. If the disease being curated displays monoallelic variants (e.g., autosomal dominant), omit the  ``allele_2_column_name`` argument.
+
+
+```python
+vmanager = VariantManager(df=df,
+                          individual_column_name="individual_id",
+                          gene_symbol="ATP13A2",
+                          transcript="NM_022089.4",
+                          allele_1_column_name="allele_1",
+                          allele_2_column_name="allele_2")
+```
+
+Check results of variant encoding.
+```python
+vmanager.to_summary()
+```
+This will show (correctly) mapped as well as unmapped variants. If an HGVS variant is unmapped, it will need to be corrected. See  [variant notation](variant_notation.md) for suggestions. If the variants are chromosomal, there are special functions that are used. See the corresponding [pyphetools](https://monarch-initiative.github.io/pyphetools/){:target="_blank"} documentation.
+
+
+Add the variants to the individual objects.
+
+```python
+vmanager.add_variants_to_individuals(individuals)
+```
+
+Perform quality control. The QC will show errors that need to be corrected as well as warnings that are for information only.
+
+```python
+cvalidator = CohortValidator(cohort=individuals, ontology=hpo_ontology, min_hpo=1,
+                                allelic_requirement=AllelicRequirement.BI_ALLELIC)
+qc = QcVisualizer(cohort_validator=cvalidator)
+display(HTML(qc.to_summary_html()))
+```
+
+Display summary of phenopackets
+```python
+individuals = cvalidator.get_error_free_individual_list()
+table = IndividualTable(individuals)
+display(HTML(table.to_html()))
+```
+Output the phenopackets to file.
+```python
+encoder.output_individuals_as_phenopackets(individual_list=individuals)
+```
+
+See the [pyphetools documentation](https://monarch-initiative.github.io/pyphetools/developers/hpoa_editing/){:target="_blank"} for information about exporting HPOA files from collections of phenoapckets.
\ No newline at end of file
diff --git a/docs/user-guide/simple_column_mapper.md b/docs/user-guide/simple_column_mapper.md
index b733cf94..37f72aec 100644
--- a/docs/user-guide/simple_column_mapper.md
+++ b/docs/user-guide/simple_column_mapper.md
@@ -15,7 +15,9 @@ not be included in the phenopacket).
 
 
 ```python title="SimpleColumnMapper constructor"
-  ddMapper = SimpleColumnMapper(hpo_id='HP:0001263',
+from pyphetools.creation import SimpleColumnMapper
+
+ddMapper = SimpleColumnMapper(hpo_id='HP:0001263',
     hpo_label='Global developmental delay',
     observed='yes',
     excluded='no')
@@ -25,25 +27,30 @@ not be included in the phenopacket).
 
 It can be convenient to add multiple SimpleColumnMappers at the same time. The following function enables this. Note that the `HpoParser` object from the pyphetools.creation package is used to create a concept recognizer object. The `column_d` dictionary is used to store the individual mappers, and will be passed later on to a `CohortMapper` object.
 
+```python title="Creating multiple SimpleColumnMapper objects at once"
+import hpotk
 
+onto_store = hpotk.configure_ontology_store()
+hpo = onto_store.load_hpo()
+
+from pyphetools.creation import HpoExactConceptRecognizer
+
+hpo_cr = HpoExactConceptRecognizer.from_hpo(hpo)
 
-```python title="Creating multiple SimpleColumnMapper objects at once"
-parser = HpoParser()
-hpo_cr = parser.get_hpo_concept_recognizer()
 ##
 column_mapper_d = {}
 items = {
-    'regression': ["Developmental regression","HP:0002376"],
+    'regression': ["Developmental regression", "HP:0002376"],
     'autism': ['Autism', 'HP:0000717'],
     'hypotonia': ['Hypotonia', 'HP:0001252'],
     'movement disorder': ['Abnormality of movement', 'HP:0100022'],
     'CVI': ['Cerebral visual impairment', 'HP:0100704'],
-    'seizures': ['Seizure','HP:0001250'],
+    'seizures': ['Seizure', 'HP:0001250'],
     'DD': ['Global developmental delay', 'HP:0001263']
 }
 item_column_mapper_d = hpo_cr.initialize_simple_column_maps(column_name_to_hpo_label_map=items,
-                            observed='yes',
-                            excluded='no')
+                                                            observed='yes',
+                                                            excluded='no')
 # Transfer to column_mapper_d
 for k, v in item_column_mapper_d.items():
     column_mapper_d[k] = v
diff --git a/docs/user-guide/template.md b/docs/user-guide/template.md
index 9eb02ada..1855d180 100644
--- a/docs/user-guide/template.md
+++ b/docs/user-guide/template.md
@@ -5,4 +5,4 @@ typical supplementary materials of publications about cohorts. This option, whic
 with skills in scripting with Python. Additionally, pyphetools can ingest data encoded in an Excel template that can be used without additional scripting.
 The template can be ingested using a standardized notebook. Alternatively, users are invited to work with the HPO team to enter the data into the HPO database.
 
-See [here](https://monarch-initiative.github.io/pyphetools/developers/hpoa_editing/) for instructions on using the template.
\ No newline at end of file
+See [here](https://monarch-initiative.github.io/phenopacket-store/contributing/){:target="\_blank"} for instructions on using the template.
\ No newline at end of file
diff --git a/docs/user-guide/tips_for_curation.md b/docs/user-guide/tips_for_curation.md
new file mode 100644
index 00000000..95234c54
--- /dev/null
+++ b/docs/user-guide/tips_for_curation.md
@@ -0,0 +1,55 @@
+# Tips for accurate curation
+
+This document explains the intended use of the fields of the template and provides guidance on common problems.
+
+### PMID:
+Please use the form ``PMID:38143450``. Please remove the space, if any, between the colon and the number.
+
+### Indentifier:
+
+Please use the identifier used in the original publication. If the original publication has a Table, it is usually obvious what identifier to use. In other cases, no explicit identifier is given. Try to extract a short identifier that will be obvious to others who might look at your curation later on. For instance, if the paper is a case report that states this:
+
+!!! example
+
+    Here we present the case of a 28-year-old man with X-linked immunodeficiency with magnesium defect, Epstein–Barr virus (EBV) infection and neoplasia (XMEN) disease.
+
+Then use “28-year-old man” as the identifier (see [PMID:38143450](https://pubmed.ncbi.nlm.nih.gov/38143450/)).
+
+### Disease id and name
+
+Please use the OMIM identifier. This can be taken from the OMIM webpage for the disease. For instance, [OMIM:300853](https://omim.org/entry/300853) shows Phenotype MIM number 300853. Please enter this as ``OMIM:300853``. The ``Phenotype`` (i.e., disease) is shown in the table as ``Immunodeficiency, X-linked, with magnesium defect, Epstein-Barr virus infection and neoplasia``. Please use this label. This will make it much easier for software to correctly identify the disease that is being curated.
+
+### Comment
+Sometimes it is hard to curate information and it is a good idea to enter some additional information about the case to help others understand the curation. Please use the ``comment`` field for this. The following box shows an example of the kind of comment that might be useful.
+
+!!! example
+
+    incl Table 1 with many neurological symptoms in these two patients + Table 2 with neurological symptoms in pther XMEN patients
+
+### Transcript
+
+It can be difficult to choose the optimal transcript, and articles about one and the same disease may even use different transcripts. We try to use the transcript that is preferred by ClinVar. See [Varint notation](variant_notation.md) for explanations.
+
+We recommend using [VariantValidator](https://variantvalidator.org/service/validate/) to check the variant identifiers used in original publications.
+
+Note that incorrect variant nomenclature is still common in the literature. For instance, ``c.C414A`` should be ``c.414C>A``.
+
+# HPO Term columns
+
+Please use only HPO terms for these columns. Do not use terms from other ontologies or terminologies such as OMIM, ORPHA, or other sources.
+
+
+Please use exactly the original HPO term label, e.g., do not use the words they used in the original publication, instead use the label found on the HPO website
+
+For instance, if the original publication states ``recurrent bronchopulmonary infection``, go to the HPO website to determine the corresponding HPO term, which in this case is [Recurrent bronchopulmonary infections](https://hpo.jax.org/app/browse/term/HP:0006538){:target="_blank"}. If ``hypogammaglobulinemia`` is used in the original publication, choose [Decreased circulating antibody level](https://hpo.jax.org/app/browse/term/HP:0004313){:target="_blank"}.
+
+This will make the curation unambiguous and reduce potential errors.
+
+### New terms
+
+The HPO is still incomplete. If you find a phenotypic abnormality in a publication but cannot find a corresponding HPO term, enter the label for
+the term in the first row and ``NTR`` (new term request) in the second row. The HPO team will help to decide if there is an existing term for the abnormality or will create a new term. Following this, the Excel file can be updated.
+
+### Scope
+
+The HPO describes phenotypic abnormalities in a clinical context. It does not cover topics such as molecular mechanisms. Terms such as “loss-of function” are not in scope.
diff --git a/docs/user-guide/tutorial.md b/docs/user-guide/tutorial.md
deleted file mode 100644
index 4cbadcd3..00000000
--- a/docs/user-guide/tutorial.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# Tutorial
-
-
-The goal of the pyphetools library is to provide a software framework for transforming tabular
-data about cohorts with rare of common disease into a collection
-of [GA4GH phenopackets](https://phenopacket-schema.readthedocs.io/en/latest/){:target="\_blank"}. We have applied to library
-to extract phenopackets from tables provided as supplemental files of publications describing cohorts of individuals with a rare disease
-(Excel, tab or comma-separated value files, and even files copied into a spreadsheet application from the original PDF file of the article).
-Additionally, an Excel template can be used to enter data about collections of case reports. pyphetools can also be used as a Python software library,
-and detailed API documentation is provided.
-
-- [Excel template](template.md)
-- [Jupyter notebook](jupyter.md)
-- [API](../api/overview.md)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/user-guide/variant_notation.md b/docs/user-guide/variant_notation.md
new file mode 100644
index 00000000..85a5e62a
--- /dev/null
+++ b/docs/user-guide/variant_notation.md
@@ -0,0 +1,32 @@
+# Variant Notation
+
+We recommend that users choose one transcript for all HGVS variant descriptions in a project. In general, the most clinicallz relevant transcript should be chosen.
+
+
+### Choosing the reference transcript for a project
+
+An easy way to identify the optimal transcript is to search with the gene symbol in [ClinVar](https://www.ncbi.nlm.nih.gov/clinvar/){:target="_blank"}. For instance, searching on [FBN2](https://www.ncbi.nlm.nih.gov/clinvar/?term=FBN2%5Bgene%5D&redir=gene){:target="_blank"} reveals a page with variants such as this: **NM_001999.4(FBN2):c.8729A>G (p.Gln2910Arg)** (Note that you may need to skip over more complex structural variants that include the FBN2 and other genes to get to this entry). The transcript **NM_001999.4** is the one to use.
+
+In general, we recommend using the MANE Select (or if available MANE Select Clinical).
+This can be found using the [Ensembl](https://www.ensembl.org/Homo_sapiens/Gene/Summary?db=core;g=ENSG00000138829;r=5:128257909-128659185) website for the gene in quenstion (here we see that the MANE Select transcript for FBN2 is ENST00000262464.9, which is equivalent to NM_001999.4). MANE refers to [MANE (Matched Annotation from NCBI and EBI)](https://www.ncbi.nlm.nih.gov/refseq/MANE/){:target="_blank"}
+
+Rarely, one transcript alone is not enough to report all clinically relevant variation. We reocmmend consulting the MANE Plus Clinical set, which includes additional transcripts for genes where MANE Select alone is not sufficient to report all "Pathogenic (P)" or "Likely Pathogenic (LP)" clinical variants available in public resources.
+
+### Checking and converting variants
+
+Older literature often uses out-of-date HGVS notation and may use transcripts other than MANE select or the transcript used in ClinVar.
+
+
+For example, [Abbas MM, et al. 2016](https://pubmed.ncbi.nlm.nih.gov/30713959/){:target="_blank"} describe a "A missense mutation c.T2525C:p.L842P in ATP13A2 (Ensemble transcript ID ENST00000341676)". There are two difficulties here. Firstly, the notation "cT2525C" is not correct [HGVS Nomenclature](https://hgvs-nomenclature.org/stable/){:target="_blank"} and should be "c.2525T>C". Secondly, the transcript used is not the desired one. By consulting the Ensembl webpage, we determined that ENST00000341676 is equivalent to NM_001141974.2.
+
+There are various ways to check and convert notation.
+
+- [Mutalyzer](https://mutalyzer.nl/){:target="_blank"}: Has tools to check notation and also to convert between transcript references
+- [VariantValidator](https://variantvalidator.org/){:target="_blank"}: Has tools to check HGVS nomenclature and to compare differnet transcript references.
+
+In this case, using VariantValidator, we determined that the correct notation is **NM_022089.4:c.2657T>C**. This can be difficult and please reach out to us in case of questions.
+
+
+
+
+We can additionally check the transcript by going to the [VariantValidator](https://variantvalidator.org/){:target="_blank"} website and testing the HGVS nucleotide variant (i.e., NM_001999.4(FBN2):c.8729A>G) using the  **Validator** tool (paste the variant in the *Variant Description:* window). If we paste the resulting VCF-like notation GRCh38:5:128259465:T:C
\ No newline at end of file
diff --git a/mkdocs.yml b/mkdocs.yml
index 96c19526..a6dfccb2 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -3,7 +3,7 @@ site_name: pyphetools
 theme:
   name: "material"
   features:
-    #- navigation.tabs
+    - navigation.tabs
     - navigation.sections
     - toc.integrate
     - navigation.top
@@ -31,15 +31,19 @@ theme:
 
 nav:
   - Home: 'index.md'
-  - Installation: 'installation.md'
+  - Data Entry Template:
+    - 'user-guide/template.md'
+    - 'user-guide/excel.md'
+    - 'user-guide/python_notebook.md'
+    - 'user-guide/tips_for_curation.md'
+    - 'user-guide/variant_notation.md'
   - Developers:
-    - For developers: 'developers.md'
+    - For developers: 'developers/developers.md'
+    - Installation: 'developers/installation.md'
     - HPOA files: 'developers/hpoa_editing.md'
-  - User guide:
-    - Introduction: 'user-guide/tutorial.md'
-    - Data Entry Template: 'user-guide/template.md'
-    - Jupyter notebook:
-      - overview: "user-guide/jupyter.md"
+    - Internal: 'developers/internal.md'
+    - Custom Python scripts:
+      - Overview: 'user-guide/jupyter.md'
       - Column mappers:
         - Choosing a column mapper: 'user-guide/choosing_column_mapper.md'
         - Simple column mapper: 'user-guide/simple_column_mapper.md'
@@ -51,9 +55,9 @@ nav:
       - Validation: 'user-guide/validation.md'
       - Visualization: 'user-guide/visualization.md'
   - API:
-    - "overview": 'api/overview.md'
-    - "creation":
-      - overview: "api/creation.md"
+    - Overview: 'api/overview.md'
+    - creation:
+      - overview: 'api/creation.md'
       - AgeColumnMapper: "api/creation/age_column_mapper.md"
       - CaseEncoder: "api/creation/case_encoder.md"
       - CaseTemplateEncoder: "api/creation/case_template_encoder.md"
@@ -77,20 +81,21 @@ nav:
       - SimpleColumnMapper: "api/creation/simple_column_mapper.md"
       - SimpleColumnMapperGenerator: "api/creation/simple_column_mapper_generator.md"
       - StructuralVariant: "api/creation/structural_variant.md"
+      - TemplateCreator: "api/creation/create_template.md"
       - Thresholder: "api/creation/thresholder.md"
       - ThresholdedColumnMapper: "api/creation/thresholded_column_mapper.md"
       - VariantColumnMapper: "api/creation/variant_column_mapper.md"
       - Variant: "api/creation/variant.md"
       - VariantManager: "api/creation/variant_manager.md"
       - VariantValidator: "api/creation/variant_validator.md"
-    - "validation":
-      - "overview": "api/validation.md"
+    - validation:
+      - Overview: "api/validation.md"
       - "CohortValidator": "api/validation/cohort_validator.md"
       - "ContentValidator": "api/validation/content_validator.md"
       - "OntologyQC": "api/validation/ontology_qc.md"
       - "ValidationResult": "api/validation/validation_result.md"
     - visualization:
-      - overview: "api/visualization.md"
+      - Overview: "api/visualization.md"
       - DetailedSupplTable:  "api/visualization/detailed_suppl_table.md"
       - HpoaTableCreator: "api/visualization/hpoa_table_creator.md"
       - OnsetCalculator: "api/visualization/onset_calculator.md"
@@ -104,6 +109,14 @@ plugins:
   - mkdocstrings:
       handlers:
         python:
+          # TODO: I am not sure if `import` takes a list of inventories. In principle, it *should* take a list,
+          # but I was not able to find an example that takes multiple inventories. Investigate..
+          import:
+            - https://docs.python.org/3/objects.inv
+            - https://thejacksonlaboratory.github.io/hpo-toolkit/stable/objects.inv
+            - https://pandas.pydata.org/docs/objects.inv
+            - https://matplotlib.org/stable/objects.inv
+            - https://docs.python-requests.org/en/stable/objects.inv
           options:
             docstring_style: sphinx
 
@@ -112,8 +125,6 @@ extra:
   social:
     - icon: fontawesome/brands/github-alt
       link: https://github.com/pnrobinson
-    - icon: fontawesome/brands/twitter
-      link: https://twitter.com/pnrobins
     - icon: fontawesome/brands/linkedin
       link: https://www.linkedin.com/in/peter-n-robinson-b7833811/
 
@@ -134,9 +145,9 @@ markdown_extensions:
   - md_in_html
   - tables
   - pymdownx.emoji:
-      emoji_index: !!python/name:materialx.emoji.twemoji
+      emoji_index: !!python/name:material.extensions.emoji.twemoji
       emoji_generator: !!python/name:materialx.emoji.to_svg
 
 copyright: |
-  &copy; 2023 <a href="https://github.com/pnrobinson"  target="_blank" rel="noopener">Peter N Robinson</a>
+  &copy; 2023-2024 <a href="https://github.com/pnrobinson"  target="_blank" rel="noopener">Peter N Robinson</a>
 
diff --git a/pyproject.toml b/pyproject.toml
index 2e32b586..85766a4f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,12 +6,12 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "pyphetools"
-# version in __init__
 requires-python = ">=3.8"
 description = "Generate and work with GA4GH phenopackets"
 readme = "README.md"
 authors = [
-    {name = "Peter Robinson", email="peter.robinson@jax.org"},
+    {name = "Peter Robinson", email="peter.robinson@bih-charite.de"},
+    {name = "Daniel Danis", email="daniel.gordon.danis@protonmail.com"},
 ]
 license = { file = "LICENSE" }
 classifiers = [
@@ -26,18 +26,28 @@ keywords = [
     "Human Phenotype Ontology",
     "GA4GH",
     "HPO"]
+
+# version in __init__
+dynamic = ["version"]
+
 dependencies = [
-    "hpo-toolkit>=0.3.0",
-    "matplotlib",
-    "openpyxl",
-    "pandas",
-    "phenopackets",
-    "protobuf",
-    "requests"
+    "hpo-toolkit>=0.5.0,<0.6.0",
+    "matplotlib>=3.2.0,<4.0",
+    "pandas[excel]>=2.0.0,<3.0.0",  # We want to parse Excel files.
+    "phenopackets>=2.0.2",
+    "protobuf>=3.15.0,<4.0.0",
+    "requests>=2.25.0,<3.0",
 ]
-dynamic = ["version"]
+
 [project.optional-dependencies]
-test = ["pytest"]
+test = ["pytest>=7.0.0,<8.0.0"]
+docs = [
+    'mkdocs-material[imaging]>=9.5.10,<10',
+    'mkdocs-material-extensions>=1.3,<2.0',
+    'mkdocstrings[python]>=0.22,<1.0',
+    'pillow',
+    'cairosvg',
+]
 
 
 [project.urls]
@@ -47,20 +57,17 @@ documentation = "https://github.com/monarch-initiative/pyphetools"
 bugtracker = "https://github.com/monarch-initiative/pyphetools/issues"
 
 
-
-
 [tool.setuptools]
-package-dir = { "" = "." }
+package-dir = { "" = "src" }
 zip-safe = false
+
 [tool.setuptools.dynamic]
 version = { attr = "pyphetools.__version__" }
-[tool.setuptools.packages.find]
-where = ["src"]
-exclude = ["notebooks", "test"]
-[tool.setuptools.package-data]
-pyphetools = ["creation/data/thresholds.tsv"]
 
 
+# See MANIFEST.in for the list of the data files
+# being included in the package.
+
 
 [tool.yapf]
 blank_line_before_nested_class_or_def = true
diff --git a/src/pyphetools/__init__.py b/src/pyphetools/__init__.py
index f4334635..3518fbe7 100644
--- a/src/pyphetools/__init__.py
+++ b/src/pyphetools/__init__.py
@@ -3,7 +3,7 @@
 from . import visualization
 from . import validation
 
-__version__ = "0.9.51"
+__version__ = "0.9.62"
 
 __all__ = [
     "creation",
diff --git a/src/pyphetools/creation/__init__.py b/src/pyphetools/creation/__init__.py
index 081e560a..1b1e8299 100644
--- a/src/pyphetools/creation/__init__.py
+++ b/src/pyphetools/creation/__init__.py
@@ -8,6 +8,7 @@
 from .cohort_encoder import CohortEncoder
 from .column_mapper import ColumnMapper
 from .constant_column_mapper import ConstantColumnMapper
+from .create_template import TemplateCreator
 from .disease import Disease
 from .disease_id_column_mapper import DiseaseIdColumnMapper
 from .hgvs_variant import HgvsVariant
@@ -15,6 +16,7 @@
 from .hpo_exact_cr import HpoExactConceptRecognizer
 from .hpo_parser import HpoParser
 from .hp_term import HpTerm, HpTermBuilder
+from .import_template import TemplateImporter
 from .individual import Individual
 from .metadata import MetaData
 from .mixed_cohort_encoder import MixedCohortEncoder
@@ -61,6 +63,8 @@
     "SimpleColumnMapper",
     "SimpleColumnMapperGenerator",
     "StructuralVariant",
+    "TemplateCreator",
+    "TemplateImporter",
     "ThresholdedColumnMapper",
     "Thresholder",
     "Variant",
diff --git a/src/pyphetools/creation/age_column_mapper.py b/src/pyphetools/creation/age_column_mapper.py
index 489e0d6d..781e00df 100644
--- a/src/pyphetools/creation/age_column_mapper.py
+++ b/src/pyphetools/creation/age_column_mapper.py
@@ -266,7 +266,7 @@ def __init__(self, column_name:str) -> None:
     def map_cell(self, cell_contents) -> str:
         contents = self._clean_contents(cell_contents=cell_contents)
         if contents in HPO_ONSET_TERMS:
-            return HpoAge(age_string=contents)
+            return HpoAge(hpo_onset_label=contents)
         else:
             self._erroneous_input_counter[contents] += 1
             return NoneAge(cell_contents)
\ No newline at end of file
diff --git a/src/pyphetools/creation/case_encoder.py b/src/pyphetools/creation/case_encoder.py
index d0567ca6..92300bac 100644
--- a/src/pyphetools/creation/case_encoder.py
+++ b/src/pyphetools/creation/case_encoder.py
@@ -73,9 +73,7 @@ def __init__(self,
         else:
             print(f"Warning: did not recognize sex symbol \"{sex}\"")
             sex_symbol = Constants.UNKNOWN_SEX_SYMBOL
-        ontology = hpo_cr.get_hpo_ontology()
-        if ontology is None:
-            raise ValueError("ontology cannot be None")
+
         self._individual = Individual(individual_id=individual_id, sex=sex_symbol, age_of_onset=age_of_onset,
                                     age_at_last_encounter=age_at_last_exam)
         if disease is not None:
diff --git a/src/pyphetools/creation/case_template_encoder.py b/src/pyphetools/creation/case_template_encoder.py
index 1c5a86e3..6370fbdf 100644
--- a/src/pyphetools/creation/case_template_encoder.py
+++ b/src/pyphetools/creation/case_template_encoder.py
@@ -7,10 +7,12 @@
 from pyphetools.creation.metadata import MetaData
 from pyphetools.creation.hp_term import HpTerm
 from pyphetools.creation.individual import Individual
+from pyphetools.creation.pyphetools_age import NoneAge, PyPheToolsAge
 import os
 import re
 import pandas as pd
 from google.protobuf.json_format import MessageToJson
+import hpotk
 import phenopackets as PPKt
 
 
@@ -21,7 +23,7 @@
 from enum import Enum
 
 
-CellType = Enum('Celltype', ['DATA', 'HPO', 'MISC', 'NULL'])
+CellType = Enum('Celltype', ['DATA', 'HPO', 'NTR', 'MISC', 'NULL'])
 
 class CellEncoder(metaclass=abc.ABCMeta):
     def __init__(self, name):
@@ -64,11 +66,12 @@ class HpoEncoder(CellEncoder):
     :param h2: contents of the second header line
     :type h2: str
     """
-    def __init__(self, h1:str, h2:str):
+    def __init__(self, h1:str, h2:str, ntr=False):
         super().__init__(name=h1)
         self._error = None
         self._hpo_label = None
         self._hpo_id = None
+        self._is_ntr = ntr
         if h1.endswith(" "):
             self._error = f"Error - HPO label ends with whitespace: \”{h1}\""
         elif h2.startswith("HP:") and len(h2) != 10:
@@ -79,33 +82,52 @@ def __init__(self, h1:str, h2:str):
             self._hpo_label = h1
             self._hpo_id = h2
 
-    def is_data(self):
+    def is_data(self) -> bool:
         return False
 
-    def columntype(self):
-        return CellType.HPO
+    def columntype(self) -> "CellType":
+        if self._is_ntr:
+            return CellType.NTR
+        else:
+            return CellType.HPO
+
+    def is_ntr(self) -> bool:
+        """
+        :returns: True if this is a new term request
+        :rtype: bool
+        """
+        return self._is_ntr
 
-    def needs_attention(self):
+    def needs_attention(self) -> bool:
         """
-        :returns: True iff there was a problem with this column
+        :returns: True iff there was a problem with this column or if this is a new term request
         :rtype: bool
         """
-        return self._error is not None
+        return self._is_ntr or self._error is not None
 
-    def get_error(self):
+    def get_error(self) -> str:
         return self._error
 
-    def encode(self, cell_contents):
+    def encode(self, cell_contents) -> None:
+        """
+        Parses one cell from the template. Valid entries are observed, excluded, na, and ISO8601 age strings.
+        Any other entry will lead to raising an Exception, probably the user entered something erroneous.
+        """
         cell_contents = str(cell_contents)
         if cell_contents == "observed":
             return HpTerm(hpo_id=self._hpo_id, label=self._hpo_label)
         elif cell_contents == "excluded":
             return  HpTerm(hpo_id=self._hpo_id, label=self._hpo_label, observed=False)
-        elif cell_contents.startswith("P"):
-            # iso8601 age
-            return  HpTerm(hpo_id=self._hpo_id, label=self._hpo_label, onset=cell_contents)
         elif cell_contents == "na" or cell_contents == "nan" or len(cell_contents) == 0:
             return None
+        elif len(cell_contents) > 0:
+            try:
+                onset = PyPheToolsAge.get_age(cell_contents)
+                if onset.is_valid(): # valid OSO8601 age of onset
+                    return HpTerm(hpo_id=self._hpo_id, label=self._hpo_label, onset=onset)
+            except Exception as parse_error:
+                raise ValueError(f"Could not parse HPO column cell_contents: \”{str(parse_error)}\"")
+            # if we cannot parse successfully, there is probably an error in the format. Drop down to end of function to warn user
         else:
             raise ValueError(f"Could not parse HPO column cell_contents: \”{cell_contents}\"")
 
@@ -151,18 +173,20 @@ def columntype(self):
         return CellType.NULL
 
 EXPECTED_HEADERS = {"PMID", "title", "individual_id", "comment", "disease_id", "disease_label",
-                    "transcript", "allele_1", "allele_2", "variant.comment", "age_of_onset", "age_at_last_encounter", "sex"}
+                    "HGNC_id", "gene_symbol", "transcript", "allele_1", "allele_2",
+                    "variant.comment", "age_of_onset", "age_at_last_encounter", "sex"}
 
-DATA_ITEMS = {"PMID", "title", "individual_id", "disease_id", "disease_label", "transcript",
-                            "allele_1", "allele_2",  "age_of_onset","age_at_last_encounter", "sex"}
+DATA_ITEMS = {"PMID", "title", "individual_id", "disease_id", "disease_label", "HGNC_id",
+                "gene_symbol", "transcript", "allele_1", "allele_2",  "age_of_onset",
+                "age_at_last_encounter", "sex"}
 
 # note that the allele_2 field is option
 REQUIRED_H1_FIELDS = ["PMID", "title", "individual_id",	"comment", "disease_id", "disease_label",
-                        "transcript", "allele_1", "allele_2", "variant.comment", "age_of_onset", "age_at_last_encounter", "sex", "HPO"]
+                    "HGNC_id", "gene_symbol", "transcript", "allele_1", "allele_2", "variant.comment", "age_of_onset", "age_at_last_encounter", "sex", "HPO"]
 ALLELE_2_IDX = 8
 
-REQUIRED_H2_FIELDS = ["str", "str",	"str", "optional", "str", "str", "str",	"str", "optional",
-                        "str", "age", "age", "M:F:O:U", "na"]
+REQUIRED_H2_FIELDS = ["CURIE", "str",	"str", "optional", "CURIE", "str", "CURIE",	"str",
+                    "str","str","str","optional",  "age", "age", "M:F:O:U", "na"]
 
 class CaseTemplateEncoder:
     """Class to encode data from user-provided Excel template.
@@ -177,13 +201,14 @@ class CaseTemplateEncoder:
 
     HPO_VERSION = None
 
-    def __init__(self, df:pd.DataFrame, hpo_cr:HpoConceptRecognizer, created_by:str) -> None:
+    def __init__(self, df:pd.DataFrame, hpo_cr:HpoConceptRecognizer, created_by:str, hpo_ontology:hpotk.MinimalOntology) -> None:
         """constructor
         """
         if not isinstance(df, pd.DataFrame):
             raise ValueError(f"argument \"df\" must be pandas DataFrame but was {type(df)}")
         self._individuals = []
         self._errors = []
+        self._ntr_set = set()
         header_1 = df.columns.values.tolist()
         header_2 = df.loc[0, :].values.tolist()
         if len(header_1) != len(header_2):
@@ -198,8 +223,9 @@ def __init__(self, df:pd.DataFrame, hpo_cr:HpoConceptRecognizer, created_by:str)
                 raise ValueError(f"Malformed header 1 field at index {idx}. Expected \"{REQUIRED_H1_FIELDS[idx]}\" but got \"{header_1[idx]}\"")
             if header_2[idx] != REQUIRED_H2_FIELDS[idx]:
                 raise ValueError(f"Malformed header 2 field at index {idx}. Expected \"{REQUIRED_H2_FIELDS[idx]}\" but got \"{header_2[idx]}\"")
+        self._header_fields_1 = header_1
         self._n_columns = len(header_1)
-        self._index_to_decoder = self._process_header(header_1=header_1, header_2=header_2,hpo_cr=hpo_cr)
+        self._index_to_decoder = self._process_header(header_1=header_1, header_2=header_2, hpo_cr=hpo_cr)
         data_df = df.iloc[1:]
         self._is_biallelic = "allele_2" in header_1
         self._allele1_d = {}
@@ -210,7 +236,7 @@ def __init__(self, df:pd.DataFrame, hpo_cr:HpoConceptRecognizer, created_by:str)
             self._allele1_d[individual.id] = row["allele_1"]
             if self._is_biallelic:
                 self._allele2_d[individual.id] = row["allele_2"]
-        CaseTemplateEncoder.HPO_VERSION = hpo_cr.get_hpo_ontology().version
+        CaseTemplateEncoder.HPO_VERSION = hpo_ontology.version
         self._created_by = created_by
         self._metadata_d = {}
         for i in self._individuals:
@@ -219,7 +245,7 @@ def __init__(self, df:pd.DataFrame, hpo_cr:HpoConceptRecognizer, created_by:str)
             metadata.default_versions_with_hpo(CaseTemplateEncoder.HPO_VERSION)
             self._metadata_d[i.id] = metadata
 
-    def  _process_header(self, header_1:List, header_2:List, hpo_cr:HpoConceptRecognizer, ) -> Dict[int, CellEncoder]:
+    def  _process_header(self, header_1:List, header_2:List, hpo_cr:HpoConceptRecognizer) -> Dict[int, CellEncoder]:
         index_to_decoder_d = {}
         in_hpo_range = False
         for i in range(self._n_columns):
@@ -237,8 +263,12 @@ def  _process_header(self, header_1:List, header_2:List, hpo_cr:HpoConceptRecogn
                 else:
                     raise ValueError(f"Malformed template header at column {i}: \"{h1}\"")
             elif in_hpo_range:
-                encoder = HpoEncoder(h1=h1, h2=h2)
-                if encoder.needs_attention():
+                ntr =  h2 == "NTR"
+                encoder = HpoEncoder(h1=h1, h2=h2, ntr=ntr)
+                if ntr:
+                    self._ntr_set.add(h1)
+                    index_to_decoder_d[i] = encoder
+                elif encoder.needs_attention():
                     self._errors.append(encoder.get_error())
                     index_to_decoder_d[i] = NullEncoder()
                 else:
@@ -246,6 +276,10 @@ def  _process_header(self, header_1:List, header_2:List, hpo_cr:HpoConceptRecogn
         if not in_hpo_range:
             raise ValueError("Did not find HPO boundary column")
         print(f"Created encoders for {len(index_to_decoder_d)} fields")
+        if len(self._ntr_set) > 0:
+            print("[WARNING] Template contains new term requests (NTR). These columns will be ignored until they are replaced with HPO terms")
+            for ntr in self._ntr_set:
+                print("\tNTR: ", ntr)
         if len(self._errors) > 0:
             for e in self._errors:
                 print(f"ERROR: {e}")
@@ -264,16 +298,23 @@ def _parse_individual(self, row:pd.Series):
             encoder = self._index_to_decoder.get(i)
             cell_contents = data[i]
             if encoder is None:
-                print(f"Encoder {i} was None for data {cell_contents}")
-                print(row)
-                raise ValueError(f"Encoder {i} was None for data {cell_contents}")
+                print(f"Encoder {i} was None for data \"{cell_contents}\"")
+                self._debug_row(i, row)
+                raise ValueError(f"Encoder {i} was None for data \"{cell_contents}\"")
+            elif encoder.columntype == CellType.NTR:
+                continue ## cannot be use yet because new term request.
             encoder_type = encoder.columntype()
             if encoder_type == CellType.DATA and encoder.name in DATA_ITEMS:
                 data_items[encoder.name] = encoder.encode(cell_contents)
             elif encoder_type == CellType.HPO:
-                hpoterm = encoder.encode(cell_contents)
-                if hpoterm is not None:
-                    hpo_terms.append(hpoterm)
+                try:
+                    hpoterm = encoder.encode(cell_contents)
+                    if hpoterm is not None:
+                        hpo_terms.append(hpoterm)
+                except Exception as hpo_parse_exception:
+                    errr = f"Could not parse contents of HPO column {encoder.name}: {cell_contents} because of {str(hpo_parse_exception)}"
+                    print(errr)
+                    raise ValueError(errr)
             elif encoder_type == CellType.MISC:
                 term_list = encoder.encode(cell_contents=cell_contents)
                 for trm in term_list:
@@ -303,14 +344,14 @@ def _parse_individual(self, row:pd.Series):
             raise ValueError(f"Unrecognized sex symbol: {sex}")
         onset_age = data_items.get(AGE_OF_ONSET_FIELDNAME)
         if onset_age is not None and isinstance(onset_age, str):
-            onset_age = onset_age
+            onset_age = PyPheToolsAge.get_age(onset_age)
         else:
-            onset_age = Constants.NOT_PROVIDED
+            onset_age = NoneAge("na")
         encounter_age = data_items.get(AGE_AT_LAST_ENCOUNTER_FIELDNAME)
         if encounter_age is not None and isinstance(encounter_age, str):
-            encounter_age = encounter_age
+            encounter_age = PyPheToolsAge.get_age(encounter_age)
         else:
-            encounter_age = Constants.NOT_PROVIDED
+            encounter_age = NoneAge("na")
         disease_id = data_items.get("disease_id")
         disease_label = data_items.get("disease_label")
         disease = Disease(disease_id=disease_id, disease_label=disease_label)
@@ -322,6 +363,15 @@ def _parse_individual(self, row:pd.Series):
                             age_at_last_encounter=encounter_age,
                             disease=disease)
 
+    def _debug_row(self, target_idx:int, row:pd.Series):
+        row_items = list(row)
+        for j in range(len(row_items)):
+            hdr = self._header_fields_1[j]
+            if j == target_idx:
+                print(f"[{j}] *** {hdr}={row_items[j]}  ***")
+            else:
+                print(f"[{j}] {hdr}={row_items[j]}")
+
     def get_individuals(self) -> List[Individual]:
         return self._individuals
 
@@ -370,7 +420,6 @@ def _transform_individuals_to_phenopackets(self, individual_list:List[Individual
             ppkt_list.append(phenopckt)
         return ppkt_list
 
-
     def output_individuals_as_phenopackets(self, individual_list:List[Individual], outdir="phenopackets") -> None:
         """write a list of Individual objects to file in GA4GH Phenopacket format
         Note that the individual_list needs to be passed to this object, because we expect that
diff --git a/src/pyphetools/creation/cohort_encoder.py b/src/pyphetools/creation/cohort_encoder.py
index e23fd257..e26ac47a 100644
--- a/src/pyphetools/creation/cohort_encoder.py
+++ b/src/pyphetools/creation/cohort_encoder.py
@@ -9,6 +9,7 @@
 from .disease import Disease
 from .hpo_cr import HpoConceptRecognizer
 from .individual import Individual
+from .pyphetools_age import NoneAge
 from .sex_column_mapper import SexColumnMapper
 from .variant_column_mapper import VariantColumnMapper
 
@@ -26,7 +27,7 @@ class CohortEncoder(AbstractEncoder):
     cases, it is preferable to use the variant_dictionary, which has key=string (cell contents) and value=Hgvs or
     StructuralVariant object.
 
-    :param df: tabular data abotu a cohort
+    :param df: tabular data about a cohort
     :type df: pd.DataFrame
     :param hpo_cr: HpoConceptRecognizer for text mining
     :type hpo_cr: pyphetools.creation.HpoConceptRecognizer
@@ -36,8 +37,10 @@ class CohortEncoder(AbstractEncoder):
     :type individual_column_name: str
     :param metadata: GA4GH MetaData object
     :type metadata: PPkt.MetaData
-    :param agemapper:Mapper for the Age column. Defaults to AgeColumnMapper.not_provided()
-    :type agemapper: pyphetools.creation.AgeColumnMapper
+    :param age_of_onset_mapper:Mapper for the Age of onset column. Defaults to AgeColumnMapper.not_provided()
+    :type age_of_onset_mapper: pyphetools.creation.AgeColumnMapper
+    :param age_at_last_encounter_mapper:Mapper for the Age at last clinical encounter column. Defaults to AgeColumnMapper.not_provided()
+    :type age_at_last_encounter_mapper: pyphetools.creation.AgeColumnMapper
     :param sexmapper: Mapper for the Sex column. Defaults to SexColumnMapper.not_provided().
     :type sexmapper: pyphetools.creation.SexColumnMapper
     :param variant_mapper: column mapper for HGVS-encoded variant column.
@@ -47,11 +50,12 @@ class CohortEncoder(AbstractEncoder):
 
     def __init__(self,
                 df,
-                hpo_cr,
+                hpo_cr: HpoConceptRecognizer,
                 column_mapper_list,
                 individual_column_name,
                 metadata,
-                agemapper=AgeColumnMapper.not_provided(),
+                age_of_onset_mapper=AgeColumnMapper.not_provided(),
+                age_at_last_encounter_mapper=AgeColumnMapper.not_provided(),
                 sexmapper=SexColumnMapper.not_provided(),
                 variant_mapper=None,
                 delimiter=None):
@@ -73,15 +77,13 @@ def __init__(self,
         self._df = df.astype(str)
         self._column_mapper_list = column_mapper_list
         self._id_column_name = individual_column_name
-        self._age_mapper = agemapper
+        self._age_of_onset_mapper = age_of_onset_mapper
+        self._age_at_last_encounter_mapper = age_at_last_encounter_mapper
         self._sex_mapper = sexmapper
         self._disease = None
         self._variant_mapper = variant_mapper
         self._disease_dictionary = None
         self._delimiter = delimiter
-        ontology = hpo_cr.get_hpo_ontology()
-        if ontology is None:
-            raise ValueError("ontology cannot be None")
 
     def preview_dataframe(self):
         """
@@ -92,7 +94,7 @@ def preview_dataframe(self):
         """
         df = self._df.reset_index()  # make sure indexes pair with number of rows
         individuals = []
-        age_column_name = self._age_mapper.get_column_name()
+        age_column_name = self._age_of_onset_mapper.get_column_name()
         sex_column_name = self._sex_mapper.get_column_name()
         for index, row in df.iterrows():
             individual_id = row[self._id_column_name]
@@ -140,6 +142,21 @@ def set_disease_dictionary(self, disease_d:Dict[str, Disease]):
         self._disease_dictionary = disease_d
         self._disease = None
 
+
+    def _get_age(row:pd.Series, mapper:AgeColumnMapper):
+        column_name = mapper.get_column_name()
+        if column_name == Constants.NOT_PROVIDED:
+            return NoneAge("na")
+        else:
+            age_cell_contents = row[column_name]
+            try:
+                age = mapper.map_cell(age_cell_contents)
+            except Exception as ee:
+                print(f"Warning: Could not parse age {ee}. Setting age to \"not provided\"")
+                age = NoneAge("na")
+            return age
+
+
     def get_individuals(self) -> List[Individual]:
         """Get a list of all Individual objects in the cohort
 
@@ -150,7 +167,8 @@ def get_individuals(self) -> List[Individual]:
         if not self._df.index.name in self._df.columns:
             df = self._df.reset_index()
         individuals = []
-        age_column_name = self._age_mapper.get_column_name()
+        age_onset_column_name = self._age_of_onset_mapper.get_column_name()
+        age_last_encounter_column_name = self._age_at_last_encounter_mapper.get_column_name()
         sex_column_name = self._sex_mapper.get_column_name()
         if self._variant_mapper is None:
             variant_colname = None
@@ -160,15 +178,8 @@ def get_individuals(self) -> List[Individual]:
             genotype_colname = self._variant_mapper.get_genotype_colname()
         for index, row in df.iterrows():
             individual_id = row[self._id_column_name]
-            if age_column_name == Constants.NOT_PROVIDED:
-                age = Constants.NOT_PROVIDED
-            else:
-                age_cell_contents = row[age_column_name]
-                try:
-                    age = self._age_mapper.map_cell(age_cell_contents)
-                except Exception as ee:
-                    print(f"Warning: Could not parse age {ee}. Setting age to \"not provided\"")
-                    age = Constants.NOT_PROVIDED
+            age_of_onset = CohortEncoder._get_age(row, self._age_of_onset_mapper)
+            age_last_encounter = CohortEncoder._get_age(row, self._age_at_last_encounter_mapper)
             if sex_column_name == Constants.NOT_PROVIDED:
                 sex = self._sex_mapper.map_cell(Constants.NOT_PROVIDED)
             else:
@@ -203,7 +214,8 @@ def get_individuals(self) -> List[Individual]:
                 disease = self._disease_dictionary.get(individual_id)
                 indi = Individual(individual_id=individual_id,
                                 sex=sex,
-                                age_of_onset=age,
+                                age_of_onset=age_of_onset,
+                                age_at_last_encounter=age_last_encounter,
                                 hpo_terms=hpo_terms,
                                 citation=self._metadata.get_citation(),
                                 interpretation_list=interpretation_list,
@@ -211,7 +223,8 @@ def get_individuals(self) -> List[Individual]:
             elif self._disease_dictionary is None and self._disease is not None:
                 indi = Individual(individual_id=individual_id,
                                 sex=sex,
-                                age_of_onset=age,
+                                age_of_onset=age_of_onset,
+                                age_at_last_encounter=age_last_encounter,
                                 hpo_terms=hpo_terms,
                                 citation=self._metadata.get_citation(),
                                 interpretation_list=interpretation_list,
@@ -219,8 +232,10 @@ def get_individuals(self) -> List[Individual]:
             else:
                 raise ValueError(f"Could not find disease data for '{individual_id}'")
             individuals.append(indi)
-        if self._age_mapper.has_error():
-            print(self._age_mapper.error_summary())
+        if self._age_of_onset_mapper.has_error():
+            print(self._age_of_onset_mapper.error_summary())
+        if self._age_at_last_encounter_mapper.has_error():
+            print(self._age_at_last_encounter_mapper.error_summary())
         if self._sex_mapper.has_error():
             print(self._sex_mapper.error_summary())
         return individuals
diff --git a/src/pyphetools/creation/create_template.py b/src/pyphetools/creation/create_template.py
new file mode 100644
index 00000000..d466501c
--- /dev/null
+++ b/src/pyphetools/creation/create_template.py
@@ -0,0 +1,103 @@
+import os
+import pandas as pd
+from collections import defaultdict
+from .hpo_parser  import HpoParser
+from .case_template_encoder import REQUIRED_H1_FIELDS, REQUIRED_H2_FIELDS
+
+class TemplateCreator:
+
+    def __init__(self, hp_json:str) -> None:
+        if not os.path.isfile(hp_json):
+            raise FileNotFoundError(f"Could not find hp.json file at {hp_json}")
+        parser = HpoParser(hpo_json_file=hp_json)
+        self._hpo_cr = parser.get_hpo_concept_recognizer()
+        self._hpo_ontology = parser.get_ontology()
+        self._hp_term_list = list()
+
+
+    def add_seed_terms(self, text:str) -> None:
+        """add HPO terms that will be used in the template
+        The function will use text mining to extract terms from the text argument, which
+        can have any desired format (e.g., a paragraph from a publication or
+        a list of HPO term labels, one per line, etc.)
+
+        :param text: free text that contains HPO term labels to be mined
+        :type text: str
+        """
+        hp_terms = set()
+        for line in text.split("\n"):
+            hpo_term_list = self._hpo_cr.parse_cell(line)
+            for hpt in hpo_term_list:
+                hp_terms.add(hpt)
+        ## Arrange hp_terms so that all terms that belong to a given top level term go together
+        PHENO_ROOT_TERM_ID = "HP:0000118"
+        top_level_term_ids = self._hpo_ontology.graph.get_children(PHENO_ROOT_TERM_ID, False)
+        top_level_term_ids = list(top_level_term_ids)
+        top_level_d = defaultdict(list)
+        for hpt in hp_terms:
+            found= False
+            for tlt in top_level_term_ids:
+                if self._hpo_ontology.graph.is_descendant_of(hpt.id,tlt):
+                    found = True
+                    top_level_d[tlt].append(hpt)
+                    break
+            if not found:
+                raise ValueError(f"Could not find top level ancestor of {hpt.label}")
+        # Now the terms can be arrange by top level ancestor, which will make it easier to enter
+        # in the Excel sheet
+        for tlt, hpt_list in top_level_d.items():
+            self._hp_term_list.extend(hpt_list)
+        print(f"[INFO] Add {len(self._hp_term_list)} HPO terms to template.")
+
+
+    def create_template(self, disease_id:str, disease_label:str, HGNC_id:str, gene_symbol:str, transcript:str):
+        """Create an Excel file that can be used to enter data as a pyphetools template
+
+        :param disease_id: an OMIM, MONDO, or other similar CURIE identifier
+        :param disease_label: the corresponding name
+        :param HGNC_id: HUGO Gene Nomenclattre Committee identifier, e.g., HGNC:3603
+        :param gene_symbol: corresponding gene symbol, e.g., FBN1
+        :transcript: transcript to be used for the HVGC nomenclature. Must be refseq with version number
+        """
+        H1_Headers = REQUIRED_H1_FIELDS
+        H2_Headers = REQUIRED_H2_FIELDS
+        if len(H1_Headers) != len(H2_Headers):
+            raise ValueError("Header lists must have same length")
+        EMPTY_STRING = ""
+        for hpt in self._hp_term_list:
+            H1_Headers.append(hpt.label)
+            H2_Headers.append(hpt.id)
+        df = pd.DataFrame(columns=H1_Headers)
+        new_row = dict()
+        for i in range(len(H1_Headers)):
+            new_row[H1_Headers[i]] = H2_Headers[i]
+        df.loc[len(df)] = new_row
+        # add 10 rows with the constant data columns
+        for i in range(10):
+            new_row = dict()
+            for i in range(len(H1_Headers)):
+                header_field = H1_Headers[i]
+                if header_field == "disease_id":
+                    new_row[header_field] = disease_id
+                elif header_field == "disease_label":
+                    new_row[header_field] = disease_label
+                elif header_field == "HGNC_id":
+                    new_row[header_field] = HGNC_id
+                elif header_field == "gene_symbol":
+                    new_row[header_field] = gene_symbol
+                elif header_field == "HGNC_id":
+                    new_row[header_field] = HGNC_id
+                elif header_field == "transcript":
+                    new_row[header_field] = transcript
+                elif header_field == "HPO":
+                    new_row[header_field] = "na"
+                else:
+                    new_row[header_field] = EMPTY_STRING
+            df.loc[len(df)] = new_row
+        ## Output as excel
+        fname = disease_id.replace(":", "_") + "_individuals.xlsx"
+        df.to_excel(fname, index=False)
+        print(f"Write excel pyphetools template file to {fname}")
+
+
+
diff --git a/src/pyphetools/creation/hpo_cr.py b/src/pyphetools/creation/hpo_cr.py
index 1d114362..2eef2ad5 100644
--- a/src/pyphetools/creation/hpo_cr.py
+++ b/src/pyphetools/creation/hpo_cr.py
@@ -9,9 +9,6 @@ class HpoConceptRecognizer(metaclass=abc.ABCMeta):
     This abstract class acts as an interface for classes that implement parse_cell to perform HPO-based concept recognition.
     """
 
-    def __init__(self):
-        pass
-
     @abc.abstractmethod
     def parse_cell(self, cell_contents, custom_d=None) -> List[HpTerm]:
         """
@@ -63,11 +60,3 @@ def initialize_simple_column_maps(self, column_name_to_hpo_label_map, observed,
         Create a dictionary of SimpleColumnMappers
         """
         pass
-
-    @abc.abstractmethod
-    def get_hpo_ontology(self):
-        """
-        :returns: a reference to the HPO-toolkit Ontology object for the HPO
-        :rtype: hpotk.MinimalOntology
-        """
-        pass
diff --git a/src/pyphetools/creation/hpo_exact_cr.py b/src/pyphetools/creation/hpo_exact_cr.py
index e1c8f575..e93c4b3e 100644
--- a/src/pyphetools/creation/hpo_exact_cr.py
+++ b/src/pyphetools/creation/hpo_exact_cr.py
@@ -1,3 +1,8 @@
+import re
+import typing
+
+from collections import defaultdict
+
 import hpotk
 
 from .hpo_cr import HpoConceptRecognizer
@@ -5,9 +10,6 @@
 from .column_mapper import ColumnMapper
 from .simple_column_mapper import SimpleColumnMapper
 
-import re
-from typing import List, Union
-from collections import defaultdict
 
 
 class ConceptMatch:
@@ -48,19 +50,64 @@ def overlaps(self, other):
             return False
 
 
+def get_label_to_id_map(hpo: hpotk.Ontology) -> typing.Mapping[str, str]:
+    """
+    Create a map from a lower case version of HPO labels to the corresponding HPO id
+    only include terms that are descendants of PHENOTYPE_ROOT
+
+    :returns: a map from lower-case HPO term labels to HPO ids
+    """
+    label_to_id_d = {}
+    for term in hpo.terms:
+        hpo_id = term.identifier
+        if not hpo.graph.is_ancestor_of(hpotk.constants.hpo.base.PHENOTYPIC_ABNORMALITY, hpo_id):
+            continue
+        label_to_id_d[term.name.lower()] = hpo_id.value
+        # Add the labels of the synonyms
+        if term.synonyms is not None and len(term.synonyms) > 0:
+            for synonym in term.synonyms:
+                lc_syn = synonym.name.lower()
+                # only take synonyms with length at least 5 to avoid spurious matches
+                if len(lc_syn) > 4:
+                    label_to_id_d[lc_syn] = hpo_id.value
+
+    return label_to_id_d
+
+
+def get_id_to_label_map(hpo: hpotk.MinimalOntology) -> typing.Mapping[str, str]:
+    """
+    :returns: a map from HPO term ids to HPO labels
+    :rtype: Dict[str,str]
+    """
+    id_to_label_d = {}
+
+    for term in hpo.terms:
+        id_to_label_d[term.identifier.value] = term.name
+
+    return id_to_label_d
+
+
 class HpoExactConceptRecognizer(HpoConceptRecognizer):
-    # self._label_to_id_d, self._id_to_primary_label
-    def __init__(self, label_to_id, id_to_primary_label, ontology:hpotk.Ontology=None):
-        super().__init__()
+
+    @staticmethod
+    def from_hpo(hpo: hpotk.Ontology):
+        label_to_id = get_label_to_id_map(hpo)
+        id_to_primary_label = get_id_to_label_map(hpo)
+
+        return HpoExactConceptRecognizer(
+            label_to_id=label_to_id,
+            id_to_primary_label=id_to_primary_label,
+        )
+
+    def __init__(self, label_to_id, id_to_primary_label):
         if not isinstance(label_to_id, dict):
             raise ValueError("label_to_id_d argument must be dictionary")
         if not isinstance(id_to_primary_label, dict):
             raise ValueError("labels_to_primary_label_d argument must be dictionary")
         self._id_to_primary_label = id_to_primary_label
         self._label_to_id = label_to_id
-        self._ontology = ontology
 
-    def parse_cell(self, cell_contents, custom_d=None) -> List[HpTerm]:
+    def parse_cell(self, cell_contents, custom_d=None) -> typing.List[HpTerm]:
         """parse the contents of one table cell
 
         Args:
@@ -81,7 +128,7 @@ def parse_cell(self, cell_contents, custom_d=None) -> List[HpTerm]:
         return self._parse_contents(cell_text=cell_text, custom_d=custom_d)
 
 
-    def _get_exact_match_in_custom_d(self, cell_text, custom_d) -> List[HpTerm]:
+    def _get_exact_match_in_custom_d(self, cell_text, custom_d) -> typing.List[HpTerm]:
         """This method is called by _parse_contents if cell_text was present in custom_d
 
         If the match does not result in at least one HpTerm match, it is an error and the custom_d probably has an incorrect string
@@ -111,7 +158,7 @@ def _get_exact_match_in_custom_d(self, cell_text, custom_d) -> List[HpTerm]:
         return results
 
 
-    def _find_text_within_custom_items(self, lc_chunk, custom_d) -> List[HpTerm]:
+    def _find_text_within_custom_items(self, lc_chunk, custom_d) -> typing.List[HpTerm]:
         hits = []
         # Note that chunk has been stripped of whitespace and lower-cased already
         for original_text, hpo_label in custom_d.items():
@@ -129,7 +176,7 @@ def _find_text_within_custom_items(self, lc_chunk, custom_d) -> List[HpTerm]:
                     hits.append(ConceptMatch(term=hp_term, start=startpos, end=endpos))
         return hits
 
-    def _find_hpo_term_in_lc_chunk(self, lc_chunk) -> List[HpTerm]:
+    def _find_hpo_term_in_lc_chunk(self, lc_chunk) -> typing.List[HpTerm]:
         hits = []
         for lower_case_hp_label, hpo_tid in self._label_to_id.items():
                 key = lower_case_hp_label.lower()
@@ -146,7 +193,7 @@ def _find_hpo_term_in_lc_chunk(self, lc_chunk) -> List[HpTerm]:
                     hits.append(ConceptMatch(term=hp_term, start=startpos, end=endpos))
         return hits
 
-    def _parse_contents(self, cell_text, custom_d) -> List[HpTerm]:
+    def _parse_contents(self, cell_text, custom_d) -> typing.List[HpTerm]:
         """Parse the contents of a cell for HPO terms
         Args:
             cell_text (str): The text of a table cell
@@ -164,7 +211,7 @@ def _parse_contents(self, cell_text, custom_d) -> List[HpTerm]:
             results.extend(self._get_non_overlapping_matches(hits=hits_1))
         return results
 
-    def parse_cell_for_exact_matches(self, cell_text, custom_d) -> List[HpTerm]:
+    def parse_cell_for_exact_matches(self, cell_text, custom_d) -> typing.List[HpTerm]:
         """
         Identify HPO Terms from the contents of a cell whose label exactly matches a string in the custom dictionary
 
@@ -183,7 +230,7 @@ def parse_cell_for_exact_matches(self, cell_text, custom_d) -> List[HpTerm]:
             results.extend(self._get_non_overlapping_matches(hits=hits))
         return
 
-    def _get_non_overlapping_matches(self, hits:[List[ConceptMatch]]) -> List[HpTerm]:
+    def _get_non_overlapping_matches(self, hits:[typing.List[ConceptMatch]]) -> typing.List[HpTerm]:
         """The prupose of this method is to choose a list of non-overlapping matches
 
         Sometimes, we get multiple matches that partially overlap. We will greedily take the longest matches and discard overlaps.
@@ -260,10 +307,3 @@ def initialize_simple_column_maps(self, column_name_to_hpo_label_map, observed,
             mpr = SimpleColumnMapper(column_name=column_name, hpo_id=hp_term.id, hpo_label=hp_term.label, observed=observed, excluded=excluded)
             simple_mapper_d[column_name] = mpr
         return simple_mapper_d
-
-    def get_hpo_ontology(self):
-        """
-        :returns: a reference to the HPO-toolkit Ontology object for the HPO
-        :rtype: hpotk.MinimalOntology
-        """
-        return self._ontology
diff --git a/src/pyphetools/creation/hpo_parser.py b/src/pyphetools/creation/hpo_parser.py
index 487f8664..63c64db7 100644
--- a/src/pyphetools/creation/hpo_parser.py
+++ b/src/pyphetools/creation/hpo_parser.py
@@ -1,81 +1,90 @@
-import hpotk
 import os
-from typing import Dict
-from .hpo_cr import HpoConceptRecognizer
-from .hpo_exact_cr import HpoExactConceptRecognizer
+import typing
 
-HPO_JSON_URL = "http://purl.obolibrary.org/obo/hp/hp.json"
+import hpotk
 
-PHENOTYPE_ROOT = "HP:0000118"
+from .hpo_cr import HpoConceptRecognizer
+from .hpo_exact_cr import HpoExactConceptRecognizer
 
 
 class HpoParser:
     """
     Class to retrieve and parse the HPO JSON file using the HPO-Toolkit
 
-    Users probably will want to pass the path to the hp.json file, but if not, the hp.json file
-    will be downloadede each time the constructor is run
+    Users probably will want to set the `release` option (e.g. `v2024-03-06` for the last release
+    as of the time of this writing) or pass the path to the `hp.json` file via `hpo_json_file` option.
+
+    Both options are optional, and the last HPO release will be used by default. The `release` has a priority
+    over `hpo_json_file`.
 
-    :param hpo_json_file: path to the hp.json file
-    :type hpo_json_file: str, optional
+    :param hpo_json_file: a `str` with a URL pointing to a remote `hp.json` (only ``http`` and ``https`` protocols
+    are supported (no ``file``, ``ftp``)) or a path to a local `hp.json` file.
+    :param release: an optional `str` with the HPO release tag or `None` if the latest HPO release should be used.
     """
+    # TODO: consider deprecating this class. It is not too useful after adding `OntologyStore` API to `hpo-toolkit>=0.5.0`.
 
-    def __init__(self, hpo_json_file:str=None):
-        if hpo_json_file is not None:
-            if not os.path.isfile(hpo_json_file):
+    def __init__(
+            self,
+            hpo_json_file: typing.Optional[str] = None,
+            release: typing.Optional[str] = None,
+    ):
+        if release is not None:
+            store = hpotk.configure_ontology_store()
+            self._ontology = store.load_hpo(release=release)
+        elif hpo_json_file is not None:
+            if not hpo_json_file.startswith('http') and not os.path.isfile(hpo_json_file):
                 raise FileNotFoundError(f"Could not find hp.json file at {hpo_json_file}")
             self._ontology = hpotk.load_ontology(hpo_json_file)
         else:
-            self._ontology = hpotk.load_ontology(HPO_JSON_URL)
+            store = hpotk.configure_ontology_store()
+            self._ontology = store.load_hpo()
 
-    def get_ontology(self):
+    def get_ontology(self) -> hpotk.Ontology:
         """
         :returns: a reference to the HPO
-        :rtype: hpotk.Ontology
         """
         return self._ontology
 
-    def get_label_to_id_map(self):
+    def get_label_to_id_map(self) -> typing.Mapping[str, str]:
         """
         Create a map from a lower case version of HPO labels to the corresponding HPO id
-        only include terms that are descendents of PHENOTYPE_ROOT
+        only include terms that are descendants of PHENOTYPE_ROOT
 
         :returns: a map from lower-case HPO term labels to HPO ids
-        :rtype: Dict[str,str]
         """
         label_to_id_d = {}
-        for t in self._ontology.terms:
-            if t.is_obsolete:
-                continue
-            hpo_id = t.identifier.value
-            if not self._ontology.graph.is_ancestor_of(PHENOTYPE_ROOT, hpo_id):
+        for term in self._ontology.terms:
+            hpo_id = term.identifier
+            if not self._ontology.graph.is_ancestor_of(hpotk.constants.hpo.base.PHENOTYPIC_ABNORMALITY, hpo_id):
                 continue
-            label_to_id_d[t.name.lower()] = hpo_id
+            label_to_id_d[term.name.lower()] = hpo_id.value
             # Add the labels of the synonyms
-            if t.synonyms is not None and len(t.synonyms) > 0:
-                for s in t.synonyms:
-                    lc_syn = s.name.lower()
+            if term.synonyms is not None and len(term.synonyms) > 0:
+                for synonym in term.synonyms:
+                    lc_syn = synonym.name.lower()
                     # only take synonyms with length at least 5 to avoid spurious matches
                     if len(lc_syn) > 4:
-                        label_to_id_d[lc_syn] = hpo_id
+                        label_to_id_d[lc_syn] = hpo_id.value
+
         return label_to_id_d
 
-    def get_id_to_label_map(self):
+    def get_id_to_label_map(self) -> typing.Mapping[str, str]:
         """
         :returns: a map from HPO term ids to HPO labels
         :rtype: Dict[str,str]
         """
         id_to_label_d = {}
-        for t in self._ontology.terms:
-            if t.is_obsolete:
-                continue
-            id_to_label_d[t.identifier.value] = t.name
+
+        for term in self._ontology.terms:
+            id_to_label_d[term.identifier.value] = term.name
+
         return id_to_label_d
 
     def get_hpo_concept_recognizer(self) -> HpoConceptRecognizer:
-        return HpoExactConceptRecognizer(label_to_id=self.get_label_to_id_map(),
-                                         id_to_primary_label=self.get_id_to_label_map(),
-                                         ontology=self.get_ontology())
+        return HpoExactConceptRecognizer(
+            label_to_id=self.get_label_to_id_map(),
+            id_to_primary_label=self.get_id_to_label_map(),
+        )
 
-    def get_version(self):
-        return self._ontology.version.__str__()
+    def get_version(self) -> typing.Optional[str]:
+        return self._ontology.version
diff --git a/src/pyphetools/creation/import_template.py b/src/pyphetools/creation/import_template.py
new file mode 100644
index 00000000..fd702b1a
--- /dev/null
+++ b/src/pyphetools/creation/import_template.py
@@ -0,0 +1,264 @@
+import os, sys, re
+import pandas as pd
+from collections import defaultdict
+import typing
+import phenopackets as PPKt
+
+
+class TemplateImporter:
+    """Convenience class to streamline the process of createing phenopackets
+    The class transforms data from an Excel file into phenopackets, and optionally creates
+    the corresponding HPOA file.
+    """
+
+    ORCID_regex = r"^\d{4}-\d{4}-\d{4}-\d{4}$"
+
+    def __init__(self,template:str,
+                hp_json:str,
+                created_by:str) -> None:
+        """Constructor
+
+        :param template: path to Excel template file
+        :type template: str
+        :param hp_json: path to hp.json file
+        :type hp_json: str
+        :param created_by: ORCID identifier of the biocurator
+        :type created_by: str
+        """
+        if "ORCID" in created_by.upper():
+            created_by = created_by.replace("ORCID:", "")
+        match = re.search(TemplateImporter.ORCID_regex, created_by)
+        if not match:
+            if "http" in created_by:
+                raise ValueError(f"Invalid ORCID {created_by} -- do not use URL! Only the ORCID number")
+            else:
+                raise ValueError(f"Invalid ORCID {created_by}")
+        self._created_by = f"ORCID:{created_by}"
+        if not os.path.isfile(template):
+            raise FileNotFoundError(f"Could not find Excel template at {template}")
+        if not os.path.isfile(hp_json):
+            raise FileNotFoundError(f"Could not find hp.json file at {hp_json}")
+        self._template = template
+        self._hp_json = hp_json
+
+    @staticmethod
+    def _get_data_from_template(df:pd.DataFrame) -> typing.Tuple[str,str,str,str,str]:
+        """Check that the template (dataframe) has the columns
+        "HGNC_id", "gene_symbol", "transcript",
+        If each row has the same value for these columns, then the template is valid
+        and we can proceed.
+
+        :param df: the template with data about a cohort
+        :type df: pd.DataFrame
+        :returns: Tuple of 5 strings-disease_id, disease_label, HGNC_id, gene_symbol, transcript
+        :rtype: typing.Tuple[str,str,str,str,str]
+        """
+        contents_d = defaultdict(set)
+        for item in ["disease_id", "disease_label", "HGNC_id", "gene_symbol", "transcript"]:
+            if item not in df.columns:
+                raise ValueError(f"Invalid template -- could not find the \"{item}\" column")
+        ## We need to skip the first row (second row of excel file), which has the datatypes
+        for _, row in df.iloc[1: , :].iterrows():
+            contents_d["disease_id"].add(row["disease_id"])
+            contents_d["disease_label"].add(row["disease_label"])
+            contents_d["HGNC_id"].add(row["HGNC_id"])
+            contents_d["gene_symbol"].add(row["gene_symbol"])
+            contents_d["transcript"].add(row["transcript"])
+        for item in ["HGNC_id", "gene_symbol", "transcript"]:
+            if len(contents_d.get(item)) != 1:
+                # should never happen unless the template is improperly formated
+                all_items = "; ".join(contents_d.get(item))
+                raise ValueError(f"invalid item set for {item}: {all_items}")
+        # If we get here, the data are complete and consistent
+        # Note that the values of contents_d are sets of strings.
+        # We also know there is only one element per set, which we get with "pop"
+        disease_id = contents_d.get("disease_id").pop()
+        disease_label = contents_d.get("disease_label").pop()
+        HGNC_id = contents_d.get("HGNC_id").pop()
+        gene_symbol = contents_d.get("gene_symbol").pop()
+        transcript = contents_d.get("transcript").pop()
+        return disease_id, disease_label, HGNC_id, gene_symbol, transcript
+
+    @staticmethod
+    def _get_allelic_requirement(df:pd.DataFrame):
+        """Determine allelic requirement
+        Note that we always expect the column allele_1 to have content.
+        If each row of allele_2 is "na", then the allelic requirement is MONO_ALLELIC
+        If each row of allele_2 is not "na" and contains a variant string, then the
+        requirement is BI_ALLELIC.
+        Mixtures of MONO and BI ALLELIC are not allowed by this script, and more involved
+        Python scripts will be needed to ingest the data.
+
+        :param df: DataFrame with cohort data
+        :type df: pd.DataFrame
+        """
+        from pyphetools.creation import AllelicRequirement
+        total_row_count = 0
+        total_allele_2_na_count = 0
+        for _, row in df.iloc[1: , :].iterrows():
+            a1 = row["allele_1"]
+            a2 = row["allele_2"]
+            if a1 == "na":
+                raise ValueError(f"Invalid row with na allele_1: {row}")
+            if a2 != "na":
+                total_allele_2_na_count += 1
+            elif len(a2) == 0:
+                raise ValueError(f"Invalid row with empty allele_2: {row}")
+            total_row_count += 1
+        if total_allele_2_na_count == 0:
+            return AllelicRequirement.MONO_ALLELIC
+        elif total_allele_2_na_count == total_row_count:
+            return AllelicRequirement.BI_ALLELIC
+        else:
+            raise ValueError(f"Error: {total_allele_2_na_count} rows with two alleles but {total_row_count} total rows")
+
+
+
+    def import_phenopackets_from_template(self, deletions:typing.Set[str]=set(),
+                                        duplications:typing.Set[str]=set(),
+                                        inversions:typing.Set[str]=set()):
+        """Import the data from an Excel template and create a collection of Phenopackets
+
+        Note that things will be completely automatic if the template just has HGNC encoding variants
+        If there are structural variants, we need to encode them by hand by passing them as
+        elements of the sets of deletions, duplications, or inversions. Note that other Structural Variant types may be added later as required.
+        :param deletions: Strings (identical to entries in the templates) that represent deletions.
+        :type deletions: (typing.Set[str], optional
+        :param duplications: Strings (identical to entries in the templates) that represent duplications.
+        :type duplications: (typing.Set[str], optional
+        :param inversions: Strings (identical to entries in the templates) that represent inversions.
+        :type inversions: (typing.Set[str], optional
+        :returns: tuple with individual list and CohortValidator that optionally can be used to display in a notebook
+        :rtype: typing.Tuple[typing.List[pyphetools.creation.Individual], pyphetools.validation.CohortValidator]
+        """
+        from pyphetools.creation  import HpoParser
+        from pyphetools.creation import CaseTemplateEncoder, AllelicRequirement
+        from pyphetools.creation import VariantManager
+        from pyphetools.validation import CohortValidator
+        parser = HpoParser(hpo_json_file=self._hp_json)
+        hpo_cr = parser.get_hpo_concept_recognizer()
+        hpo_ontology = parser.get_ontology()
+        print(f"HPO version {hpo_ontology.version}")
+        df = pd.read_excel(self._template)
+        encoder = CaseTemplateEncoder(df=df, hpo_cr=hpo_cr, created_by=self._created_by, hpo_ontology=hpo_ontology)
+        individuals = encoder.get_individuals()
+        disease_id, disease_label, HGNC_id, gene_symbol, transcript = TemplateImporter._get_data_from_template(df)
+        print(f" we got {disease_id}  {transcript}")
+        vman = VariantManager(df=df, individual_column_name="individual_id",
+                                allele_1_column_name="allele_1",
+                                allele_2_column_name="allele_2",
+                                gene_id=HGNC_id,
+                                gene_symbol=gene_symbol,
+                                transcript=transcript)
+        if len(deletions) > 0:
+            vman.code_as_chromosomal_deletion(deletions)
+        if len(duplications) > 0:
+            vman.code_as_chromosomal_duplication(duplications)
+        if len(inversions) > 0:
+            vman.code_as_chromosomal_inversion(inversions)
+        if vman.has_unmapped_alleles():
+            mapped = vman.get_mapped_allele_count()
+            print(f"We were able to map {mapped} alleles.")
+            print("The following alleles could not be mapped. Either there is an error or the variants are structural and require special treatment (see documentation)")
+            for uma in vman.get_unmapped_alleles():
+                if uma.startswith("c."):
+                    print(f"- {transcript}:{uma}")
+                else:
+                    print(f"- {uma} (may require coding as structural variant)")
+            print("Fix this error and then try again!")
+            sys.exit(1)
+        vman.add_variants_to_individuals(individuals)
+        all_req = TemplateImporter._get_allelic_requirement(df)
+        cvalidator = CohortValidator(cohort=individuals, ontology=hpo_ontology, min_hpo=1, allelic_requirement=all_req)
+        ef_individuals = cvalidator.get_error_free_individual_list()
+        encoder.output_individuals_as_phenopackets(individual_list=ef_individuals)
+        return individuals, cvalidator
+
+
+
+    @staticmethod
+    def check_disease_entries(ppkt_list:typing.List[PPKt.Phenopacket]) -> None:
+        disease_count_d = defaultdict(int)
+        for ppkt in ppkt_list:
+            # Each phenopacket must have one disease
+            if len(ppkt.diseases) == 0:
+                error_msg = f"Phenopackt {ppkt.id} did not have a disease entry."
+                print(error_msg)
+                raise ValueError(error_msg)
+            elif len(ppkt.diseases) > 1:
+                # multiple disease per phenopacket are not allowed in this script
+                diseases = ppkt.diseases
+                disease_str = "; ".join([f"{d.term.label} ({d.term.id})" for d in diseases])
+                error_msg = f"Phenopackt {ppkt.id} had multiple disease entries: {disease_str}."
+                print(error_msg)
+                raise ValueError(error_msg)
+            d = ppkt.diseases[0]
+            d_str = f"{d.term.label} ({d.term.id})"
+            disease_count_d[d_str] += 1
+        # The cohort is only allowed to have one and the same disease
+        if len(disease_count_d) > 1:
+            print(f"[ERROR] Multiple diseases found in cohort")
+            for k, v in disease_count_d.items():
+                print(f"\t{k}: n={v}")
+            print("Only a single disease allowed to create HPOA files.")
+            print("Consider using \"target\" argument in create_hpoa_from_phenopackets")
+            sys.exit(1)
+        else:
+            # all is good, print out the disease and number of individuals
+            for k, v in disease_count_d.items():
+                print(f"\t{k}: n={v}")
+
+    @staticmethod
+    def filter_diseases(disease_id, ppkt_list):
+        target_list = list()
+        for ppkt in ppkt_list:
+            for d in ppkt.diseases:
+                if d.term.id == disease_id:
+                    target_list.append(ppkt)
+                    break
+        print(f"[INFO] Extracted {(len(target_list))} from {(len(ppkt_list))} phenopackets with {disease_id}\n")
+        return target_list
+
+
+    def create_hpoa_from_phenopackets(self,
+                                    pmid:str,
+                                    moi:str, ppkt_dir:str="phenopackets",
+                                    target:str=None) -> pd.DataFrame:
+        """Create an HPO annotation (HPOA) file from the current cohort
+
+        :param pmid: PubMed id for the mode of inheritance
+        :type pmid: str
+        :param moi: Mode of inheritance (Autosomal dominant, Autosomal recessive, etc)
+        :type moi: str
+        :param ppkt_dir: Directory with phenopackets Defaults to "phenopackets".
+        :param target: Disease id (e.g., OMIM:600123) to select only phenopackets with this disease. Defaults to None.
+        :type target: str
+        :returns: dataframe representing the HPOA file (which is also written to disk)
+        :rtype: pd.DataFrame
+        """
+        from pyphetools.visualization import PhenopacketIngestor
+        from pyphetools.visualization import HpoaTableBuilder
+        if not os.path.isdir(ppkt_dir):
+            raise FileNotFoundError(f"Could not find directory at {ppkt_dir}")
+        ingestor = PhenopacketIngestor(indir=ppkt_dir)
+        ppkt_d = ingestor.get_phenopacket_dictionary()
+        ppkt_list = list(ppkt_d.values())
+        if target is not None:
+            ppkt_list = TemplateImporter.filter_diseases(target, ppkt_list)
+        TemplateImporter.check_disease_entries(ppkt_list)
+        builder = HpoaTableBuilder(phenopacket_list=ppkt_list)
+        if moi == "Autosomal dominant":
+            builder.autosomal_dominant(pmid)
+        elif moi == "Autosomal recessive":
+            builder.autosomal_recessive(pmid)
+        elif moi == "X-linked inheritance":
+            builder.x_linked(pmid)
+        elif moi == "X-linked recessive inheritance":
+            builder.x_linked_recessive(pmid)
+        elif moi == "X-linked dominant inheritance":
+            builder.x_linked_dominant()
+        else:
+            raise ValueError(f"Did not recognize mode of inheritance {moi}")
+        hpoa_creator = builder.build()
+        hpoa_creator.write_data_frame()
+        return hpoa_creator.get_dataframe()
diff --git a/src/pyphetools/creation/individual.py b/src/pyphetools/creation/individual.py
index fdea58df..55b6b920 100644
--- a/src/pyphetools/creation/individual.py
+++ b/src/pyphetools/creation/individual.py
@@ -286,8 +286,6 @@ def to_ga4gh_phenopacket(self, metadata, phenopacket_id=None) -> PPKt.Phenopacke
             php.subject.sex = PPKt.Sex.OTHER_SEX
         elif self._sex == Constants.UNKNOWN_SEX_SYMBOL:
             php.subject.sex = PPKt.Sex.UNKNOWN_SEX
-        if self._age_of_onset is not None and self._age_of_onset.is_valid():
-            php.subject.time_at_last_encounter.CopyFrom(self._age_of_onset.to_ga4gh_time_element())
         if self._vital_status is not None:
             php.subject.vital_status.CopyFrom(self._vital_status)
         disease_object = self._get_disease_object()
@@ -327,7 +325,10 @@ def to_ga4gh_phenopacket(self, metadata, phenopacket_id=None) -> PPKt.Phenopacke
             php.interpretations.append(interpretation)
         if self._citation is not None:
             # overrides the "general" setting of the external reference for the entire cohort
-            metadata.external_references.clear()
+            while len(metadata.external_references) > 0:
+                # `protobuf` devs must have removed `clear()` method
+                # This is a workaround to clear the list of external references.
+                _ = metadata.external_references.pop()
             extref = PPKt.ExternalReference()
             extref.id = self._citation.pmid
             pm = self._citation.pmid.replace("PMID:", "")
diff --git a/src/pyphetools/creation/pyphetools_age.py b/src/pyphetools/creation/pyphetools_age.py
index 8fd34a58..32e1f0a7 100644
--- a/src/pyphetools/creation/pyphetools_age.py
+++ b/src/pyphetools/creation/pyphetools_age.py
@@ -104,13 +104,13 @@ def get_age(age_string) -> "PyPheToolsAge":
         elif age_string.startswith("P"):
             return IsoAge.from_iso8601(age_string)
         elif age_string in HPO_ONSET_TERMS:
-            return HpoAge(age_string=age_string)
+            return HpoAge(hpo_onset_label=age_string)
         elif GestationalAge.is_gestational_age(age_string):
             return GestationalAge(age_string)
         else:
             # only warn if the user did not enter na=not available
             if age_string != 'na':
-                print(f"[WARNING] Could not parse {age_string} as age.")
+                raise ValueError(f"Could not parse \"{age_string}\" as age.")
             return NoneAge(age_string=age_string)
 
 
diff --git a/src/pyphetools/creation/variant_manager.py b/src/pyphetools/creation/variant_manager.py
index d2f17e5a..10a49e77 100644
--- a/src/pyphetools/creation/variant_manager.py
+++ b/src/pyphetools/creation/variant_manager.py
@@ -141,8 +141,8 @@ def _create_variant_d(self, overwrite):
         # The DataFrame has two header rows.
         # For CaseTemplateEncoder, the second header row in effect is the first row of the DataFrame, so we drop it here.
         # For CaseTemplateEncoder, the second row will contain "str" in the second row of the PMID column
-        # For other encoders, there may not be a "PMID" column, and if so it will not contain "str" in the second row
-        if "PMID" in self._dataframe.columns and self._dataframe.iloc[0]["PMID"] == "str":
+        # For other encoders, there may not be a "PMID" column, and if so it will not contain "CURIE" in the second row
+        if "PMID" in self._dataframe.columns and self._dataframe.iloc[0]["PMID"] == "CURIE":
             df = self._dataframe.iloc[1:]
         else:
             df = self._dataframe
@@ -186,6 +186,9 @@ def code_as_chromosomal_deletion(self, allele_set):
         """
         # first check that all of the alleles are in self._unmapped_alleles
         if not allele_set.issubset(self._unmapped_alleles):
+            for a in allele_set:
+                if not a in self._unmapped_alleles:
+                    print(f"Could not find allele {a}")
             raise ValueError("[ERROR] We can only map alleles that were passed to the constructor - are you trying to map \"new\" alleles?")
         if self._gene_id is None or self._gene_symbol is None:
             raise ValueError("[ERROR] We cannot use this method unless the gene ID (HGNC) and symbol were passed to the constructor")
@@ -245,6 +248,16 @@ def to_summary(self) -> pd.DataFrame:
         dlist.append(d)
         return pd.DataFrame(dlist)
 
+    def has_unmapped_alleles(self):
+        return len(self._unmapped_alleles) > 0
+
+    def get_unmapped_alleles(self):
+        return self._unmapped_alleles
+
+    def get_mapped_allele_count(self):
+        return len(self._var_d)
+
+
     def add_variants_to_individuals(self, individual_list:List[Individual], hemizygous:bool=False):
         """
         Add Variant objects to individuals. Here, we use the map self._individual_to_alleles_d that
diff --git a/src/pyphetools/validation/ontology_qc.py b/src/pyphetools/validation/ontology_qc.py
index af4274df..745ce23b 100644
--- a/src/pyphetools/validation/ontology_qc.py
+++ b/src/pyphetools/validation/ontology_qc.py
@@ -140,13 +140,13 @@ def _clean_terms(self) -> List[HpTerm]:
             if not term.measured:
                 self._errors.append(ValidationResultBuilder(self._phenopacket_id).not_measured(term=term).build())
             else:
-                if term.onset == Constants.NOT_PROVIDED:
+                if term.onset.is_valid():
+                    by_age_dictionary[term.onset.age_string].append(term)
+                else:
                     if term.observed:
                         observed_terms_without_onset.append(term)
                     else:
                         excluded_terms_without_onset.append(term)
-                else:
-                    by_age_dictionary[term.onset].append(term)
         self._check_term_ids_and_labels(self._individual.hpo_terms)
         clean_terms = []
 
diff --git a/src/pyphetools/visualization/hpoa_table_creator.py b/src/pyphetools/visualization/hpoa_table_creator.py
index 8a24cd5c..0e7714f7 100644
--- a/src/pyphetools/visualization/hpoa_table_creator.py
+++ b/src/pyphetools/visualization/hpoa_table_creator.py
@@ -110,7 +110,7 @@ class HpoaTableCreator:
     These should be tab separated fields.
 
     """
-    def __init__(self, phenopacket_list, onset_term_d,  moi_d) -> None:
+    def __init__(self, phenopacket_list, onset_term_d,  moi_d, created_by:str=None) -> None:
         """Constructor
 
         :param phenopacket_list: List of GA4GH phenopackets
@@ -126,6 +126,7 @@ def __init__(self, phenopacket_list, onset_term_d,  moi_d) -> None:
         self._biocurator_d = self._get_biocurator_d()
         self._onset_rows = self._add_age_of_onset_terms(onset_term_d)
         self._moi_rows = self._add_moi_rows(moi_d)
+        self._created_by = created_by
 
     def _get_all_hpos(self) -> Dict[str,HpTerm]:
         """Get a dictionary of HpTerms, with key being HPO id and the value the corresponding HpTerm
@@ -234,6 +235,10 @@ def _add_moi_rows(self, moi_d) -> List[HpoaTableRow]:
         moi_rows = list()
         for pmid, hpterm_list in moi_d.items():
             biocurator = self._biocurator_d.get(pmid)
+            # If we add an MOI outside of the template, then it will not have a PMID
+            # the template builder requires a created_by field which is designed for this.
+            if biocurator is None:
+                biocurator = self._created_by
             for hpterm in hpterm_list:
                 row = HpoaTableRow(disease=self._disease, hpo_term=hpterm, publication=pmid, biocurator=biocurator)
                 moi_rows.append(row)
@@ -276,7 +281,7 @@ def write_data_frame(self):
 
 class HpoaTableBuilder:
 
-    def __init__(self, indir=None, phenopacket_list=None) -> None:
+    def __init__(self, indir=None, phenopacket_list=None, created_by:str=None) -> None:
         if indir is not None:
             if not os.path.isdir(indir):
                 raise ValueError(f"indir argument {indir} must be directory!")
@@ -296,6 +301,7 @@ def __init__(self, indir=None, phenopacket_list=None) -> None:
             raise ValueError("A valid value must be supplied for either \"indir\" or \"phenopacket_list\"")
         self._onset_term_d = defaultdict(list)
         self._moi_d = defaultdict(list)
+        self._created_by = created_by
         onset_calc = OnsetCalculator(self._phenopackets)
         pmid_onset_d = onset_calc.get_pmid_to_onset_d()
         for pmid, onset_list in pmid_onset_d.items():
@@ -331,7 +337,7 @@ def x_linked_dominant(self, pmid):
 
 
     def build(self):
-        return HpoaTableCreator(phenopacket_list=self._phenopackets, onset_term_d=self._onset_term_d, moi_d=self._moi_d)
+        return HpoaTableCreator(phenopacket_list=self._phenopackets, onset_term_d=self._onset_term_d, moi_d=self._moi_d, created_by=self._created_by)
 
     @staticmethod
     def from_individuals(individual_list:List[Individual], created_by:str):
@@ -341,13 +347,15 @@ def from_individuals(individual_list:List[Individual], created_by:str):
 
         :param individual_list: List of individuals to be summarized in HPOA format
         :type individual_list: List[Individual]
+        :param created_by: the ORCID id of the person who biocurated the data
+        :type created_by: str
         :returns: HPOA table builder object
         :rtype: HpoaTableBuilder
         """
         ppkt_list = list()
         for i in individual_list:
             cite = i.get_citation()
-            metadata = MetaData(created_by="ORCID:0000-0002-0736-9199", citation=cite)
+            metadata = MetaData(created_by=created_by, citation=cite)
             ppkt = i.to_ga4gh_phenopacket(metadata=metadata)
             ppkt_list.append(ppkt)
         return HpoaTableBuilder(phenopacket_list=ppkt_list)
diff --git a/src/pyphetools/visualization/phenopacket_ingestor.py b/src/pyphetools/visualization/phenopacket_ingestor.py
index 0e2603bb..f68e1418 100644
--- a/src/pyphetools/visualization/phenopacket_ingestor.py
+++ b/src/pyphetools/visualization/phenopacket_ingestor.py
@@ -14,9 +14,11 @@ class PhenopacketIngestor:
     :type indir: str
     :param recursive: Iff True, search subdirectorys for phenopackets
     :type recursive: bool, default False
+    :param disease_id: If provided, limit ingest to phenopackets with this disease ID
+    :type disease_id: str
     """
 
-    def __init__(self, indir, recursive:bool=False) -> None:
+    def __init__(self, indir, recursive:bool=False, disease_id:str=None) -> None:
         if not os.path.isdir(indir):
             raise ValueError(f"indir argument {indir} must be directory!")
         self._indir = indir
@@ -28,9 +30,22 @@ def __init__(self, indir, recursive:bool=False) -> None:
                     data = f.read()
                     jsondata = json.loads(data)
                     ppack = Parse(json.dumps(jsondata), PPKt.Phenopacket())
+                    if disease_id is not None:
+                        if not PhenopacketIngestor.has_disease_id(ppkt=ppack, disease_id=disease_id):
+                            continue
                     self._phenopackets.append(ppack)
         print(f"[pyphetools] Ingested {len(self._phenopackets)} GA4GH phenopackets.")
 
+    @staticmethod
+    def has_disease_id(ppkt:PPKt.Phenopacket, disease_id:str) -> bool:
+        if len(ppkt.diseases) == 0:
+            return False
+        for disease in ppkt.diseases:
+            if disease.HasField("term"):
+                if disease_id == disease.term.id:
+                    return True
+        return False
+
 
     def get_simple_patient_dictionary(self) -> Dict:
         patient_d = defaultdict(SimplePatient)
diff --git a/src/pyphetools/visualization/simple_patient.py b/src/pyphetools/visualization/simple_patient.py
index a78dd6df..561c4ba1 100644
--- a/src/pyphetools/visualization/simple_patient.py
+++ b/src/pyphetools/visualization/simple_patient.py
@@ -28,18 +28,23 @@ def __init__(self, ga4gh_phenopacket) -> None:
         excluded_hpo_terms = defaultdict(HpTerm)
         self._by_age_dictionary = defaultdict(list)
         self._phenopacket_id = ppack.id
-        if ppack.subject is None:
+        if not ppack.HasField("subject"):
             raise ValueError("Phenopackets must have a subject message to be used with this package")
         subj = ppack.subject
-        if subj.id is None:
-            raise ValueError("Phenopacket subjects must have an id field to be used with this package")
+        if not subj.id:
+            # This means the subject identifier was not set. This should never happen but is not critical.
+            self._subject_id = self._phenopacket_id
         else:
             self._subject_id = subj.id
-        if subj.time_at_last_encounter is None or subj.time_at_last_encounter.age.iso8601duration is None:
-            print("Warning: No age found for phenopacket")
-            self._time_at_last_encounter = None
-        else:
-            self._time_at_last_encounter = subj.time_at_last_encounter.age.iso8601duration
+        self._time_at_last_encounter = None
+        if subj.HasField("time_at_last_encounter"):
+            time_at_last_encounter = phenopackets.TimeElement()
+            time_at_last_encounter.CopyFrom(subj.time_at_last_encounter)
+            if time_at_last_encounter.HasField("age"):
+                self._time_at_last_encounter = time_at_last_encounter.age.iso8601duration
+            elif time_at_last_encounter.HasField("ontology_class"):
+                clz = time_at_last_encounter.ontology_class
+                self._time_at_last_encounter = f"{clz.label} ({clz.id})"
         if ppack.subject.sex == phenopackets.MALE:
             self._sex = "MALE"
         elif ppack.subject.sex == phenopackets.FEMALE:
@@ -118,23 +123,20 @@ def from_individual(individual, metadata):
         return SimplePatient(ga4gh_phenopacket=ppack)
 
 
-    def get_phenopacket_id(self):
+    def get_phenopacket_id(self) -> str:
         return self._phenopacket_id
 
-    def get_subject_id(self):
+    def get_subject_id(self) -> str:
         return self._subject_id
 
     def get_sex(self):
         return self._sex
 
-    def get_age(self):
-        return self._time_at_last_encounter
+    def get_age(self)-> str:
+        return self._time_at_last_encounter or "n/a"
 
-    def get_disease(self):
-        if self._disease is None:
-            return "n/a"
-        else:
-            return self._disease
+    def get_disease(self) -> str:
+        return self._disease or "n/a"
 
     def get_observed_hpo_d(self):
         """


Test patch:
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 00000000..df3eb518
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+addopts = --doctest-modules
diff --git a/test/conftest.py b/test/conftest.py
new file mode 100644
index 00000000..e0fe872d
--- /dev/null
+++ b/test/conftest.py
@@ -0,0 +1,20 @@
+import os
+
+import hpotk
+import pytest
+
+
+@pytest.fixture(scope='session')
+def fpath_data() -> str:
+    parent = os.path.dirname(__file__)
+    return os.path.join(parent, 'data')
+
+
+@pytest.fixture(scope='session')
+def fpath_hpo(fpath_data: str) -> str:
+    return os.path.join(fpath_data, 'hp.json')
+
+
+@pytest.fixture(scope='session')
+def hpo(fpath_hpo: str) -> hpotk.Ontology:
+    return hpotk.load_ontology(fpath_hpo)
diff --git a/test/test_age_column_mapper.py b/test/test_age_column_mapper.py
index 3a839861..690f5d25 100644
--- a/test/test_age_column_mapper.py
+++ b/test/test_age_column_mapper.py
@@ -1,7 +1,7 @@
 import unittest
 import os
 import re
-from src.pyphetools.creation import AgeColumnMapper
+from pyphetools.creation import AgeColumnMapper
 
 HP_JSON_FILENAME = os.path.join(os.path.dirname(__file__), 'data', 'hp.json')
 # The API requires us to pass a column name but the column name will not be used in the tests
diff --git a/test/test_age_isoformater.py b/test/test_age_isoformater.py
index fcadfbc4..25fb7a3f 100644
--- a/test/test_age_isoformater.py
+++ b/test/test_age_isoformater.py
@@ -1,5 +1,6 @@
 import unittest
-from src.pyphetools.creation import AgeIsoFormater
+
+from pyphetools.creation import AgeIsoFormater
 
 
 
diff --git a/test/test_assume_excluded.py b/test/test_assume_excluded.py
index b594312e..032e72e0 100644
--- a/test/test_assume_excluded.py
+++ b/test/test_assume_excluded.py
@@ -1,6 +1,7 @@
-import unittest
 import os
-from src.pyphetools.creation import HpoParser, OptionColumnMapper
+import unittest
+
+from pyphetools.creation import HpoParser, OptionColumnMapper
 
 HP_JSON_FILENAME = os.path.join(os.path.dirname(__file__), 'data', 'hp.json')
 
diff --git a/test/test_case_encoder.py b/test/test_case_encoder.py
index f0b7a72e..37bb3e8f 100644
--- a/test/test_case_encoder.py
+++ b/test/test_case_encoder.py
@@ -1,35 +1,42 @@
-import unittest
-import os
+import hpotk
 import pandas as pd
-from src.pyphetools.creation import HpoParser, CaseEncoder, MetaData, Citation
-from src.pyphetools.creation import PyPheToolsAge, IsoAge
-HP_JSON_FILENAME = os.path.join(os.path.dirname(__file__), 'data', 'hp.json')
+import pytest
+
 import phenopackets as PPkt
 
+from pyphetools.creation import CaseEncoder, MetaData, Citation
+from pyphetools.creation import PyPheToolsAge, IsoAge
+from pyphetools.creation import HpoConceptRecognizer, HpoExactConceptRecognizer
+
+
 # The API requires us to pass a column name but the column name will not be used in the tests
 TEST_COLUMN = "test"
 
 
-class TestCaseParse(unittest.TestCase):
+class TestCaseParse:
 
-    @classmethod
-    def setUpClass(cls) -> None:
-        hpparser = HpoParser(hpo_json_file=HP_JSON_FILENAME)
-        cls._hpo_cr = hpparser.get_hpo_concept_recognizer()
+    @pytest.fixture(scope='class')
+    def hpo_cr(self, hpo: hpotk.Ontology) -> HpoConceptRecognizer:
+        return HpoExactConceptRecognizer.from_hpo(hpo)
+
+    @pytest.fixture
+    def case_encoder(self, hpo_cr: HpoConceptRecognizer) -> CaseEncoder:
         metadata = MetaData(created_by="ORCID:0000-0002-0736-9199")
         metadata.default_versions_with_hpo(version="2022-05-05")
         age_of_last_examination = IsoAge.from_iso8601("P4Y11M")
         sex = "female"
         citation = Citation(pmid="PMID:123", title="example title")
-        cls._parser = CaseEncoder(hpo_cr=cls._hpo_cr,
-                                individual_id="A",
-                                citation=citation,
-                                age_of_onset=age_of_last_examination,
-                                age_at_last_exam=age_of_last_examination,
-                                sex=sex,
-                                metadata=metadata.to_ga4gh())
-
-    def test_chief_complaint(self):
+        return CaseEncoder(
+            hpo_cr=hpo_cr,
+            individual_id="A",
+            citation=citation,
+            age_of_onset=age_of_last_examination,
+            age_at_last_exam=age_of_last_examination,
+            sex=sex,
+            metadata=metadata.to_ga4gh(),
+        )
+
+    def test_chief_complaint(self, case_encoder: CaseEncoder):
         """
         Expect to get
         HP:0000365 	Hearing impairment 	True 	True
@@ -37,13 +44,16 @@ def test_chief_complaint(self):
         HP:0025267 	Snoring
         """
         vignette = "A 17-mo-old boy presented with progressive nasal obstruction, snoring and hearing loss symptoms when referred to the hospital."
-        results = self._parser.add_vignette(vignette=vignette)
-        self.assertEqual(3, len(results))
-        self.assertTrue(isinstance(results, pd.DataFrame))
+        results = case_encoder.add_vignette(vignette=vignette)
+
+        assert len(results) == 3
+
+        assert isinstance(results, pd.DataFrame)
+
         tid = results.loc[(results['id'] == 'HP:0000365')]['id'].values[0]
-        self.assertEqual("HP:0000365", tid)
+        assert tid == "HP:0000365"
 
-    def test_excluded(self):
+    def test_excluded(self, case_encoder: CaseEncoder):
         """
         Currently, our text mining does not attempt to detect negation, but users can enter a set of term labels that are excluded
         In this case, He had no nasal obstruction should lead to excluded/Nasal congestion
@@ -51,18 +61,22 @@ def test_excluded(self):
         vignette = "He had no nasal obstruction and he stated that his smell sensation was intact."
         excluded = set()
         excluded.add("Nasal congestion")
-        results = self._parser.add_vignette(vignette=vignette, excluded_terms=excluded)
-        self.assertEqual(1, len(results))
+        results = case_encoder.add_vignette(vignette=vignette, excluded_terms=excluded)
+        assert len(results) == 1
+
         tid = results.loc[(results['id'] == 'HP:0001742')]['id'].values[0]
-        self.assertEqual("HP:0001742", tid)
+        assert tid == "HP:0001742"
+
         label = results.loc[(results['id'] == 'HP:0001742')]['label'].values[0]
-        self.assertEqual("Nasal congestion", label)
+        assert label == "Nasal congestion"
+
         observed = results.loc[(results['id'] == 'HP:0001742')]['observed'].values[0]
-        self.assertFalse(observed)
+        assert not observed
+
         measured = results.loc[(results['id'] == 'HP:0001742')]['measured'].values[0]
-        self.assertTrue(measured)
+        assert measured
 
-    def test_excluded2(self):
+    def test_excluded2(self, hpo_cr: HpoConceptRecognizer):
         """
         The goal if this test is to ensure that 'Seizure' is annotated as 'excluded'
         We label the word as false positive and it should just be skipped
@@ -74,36 +88,39 @@ def test_excluded2(self):
         metadata = MetaData(created_by="ORCID:0000-0002-0736-9199")
         metadata.default_versions_with_hpo(version="2022-05-05")
         citation = Citation(pmid="PMID:1", title="excluded")
-        encoder = CaseEncoder(hpo_cr=self._hpo_cr, individual_id="id", metadata=metadata.to_ga4gh(), citation=citation)
+        encoder = CaseEncoder(hpo_cr=hpo_cr, individual_id="id", metadata=metadata.to_ga4gh(), citation=citation)
         df = encoder.add_vignette(vignette=vignette, excluded_terms=excluded, false_positive=false_positive)
-        self.assertEqual(0, len(df))
+
+        assert len(df) == 0
 
 
 
-    def test_phenopacket_id(self):
+    def test_phenopacket_id(self, case_encoder: CaseEncoder):
         """
         Test that we construct the phenopacket ID to include the PMID and the individual id
         """
-        ppkt = self._parser.get_phenopacket()
-        expected_ppkt_id = "PMID_123_A"
-        self.assertEqual(expected_ppkt_id, ppkt.id)
+        ppkt = case_encoder.get_phenopacket()
+        assert ppkt.id == "PMID_123_A"
 
 
-    def test_age_last_exam(self):
-        individual = self._parser.get_individual()
-        self.assertIsNotNone(individual)
-        self.assertIsNotNone(individual.age_of_onset)
+    def test_age_last_exam(self, case_encoder: CaseEncoder):
+        individual = case_encoder.get_individual()
+        assert individual is not None
+        assert individual.age_of_onset is not None
+
         expected_age = "P4Y11M"
         onset_age = individual.age_of_onset
-        self.assertIsNotNone(onset_age)
-        self.assertTrue(isinstance(onset_age, PyPheToolsAge))
-        self.assertEqual(expected_age, onset_age.age_string)
-
-    def test_sex(self):
-        ppkt = self._parser.get_phenopacket()
-        self.assertIsNotNone(ppkt.subject)
-        self.assertIsNotNone(ppkt.subject.sex)
+        assert onset_age is not None
+
+        assert isinstance(onset_age, PyPheToolsAge)
+        assert onset_age.age_string == expected_age
+
+    def test_sex(self, case_encoder: CaseEncoder):
+        ppkt = case_encoder.get_phenopacket()
+        assert ppkt.subject is not None
+        assert ppkt.subject.sex is not None
+
         expected_sex = PPkt.Sex.FEMALE
-        self.assertEqual(expected_sex, ppkt.subject.sex)
+        assert ppkt.subject.sex == expected_sex
 
 
diff --git a/test/test_cohort_validator.py b/test/test_cohort_validator.py
index 7f88d89d..88f3bde6 100644
--- a/test/test_cohort_validator.py
+++ b/test/test_cohort_validator.py
@@ -1,61 +1,69 @@
-import os
-import unittest
+import hpotk
+import pytest
 
-from src.pyphetools.creation import Individual, HpTerm, HpoParser
-from src.pyphetools.validation import CohortValidator
-from src.pyphetools.validation.validation_result import ErrorLevel, Category
+from pyphetools.creation import Individual, HpTerm
+from pyphetools.validation import CohortValidator
 
-HP_JSON_FILENAME = os.path.join(os.path.dirname(__file__), 'data', 'hp.json')
 
 # The API requires us to pass a column name but the column name will not be used in the tests
 TEST_COLUMN = "test"
 
-HP_JSON_FILENAME = os.path.join(os.path.dirname(__file__), 'data', 'hp.json')
-
-class TestCohortValidator(unittest.TestCase):
-
-    @classmethod
-    def setUpClass(cls) -> None:
-        individual_A = Individual(individual_id="A")
-        individual_A.add_hpo_term(HpTerm(hpo_id="HP:0000490", label="Deeply set eye"))
-        individual_A.add_hpo_term(HpTerm(hpo_id="HP:0011525", label="Iris nevus"))
-        individual_A.add_hpo_term(HpTerm(hpo_id="HP:0000490", label="Deeply set eye"))
-        individual_B = Individual(individual_id="B")
-        individual_B.add_hpo_term(HpTerm(hpo_id="HP:0000490", label="Deeply set eye"))
-        individual_B.add_hpo_term(HpTerm(hpo_id="HP:0011525", label="Iris nevus"))
-        cls._individual_A = individual_A
-        cls._individual_B = individual_B
-        parser = HpoParser(hpo_json_file=HP_JSON_FILENAME)
-        cls.hpo_cr = parser.get_hpo_concept_recognizer()
-
-
-    def test_redundant_term(self):
-        cohort = [self._individual_A]
-        hpo_ontology = self.hpo_cr.get_hpo_ontology()
-        cvalidator = CohortValidator(cohort=cohort, ontology=hpo_ontology, min_hpo=1)
+
+class TestCohortValidator:
+
+    @pytest.fixture
+    def ind_a(self) -> Individual:
+        i = Individual(individual_id="A")
+        i.add_hpo_term(HpTerm(hpo_id="HP:0000490", label="Deeply set eye"))
+        i.add_hpo_term(HpTerm(hpo_id="HP:0011525", label="Iris nevus"))
+        i.add_hpo_term(HpTerm(hpo_id="HP:0000490", label="Deeply set eye"))
+        return i
+
+    @pytest.fixture
+    def ind_b(self) -> Individual:
+        i = Individual(individual_id="B")
+        i.add_hpo_term(HpTerm(hpo_id="HP:0000490", label="Deeply set eye"))
+        i.add_hpo_term(HpTerm(hpo_id="HP:0011525", label="Iris nevus"))
+        return i
+
+    def test_redundant_term(
+            self,
+            hpo: hpotk.Ontology,
+            ind_a: Individual,
+    ):
+        cohort = [ind_a]
+        cvalidator = CohortValidator(cohort=cohort, ontology=hpo, min_hpo=1)
         validated_individuals = cvalidator.get_validated_individual_list()
-        self.assertEqual(1, len(validated_individuals))
+        assert len(validated_individuals) == 1
+
         vindindividualA = validated_individuals[0]
-        self.assertTrue(vindindividualA.has_error())
+        assert vindindividualA.has_error()
+
         errors = vindindividualA.get_validation_errors()
-        self.assertEqual(1, len(errors))
+        assert len(errors) == 1
+
         error = errors[0]
-        self.assertEqual("WARNING", error.error_level)
-        self.assertEqual("DUPLICATE", error.category)
-        self.assertEqual("<b>Deeply set eye</b> is listed multiple times", error.message)
-        self.assertEqual("HP:0000490", error.term.id)
-        self.assertEqual("Deeply set eye", error.term.label)
-
-    def test_no_redundant_term(self):
-        cohort = [self._individual_B]
-        hpo_ontology = self.hpo_cr.get_hpo_ontology()
-        cvalidator = CohortValidator(cohort=cohort, ontology=hpo_ontology, min_hpo=1)
+        assert error.error_level == "WARNING"
+        assert error.category == "DUPLICATE"
+        assert error.message == "<b>Deeply set eye</b> is listed multiple times"
+        assert error.term.id == "HP:0000490"
+        assert error.term.label == "Deeply set eye"
+
+    def test_no_redundant_term(
+            self,
+            hpo: hpotk.Ontology,
+            ind_b: Individual,
+    ):
+        cohort = [ind_b]
+        cvalidator = CohortValidator(cohort=cohort, ontology=hpo, min_hpo=1)
+
         validated_individuals = cvalidator.get_validated_individual_list()
-        self.assertEqual(1, len(validated_individuals))
+        assert len(validated_individuals) == 1
+
         vindindividualB = validated_individuals[0]
-        self.assertFalse(vindindividualB.has_error())
+        assert not vindindividualB.has_error()
 
-    def test_redundant_in_hierarchy(self):
+    def test_redundant_in_hierarchy(self, hpo: hpotk.Ontology):
         """
         0358596_patientA.json,ERROR,HpoAncestryValidator,Violation of the annotation propagation rule,
         "Phenotypic features of PMID_20358596_patient_A must not contain both an
@@ -66,15 +74,17 @@ def test_redundant_in_hierarchy(self):
         individual_C.add_hpo_term(HpTerm(hpo_id="HP:0011525", label="Iris nevus"))
         individual_C.add_hpo_term(HpTerm(hpo_id="HP:0430025", label="Bilateral facial palsy"))
         individual_C.add_hpo_term(HpTerm(hpo_id="HP:0010628", label="Facial palsy"))
+
         cohort = [individual_C]
-        hpo_ontology = self.hpo_cr.get_hpo_ontology()
-        cvalidator = CohortValidator(cohort=cohort, ontology=hpo_ontology, min_hpo=1)
+        cvalidator = CohortValidator(cohort=cohort, ontology=hpo, min_hpo=1)
         validated_individuals = cvalidator.get_validated_individual_list()
-        self.assertEqual(1, len(validated_individuals))
+        assert len(validated_individuals) == 1
+
         individual_C = validated_individuals[0]
-        self.assertTrue(individual_C.has_error())
+        assert individual_C.has_error()
+
         errors = individual_C.get_validation_errors()
-        self.assertEqual(1, len(errors))
-        error = errors[0]
-        self.assertEqual("<b>Facial palsy</b> is redundant because of <b>Bilateral facial palsy</b>", error.message)
+        assert len(errors) == 1
 
+        error = errors[0]
+        assert error.message == "<b>Facial palsy</b> is redundant because of <b>Bilateral facial palsy</b>"
diff --git a/test/test_constant_column_mapper.py b/test/test_constant_column_mapper.py
index 01540c8c..ea0172fd 100644
--- a/test/test_constant_column_mapper.py
+++ b/test/test_constant_column_mapper.py
@@ -1,5 +1,5 @@
 import unittest
-from src.pyphetools.creation import ConstantColumnMapper
+from pyphetools.creation import ConstantColumnMapper
 
 
 
diff --git a/test/test_hp_term.py b/test/test_hp_term.py
index cde1ca2f..841fed36 100644
--- a/test/test_hp_term.py
+++ b/test/test_hp_term.py
@@ -1,6 +1,6 @@
 import unittest
 
-from src.pyphetools.creation import HpTerm, HpTermBuilder, IsoAge
+from pyphetools.creation import HpTerm, HpTermBuilder, IsoAge
 
 
 class TestHpTerm(unittest.TestCase):
diff --git a/test/test_hpo_category.py b/test/test_hpo_category.py
index 65a455cd..a061ed4e 100644
--- a/test/test_hpo_category.py
+++ b/test/test_hpo_category.py
@@ -1,6 +1,6 @@
 import unittest
 import os
-from src.pyphetools.visualization import HpoCategorySet
+from pyphetools.visualization import HpoCategorySet
 
 from hpotk.ontology.load.obographs import load_ontology
 
diff --git a/test/test_hpo_cr.py b/test/test_hpo_cr.py
index 3aba0550..3ab9c79a 100644
--- a/test/test_hpo_cr.py
+++ b/test/test_hpo_cr.py
@@ -1,7 +1,7 @@
 import os
 import unittest
 
-from src.pyphetools.creation import HpoParser
+from pyphetools.creation import HpoParser
 
 HP_JSON_FILENAME = os.path.join(os.path.dirname(__file__), 'data', 'hp.json')
 
diff --git a/test/test_hpo_parser.py b/test/test_hpo_parser.py
index c92205dd..9d5bbc02 100644
--- a/test/test_hpo_parser.py
+++ b/test/test_hpo_parser.py
@@ -1,6 +1,6 @@
 import unittest
 import os
-from src.pyphetools.creation import HpoParser
+from pyphetools.creation import HpoParser
 
 HP_JSON_FILENAME = os.path.join(os.path.dirname(__file__), 'data', 'hp.json')
 
diff --git a/test/test_metadata.py b/test/test_metadata.py
index db98fe16..d8eb7f40 100644
--- a/test/test_metadata.py
+++ b/test/test_metadata.py
@@ -2,7 +2,7 @@
 
 import pytest
 
-from src.pyphetools.creation import MetaData, Citation
+from pyphetools.creation import MetaData, Citation
 
 
 class TestMetaData(unittest.TestCase):
diff --git a/test/test_ontology_qc.py b/test/test_ontology_qc.py
index eb56c890..0c433b6e 100644
--- a/test/test_ontology_qc.py
+++ b/test/test_ontology_qc.py
@@ -1,25 +1,21 @@
-import unittest
-import os
-from src.pyphetools.creation import HpoParser, HpTerm, Individual
-from src.pyphetools.validation import OntologyQC
+import hpotk
+import pytest
 
+from pyphetools.creation import HpTerm, Individual, IsoAge
+from pyphetools.validation import OntologyQC
 
-HP_JSON_FILENAME = os.path.join(os.path.dirname(__file__), 'data', 'hp.json')
 
 
-class TestOntologyQC(unittest.TestCase):
-    @classmethod
-    def setUpClass(cls) -> None:
-        parser = HpoParser(hpo_json_file=HP_JSON_FILENAME)
-        hpo_cr = parser.get_hpo_concept_recognizer()
-        cls._ontology = hpo_cr.get_hpo_ontology()
+
+class TestOntologyQC:
+
+    @pytest.fixture
+    def ontology_qc(self, hpo: hpotk.Ontology) -> OntologyQC:
         individual = Individual(individual_id="id")
-        cls._qc = OntologyQC(ontology=cls._ontology, individual=individual)
+        return OntologyQC(ontology=hpo, individual=individual)
 
-    def test_non_null(self):
-        self.assertIsNotNone(self._qc)
 
-    def test_conflict(self):
+    def test_conflict(self, hpo: hpotk.Ontology):
         """
         Arachnodactyly is a child of Slender finger, and so the following is a conflict
         """
@@ -27,11 +23,13 @@ def test_conflict(self):
         slenderTerm = HpTerm(hpo_id="HP:0001238", label="Slender finger", observed=False)
         hpo_terms = [arachTerm, slenderTerm]
         individual = Individual(individual_id="id", hpo_terms=hpo_terms)
-        qc = OntologyQC(ontology=self._ontology, individual=individual)
+
+        qc = OntologyQC(ontology=hpo, individual=individual)
+
         qc_hpo_terms = qc.get_clean_terms()
-        self.assertEqual(1, len(qc_hpo_terms))
+        assert len(qc_hpo_terms) == 1
 
-    def test_do_not_detect_conflict_if_there_is_no_conflict(self):
+    def test_do_not_detect_conflict_if_there_is_no_conflict(self, hpo: hpotk.Ontology):
         """
         These terms are unrelated so it is NOT a conflict
         """
@@ -40,11 +38,13 @@ def test_do_not_detect_conflict_if_there_is_no_conflict(self):
         hipDislocation = HpTerm(hpo_id="HP:0002827", label="Hip dislocation", observed=False)
         hpo_terms = [arachTerm, hipDislocation]
         individual = Individual(individual_id="id", hpo_terms=hpo_terms)
-        qc = OntologyQC(ontology=self._ontology, individual=individual)
+
+        qc = OntologyQC(ontology=hpo, individual=individual)
         qc_hpo_terms = qc.get_clean_terms()
-        self.assertEqual(2, len(qc_hpo_terms))
 
-    def test_do_not_detect_conflict_if_there_is_no_conflict_2(self):
+        assert len(qc_hpo_terms) == 2
+
+    def test_do_not_detect_conflict_if_there_is_no_conflict_2(self, hpo: hpotk.Ontology):
         """
         This is not a conflict because the subclass is excluded while the superclass is observed
         """
@@ -52,59 +52,84 @@ def test_do_not_detect_conflict_if_there_is_no_conflict_2(self):
         slenderTerm = HpTerm(hpo_id="HP:0001238", label="Slender finger", observed=True)
         hpo_terms = [arachTerm, slenderTerm]
         individual = Individual(individual_id="id", hpo_terms=hpo_terms)
-        qc = OntologyQC(ontology=self._ontology, individual=individual)
+        qc = OntologyQC(ontology=hpo, individual=individual)
         qc_hpo_terms = qc.get_clean_terms()
-        self.assertEqual(2, len(qc_hpo_terms))
+        assert len(qc_hpo_terms) == 2
 
-    def test_redundancy(self):
+    def test_redundancy(self, hpo: hpotk.Ontology):
         arachTerm = HpTerm(hpo_id="HP:0001166", label="Arachnodactyly", observed=True)
         slenderTerm = HpTerm(hpo_id="HP:0001238", label="Slender finger", observed=True)
         hpo_terms = [arachTerm, slenderTerm]
         individual = Individual(individual_id="id", hpo_terms=hpo_terms)
-        qc = OntologyQC(ontology=self._ontology, individual=individual)
+        qc = OntologyQC(ontology=hpo, individual=individual)
         qc_hpo_terms = qc.get_clean_terms()
-        self.assertEqual(1, len(qc_hpo_terms))
 
-    def test_do_not_remove_if_not_redundant(self):
+        assert len(qc_hpo_terms) == 1
+
+    def test_do_not_remove_if_not_redundant(self, hpo: hpotk.Ontology):
         arachTerm = HpTerm(hpo_id="HP:0001166", label="Arachnodactyly", observed=True)
         hipDislocation = HpTerm(hpo_id="HP:0002827", label="Hip dislocation", observed=False)
         hpo_terms = [arachTerm, hipDislocation]
         individual = Individual(individual_id="id", hpo_terms=hpo_terms)
-        qc = OntologyQC(ontology=self._ontology, individual=individual)
+        qc = OntologyQC(ontology=hpo, individual=individual)
         qc_hpo_terms = qc.get_clean_terms()
-        self.assertEqual(2, len(qc_hpo_terms))
+
+        assert len(qc_hpo_terms) == 2
 
 
-    def test_redundancy(self):
+    def test_exact_redundancy(self, hpo: hpotk.Ontology):
         arachTerm = HpTerm(hpo_id="HP:0001166", label="Arachnodactyly", observed=True)
         arachTerm2 = HpTerm(hpo_id="HP:0001166", label="Arachnodactyly", observed=True)
         hipDislocation = HpTerm(hpo_id="HP:0002827", label="Hip dislocation", observed=False)
         hpo_terms = [arachTerm, arachTerm2, hipDislocation]
         individual = Individual(individual_id="id", hpo_terms=hpo_terms)
-        qc = OntologyQC(ontology=self._ontology, individual=individual)
+        qc = OntologyQC(ontology=hpo, individual=individual)
         qc_hpo_terms = qc.get_clean_terms()
-        self.assertEqual(2, len(qc_hpo_terms))
+
+        assert len(qc_hpo_terms) == 2
+
         error_list = qc.get_error_list()
-        self.assertEqual(1, len(error_list))
+
+        assert len(error_list) == 1
+
         error = error_list[0]
-        self.assertEqual("WARNING", error.error_level)
-        self.assertEqual("DUPLICATE", error.category)
+
+        assert error.error_level == 'WARNING'
+        assert error.category == 'DUPLICATE'
 
 
-    def test_same_term_observed_and_excluded(self):
+    def test_same_term_observed_and_excluded(self, hpo: hpotk.Ontology):
         arachTermObserved = HpTerm(hpo_id="HP:0001166", label="Arachnodactyly", observed=True)
         arachTermExcluded = HpTerm(hpo_id="HP:0001166", label="Arachnodactyly", observed=False)
         hipDislocation = HpTerm(hpo_id="HP:0002827", label="Hip dislocation", observed=True)
         hpo_terms = [arachTermObserved, hipDislocation, arachTermExcluded]
         individual = Individual(individual_id="id", hpo_terms=hpo_terms)
-        qc = OntologyQC(ontology=self._ontology, individual=individual)
+        qc = OntologyQC(ontology=hpo, individual=individual)
         error_list = qc.get_error_list()
-        self.assertEqual(1, len(error_list))
+
+        assert len(error_list) == 1
+
         error = error_list[0]
-        self.assertEqual("ERROR", error.error_level)
-        self.assertEqual("OBSERVED_AND_EXCLUDED", error.category)
-        self.assertEqual("Term Arachnodactyly (HP:0001166) was annotated to be both observed and excluded.", error.message)
 
+        assert error.error_level == "ERROR"
+        assert error.category == "OBSERVED_AND_EXCLUDED"
+        assert error.message == "Term Arachnodactyly (HP:0001166) was annotated to be both observed and excluded."
 
+    def test_redundancy_with_and_without_onset(self, hpo: hpotk.Ontology):
+        """
+        If we have Myoclonic seizure with onset P1Y and Seizure with no onset, then we do not want to include
+        Seizure in the final output because it is redundant.
+        Myoclonic seizure HP:0032794 ("grandchild" of Seizure)
+        Seizure HP:0001250
+        """
+        onset = IsoAge.from_iso8601("P1Y")
+        myoclonic_seiz = HpTerm(hpo_id="HP:0032794", label="Myoclonic seizure", observed=True, onset=onset)
+        seiz = HpTerm(hpo_id="HP:0001250", label="Seizure", observed=True)
+        hpo_terms = [myoclonic_seiz, seiz]
+        individual = Individual(individual_id="id", hpo_terms=hpo_terms)
 
+        qc = OntologyQC(ontology=hpo, individual=individual)
+
+        qc_hpo_terms = qc.get_clean_terms()
 
+        assert len(qc_hpo_terms) == 1
diff --git a/test/test_option_column_mapper.py b/test/test_option_column_mapper.py
index db3189cb..96f9a5e3 100644
--- a/test/test_option_column_mapper.py
+++ b/test/test_option_column_mapper.py
@@ -1,6 +1,6 @@
 import unittest
 import os
-from src.pyphetools.creation import HpoParser, OptionColumnMapper
+from pyphetools.creation import HpoParser, OptionColumnMapper
 
 HP_JSON_FILENAME = os.path.join(os.path.dirname(__file__), 'data', 'hp.json')
 
diff --git a/test/test_option_column_mapper2.py b/test/test_option_column_mapper2.py
index 81ddd473..5cb1534d 100644
--- a/test/test_option_column_mapper2.py
+++ b/test/test_option_column_mapper2.py
@@ -1,6 +1,6 @@
 import unittest
 import os
-from src.pyphetools.creation import HpoParser, OptionColumnMapper
+from pyphetools.creation import HpoParser, OptionColumnMapper
 
 HP_JSON_FILENAME = os.path.join(os.path.dirname(__file__), 'data', 'hp.json')
 
diff --git a/test/test_phenopacket_table.py b/test/test_phenopacket_table.py
index 20858f95..f528d32f 100644
--- a/test/test_phenopacket_table.py
+++ b/test/test_phenopacket_table.py
@@ -1,6 +1,6 @@
 import unittest
 import os
-from src.pyphetools.visualization import PhenopacketTable
+from pyphetools.visualization import PhenopacketTable
 
 HP_JSON_FILENAME = os.path.join(os.path.dirname(__file__), 'data', 'hp.json')
 
diff --git a/test/test_pyphetools_age.py b/test/test_pyphetools_age.py
new file mode 100644
index 00000000..ae829200
--- /dev/null
+++ b/test/test_pyphetools_age.py
@@ -0,0 +1,23 @@
+import unittest
+from pyphetools.creation import PyPheToolsAge, HpoAge, IsoAge
+
+
+class TestSimpleAge(unittest.TestCase):
+    #'age_of_onset': 'Infantile onset', 'age_at_last_encounter': 'P3Y9M', 'sex': 'M'}
+    def test_infantile_onset(self):
+        onset_age = PyPheToolsAge.get_age('Infantile onset')
+        self.assertIsNotNone(onset_age)
+        self.assertEqual('Infantile onset', onset_age.age_string)
+        self.assertTrue(isinstance(onset_age, HpoAge))
+
+    def test_3m9m(self):
+        onset_age = PyPheToolsAge.get_age('P3Y9M')
+        self.assertIsNotNone(onset_age)
+        self.assertEqual('P3Y9M', onset_age.age_string)
+        self.assertTrue(isinstance(onset_age, IsoAge))
+
+    def test_2m(self):
+        onset_age = PyPheToolsAge.get_age('P2M')
+        self.assertIsNotNone(onset_age)
+        self.assertEqual('P2M', onset_age.age_string)
+        self.assertTrue(isinstance(onset_age, IsoAge))
diff --git a/test/test_simple_age.py b/test/test_simple_age.py
index 8695fe07..25d16348 100644
--- a/test/test_simple_age.py
+++ b/test/test_simple_age.py
@@ -1,5 +1,5 @@
 import unittest
-from src.pyphetools.visualization import SimpleAge
+from pyphetools.visualization import SimpleAge
 
 
 class TestSimpleAge(unittest.TestCase):
diff --git a/test/test_simple_variant.py b/test/test_simple_variant.py
index 4dfa2f3f..38ac57b6 100644
--- a/test/test_simple_variant.py
+++ b/test/test_simple_variant.py
@@ -1,8 +1,8 @@
 import unittest
 import os
 import phenopackets as PPkt
-from src.pyphetools.visualization import SimpleVariant
-from src.pyphetools.creation import StructuralVariant
+from pyphetools.visualization import SimpleVariant
+from pyphetools.creation import StructuralVariant
 
 HP_JSON_FILENAME = os.path.join(os.path.dirname(__file__), 'data', 'hp.json')
 
diff --git a/test/test_structural_variant.py b/test/test_structural_variant.py
index 0c0faa8e..772750b8 100644
--- a/test/test_structural_variant.py
+++ b/test/test_structural_variant.py
@@ -1,5 +1,5 @@
 import unittest
-from src.pyphetools.creation import StructuralVariant
+from pyphetools.creation import StructuralVariant
 
 
 class TestVariantValidator(unittest.TestCase):
diff --git a/test/test_threshold_column_mapper.py b/test/test_threshold_column_mapper.py
index 79c68030..9c542c58 100644
--- a/test/test_threshold_column_mapper.py
+++ b/test/test_threshold_column_mapper.py
@@ -1,6 +1,6 @@
 import unittest
-from src.pyphetools.creation import ThresholdedColumnMapper, Thresholder
-from src.pyphetools.creation import HpTerm
+from pyphetools.creation import ThresholdedColumnMapper, Thresholder
+from pyphetools.creation import HpTerm
 
 class TestThresholdedColumnMapper(unittest.TestCase):
 
diff --git a/test/test_thresholder.py b/test/test_thresholder.py
index bbc76553..40549d92 100644
--- a/test/test_thresholder.py
+++ b/test/test_thresholder.py
@@ -1,5 +1,5 @@
 import unittest
-from src.pyphetools.creation import Thresholder
+from pyphetools.creation import Thresholder
 
 
 
diff --git a/test/test_validation_result.py b/test/test_validation_result.py
index 91c8cda3..86447642 100644
--- a/test/test_validation_result.py
+++ b/test/test_validation_result.py
@@ -1,6 +1,6 @@
 import unittest
-from src.pyphetools.creation import HpTerm, AllelicRequirement
-from src.pyphetools.validation import ValidationResultBuilder
+from pyphetools.creation import HpTerm, AllelicRequirement
+from pyphetools.validation import ValidationResultBuilder
 
 
 
diff --git a/test/test_variant.py b/test/test_variant.py
index ec9a6f4a..be1b7cb9 100644
--- a/test/test_variant.py
+++ b/test/test_variant.py
@@ -1,5 +1,5 @@
 import unittest
-from src.pyphetools.creation import HgvsVariant
+from pyphetools.creation import HgvsVariant
 
 # The following dictionary has the same structure as a subelement of the response from Variant Validator
 VCF_DICTIONARY =  {'alt': 'C', 'chr': '16', 'pos': '1756403', 'ref': 'CG'}
diff --git a/test/test_vital_status.py b/test/test_vital_status.py
index ecdf34ee..5a3a36d1 100644
--- a/test/test_vital_status.py
+++ b/test/test_vital_status.py
@@ -1,5 +1,5 @@
 import unittest
-from src.pyphetools.creation import AgeOfDeathColumnMapper, Individual, MetaData
+from pyphetools.creation import AgeOfDeathColumnMapper, Individual, MetaData
 import phenopackets as PPKt