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

Issue:
Improve ergonomics
Improve ergonomics of working with phenopacket data.

Closes #86 


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python_ci.yml b/.github/workflows/python_ci.yml
index b638df38..0e1c4b78 100644
--- a/.github/workflows/python_ci.yml
+++ b/.github/workflows/python_ci.yml
@@ -18,13 +18,15 @@ jobs:
       steps:
         - uses: actions/checkout@v4
 
-        - name: Initialize Python ${{ matrix.python }}
+        - name: Initialize Python 3.10
           uses: actions/setup-python@v5
           with:
-            python-version: "3.11"
+            python-version: "3.10"
 
         - name: Install pyphetools
-          run: python3 -m pip install .[test]
+          run: |
+            python3 -m pip install --editable .[test]
 
         - name: Run all tests
-          run: python3 -m pytest
+          run: |
+            pytest
diff --git a/docs/user-guide/python_notebook.md b/docs/user-guide/python_notebook.md
index b44d0931..08b2696c 100644
--- a/docs/user-guide/python_notebook.md
+++ b/docs/user-guide/python_notebook.md
@@ -5,87 +5,58 @@ The following sections explain how to use Python code to create phenopackets fro
 
 ## 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.
+We first import the TemplateImporter to import the data and create phenopackets, and several classes to visualize the data.
 
 ```python
-import pandas as pd
+from pyphetools.creation import TemplateImporter
+from pyphetools.visualization import IndividualTable, QcVisualizer
 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.
+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 enclosing directory. Update the ORCID identifier to your own [ORCID](https://orcid.org/){:target="_blank"}  id. Indicate
+the location of the template file.
 
 ```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}")
+template = "input/BRD4_individuals.xlsx"
+hp_json = "../hp.json"
+created_by = "0000-0002-0736-9199"
 ```
 
-import the template file and show the contents (Adjust the head command to show as many lines as needed to check the data).
+import the template file. The code returns the pyphetools Individual objects, each of which contains all of the information needed to create a phenopacket and which here can be used if desired for debugging or further analysis. The cvalidator object is used to display quality assessment information.
 
 ```
-df = pd.read_excel("input/ATP13A2_Kufor_Rakeb_individuals.xlsx")
-df.head(2)
+timporter = TemplateImporter(template=template, hp_json=hp_json, created_by=created_by)
+individual_list, cvalidator = timporter.import_phenopackets_from_template()
 ```
-Create the encoder.
-
+Display quality assessment data.
 ```
-encoder = CaseTemplateEncoder(df=df, hpo_cr=hpo_cr, created_by=created_by)
-individuals = encoder.get_individuals()
+qc = QcVisualizer(cohort_validator=cvalidator)
+display(HTML(qc.to_summary_html()))
 ```
-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.
+Display summaries of each phenopacket. The command ``cvalidator.get_error_free_individual_list()``returns versions of the Individual objects
+in which errors such as redundancies have been removed; this is the data that gets transformed into phenopackets.
 
 
 ```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()
+table = IndividualTable(cvalidator.get_error_free_individual_list())
+display(HTML(table.to_html()))
 ```
-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.
 
+### HPOA files
 
-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.
+If desired, we can transform the phenopacket data into an HPOA file. This is the format that the HPO team uses to create the phenotype.hpoa file that is distributed on the [Human Phenotype Ontology](https://hpo.jax.org/){target="_blank"} website. We need to choose a PubMed identifier that documents
+the mode of inheritance (MOI) and then indicate the MOI. If multiple distinct diseases are stored in the phenopackets directory, then we need to choose one of them using the target argument.
 
+Check results of variant encoding.
 ```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()))
+pmid = "PMID:36333996"
+timporter.create_hpoa_from_phenopackets(pmid=pmid, moi="Autosomal recessive")
 ```
+or
 
-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)
+pmid = "PMID:36333996"
+timporter.create_hpoa_from_phenopackets(pmid=pmid, moi="Autosomal recessive", target="OMIM:620427")
 ```
-
-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/template.md b/docs/user-guide/template.md
index 1855d180..b6a9fdcb 100644
--- a/docs/user-guide/template.md
+++ b/docs/user-guide/template.md
@@ -5,4 +5,56 @@ 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/phenopacket-store/contributing/){:target="\_blank"} for instructions on using the template.
\ No newline at end of file
+See [here](https://monarch-initiative.github.io/phenopacket-store/){:target="\_blank"} for many examples of how to use the template.
+
+
+## Template Creation
+
+Excel templates can be  created in a Jupyter notebook to prepopulate them with the HPO terms, gene and disease ids.
+
+We will show an example for creating a template for
+[Simpson-Golabi-Behmel syndrome, type 2-OMIM:300209](https://omim.org/entry/300209).
+
+
+```python
+from pyphetools.creation import TemplateCreator
+```
+
+First initialize the creator object with the path to a ``hp.json`` file (adjust the path as needed on your system).
+
+```python
+tcreator = TemplateCreator(hp_json="../hp.json")
+```
+
+We will then use HPO text mining to retrieve as many terms as possible from a description of the phenotypic features that can be derived from the article you are curating or any relevant source. We took the following description from [Tenorio J, et al., Simpson-Golabi-Behmel syndrome types I and II. Orphanet J Rare Dis. 2014](https://pubmed.ncbi.nlm.nih.gov/25238977/){:target="_blank"}
+
+```python
+tcreator.add_seed_terms("""
+It is an infantile lethal variant of SGBS associated with hydrops fetalis.
+In the first report, the authors reported 4 maternally-related male cousins
+with a severe variant of SGBS [6]. One of these males was aborted therapeutically
+at 19 weeks of gestation following the detection of multicystic kidneys on ultrasound.
+The three live born males were hydropic at birth. They also depicted craniofacial
+anomalies including macrocephaly; apparently low-set, posteriorly angulated ears; hypertelorism;
+short, broad nose with anteverted nares; large mouth with thin upper vermilion border;
+prominent philtrum; high-arched and cleft palate. Other findings were short neck;
+redundant skin; hypoplastic nails; skeletal defects involving upper and lower limbs;
+gastrointestinal and genitourinary anomalies, hypotonia and neurologic impairment.
+""")```
+
+The script added 14 HPO terms to the template. Now we specify the remaining information.
+
+```python
+disease_id = "OMIM:300209"
+disease_label = "Simpson-Golabi-Behmel syndrome, type 2"
+symbol = "OFD1"
+hgnc_id = "HGNC:2567"
+ofd1_transcript='NM_003611.3'
+tcreator.create_template(disease_id=disease_id,
+                         disease_label=disease_label,
+                         gene_symbol=symbol,
+                         HGNC_id=hgnc_id,
+                         transcript=ofd1_transcript)
+```
+
+The script creates a file that can be opened in Excel and used for curation. Add additional HPO terms as necessary and remove terms that are not needed.
\ No newline at end of file
diff --git a/notebooks/ANKH_case_report.ipynb b/notebooks/ANKH_case_report.ipynb
deleted file mode 100644
index 267cc3e5..00000000
--- a/notebooks/ANKH_case_report.ipynb
+++ /dev/null
@@ -1,796 +0,0 @@
-{
- "cells": [
-  {
-   "cell_type": "markdown",
-   "id": "9504e768",
-   "metadata": {},
-   "source": [
-    "<H1>ANKH</H1>"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 15,
-   "id": "c9d63389",
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "pyphetools version 0.8.17\n"
-     ]
-    }
-   ],
-   "source": [
-    "import phenopackets as php\n",
-    "from google.protobuf.json_format import MessageToDict, MessageToJson\n",
-    "from google.protobuf.json_format import Parse, ParseDict\n",
-    "import pandas as pd\n",
-    "pd.set_option('display.max_colwidth', None) # show entire column contents, important!\n",
-    "from collections import defaultdict\n",
-    "import pyphetools\n",
-    "from pyphetools.creation import *\n",
-    "from pyphetools.visualization import *\n",
-    "print(f\"pyphetools version {pyphetools.__version__}\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "37eb44d5-eb26-4de4-8b72-ee63819fde93",
-   "metadata": {},
-   "source": [
-    "<h3>Import HPO Dara</h3>"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 16,
-   "id": "1ce19b0e-aa6c-43a6-8491-50636db06484",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "parser = HpoParser()\n",
-    "hpo_cr = parser.get_hpo_concept_recognizer()\n",
-    "hpo_version = parser.get_version()\n",
-    "hpo_intology = parser.get_ontology()\n",
-    "PMID = \"PMID: 33748234\"\n",
-    "title = \"A three-year clinical investigation of a Chinese child with craniometaphyseal dysplasia caused by a mutated ANKH gene\"\n",
-    "metadata = MetaData(created_by=\"ORCID:0000-0002-0736-9199\", pmid=PMID, pubmed_title=title)\n",
-    "metadata.default_versions_with_hpo(version=hpo_version)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "1a81ef26",
-   "metadata": {},
-   "source": [
-    "<H1>Importing a single case report</H1>\n",
-    "<p>Here, we use functions of the pyphetools package to import data from a typical case report: <a href=\"https://pubmed.ncbi.nlm.nih.gov/33748234/\" target=\"__blank\">Wu JL, et al.</a> A three-year clinical investigation of a Chinese child with craniometaphyseal dysplasia caused by a mutated ANKH gene. World J Clin Cases. 2021 Mar 16;9(8):1853-1862.</p>\n",
-    "<p>The case report consists of several sections to which we can apply text mining and add some corrections for cases in which text mining fails to capture an HPO term or calls a false-positive term.</p>\n",
-    "<p>The basic strategy is to use the <tt>add_vignette</tt> function for each section and judge the results by manual inspecting, adding any missed terms using the custom dictionary (see examples below).</p>"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 17,
-   "id": "feec129c",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "individual_id = \"17-mo-old boy\"\n",
-    "age = \"P1Y5M\"\n",
-    "cmd_disease = Disease(disease_id = \"OMIM:118600\", disease_label = \"Chondrocalcinosis 2\")\n",
-    "encoder = CaseEncoder(hpo_cr=hpo_cr,\n",
-    "                    pmid=PMID,\n",
-    "                    age_at_last_exam=age,\n",
-    "                    sex=\"MALE\",\n",
-    "                    individual_id=individual_id,\n",
-    "                    metadata=metadata.to_ga4gh(),\n",
-    "                    disease=cmd_disease)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "951caefa",
-   "metadata": {},
-   "source": [
-    "<h3>Chief complaints</h3>\n",
-    "<p>A 17-mo-old boy presented with progressive nasal obstruction, snoring and hearing loss symptoms when referred to the hospital.</p>"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 18,
-   "id": "408051a7",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "vignette = \"A 17-mo-old boy presented with progressive nasal obstruction, snoring and hearing loss symptoms when referred to the hospital.\"\n",
-    "results = encoder.add_vignette(vignette=vignette)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 19,
-   "id": "46b2d3fd",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "text/html": [
-       "<div>\n",
-       "<style scoped>\n",
-       "    .dataframe tbody tr th:only-of-type {\n",
-       "        vertical-align: middle;\n",
-       "    }\n",
-       "\n",
-       "    .dataframe tbody tr th {\n",
-       "        vertical-align: top;\n",
-       "    }\n",
-       "\n",
-       "    .dataframe thead th {\n",
-       "        text-align: right;\n",
-       "    }\n",
-       "</style>\n",
-       "<table border=\"1\" class=\"dataframe\">\n",
-       "  <thead>\n",
-       "    <tr style=\"text-align: right;\">\n",
-       "      <th></th>\n",
-       "      <th>id</th>\n",
-       "      <th>label</th>\n",
-       "      <th>observed</th>\n",
-       "      <th>measured</th>\n",
-       "    </tr>\n",
-       "  </thead>\n",
-       "  <tbody>\n",
-       "    <tr>\n",
-       "      <th>0</th>\n",
-       "      <td>HP:0001742</td>\n",
-       "      <td>Nasal congestion</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>1</th>\n",
-       "      <td>HP:0000365</td>\n",
-       "      <td>Hearing impairment</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>2</th>\n",
-       "      <td>HP:0025267</td>\n",
-       "      <td>Snoring</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "  </tbody>\n",
-       "</table>\n",
-       "</div>"
-      ],
-      "text/plain": [
-       "           id               label  observed  measured\n",
-       "0  HP:0001742    Nasal congestion      True      True\n",
-       "1  HP:0000365  Hearing impairment      True      True\n",
-       "2  HP:0025267             Snoring      True      True"
-      ]
-     },
-     "execution_count": 19,
-     "metadata": {},
-     "output_type": "execute_result"
-    }
-   ],
-   "source": [
-    "results"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "0108f8d3",
-   "metadata": {},
-   "source": [
-    "<h2>History of present illness</h2>"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 20,
-   "id": "0e81efe3",
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "           id             label  observed  measured\n",
-      "0  HP:0000452  Choanal stenosis      True      True\n",
-      "           id                         label  observed  measured\n",
-      "0  HP:0001742              Nasal congestion      True      True\n",
-      "1  HP:0000431             Wide nasal bridge      True      True\n",
-      "2  HP:0004349  Reduced bone mineral density      True      True\n",
-      "3  HP:0000256                  Macrocephaly      True      True\n",
-      "4  HP:0011220            Prominent forehead      True      True\n",
-      "5  HP:0000303         Mandibular prognathia      True      True\n",
-      "           id               label  observed  measured\n",
-      "0  HP:0000365  Hearing impairment      True      True\n"
-     ]
-    }
-   ],
-   "source": [
-    "v_6m = \"\"\"\n",
-    "The patient’s medical history was first reviewed before the diagnosis. His head circumference was 45.5 cm,\n",
-    "46.5 cm and 49.5 cm at age 3 mo, 6 mo and 12 mo, respectively. When he was 6 mo old, fiber nasopharyngoscopy\n",
-    "revealed a double choanal stenosis.\n",
-    "\"\"\"\n",
-    "d = {'low bone mineral density':'Reduced bone mineral density',\n",
-    "    'head circumference increased to 51 cm':'Macrocephaly'}\n",
-    "results = encoder.add_vignette(vignette=v_6m, custom_d=d, custom_age=\"P6M\")\n",
-    "print(results)\n",
-    "v_12m = \"\"\"\n",
-    "The patient was found to have a serious nasal obstruction at the age of\n",
-    "12 mo due to a wide nasal bridge. Occasionally, he resorted to mouth breathing, especially at night.\n",
-    "The patient was examined at the local hospital, showed low bone mineral density and commenced\n",
-    "oral calcium supplements. His head circumference increased to 51 cm (standard value 45.2 cm).\n",
-    "Consequently, the patient developed a prominent forehead, prognathism and occipital protuberance.\n",
-    "\"\"\"\n",
-    "results = encoder.add_vignette(vignette=v_12m, custom_d=d, custom_age=\"P1Y\")\n",
-    "print(results)\n",
-    "v_16m =\"\"\"\n",
-    "At the age of 16 mo, the patient presented with mild hearing loss. He had been receiving calcium and\n",
-    "vitamin D supplementation for 4 mo prior to examination at other hospital; however, the patient’s symptoms\n",
-    "developed progressively.\n",
-    "\"\"\"\n",
-    "results = encoder.add_vignette(vignette=v_16m, custom_d=d, custom_age=\"P1Y4M\")\n",
-    "print(results)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "f170c557",
-   "metadata": {},
-   "source": [
-    "<h2>Physical examination</h2>"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 21,
-   "id": "30e53ab8",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "text/html": [
-       "<div>\n",
-       "<style scoped>\n",
-       "    .dataframe tbody tr th:only-of-type {\n",
-       "        vertical-align: middle;\n",
-       "    }\n",
-       "\n",
-       "    .dataframe tbody tr th {\n",
-       "        vertical-align: top;\n",
-       "    }\n",
-       "\n",
-       "    .dataframe thead th {\n",
-       "        text-align: right;\n",
-       "    }\n",
-       "</style>\n",
-       "<table border=\"1\" class=\"dataframe\">\n",
-       "  <thead>\n",
-       "    <tr style=\"text-align: right;\">\n",
-       "      <th></th>\n",
-       "      <th>id</th>\n",
-       "      <th>label</th>\n",
-       "      <th>observed</th>\n",
-       "      <th>measured</th>\n",
-       "    </tr>\n",
-       "  </thead>\n",
-       "  <tbody>\n",
-       "    <tr>\n",
-       "      <th>0</th>\n",
-       "      <td>HP:0000431</td>\n",
-       "      <td>Wide nasal bridge</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>1</th>\n",
-       "      <td>HP:0000316</td>\n",
-       "      <td>Hypertelorism</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>2</th>\n",
-       "      <td>HP:0000303</td>\n",
-       "      <td>Mandibular prognathia</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>3</th>\n",
-       "      <td>HP:0000316</td>\n",
-       "      <td>Hypertelorism</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>4</th>\n",
-       "      <td>HP:0012531</td>\n",
-       "      <td>Pain</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>5</th>\n",
-       "      <td>HP:0010628</td>\n",
-       "      <td>Facial palsy</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>6</th>\n",
-       "      <td>HP:0000687</td>\n",
-       "      <td>Widely spaced teeth</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>7</th>\n",
-       "      <td>HP:0000452</td>\n",
-       "      <td>Choanal stenosis</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "  </tbody>\n",
-       "</table>\n",
-       "</div>"
-      ],
-      "text/plain": [
-       "           id                  label  observed  measured\n",
-       "0  HP:0000431      Wide nasal bridge      True      True\n",
-       "1  HP:0000316          Hypertelorism      True      True\n",
-       "2  HP:0000303  Mandibular prognathia      True      True\n",
-       "3  HP:0000316          Hypertelorism      True      True\n",
-       "4  HP:0012531                   Pain      True      True\n",
-       "5  HP:0010628           Facial palsy      True      True\n",
-       "6  HP:0000687    Widely spaced teeth      True      True\n",
-       "7  HP:0000452       Choanal stenosis      True      True"
-      ]
-     },
-     "execution_count": 21,
-     "metadata": {},
-     "output_type": "execute_result"
-    }
-   ],
-   "source": [
-    "v3 = \"\"\"\n",
-    "A wide nasal bridge, paranasal bossing, widely spaced eyes with an increased bizygomatic width, and\n",
-    "prominent mandible (Figure 1) were noted. However, hypertelorism was not obviously discernible.\n",
-    "Additionally, the patient’s frontal and maxillary sinuses were severely obstructed. He had 20 teeth\n",
-    "with wide spacing between the teeth. His teeth appeared small. He exhibited no facial nerve palsy or\n",
-    "limb muscle tension. His pain perception and muscular strength appeared normal. Nasal laryngeal mirror\n",
-    "showed serious choanal stenosis on both sides. The bottom of the patient’s nose exhibited bossing and\n",
-    "his palatine bone appeared thickened. The patient’s parents and his elder brother had completely normal\n",
-    "features.\n",
-    "\"\"\"\n",
-    "d3 = {\n",
-    "    'wide spacing between the teeth': 'Widely spaced teeth'\n",
-    "}\n",
-    "results = encoder.add_vignette(vignette=v3, custom_d=d3, custom_age=\"P1Y4M\")\n",
-    "results"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "19bb198b",
-   "metadata": {},
-   "source": [
-    "<h2>Radiograph</h2>"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 22,
-   "id": "056526a5",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "text/html": [
-       "<div>\n",
-       "<style scoped>\n",
-       "    .dataframe tbody tr th:only-of-type {\n",
-       "        vertical-align: middle;\n",
-       "    }\n",
-       "\n",
-       "    .dataframe tbody tr th {\n",
-       "        vertical-align: top;\n",
-       "    }\n",
-       "\n",
-       "    .dataframe thead th {\n",
-       "        text-align: right;\n",
-       "    }\n",
-       "</style>\n",
-       "<table border=\"1\" class=\"dataframe\">\n",
-       "  <thead>\n",
-       "    <tr style=\"text-align: right;\">\n",
-       "      <th></th>\n",
-       "      <th>id</th>\n",
-       "      <th>label</th>\n",
-       "      <th>observed</th>\n",
-       "      <th>measured</th>\n",
-       "    </tr>\n",
-       "  </thead>\n",
-       "  <tbody>\n",
-       "    <tr>\n",
-       "      <th>0</th>\n",
-       "      <td>HP:0011001</td>\n",
-       "      <td>Increased bone mineral density</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>1</th>\n",
-       "      <td>HP:0003015</td>\n",
-       "      <td>Flared metaphysis</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>2</th>\n",
-       "      <td>HP:0000431</td>\n",
-       "      <td>Wide nasal bridge</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>3</th>\n",
-       "      <td>HP:0000316</td>\n",
-       "      <td>Hypertelorism</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>4</th>\n",
-       "      <td>HP:0000303</td>\n",
-       "      <td>Mandibular prognathia</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "  </tbody>\n",
-       "</table>\n",
-       "</div>"
-      ],
-      "text/plain": [
-       "           id                           label  observed  measured\n",
-       "0  HP:0011001  Increased bone mineral density      True      True\n",
-       "1  HP:0003015               Flared metaphysis      True      True\n",
-       "2  HP:0000431               Wide nasal bridge      True      True\n",
-       "3  HP:0000316                   Hypertelorism      True      True\n",
-       "4  HP:0000303           Mandibular prognathia      True      True"
-      ]
-     },
-     "execution_count": 22,
-     "metadata": {},
-     "output_type": "execute_result"
-    }
-   ],
-   "source": [
-    "v4 =\"\"\"\n",
-    "Radiograph and facial appearance of the child aged 17 mo. A and B: Cranial computed tomography (CT) scan\n",
-    "shows significantly increased bone density and thickened bone plate of the skull. The sinus cavity was small\n",
-    "without inflation, and the nasal cavity was obviously narrowed. The nasal bone was thickened with abnormal\n",
-    "morphology; C: CT scan shows that the middle ear cavities were narrowed; the lumen of the labyrinth\n",
-    "(vestibular, semicircular canal and cochlear) was sclerotic and the ossicular chain was thickened. The width\n",
-    "of the left optic canal was 3.93 mm, the width of the right optic canal was 4.17 mm; D: CT scan shows\n",
-    "sclerosis of the clavicles and ribs; E: X-ray image shows pronounced metaphyseal flaring in the distal\n",
-    "femora and “Flask deformation” of the proximal metaphysis on both sides (Erlenmeyer flask configuration);\n",
-    "F: Facial appearance of the patient shows a wide nasal bridge, paranasal bossing, widely spaced eyes with\n",
-    "an increased bizygomatic width, and a prominent mandible.\n",
-    "\"\"\"\n",
-    "d4 = {}\n",
-    "results = encoder.add_vignette(vignette=v4, custom_d=d4, custom_age=\"P1Y5M\")\n",
-    "results"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "28171678",
-   "metadata": {},
-   "source": [
-    "<h2>Laboratory examinations</h2>\n",
-    "<p>To add this data, we consulted Table 1 of the original publication and use the <tt>add_term</tt> function to add abnormal or excluded (normal) findings.</p>"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 23,
-   "id": "fa46bc66",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "term=\"Increased circulating osteocalcin level\"\n",
-    "results = encoder.add_term(label=term, custom_age=\"P1Y8M\")\n",
-    "# Combined β-CTX (ng/mL)\n",
-    "term = \"Increased circulating beta-C-terminal telopeptide concentration\" # HP:0031425\n",
-    "results = encoder.add_term(label=term, custom_age=\"P1Y8M\")\n",
-    "term = \"Decreased circulating calcifediol concentration\" # HP:0012053\n",
-    "results = encoder.add_term(label=term, custom_age=\"P1Y8M\")\n",
-    "term = \"Elevated circulating alkaline phosphatase concentration\"\n",
-    "results = encoder.add_term(label=term, custom_age=\"P1Y8M\")\n",
-    "term = \"Abnormal circulating calcium concentration\"\n",
-    "results = encoder.add_term(label=term, excluded=True, custom_age=\"P1Y8M\")\n",
-    "# Abnormal blood phosphate concentration HP:0100529\n",
-    "results = encoder.add_term(hpo_id=\"HP:0100529\", excluded=True, custom_age=\"P1Y8M\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "86bd3248",
-   "metadata": {},
-   "source": [
-    "<h2>Imaging examinations</h2>"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 24,
-   "id": "1fb38e27",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "text/html": [
-       "<div>\n",
-       "<style scoped>\n",
-       "    .dataframe tbody tr th:only-of-type {\n",
-       "        vertical-align: middle;\n",
-       "    }\n",
-       "\n",
-       "    .dataframe tbody tr th {\n",
-       "        vertical-align: top;\n",
-       "    }\n",
-       "\n",
-       "    .dataframe thead th {\n",
-       "        text-align: right;\n",
-       "    }\n",
-       "</style>\n",
-       "<table border=\"1\" class=\"dataframe\">\n",
-       "  <thead>\n",
-       "    <tr style=\"text-align: right;\">\n",
-       "      <th></th>\n",
-       "      <th>id</th>\n",
-       "      <th>label</th>\n",
-       "      <th>observed</th>\n",
-       "      <th>measured</th>\n",
-       "    </tr>\n",
-       "  </thead>\n",
-       "  <tbody>\n",
-       "    <tr>\n",
-       "      <th>0</th>\n",
-       "      <td>HP:0002684</td>\n",
-       "      <td>Thickened calvaria</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>1</th>\n",
-       "      <td>HP:0002694</td>\n",
-       "      <td>Sclerosis of skull base</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>2</th>\n",
-       "      <td>HP:0100923</td>\n",
-       "      <td>Clavicular sclerosis</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>3</th>\n",
-       "      <td>HP:0003015</td>\n",
-       "      <td>Flared metaphysis</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>4</th>\n",
-       "      <td>HP:0004975</td>\n",
-       "      <td>Erlenmeyer flask deformity of the femurs</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>5</th>\n",
-       "      <td>HP:0012531</td>\n",
-       "      <td>Pain</td>\n",
-       "      <td>True</td>\n",
-       "      <td>True</td>\n",
-       "    </tr>\n",
-       "  </tbody>\n",
-       "</table>\n",
-       "</div>"
-      ],
-      "text/plain": [
-       "           id                                     label  observed  measured\n",
-       "0  HP:0002684                        Thickened calvaria      True      True\n",
-       "1  HP:0002694                   Sclerosis of skull base      True      True\n",
-       "2  HP:0100923                      Clavicular sclerosis      True      True\n",
-       "3  HP:0003015                         Flared metaphysis      True      True\n",
-       "4  HP:0004975  Erlenmeyer flask deformity of the femurs      True      True\n",
-       "5  HP:0012531                                      Pain      True      True"
-      ]
-     },
-     "execution_count": 24,
-     "metadata": {},
-     "output_type": "execute_result"
-    }
-   ],
-   "source": [
-    "v6 = \"\"\"\n",
-    "Cranial radiography showed that he had increased thickness of the craniofacial bones with obstructed\n",
-    "sinuses and narrowing ears and other cavities (Figure 1A-C). Specifically, the middle ear cavities\n",
-    "appeared narrowed, while the lumen of the labyrinth (the vestibule, semicircular canal and cochlea)\n",
-    "appeared sclerotic. His mastoid cavity disappeared without tympanic cavity effusion. The width of left\n",
-    "bony optic canal was 3.93 mm and the width on the right side was 4.17 mm. Prominent thickening of the\n",
-    "calvaria was apparent and sclerosis of the skull base and maxilla was noted. The patient’s frontal and\n",
-    "maxillary sinuses were severely obstructed with marked thickening of the cranial walls. His forehead\n",
-    "bone was 14.22 mm thick. Thickening of his middle turbinate and inferior turbinate was also obvious.\n",
-    "The patient’s nasal septum thickness was 8.44 mm, causing his nose and choanae to become significantly\n",
-    "narrowed. His frontal sinus and maxillary sinus were invaded by dysplastic tissue. Marked thickening\n",
-    "of the cranial walls was also noted.\n",
-    "Sclerosis of the clavicles and ribs was observed by computed tomography (CT) and X-ray (Figure 1E).\n",
-    "Metaphyseal flaring in the distal femora due to a modeling defect was the most pronounced abnormality.\n",
-    "“Flask deformation” of the proximal metaphysis on both sides (Erlenmeyer flask configuration) (Figure 1F)\n",
-    "was also noted. When the patient was four years and 2 mo, he complained of pain in the legs; thus, long\n",
-    "bone examination was performed again. CT and X-ray did not demonstrate significant changes compared to\n",
-    "his previous image (Figure 2). CT of the middle ears showed the absence of effusions. The patient’s\n",
-    "ossicular chain was large.\n",
-    "\n",
-    "\"\"\"\n",
-    "d6 = {'Sclerosis of the clavicles': 'Clavicular sclerosis',\n",
-    "     'Erlenmeyer flask configuration': 'Erlenmeyer flask deformity of the femurs'}\n",
-    "results = encoder.add_vignette(vignette=v6, custom_d=d6)\n",
-    "results"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "429a8580",
-   "metadata": {},
-   "source": [
-    "<h2>The variant</h2>\n",
-    "<p> A heterozygous mutation c.1129_1131del (NM_054027) was found on exon 9 of the ANKH gene.</p>"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 25,
-   "id": "e3aa72b8",
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "https://rest.variantvalidator.org/VariantValidator/variantvalidator/hg38/NM_054027.4%3Ac.1129_1131del/NM_054027.4?content-type=application%2Fjson\n"
-     ]
-    },
-    {
-     "data": {
-      "text/plain": [
-       "'chr5:14716715GGAA>G'"
-      ]
-     },
-     "execution_count": 25,
-     "metadata": {},
-     "output_type": "execute_result"
-    }
-   ],
-   "source": [
-    "genome = 'hg38'\n",
-    "default_genotype = 'heterozygous'\n",
-    "transcript='NM_054027.4'\n",
-    "varValidator = VariantValidator(genome_build=genome, transcript=transcript)\n",
-    "var = varValidator.encode_hgvs(hgvs=\"c.1129_1131del\")\n",
-    "var.set_heterozygous()\n",
-    "var.to_string()"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 26,
-   "id": "cac79d5d",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "individual = encoder.get_individual()\n",
-    "individual.add_variant(var)\n",
-    "ppacket = individual.to_ga4gh_phenopacket(metadata=metadata.to_ga4gh())"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 27,
-   "id": "ce994ded",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "text/html": [
-       "<table style=\"border: 2px solid black; align: \"left\">\n",
-       "<caption>1 phenopacket - PMID: 33748234 (n=1)</caption>\n",
-       "<tr><th style=\"text-align: left;font-weight: bold;\">Individual</th><th style=\"text-align: left;font-weight: bold;\">Disease</th><th style=\"text-align: left;font-weight: bold;\">Genotype</th><th style=\"text-align: left;font-weight: bold;\">Phenotypic features</th></tr>\n",
-       "<tr><td style=\"text-align: left;\">17-mo-old boy (MALE; P1Y5M)</td><td style=\"text-align: left;\">Chondrocalcinosis 2 (OMIM:118600)</td><td style=\"text-align: left;\">NM_054027.4:c.1129_1131del (heterozygous)</td><td style=\"text-align: left;\"><b>P6M</b>: Choanal stenosis (HP:0000452)<br/><b>P1Y</b>: Nasal congestion (HP:0001742); Wide nasal bridge (HP:0000431); Reduced bone mineral density (HP:0004349); Macrocephaly (HP:0000256); Prominent forehead (HP:0011220); Mandibular prognathia (HP:0000303)<br/><b>P1Y4M</b>: Hearing impairment (HP:0000365); Wide nasal bridge (HP:0000431); Hypertelorism (HP:0000316); Mandibular prognathia (HP:0000303); Hypertelorism (HP:0000316); Pain (HP:0012531); Facial palsy (HP:0010628); Widely spaced teeth (HP:0000687); Choanal stenosis (HP:0000452)<br/><b>P1Y5M</b>: Nasal congestion (HP:0001742); Hearing impairment (HP:0000365); Snoring (HP:0025267); Increased bone mineral density (HP:0011001); Flared metaphysis (HP:0003015); Wide nasal bridge (HP:0000431); Hypertelorism (HP:0000316); Mandibular prognathia (HP:0000303); Thickened calvaria (HP:0002684); Sclerosis of skull base (HP:0002694); Clavicular sclerosis (HP:0100923); Flared metaphysis (HP:0003015); Erlenmeyer flask deformity of the femurs (HP:0004975); Pain (HP:0012531)<br/><b>P1Y8M</b>: Increased circulating osteocalcin level (HP:0031428); Increased circulating beta-C-terminal telopeptide concentration (HP:0031425); Decreased circulating calcifediol concentration (HP:0012053); Elevated circulating alkaline phosphatase concentration (HP:0003155); excluded: Abnormal circulating calcium concentration (HP:0004363); excluded: Abnormal blood phosphate concentration (HP:0100529)</td></tr>\n",
-       "</table>"
-      ],
-      "text/plain": [
-       "<IPython.core.display.HTML object>"
-      ]
-     },
-     "metadata": {},
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "from IPython.display import HTML, display\n",
-    "\n",
-    "phenopackets = [individual.to_ga4gh_phenopacket(metadata=metadata.to_ga4gh()) ]\n",
-    "table = PhenopacketTable(phenopacket_list=phenopackets)\n",
-    "display(HTML(table.to_html()))"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 28,
-   "id": "3fea8731",
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "We output 1 GA4GH phenopackets to the directory phenopackets\n"
-     ]
-    }
-   ],
-   "source": [
-    "output_directory = \"phenopackets\"\n",
-    "Individual.output_individuals_as_phenopackets(individual_list=[individual],\n",
-    "                                            metadata=metadata,\n",
-    "                                            outdir=output_directory)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": []
-  }
- ],
- "metadata": {
-  "kernelspec": {
-   "display_name": "ppt_venv",
-   "language": "python",
-   "name": "ppt_venv"
-  },
-  "language_info": {
-   "codemirror_mode": {
-    "name": "ipython",
-    "version": 3
-   },
-   "file_extension": ".py",
-   "mimetype": "text/x-python",
-   "name": "python",
-   "nbconvert_exporter": "python",
-   "pygments_lexer": "ipython3",
-   "version": "3.11.3"
-  }
- },
- "nbformat": 4,
- "nbformat_minor": 5
-}
diff --git a/notebooks/HPOA_exporter.ipynb b/notebooks/HPOA_exporter.ipynb
deleted file mode 100644
index 3ff3fe98..00000000
--- a/notebooks/HPOA_exporter.ipynb
+++ /dev/null
@@ -1,1449 +0,0 @@
-{
- "cells": [
-  {
-   "cell_type": "markdown",
-   "id": "1c7de443-8a07-4957-972f-720de6120bed",
-   "metadata": {},
-   "source": [
-    "# HPOA File creator\n",
-    "This notebook shows how to convert a collection of phenopackets into a file that can be use for the phenotype.hpoa resource. The latter provides aggregate frequency data for each source.\n",
-    "\n",
-    "For this example we first create a collection of phenopackets and then transform them into a file for the HPOA.\n",
-    "\n",
-    "Our example will be [Yogev Y, et al. (2023) Limb girdle muscular disease caused by HMGCR mutation and statin myopathy treatable with mevalonolactone. Proc Natl Acad Sci U S A.  120:e2217831120 PMID: 36745799](https://pubmed.ncbi.nlm.nih.gov/36745799/)."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 1,
-   "id": "a5500cec-34f9-4ac6-a4d8-a838585d0dab",
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "Using pyphetools version 0.8.7\n"
-     ]
-    }
-   ],
-   "source": [
-    "import phenopackets as PPKt\n",
-    "from google.protobuf.json_format import MessageToDict, MessageToJson\n",
-    "from google.protobuf.json_format import Parse, ParseDict\n",
-    "import pandas as pd\n",
-    "import os, sys\n",
-    "pd.set_option('display.max_colwidth', None) # show entire column contents, important!\n",
-    "pd.set_option('display.max_rows', None)\n",
-    "from collections import defaultdict\n",
-    "\n",
-    "from phenopackets import Phenopacket\n",
-    "from google.protobuf.json_format import Parse\n",
-    "import json\n",
-    "import hpotk\n",
-    "\n",
-    "from IPython.display import display, HTML\n",
-    "from pyphetools.creation import *\n",
-    "from pyphetools.visualization import *\n",
-    "from pyphetools.validation import *\n",
-    "\n",
-    "import importlib.metadata\n",
-    "__version__ = importlib.metadata.version(\"pyphetools\")\n",
-    "print(f\"Using pyphetools version {__version__}\")"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 2,
-   "id": "df2aa98e-c8f6-4720-9875-5bed0226b01c",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "parser = HpoParser()\n",
-    "hpo_cr = parser.get_hpo_concept_recognizer()\n",
-    "hpo_version = parser.get_version()\n",
-    "hpo_ontology = parser.get_ontology()\n",
-    "PMID=\"PMID:36745799\"\n",
-    "title = \"Limb girdle muscular disease caused by HMGCR mutation and statin myopathy treatable with mevalonolactone\"\n",
-    "metadata = MetaData(created_by=\"ORCID:0000-0002-0736-9199\", pmid=PMID, pubmed_title=title)\n",
-    "metadata.default_versions_with_hpo(version=hpo_version)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 3,
-   "id": "13a35947-43c6-4afe-af0d-06da3882147a",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "data = \"data/LGMDR28.xlsx\"\n",
-    "df = pd.read_excel(data)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 4,
-   "id": "168e0d88-4d97-4e8c-9969-377081698abe",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "text/html": [
-       "<div>\n",
-       "<style scoped>\n",
-       "    .dataframe tbody tr th:only-of-type {\n",
-       "        vertical-align: middle;\n",
-       "    }\n",
-       "\n",
-       "    .dataframe tbody tr th {\n",
-       "        vertical-align: top;\n",
-       "    }\n",
-       "\n",
-       "    .dataframe thead th {\n",
-       "        text-align: right;\n",
-       "    }\n",
-       "</style>\n",
-       "<table border=\"1\" class=\"dataframe\">\n",
-       "  <thead>\n",
-       "    <tr style=\"text-align: right;\">\n",
-       "      <th></th>\n",
-       "      <th>INDIVIDUAL</th>\n",
-       "      <th>V:2</th>\n",
-       "      <th>V:5</th>\n",
-       "      <th>V:8</th>\n",
-       "      <th>V:9</th>\n",
-       "      <th>V:12</th>\n",
-       "      <th>V:13</th>\n",
-       "    </tr>\n",
-       "  </thead>\n",
-       "  <tbody>\n",
-       "    <tr>\n",
-       "      <th>0</th>\n",
-       "      <td>SEX</td>\n",
-       "      <td>F</td>\n",
-       "      <td>M</td>\n",
-       "      <td>M</td>\n",
-       "      <td>M</td>\n",
-       "      <td>F</td>\n",
-       "      <td>M</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>1</th>\n",
-       "      <td>AGE_AT_EXAMINATION</td>\n",
-       "      <td>49</td>\n",
-       "      <td>58</td>\n",
-       "      <td>37</td>\n",
-       "      <td>42</td>\n",
-       "      <td>51</td>\n",
-       "      <td>41</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>2</th>\n",
-       "      <td>AGE_AT_ONSET</td>\n",
-       "      <td>31</td>\n",
-       "      <td>39</td>\n",
-       "      <td>24</td>\n",
-       "      <td>33</td>\n",
-       "      <td>31</td>\n",
-       "      <td>34</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>3</th>\n",
-       "      <td>PROXIMAL_STRENGTH-UPPER_LIMB</td>\n",
-       "      <td>0/5</td>\n",
-       "      <td>3/5</td>\n",
-       "      <td>5/5</td>\n",
-       "      <td>5/5</td>\n",
-       "      <td>2/5</td>\n",
-       "      <td>3/5</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>4</th>\n",
-       "      <td>PROXIMAL_STRENGTH-LOWER_LIMB</td>\n",
-       "      <td>0/5</td>\n",
-       "      <td>2/5</td>\n",
-       "      <td>5/5</td>\n",
-       "      <td>4/5</td>\n",
-       "      <td>2/5</td>\n",
-       "      <td>4/5</td>\n",
-       "    </tr>\n",
-       "  </tbody>\n",
-       "</table>\n",
-       "</div>"
-      ],
-      "text/plain": [
-       "                     INDIVIDUAL  V:2  V:5  V:8  V:9 V:12 V:13\n",
-       "0                           SEX    F    M    M    M    F    M\n",
-       "1            AGE_AT_EXAMINATION   49   58   37   42   51   41\n",
-       "2                  AGE_AT_ONSET   31   39   24   33   31   34\n",
-       "3  PROXIMAL_STRENGTH-UPPER_LIMB  0/5  3/5  5/5  5/5  2/5  3/5\n",
-       "4  PROXIMAL_STRENGTH-LOWER_LIMB  0/5  2/5  5/5  4/5  2/5  4/5"
-      ]
-     },
-     "execution_count": 4,
-     "metadata": {},
-     "output_type": "execute_result"
-    }
-   ],
-   "source": [
-    "df.head()"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 5,
-   "id": "7648875d-1483-4474-87ab-a609f5c4d72d",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "text/html": [
-       "<div>\n",
-       "<style scoped>\n",
-       "    .dataframe tbody tr th:only-of-type {\n",
-       "        vertical-align: middle;\n",
-       "    }\n",
-       "\n",
-       "    .dataframe tbody tr th {\n",
-       "        vertical-align: top;\n",
-       "    }\n",
-       "\n",
-       "    .dataframe thead th {\n",
-       "        text-align: right;\n",
-       "    }\n",
-       "</style>\n",
-       "<table border=\"1\" class=\"dataframe\">\n",
-       "  <thead>\n",
-       "    <tr style=\"text-align: right;\">\n",
-       "      <th>INDIVIDUAL</th>\n",
-       "      <th>SEX</th>\n",
-       "      <th>AGE_AT_EXAMINATION</th>\n",
-       "      <th>AGE_AT_ONSET</th>\n",
-       "      <th>PROXIMAL_STRENGTH-UPPER_LIMB</th>\n",
-       "      <th>PROXIMAL_STRENGTH-LOWER_LIMB</th>\n",
-       "      <th>ATROPHY_UPPER_LIMB</th>\n",
-       "      <th>ATROPHY_LOWER_LIMB</th>\n",
-       "      <th>DEEP_TENDON_REFLEXES</th>\n",
-       "      <th>PAIN_ON_EXERTION</th>\n",
-       "      <th>AMBULATORY</th>\n",
-       "      <th>...</th>\n",
-       "      <th>VLDL</th>\n",
-       "      <th>FASTING_BLOOD_SUGAR</th>\n",
-       "      <th>ANA,RF,C3,C4ABNORMALITIES</th>\n",
-       "      <th>ANTI-SM,ANTIJO-1,ANTI-SSA/B,ANCA,AMA</th>\n",
-       "      <th>ANTI-HMGCR_AB</th>\n",
-       "      <th>ABNORMAL_BRAIN_IMAGING</th>\n",
-       "      <th>MYOPATHIC_CHANGES_IN_EMG</th>\n",
-       "      <th>ABNORMAL_NCV</th>\n",
-       "      <th>COMORBIDITIES</th>\n",
-       "      <th>patient_id</th>\n",
-       "    </tr>\n",
-       "  </thead>\n",
-       "  <tbody>\n",
-       "    <tr>\n",
-       "      <th>V:2</th>\n",
-       "      <td>F</td>\n",
-       "      <td>49</td>\n",
-       "      <td>31</td>\n",
-       "      <td>0/5</td>\n",
-       "      <td>0/5</td>\n",
-       "      <td>Marked</td>\n",
-       "      <td>Marked</td>\n",
-       "      <td>Absent</td>\n",
-       "      <td>+</td>\n",
-       "      <td>-</td>\n",
-       "      <td>...</td>\n",
-       "      <td>17(9-26)</td>\n",
-       "      <td>390</td>\n",
-       "      <td>-</td>\n",
-       "      <td>-</td>\n",
-       "      <td>-</td>\n",
-       "      <td>-</td>\n",
-       "      <td>+</td>\n",
-       "      <td>-</td>\n",
-       "      <td>Insulindependentdiabetes-onsetatage19</td>\n",
-       "      <td>V:2</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>V:5</th>\n",
-       "      <td>M</td>\n",
-       "      <td>58</td>\n",
-       "      <td>39</td>\n",
-       "      <td>3/5</td>\n",
-       "      <td>2/5</td>\n",
-       "      <td>Marked</td>\n",
-       "      <td>Marked</td>\n",
-       "      <td>Diminished</td>\n",
-       "      <td>+</td>\n",
-       "      <td>-</td>\n",
-       "      <td>...</td>\n",
-       "      <td>25(15-46)</td>\n",
-       "      <td>123</td>\n",
-       "      <td>-</td>\n",
-       "      <td>NaN</td>\n",
-       "      <td>-</td>\n",
-       "      <td>-</td>\n",
-       "      <td>+</td>\n",
-       "      <td>(+)L4-5radiculopathy</td>\n",
-       "      <td>COPD,Diastolicdysfunction,ICRBBB,Lymphocytosis</td>\n",
-       "      <td>V:5</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>V:8</th>\n",
-       "      <td>M</td>\n",
-       "      <td>37</td>\n",
-       "      <td>24</td>\n",
-       "      <td>5/5</td>\n",
-       "      <td>5/5</td>\n",
-       "      <td>-</td>\n",
-       "      <td>-</td>\n",
-       "      <td>+</td>\n",
-       "      <td>+</td>\n",
-       "      <td>+</td>\n",
-       "      <td>...</td>\n",
-       "      <td>19</td>\n",
-       "      <td>127</td>\n",
-       "      <td>-</td>\n",
-       "      <td>NaN</td>\n",
-       "      <td>-</td>\n",
-       "      <td>NaN</td>\n",
-       "      <td>NaN</td>\n",
-       "      <td>NaN</td>\n",
-       "      <td>NaN</td>\n",
-       "      <td>V:8</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>V:9</th>\n",
-       "      <td>M</td>\n",
-       "      <td>42</td>\n",
-       "      <td>33</td>\n",
-       "      <td>5/5</td>\n",
-       "      <td>4/5</td>\n",
-       "      <td>-</td>\n",
-       "      <td>-</td>\n",
-       "      <td>+</td>\n",
-       "      <td>+</td>\n",
-       "      <td>+</td>\n",
-       "      <td>...</td>\n",
-       "      <td>22(12-32)</td>\n",
-       "      <td>111</td>\n",
-       "      <td>NaN</td>\n",
-       "      <td>NaN</td>\n",
-       "      <td>-</td>\n",
-       "      <td>-</td>\n",
-       "      <td>NaN</td>\n",
-       "      <td>NaN</td>\n",
-       "      <td>ICRBBB</td>\n",
-       "      <td>V:9</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>V:12</th>\n",
-       "      <td>F</td>\n",
-       "      <td>51</td>\n",
-       "      <td>31</td>\n",
-       "      <td>2/5</td>\n",
-       "      <td>2/5</td>\n",
-       "      <td>Evident</td>\n",
-       "      <td>Evident</td>\n",
-       "      <td>Diminished</td>\n",
-       "      <td>+</td>\n",
-       "      <td>-</td>\n",
-       "      <td>...</td>\n",
-       "      <td>30(11-154)</td>\n",
-       "      <td>124</td>\n",
-       "      <td>-</td>\n",
-       "      <td>-</td>\n",
-       "      <td>-</td>\n",
-       "      <td>-</td>\n",
-       "      <td>+</td>\n",
-       "      <td>-</td>\n",
-       "      <td>Single kidney</td>\n",
-       "      <td>V:12</td>\n",
-       "    </tr>\n",
-       "  </tbody>\n",
-       "</table>\n",
-       "<p>5 rows × 34 columns</p>\n",
-       "</div>"
-      ],
-      "text/plain": [
-       "INDIVIDUAL SEX AGE_AT_EXAMINATION AGE_AT_ONSET PROXIMAL_STRENGTH-UPPER_LIMB  \\\n",
-       "V:2          F                 49           31                          0/5   \n",
-       "V:5          M                 58           39                          3/5   \n",
-       "V:8          M                 37           24                          5/5   \n",
-       "V:9          M                 42           33                          5/5   \n",
-       "V:12         F                 51           31                          2/5   \n",
-       "\n",
-       "INDIVIDUAL PROXIMAL_STRENGTH-LOWER_LIMB ATROPHY_UPPER_LIMB ATROPHY_LOWER_LIMB  \\\n",
-       "V:2                                 0/5             Marked             Marked   \n",
-       "V:5                                 2/5             Marked             Marked   \n",
-       "V:8                                 5/5                  -                  -   \n",
-       "V:9                                 4/5                  -                  -   \n",
-       "V:12                                2/5            Evident            Evident   \n",
-       "\n",
-       "INDIVIDUAL DEEP_TENDON_REFLEXES PAIN_ON_EXERTION AMBULATORY  ...        VLDL  \\\n",
-       "V:2                      Absent                +          -  ...    17(9-26)   \n",
-       "V:5                  Diminished                +          -  ...   25(15-46)   \n",
-       "V:8                           +                +          +  ...          19   \n",
-       "V:9                           +                +          +  ...   22(12-32)   \n",
-       "V:12                 Diminished                +          -  ...  30(11-154)   \n",
-       "\n",
-       "INDIVIDUAL FASTING_BLOOD_SUGAR ANA,RF,C3,C4ABNORMALITIES  \\\n",
-       "V:2                        390                         -   \n",
-       "V:5                        123                         -   \n",
-       "V:8                        127                         -   \n",
-       "V:9                        111                       NaN   \n",
-       "V:12                       124                         -   \n",
-       "\n",
-       "INDIVIDUAL ANTI-SM,ANTIJO-1,ANTI-SSA/B,ANCA,AMA ANTI-HMGCR_AB  \\\n",
-       "V:2                                           -             -   \n",
-       "V:5                                         NaN             -   \n",
-       "V:8                                         NaN             -   \n",
-       "V:9                                         NaN             -   \n",
-       "V:12                                          -             -   \n",
-       "\n",
-       "INDIVIDUAL ABNORMAL_BRAIN_IMAGING MYOPATHIC_CHANGES_IN_EMG  \\\n",
-       "V:2                             -                        +   \n",
-       "V:5                             -                        +   \n",
-       "V:8                           NaN                      NaN   \n",
-       "V:9                             -                      NaN   \n",
-       "V:12                            -                        +   \n",
-       "\n",
-       "INDIVIDUAL          ABNORMAL_NCV  \\\n",
-       "V:2                            -   \n",
-       "V:5         (+)L4-5radiculopathy   \n",
-       "V:8                          NaN   \n",
-       "V:9                          NaN   \n",
-       "V:12                           -   \n",
-       "\n",
-       "INDIVIDUAL                                   COMORBIDITIES patient_id  \n",
-       "V:2                  Insulindependentdiabetes-onsetatage19        V:2  \n",
-       "V:5         COPD,Diastolicdysfunction,ICRBBB,Lymphocytosis        V:5  \n",
-       "V:8                                                    NaN        V:8  \n",
-       "V:9                                                 ICRBBB        V:9  \n",
-       "V:12                                         Single kidney       V:12  \n",
-       "\n",
-       "[5 rows x 34 columns]"
-      ]
-     },
-     "execution_count": 5,
-     "metadata": {},
-     "output_type": "execute_result"
-    }
-   ],
-   "source": [
-    "dft = df.transpose()\n",
-    "dft.columns = dft.iloc[0]\n",
-    "dft.drop(dft.index[0], inplace=True)\n",
-    "dft['patient_id'] = dft.index  # Set the new column 'patient_id' to be identical to the contents of the index\n",
-    "dft.head() "
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 6,
-   "id": "5bb3a1db-fb86-454e-8bca-765a8f0a8f70",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "column_mapper_d = {}"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 7,
-   "id": "5e3262c8-da36-4885-a467-c3f21d6eb9b8",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Only needed to stream construction of mappers\n",
-    "#result = OptionColumnMapper.autoformat(df=dft, concept_recognizer=hpo_cr, delimiter=\",\")\n",
-    "#print(result)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 8,
-   "id": "55648ebf-bed4-4574-a8cb-53d619ffc445",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "ageMapper = AgeColumnMapper.by_year(column_name='AGE_AT_EXAMINATION')\n",
-    "#ageColumn.preview_column(dft['AGE_AT_EXAMINATION'])"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 9,
-   "id": "1b9e1c1c-4041-4ff4-9b6d-e4827d1a6d6f",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "sexMapper = SexColumnMapper(male_symbol=\"M\", female_symbol=\"F\", column_name=\"SEX\")\n",
-    "#sexColumn.preview_column(dft[\"SEX\"])"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 10,
-   "id": "cfc58fb0-feaa-4103-9988-b222be6f4e1b",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "proximal_strength_upper_limb_d = {\n",
-    " '3/5': 'Proximal muscle weakness in upper limbs',\n",
-    " '5/5': 'Proximal muscle weakness in upper limbs',\n",
-    " '2/5': 'Proximal muscle weakness in upper limbs'}\n",
-    "excluded_d = {'0/5': 'Proximal muscle weakness in upper limbs'}\n",
-    "proximal_strength_upper_limbMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d=proximal_strength_upper_limb_d, excluded_d=excluded_d)\n",
-    "proximal_strength_upper_limbMapper.preview_column(dft['PROXIMAL_STRENGTH-UPPER_LIMB'])\n",
-    "column_mapper_d['PROXIMAL_STRENGTH-UPPER_LIMB'] = proximal_strength_upper_limbMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 11,
-   "id": "9477af81-0b37-482b-8344-213d9385cded",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "proximal_strength_lower_limb_d = {\n",
-    " '2/5': 'Proximal muscle weakness in lower limbs',\n",
-    " '5/5': 'Proximal muscle weakness in lower limbs',\n",
-    " '4/5': 'Proximal muscle weakness in lower limbs'}\n",
-    "excluded = {'0/5': 'Proximal muscle weakness in lower limbs'}\n",
-    "proximal_strength_lower_limbMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d=proximal_strength_lower_limb_d, excluded_d=excluded)\n",
-    "proximal_strength_lower_limbMapper.preview_column(dft['PROXIMAL_STRENGTH-LOWER_LIMB'])\n",
-    "column_mapper_d['PROXIMAL_STRENGTH-LOWER_LIMB'] = proximal_strength_lower_limbMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 12,
-   "id": "d6d68659-37f7-41f9-97f6-6a4f975d0963",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "atrophy_upper_limb_d = {'Marked': 'Upper limb amyotrophy',\n",
-    " 'Evident': 'Upper limb amyotrophy'}\n",
-    "atrophy_upper_limbMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d=atrophy_upper_limb_d)\n",
-    "atrophy_upper_limbMapper.preview_column(dft['ATROPHY_UPPER_LIMB'])\n",
-    "column_mapper_d['ATROPHY_UPPER_LIMB'] = atrophy_upper_limbMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 13,
-   "id": "d1f6749e-273d-4f0b-aaf2-e98a6ee34c09",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "atrophy_lower_limb_d = {'Marked': 'Lower limb amyotrophy',\n",
-    " 'Evident': 'Lower limb amyotrophy'}\n",
-    "atrophy_lower_limbMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d=atrophy_lower_limb_d)\n",
-    "atrophy_lower_limbMapper.preview_column(dft['ATROPHY_LOWER_LIMB'])\n",
-    "column_mapper_d['ATROPHY_LOWER_LIMB'] = atrophy_lower_limbMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 14,
-   "id": "f49adb61-0825-4a5b-8789-31f0d4ae7b0d",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "deep_tendon_reflexes_d = {'Absent': 'Areflexia',\n",
-    " 'Diminished': 'Hyporeflexia'}\n",
-    "deep_tendon_reflexesMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d=deep_tendon_reflexes_d)\n",
-    "deep_tendon_reflexesMapper.preview_column(dft['DEEP_TENDON_REFLEXES'])\n",
-    "column_mapper_d['DEEP_TENDON_REFLEXES'] = deep_tendon_reflexesMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 15,
-   "id": "0f7b8bd5-7a96-48c3-9309-8fa025f1ab02",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "#Exercise-induced myalgia HP:0003738\n",
-    "pain_on_exertion_d = {\"+\":\"Exercise-induced myalgia\"}\n",
-    "pain_on_exertionMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d=pain_on_exertion_d)\n",
-    "pain_on_exertionMapper.preview_column(dft['PAIN_ON_EXERTION'])\n",
-    "column_mapper_d['PAIN_ON_EXERTION'] = pain_on_exertionMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 16,
-   "id": "ac8d323b-8790-48fd-a280-6feda5b6bc52",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Loss of ambulation HP:0002505\n",
-    "ambulatory_d = {}\n",
-    "ambulatoryMapper = SimpleColumnMapper(hpo_id=\"HP:0002505\", hpo_label=\"Loss of ambulation\", observed=\"+\", excluded=\"-\")\n",
-    "ambulatoryMapper.preview_column(dft['AMBULATORY'])\n",
-    "column_mapper_d['AMBULATORY'] = ambulatoryMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 17,
-   "id": "b86dd41e-d537-48fe-848d-70f9a342168e",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# dft['MOBILITY_RESTRICTION'])\n",
-    "# Not coding"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 18,
-   "id": "c3bc1b6f-163e-4d6b-afc0-b51e70201d3e",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Respiratory insufficiency HP:0002093\n",
-    "respiratory_difficulties_d = {'Ventilated_through_tracheostomy': 'Respiratory insufficiency', \"+\":\"Respiratory insufficiency\"}\n",
-    "excluded = {\"-\":\"Respiratory insufficiency\"}\n",
-    "respiratory_difficultiesMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d=respiratory_difficulties_d, excluded_d=excluded)\n",
-    "respiratory_difficultiesMapper.preview_column(dft['RESPIRATORY_DIFFICULTIES'])\n",
-    "column_mapper_d['RESPIRATORY_DIFFICULTIES'] = respiratory_difficultiesMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 19,
-   "id": "720b42e5-c296-40bb-a6de-992fa6b26e9a",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "dysphagia_d = {}\n",
-    "dysphagiaMapper = SimpleColumnMapper(hpo_id=\"HP:0002015\", hpo_label=\"Dysphagia\", observed=\"+\", excluded=\"-\")\n",
-    "dysphagiaMapper.preview_column(dft['DYSPHAGIA'])\n",
-    "column_mapper_d['DYSPHAGIA'] = dysphagiaMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 20,
-   "id": "a0eb5340-9027-4f59-bd3e-ba4345214153",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "echocardiography_d = {\n",
-    " 'Mild diastolic_dysfunction': 'Left ventricular diastolic dysfunction',\n",
-    "}\n",
-    "echocardiographyMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d=echocardiography_d)\n",
-    "echocardiographyMapper.preview_column(dft['ECHOCARDIOGRAPHY'])\n",
-    "column_mapper_d['ECHOCARDIOGRAPHY'] = echocardiographyMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 21,
-   "id": "2ca27f8e-dd53-449f-a796-658098280d94",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "cpkMapper = ConstantColumnMapper(hpo_id=\"HP:0003236\", hpo_label='Elevated circulating creatine kinase concentration')\n",
-    "cpkMapper.preview_column(dft['CPK(REFERENCE_20-180_U/L)'])\n",
-    "column_mapper_d['CPK(REFERENCE_20-180_U/L)'] = cpkMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 22,
-   "id": "04506059-e246-48f7-bba2-79d8729635a4",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "troponinMapper = ThresholdedColumnMapper(hpo_id=\"HP:0410174\", \n",
-    "                                         hpo_label=\"Increased circulating troponin T concentration\", \n",
-    "                                         threshold=14, \n",
-    "                                         call_if_above=True)\n",
-    "troponinMapper.preview_column(dft['MAXIMAL TROPONIN T(0-14NG/L)'])\n",
-    "column_mapper_d['MAXIMAL TROPONIN T(0-14NG/L)'] = troponinMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 23,
-   "id": "84ec62c6-0189-4a8c-bff8-43d9bcf8eb62",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Not abnormal for any individual\n",
-    "creatinineMapper = ConstantColumnMapper(hpo_id=\"HP:0012100\", hpo_label=\"Abnormal circulating creatinine concentration\", excluded=True)\n",
-    "creatinineMapper.preview_column(dft['CREATININE'])\n",
-    "column_mapper_d['CREATININE'] = creatinineMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 24,
-   "id": "e0cf6258-a20f-4b91-ade6-6480f8cda5f1",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "ast_d = {'34(12-106)': 'Elevated circulating aspartate aminotransferase concentration',\n",
-    " '54(15-241)': 'Elevated circulating aspartate aminotransferase concentration',\n",
-    " '277(68-905)': 'Elevated circulating aspartate aminotransferase concentration',\n",
-    " '43(21-138)': 'Elevated circulating aspartate aminotransferase concentration',\n",
-    " '98(28-566)': 'Elevated circulating aspartate aminotransferase concentration'}\n",
-    "excluded = {'23(19-29)': 'Elevated circulating aspartate aminotransferase concentration'}\n",
-    "astMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d=ast_d, excluded_d=excluded)\n",
-    "astMapper.preview_column(dft['AST(REFERENCE_0-35U/L)'])\n",
-    "column_mapper_d['AST(REFERENCE_0-35U/L)'] = astMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 25,
-   "id": "c28d5429-6328-489e-8318-c523b6553b7e",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "alt_d = {'31(9-113)': 'Elevated circulating alanine aminotransferase concentration',\n",
-    " '50(10-199)': 'Elevated circulating alanine aminotransferase concentration',\n",
-    " '322(43-911)': 'Elevated circulating alanine aminotransferase concentration',\n",
-    " '44(12-173)': 'Elevated circulating alanine aminotransferase concentration',\n",
-    " '80(21-375)': 'Elevated circulating alanine aminotransferase concentration'}\n",
-    "excluded = {'15(11-25)': 'Elevated circulating alanine aminotransferase concentration'}\n",
-    "altMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d=alt_d, excluded_d=excluded)\n",
-    "altMapper.preview_column(dft['ALT(REFERENCE_0-45U/L)'])\n",
-    "column_mapper_d['ALT(REFERENCE_0-45U/L)'] = altMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 26,
-   "id": "86d56547-d497-4652-8d86-cce495045ea9",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "alkaline_phosphatase_d = {'151(108-331)': 'Elevated circulating alkaline phosphatase concentration',\n",
-    " }\n",
-    "excluded = {'109(78-130)': 'Elevated circulating alkaline phosphatase concentration',\n",
-    " '78(67-88)': 'Elevated circulating alkaline phosphatase concentration',\n",
-    " '89(65-107)': 'Elevated circulating alkaline phosphatase concentration',\n",
-    " '100(72-132)': 'Elevated circulating alkaline phosphatase concentration',\n",
-    " '79(68-94)': 'Elevated circulating alkaline phosphatase concentration'}\n",
-    "alkaline_phosphataseMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d=alkaline_phosphatase_d, excluded_d=excluded)\n",
-    "alkaline_phosphataseMapper.preview_column(dft['ALKALINE_PHOSPHATASE(REFERENCE_30-120U/L)'])\n",
-    "column_mapper_d['ALKALINE_PHOSPHATASE(REFERENCE_30-120U/L)'] = alkaline_phosphataseMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 27,
-   "id": "3502e070-ce4a-45b9-9ce2-5cc3c46bbd91",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "total_cholesterol_averageMapper = ConstantColumnMapper(hpo_id=\"HP:0003124\", hpo_label=\"Hypercholesterolemia\", excluded=True)\n",
-    "total_cholesterol_averageMapper.preview_column(dft['TOTAL_CHOLESTEROL_AVERAGE(RECOMMENDED<200MG/DL)'])\n",
-    "column_mapper_d['TOTAL_CHOLESTEROL_AVERAGE(RECOMMENDED<200MG/DL)'] = total_cholesterol_averageMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 28,
-   "id": "f39983d9-5ce6-4426-af3f-de9ec1b8a74f",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "#Hypertriglyceridemia HP:0002155\n",
-    "triglycerides_d = {\n",
-    " '149(55-270)': 'Hypertriglyceridemia',\n",
-    " '167(77-232)': 'Hypertriglyceridemia'}\n",
-    "excluded = {'87(47-129)': 'Hypertriglyceridemia',\n",
-    " '123(79-230)': 'Hypertriglyceridemia',\n",
-    " '95.5(95-96)': 'Hypertriglyceridemia',\n",
-    " '108(58-160)': 'Hypertriglyceridemia'\n",
-    "           }\n",
-    "triglyceridesMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d=triglycerides_d, excluded_d=excluded)\n",
-    "triglyceridesMapper.preview_column(dft['TRIGLYCERIDES(RECOMMENDED<150MG/DL)'])\n",
-    "column_mapper_d['TRIGLYCERIDES(RECOMMENDED<150MG/DL)'] = triglyceridesMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 29,
-   "id": "6d8330ad-6895-4687-b02a-f3e4432a948e",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "hdl_d = {'49(31-65)': 'Decreased HDL cholesterol concentration',\n",
-    " '49(43-57)': 'Decreased HDL cholesterol concentration',\n",
-    " '38(30-46)': 'Decreased HDL cholesterol concentration',\n",
-    " '45(31-50)': 'Decreased HDL cholesterol concentration',\n",
-    " '41(27-49)': 'Decreased HDL cholesterol concentration'}\n",
-    "hdlMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d=hdl_d)\n",
-    "hdlMapper.preview_column(dft['HDL(RECOMMENDED>60MG/DL)'])\n",
-    "column_mapper_d['HDL(RECOMMENDED>60MG/DL)'] = hdlMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 30,
-   "id": "4786018c-fb91-46c3-80a1-4a6bb1333797",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "#Increased LDL cholesterol concentration HP:0003141\n",
-    "# no individual has increased LDL\n",
-    "ldlMapper = ConstantColumnMapper(hpo_id=\"HP:0003141\", hpo_label=\"Increased LDL cholesterol concentration\", excluded=True)\n",
-    "ldlMapper.preview_column(dft['LDL(RECOMMENDED<100MG/DL)'])\n",
-    "column_mapper_d['LDL(RECOMMENDED<100MG/DL)'] = ldlMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 31,
-   "id": "ff57f34e-e496-41cc-80d2-7022ddf2c508",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Normal range between 2-30, we will omit since the values are not clearly abnormal\n",
-    "# dft['VLDL'])"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 32,
-   "id": "34585067-ffb0-4187-90cc-2e4ceeb294f3",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# normal range 70-100\n",
-    "fasting_blood_sugar_d = {'390': 'Hyperglycemia',\n",
-    " '123': 'Hyperglycemia',\n",
-    " '127': 'Hyperglycemia',\n",
-    " '111': 'Hyperglycemia',\n",
-    " '124': 'Hyperglycemia',\n",
-    " '155': 'Hyperglycemia'}\n",
-    "fasting_blood_sugarMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d=fasting_blood_sugar_d)\n",
-    "fasting_blood_sugarMapper.preview_column(dft['FASTING_BLOOD_SUGAR'])\n",
-    "column_mapper_d['FASTING_BLOOD_SUGAR'] = fasting_blood_sugarMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 33,
-   "id": "b00762db-b445-4536-bec2-14f7478b3f2f",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "ana_d = {'-': 'Antinuclear antibody positivity'}\n",
-    "anaMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d={},excluded_d=ana_d)\n",
-    "anaMapper.preview_column(dft['ANA,RF,C3,C4ABNORMALITIES'])\n",
-    "column_mapper_d['ANA,RF,C3,C4ABNORMALITIES'] = anaMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 34,
-   "id": "10bc812f-dc1d-4cce-92a2-04fd0b29e06f",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# negative in two datalackjong in others scant details, thius omitting\n",
-    "#dft['ANTI-SM,ANTIJO-1,ANTI-SSA/B,ANCA,AMA']"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 35,
-   "id": "71cb0005-88d2-4c24-9cd7-f443e97ee131",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Anti-3-hydroxy- 3-methylglutaryl-coA reductase antibody positivity\n",
-    "HMGCR_d = {\"-\": \"Anti-3-hydroxy- 3-methylglutaryl-coA reductase antibody positivity\"}\n",
-    "ana_d = {'-': 'Antinuclear antibody positivity'}\n",
-    "hmgcrMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d={},excluded_d=HMGCR_d)\n",
-    "hmgcrMapper.preview_column(dft['ANTI-HMGCR_AB'])\n",
-    "column_mapper_d['ANTI-HMGCR_AB'] = hmgcrMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 36,
-   "id": "dd3607c6-a95a-45a9-b36d-e49427c563ed",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# no abnormal finding but no details, omit\n",
-    "#dft['ABNORMAL_BRAIN_IMAGING']"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 37,
-   "id": "e4bdd85b-8d0f-4632-91e7-e0842fb5ba25",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "myopathic_changes_in_emg_d = {'+': 'Myopathy'}\n",
-    "myopathic_changes_in_emgMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d=myopathic_changes_in_emg_d)\n",
-    "myopathic_changes_in_emgMapper.preview_column(dft['MYOPATHIC_CHANGES_IN_EMG'])\n",
-    "column_mapper_d['MYOPATHIC_CHANGES_IN_EMG'] = myopathic_changes_in_emgMapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 38,
-   "id": "7e792196-a8af-4a07-914b-f6fb05908578",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "comorbidities_d = {'Insulindependentdiabetes-onsetatage19': 'Type II diabetes mellitus',\n",
-    " 'COPD': 'Chronic pulmonary obstruction',\n",
-    " 'Diastolicdysfunction': 'Left ventricular diastolic dysfunction',\n",
-    " 'Lymphocytosis': 'Lymphocytosis',\n",
-    " 'Single kidney': 'Unilateral renal agenesis'}\n",
-    "comorbiditiesMapper = OptionColumnMapper(concept_recognizer=hpo_cr, option_d=comorbidities_d, assumeExcluded=True)\n",
-    "comorbiditiesMapper.preview_column(dft['COMORBIDITIES'])\n",
-    "column_mapper_d['COMORBIDITIES'] = comorbiditiesMapper"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "73f8cf0a-3773-42fe-b340-e11d8d86edb8",
-   "metadata": {},
-   "source": [
-    "# Variant\n",
-    "The affected members of the family share this variant: NM_000859.3:c.2465G>A; p.(G822D) in HMGCR"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 39,
-   "id": "ccbdf6a5-763a-4048-b1ce-b2dc8488ee40",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "vvalidator = VariantValidator(genome_build=\"hg38\", transcript=\"NM_000859.3\")"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 40,
-   "id": "43c741c7-7433-4e06-ae0a-279243b7cb80",
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "https://rest.variantvalidator.org/VariantValidator/variantvalidator/hg38/NM_000859.3%3Ac.2465G>A/NM_000859.3?content-type=application%2Fjson\n"
-     ]
-    }
-   ],
-   "source": [
-    "var = vvalidator.encode_hgvs(\"c.2465G>A\")\n",
-    "var.set_heterozygous()"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "6235377b-805b-4b87-ac2c-11b1d393b417",
-   "metadata": {},
-   "source": [
-    "# Cohort mapper"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 41,
-   "id": "cd44f1ac-09ad-476e-b428-b543fe8f2ba8",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "encoder = CohortEncoder(df=dft, \n",
-    "                        hpo_cr=hpo_cr, \n",
-    "                        column_mapper_d=column_mapper_d, \n",
-    "                        individual_column_name=\"patient_id\", \n",
-    "                        agemapper=ageMapper, \n",
-    "                        sexmapper=sexMapper,\n",
-    "                        metadata=metadata,\n",
-    "                        pmid=PMID)\n",
-    "omim_label = \"Muscular dystrophy, limb-girdle, autosomal recessive 28\"\n",
-    "omim_id = \"OMIM:620375\"\n",
-    "disease = Disease(disease_id=omim_id, disease_label=omim_label)\n",
-    "encoder.set_disease(disease)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 42,
-   "id": "16d55c23-89c4-445d-8ee6-539e6816ec4c",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "individuals = encoder.get_individuals()"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 43,
-   "id": "99a1b8d7-1a7f-48f4-834c-020901705f32",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "for indi in individuals:\n",
-    "    indi.add_variant(var)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "2d51feb7-81da-4ce1-b5bb-c9bcbcbd08ba",
-   "metadata": {},
-   "source": [
-    "# Validation"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 44,
-   "id": "ea249fea-3972-4608-a4f6-111cf833f4f8",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "cohort_validator = CohortValidator(cohort=individuals, ontology=hpo_ontology, min_hpo=1, min_allele=1, min_var=1)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 45,
-   "id": "34a22171-aac8-498f-97af-e4b53b8443e6",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "validated = cohort_validator.get_validated_individual_list()"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 46,
-   "id": "fdddfb13-9e4a-4fbd-ab63-d1cf322d723a",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "qcViz = QcVisualizer(ontology=hpo_ontology)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 47,
-   "id": "6998b139-95b7-4620-8b96-1b6cc42efeb2",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "text/html": [
-       "<h2>Cohort validation</h2>\n",
-       "<p>No errors found for the cohort with 6 individuals</p>"
-      ],
-      "text/plain": [
-       "<IPython.core.display.HTML object>"
-      ]
-     },
-     "metadata": {},
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "display(HTML(qcViz.to_html(validated_individual_list=validated)))"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 48,
-   "id": "6c409eeb-3f7e-480b-86bf-ee22cddf4c9f",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Visualization"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 54,
-   "id": "37ef7e90-2f52-4281-9d9f-ad4d6588b8e9",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "text/html": [
-       "<table style=\"border: 2px solid black;\">\n",
-       "\n",
-       "<caption>6 phenopackets - PMID:36745799 (n=6)</caption>\n",
-       "\n",
-       "<tr>\n",
-       "            <th>Individual</th>\n",
-       "            <th>Disease</th>\n",
-       "            <th>Genotype</th>\n",
-       "            <th>Phenotypic features</th>\n",
-       "        </tr>\n",
-       "        \n",
-       "<tr>\n",
-       "<td>V:2 (FEMALE; P49Y)</ts>\n",
-       "<td>Muscular dystrophy, limb-girdle, autosomal recessive 28 (OMIM:620375)</ts>\n",
-       "<td>NM_000859.3:c.2465G>A (heterozygous)</td>\n",
-       "<td class=\"table-data\">Upper limb amyotrophy (HP:0009129); Lower limb amyotrophy (HP:0007210); Areflexia (HP:0001284); Exercise-induced myalgia (HP:0003738); Respiratory insufficiency (HP:0002093); Elevated circulating creatine kinase concentration (HP:0003236); Increased circulating troponin T concentration (HP:0410174); Elevated circulating aspartate aminotransferase concentration (HP:0031956); Elevated circulating alanine aminotransferase concentration (HP:0031964); Elevated circulating alkaline phosphatase concentration (HP:0003155); Decreased HDL cholesterol concentration (HP:0003233); Hyperglycemia (HP:0003074); Myopathy (HP:0003198); Type II diabetes mellitus (HP:0005978)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>V:5 (MALE; P58Y)</ts>\n",
-       "<td>Muscular dystrophy, limb-girdle, autosomal recessive 28 (OMIM:620375)</ts>\n",
-       "<td>NM_000859.3:c.2465G>A (heterozygous)</td>\n",
-       "<td class=\"table-data\">Proximal muscle weakness in upper limbs (HP:0008997); Proximal muscle weakness in lower limbs (HP:0008994); Upper limb amyotrophy (HP:0009129); Lower limb amyotrophy (HP:0007210); Hyporeflexia (HP:0001265); Exercise-induced myalgia (HP:0003738); Respiratory insufficiency (HP:0002093); Left ventricular diastolic dysfunction (HP:0025168); Elevated circulating creatine kinase concentration (HP:0003236); Increased circulating troponin T concentration (HP:0410174); Elevated circulating aspartate aminotransferase concentration (HP:0031956); Elevated circulating alanine aminotransferase concentration (HP:0031964); Decreased HDL cholesterol concentration (HP:0003233); Hyperglycemia (HP:0003074); Myopathy (HP:0003198); Chronic pulmonary obstruction (HP:0006510); Lymphocytosis (HP:0100827)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>V:8 (MALE; P37Y)</ts>\n",
-       "<td>Muscular dystrophy, limb-girdle, autosomal recessive 28 (OMIM:620375)</ts>\n",
-       "<td>NM_000859.3:c.2465G>A (heterozygous)</td>\n",
-       "<td class=\"table-data\">Proximal muscle weakness in upper limbs (HP:0008997); Proximal muscle weakness in lower limbs (HP:0008994); Exercise-induced myalgia (HP:0003738); Loss of ambulation (HP:0002505); Respiratory insufficiency (HP:0002093); Elevated circulating creatine kinase concentration (HP:0003236); Elevated circulating aspartate aminotransferase concentration (HP:0031956); Elevated circulating alanine aminotransferase concentration (HP:0031964); Decreased HDL cholesterol concentration (HP:0003233); Hyperglycemia (HP:0003074)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>V:9 (MALE; P42Y)</ts>\n",
-       "<td>Muscular dystrophy, limb-girdle, autosomal recessive 28 (OMIM:620375)</ts>\n",
-       "<td>NM_000859.3:c.2465G>A (heterozygous)</td>\n",
-       "<td class=\"table-data\">Proximal muscle weakness in upper limbs (HP:0008997); Proximal muscle weakness in lower limbs (HP:0008994); Exercise-induced myalgia (HP:0003738); Loss of ambulation (HP:0002505); Elevated circulating creatine kinase concentration (HP:0003236); Increased circulating troponin T concentration (HP:0410174); Decreased HDL cholesterol concentration (HP:0003233); Hyperglycemia (HP:0003074)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>V:12 (FEMALE; P51Y)</ts>\n",
-       "<td>Muscular dystrophy, limb-girdle, autosomal recessive 28 (OMIM:620375)</ts>\n",
-       "<td>NM_000859.3:c.2465G>A (heterozygous)</td>\n",
-       "<td class=\"table-data\">Proximal muscle weakness in upper limbs (HP:0008997); Proximal muscle weakness in lower limbs (HP:0008994); Upper limb amyotrophy (HP:0009129); Lower limb amyotrophy (HP:0007210); Hyporeflexia (HP:0001265); Exercise-induced myalgia (HP:0003738); Elevated circulating creatine kinase concentration (HP:0003236); Increased circulating troponin T concentration (HP:0410174); Elevated circulating aspartate aminotransferase concentration (HP:0031956); Elevated circulating alanine aminotransferase concentration (HP:0031964); Hypertriglyceridemia (HP:0002155); Hyperglycemia (HP:0003074); Myopathy (HP:0003198); Unilateral renal agenesis (HP:0000122)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>V:13 (MALE; P41Y)</ts>\n",
-       "<td>Muscular dystrophy, limb-girdle, autosomal recessive 28 (OMIM:620375)</ts>\n",
-       "<td>NM_000859.3:c.2465G>A (heterozygous)</td>\n",
-       "<td class=\"table-data\">Proximal muscle weakness in upper limbs (HP:0008997); Proximal muscle weakness in lower limbs (HP:0008994); Upper limb amyotrophy (HP:0009129); Hyporeflexia (HP:0001265); Exercise-induced myalgia (HP:0003738); Loss of ambulation (HP:0002505); Respiratory insufficiency (HP:0002093); Elevated circulating creatine kinase concentration (HP:0003236); Elevated circulating aspartate aminotransferase concentration (HP:0031956); Elevated circulating alanine aminotransferase concentration (HP:0031964); Hypertriglyceridemia (HP:0002155); Decreased HDL cholesterol concentration (HP:0003233); Hyperglycemia (HP:0003074); Myopathy (HP:0003198)</td>\n",
-       "</tr>\n",
-       "</table>\n"
-      ],
-      "text/plain": [
-       "<IPython.core.display.HTML object>"
-      ]
-     },
-     "metadata": {},
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "individuals = [v.get_individual_with_clean_terms() for v in validated]\n",
-    "ppacket_list = [i.to_ga4gh_phenopacket(metadata=metadata.to_ga4gh()) for i in individuals]\n",
-    "table = PhenopacketTable(phenopacket_list=ppacket_list)\n",
-    "display(HTML(table.to_html()))"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 55,
-   "id": "9de89d03-da43-40f2-bc6a-d91f0d7651a1",
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "We output 6 GA4GH phenopackets to the directory phenopackets\n"
-     ]
-    }
-   ],
-   "source": [
-    "Individual.output_individuals_as_phenopackets(individual_list=individuals,\n",
-    "                                              metadata=metadata,\n",
-    "                                              pmid=PMID,\n",
-    "                                              outdir=\"phenopackets\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "eb486e34-99cd-40e2-964c-5f18ad67137c",
-   "metadata": {},
-   "source": [
-    "# Transform to HPOA-style aggregate file\n",
-    "The HPO project provides the phenotype.hpoa file that contains aggregate HPO associations for currently over 8.5 thousand diseases. Here, we show how to take a collection of phenopackets and create a HPOA file in the format of the phenotype.hpoa file that can be added to that resource.\n",
-    "\n",
-    "The strategy is to read in a collection of phenopackets (we will use the above 6 phenopackets) and to output one line for each HPO mentioned in the data."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 56,
-   "id": "591507e3-b685-4719-bf61-55e48fdda8cf",
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "[pyphetools] Ingested 6 GA4GH phenopackets.\n",
-      "We found a total of 29 HPO terms\n",
-      "extracted PubMed identifier: PMID:36745799\n",
-      "Extracted disease: Muscular dystrophy, limb-girdle, autosomal recessive 28 (OMIM:620375)\n"
-     ]
-    },
-    {
-     "data": {
-      "text/html": [
-       "<div>\n",
-       "<style scoped>\n",
-       "    .dataframe tbody tr th:only-of-type {\n",
-       "        vertical-align: middle;\n",
-       "    }\n",
-       "\n",
-       "    .dataframe tbody tr th {\n",
-       "        vertical-align: top;\n",
-       "    }\n",
-       "\n",
-       "    .dataframe thead th {\n",
-       "        text-align: right;\n",
-       "    }\n",
-       "</style>\n",
-       "<table border=\"1\" class=\"dataframe\">\n",
-       "  <thead>\n",
-       "    <tr style=\"text-align: right;\">\n",
-       "      <th></th>\n",
-       "      <th>#diseaseID</th>\n",
-       "      <th>diseaseName</th>\n",
-       "      <th>phenotypeID</th>\n",
-       "      <th>phenotypeName</th>\n",
-       "      <th>onsetID</th>\n",
-       "      <th>onsetName</th>\n",
-       "      <th>frequency</th>\n",
-       "      <th>sex</th>\n",
-       "      <th>negation</th>\n",
-       "      <th>modifier</th>\n",
-       "      <th>description</th>\n",
-       "      <th>publication</th>\n",
-       "      <th>evidence</th>\n",
-       "      <th>biocuration</th>\n",
-       "    </tr>\n",
-       "  </thead>\n",
-       "  <tbody>\n",
-       "    <tr>\n",
-       "      <th>0</th>\n",
-       "      <td>OMIM:620375</td>\n",
-       "      <td>Muscular dystrophy, limb-girdle, autosomal recessive 28</td>\n",
-       "      <td>HP:0410174</td>\n",
-       "      <td>Increased circulating troponin T concentration</td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td>4/6</td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td>PMID:36745799</td>\n",
-       "      <td>PCS</td>\n",
-       "      <td>ORCID:0000-0002-0736-9199</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>1</th>\n",
-       "      <td>OMIM:620375</td>\n",
-       "      <td>Muscular dystrophy, limb-girdle, autosomal recessive 28</td>\n",
-       "      <td>HP:0003493</td>\n",
-       "      <td>Antinuclear antibody positivity</td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td>PMID:36745799</td>\n",
-       "      <td>PCS</td>\n",
-       "      <td>ORCID:0000-0002-0736-9199</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>2</th>\n",
-       "      <td>OMIM:620375</td>\n",
-       "      <td>Muscular dystrophy, limb-girdle, autosomal recessive 28</td>\n",
-       "      <td>HP:0009129</td>\n",
-       "      <td>Upper limb amyotrophy</td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td>4/4</td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td>PMID:36745799</td>\n",
-       "      <td>PCS</td>\n",
-       "      <td>ORCID:0000-0002-0736-9199</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>3</th>\n",
-       "      <td>OMIM:620375</td>\n",
-       "      <td>Muscular dystrophy, limb-girdle, autosomal recessive 28</td>\n",
-       "      <td>HP:0005978</td>\n",
-       "      <td>Type II diabetes mellitus</td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td>1/6</td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td>PMID:36745799</td>\n",
-       "      <td>PCS</td>\n",
-       "      <td>ORCID:0000-0002-0736-9199</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>4</th>\n",
-       "      <td>OMIM:620375</td>\n",
-       "      <td>Muscular dystrophy, limb-girdle, autosomal recessive 28</td>\n",
-       "      <td>HP:0001265</td>\n",
-       "      <td>Hyporeflexia</td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td>3/3</td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td></td>\n",
-       "      <td>PMID:36745799</td>\n",
-       "      <td>PCS</td>\n",
-       "      <td>ORCID:0000-0002-0736-9199</td>\n",
-       "    </tr>\n",
-       "  </tbody>\n",
-       "</table>\n",
-       "</div>"
-      ],
-      "text/plain": [
-       "    #diseaseID                                              diseaseName  \\\n",
-       "0  OMIM:620375  Muscular dystrophy, limb-girdle, autosomal recessive 28   \n",
-       "1  OMIM:620375  Muscular dystrophy, limb-girdle, autosomal recessive 28   \n",
-       "2  OMIM:620375  Muscular dystrophy, limb-girdle, autosomal recessive 28   \n",
-       "3  OMIM:620375  Muscular dystrophy, limb-girdle, autosomal recessive 28   \n",
-       "4  OMIM:620375  Muscular dystrophy, limb-girdle, autosomal recessive 28   \n",
-       "\n",
-       "  phenotypeID                                   phenotypeName onsetID  \\\n",
-       "0  HP:0410174  Increased circulating troponin T concentration           \n",
-       "1  HP:0003493                 Antinuclear antibody positivity           \n",
-       "2  HP:0009129                           Upper limb amyotrophy           \n",
-       "3  HP:0005978                       Type II diabetes mellitus           \n",
-       "4  HP:0001265                                    Hyporeflexia           \n",
-       "\n",
-       "  onsetName frequency sex negation modifier description    publication  \\\n",
-       "0                 4/6                                    PMID:36745799   \n",
-       "1                                                        PMID:36745799   \n",
-       "2                 4/4                                    PMID:36745799   \n",
-       "3                 1/6                                    PMID:36745799   \n",
-       "4                 3/3                                    PMID:36745799   \n",
-       "\n",
-       "  evidence                biocuration  \n",
-       "0      PCS  ORCID:0000-0002-0736-9199  \n",
-       "1      PCS  ORCID:0000-0002-0736-9199  \n",
-       "2      PCS  ORCID:0000-0002-0736-9199  \n",
-       "3      PCS  ORCID:0000-0002-0736-9199  \n",
-       "4      PCS  ORCID:0000-0002-0736-9199  "
-      ]
-     },
-     "execution_count": 56,
-     "metadata": {},
-     "output_type": "execute_result"
-    }
-   ],
-   "source": [
-    "creator = HpoaTableCreator(phenopacket_list=ppacket_list)\n",
-    "df = creator.get_dataframe()\n",
-    "df.head()"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 57,
-   "id": "da59f171-afa7-40a1-ae16-dba88be8b428",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "name = disease.id.replace(\":\", \"_\") + \".tab\"\n",
-    "df.to_csv(name, sep=\"\\t\", index=False)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 58,
-   "id": "193dcadc-d74d-4df1-be6d-040f028eef6e",
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "#diseaseID\tdiseaseName\tphenotypeID\tphenotypeName\tonsetID\tonsetName\tfrequency\tsex\tnegation\tmodifier\tdescription\tpublication\tevidence\tbiocuration\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0410174\tIncreased circulating troponin T concentration\t\t\t4/6\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0003493\tAntinuclear antibody positivity\t\t\t\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0009129\tUpper limb amyotrophy\t\t\t4/4\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0005978\tType II diabetes mellitus\t\t\t1/6\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0001265\tHyporeflexia\t\t\t3/3\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0100827\tLymphocytosis\t\t\t1/6\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0003074\tHyperglycemia\t\t\t6/6\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0002093\tRespiratory insufficiency\t\t\t4/6\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0000122\tUnilateral renal agenesis\t\t\t1/6\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0008997\tProximal muscle weakness in upper limbs\t\t\t5/6\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0007210\tLower limb amyotrophy\t\t\t3/3\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0003198\tMyopathy\t\t\t4/4\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0003738\tExercise-induced myalgia\t\t\t6/6\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0003236\tElevated circulating creatine kinase concentration\t\t\t6/6\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0012100\tAbnormal circulating creatinine concentration\t\t\t\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0033708\tAnti-3-hydroxy- 3-methylglutaryl-coA reductase antibody positivity\t\t\t\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0031964\tElevated circulating alanine aminotransferase concentration\t\t\t5/6\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0003141\tIncreased LDL cholesterol concentration\t\t\t\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0002505\tLoss of ambulation\t\t\t3/6\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0002155\tHypertriglyceridemia\t\t\t2/6\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0002015\tDysphagia\t\t\t\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0025168\tLeft ventricular diastolic dysfunction\t\t\t2/7\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0003233\tDecreased HDL cholesterol concentration\t\t\t5/5\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0003155\tElevated circulating alkaline phosphatase concentration\t\t\t1/6\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0031956\tElevated circulating aspartate aminotransferase concentration\t\t\t5/6\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0003124\tHypercholesterolemia\t\t\t\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0006510\tChronic pulmonary obstruction\t\t\t1/6\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0008994\tProximal muscle weakness in lower limbs\t\t\t5/6\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n",
-      "OMIM:620375\tMuscular dystrophy, limb-girdle, autosomal recessive 28\tHP:0001284\tAreflexia\t\t\t1/1\t\t\t\t\tPMID:36745799\tPCS\tORCID:0000-0002-0736-9199\n"
-     ]
-    }
-   ],
-   "source": [
-    "! cat OMIM_620375.tab  "
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "e0616dfd-5bb2-4f23-a9db-473ec3269ce1",
-   "metadata": {},
-   "outputs": [],
-   "source": []
-  }
- ],
- "metadata": {
-  "kernelspec": {
-   "display_name": "ppt_venv",
-   "language": "python",
-   "name": "ppt_venv"
-  },
-  "language_info": {
-   "codemirror_mode": {
-    "name": "ipython",
-    "version": 3
-   },
-   "file_extension": ".py",
-   "mimetype": "text/x-python",
-   "name": "python",
-   "nbconvert_exporter": "python",
-   "pygments_lexer": "ipython3",
-   "version": "3.11.3"
-  }
- },
- "nbformat": 4,
- "nbformat_minor": 5
-}
diff --git a/notebooks/detailed_table.ipynb b/notebooks/detailed_table.ipynb
deleted file mode 100644
index c5ad25ab..00000000
--- a/notebooks/detailed_table.ipynb
+++ /dev/null
@@ -1,157 +0,0 @@
-{
- "cells": [
-  {
-   "cell_type": "code",
-   "execution_count": 1,
-   "id": "e876c2a1",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "import hpotk\n",
-    "from hpotk.ontology import Ontology\n",
-    "from hpotk.ontology.load.obographs import load_ontology\n",
-    "import sys\n",
-    "import os\n",
-    "sys.path.insert(0, os.path.abspath('../../pyphetools'))\n",
-    "from pyphetools.output import *\n",
-    "\n",
-    "# to peek under the hood\n",
-    "import logging\n",
-    "hpotk.util.setup_logging(logging.DEBUG)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 2,
-   "id": "f8eb1bb1",
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stderr",
-     "output_type": "stream",
-     "text": [
-      "2023-01-13 12:49:18,045 hpotk.util           DEBUG : Opening https://raw.githubusercontent.com/obophenotype/human-phenotype-ontology/master/hp.json\n",
-      "2023-01-13 12:49:18,046 hpotk.util           DEBUG : Using default encoding 'utf-8'\n",
-      "2023-01-13 12:49:18,046 hpotk.util           DEBUG : Looks like a URL: https://raw.githubusercontent.com/obophenotype/human-phenotype-ontology/master/hp.json\n",
-      "2023-01-13 12:49:18,046 hpotk.util           DEBUG : Downloading with timeout=30s\n",
-      "2023-01-13 12:49:18,931 hpotk.util           DEBUG : Looks like un-compressed data\n",
-      "2023-01-13 12:49:22,904 hpotk.ontology.load.obographs._load DEBUG : Extracting ontology terms\n",
-      "2023-01-13 12:49:22,905 hpotk.ontology.io.obographs DEBUG : Missing node type in {'id': 'http://purl.obolibrary.org/obo/GO_0000016', 'lbl': 'lactase activity'}\n",
-      "2023-01-13 12:49:22,905 hpotk.ontology.io.obographs DEBUG : Missing node type in {'id': 'http://purl.obolibrary.org/obo/GO_0003857', 'lbl': '3-hydroxyacyl-CoA dehydrogenase activity'}\n",
-      "2023-01-13 12:49:22,905 hpotk.ontology.io.obographs DEBUG : Missing node type in {'id': 'http://purl.obolibrary.org/obo/BFO_0000051', 'lbl': 'has part', 'meta': {'basicPropertyValues': [{'pred': 'http://www.geneontology.org/formats/oboInOwl#shorthand', 'val': 'has_part'}], 'xrefs': [{'val': 'BFO:0000051'}]}}\n",
-      "2023-01-13 12:49:22,905 hpotk.ontology.io.obographs DEBUG : Missing node type in {'id': 'http://purl.obolibrary.org/obo/BFO_0000066', 'lbl': 'occurs in', 'meta': {'basicPropertyValues': [{'pred': 'http://www.geneontology.org/formats/oboInOwl#shorthand', 'val': 'occurs_in'}], 'xrefs': [{'val': 'BFO:0000066'}]}}\n",
-      "2023-01-13 12:49:22,906 hpotk.ontology.io.obographs DEBUG : Missing node type in {'id': 'http://purl.obolibrary.org/obo/RO_0002503', 'lbl': 'towards', 'meta': {'basicPropertyValues': [{'pred': 'http://www.geneontology.org/formats/oboInOwl#shorthand', 'val': 'towards'}], 'xrefs': [{'val': 'RO:0002503'}]}}\n",
-      "2023-01-13 12:49:22,906 hpotk.ontology.io.obographs DEBUG : Missing node type in {'id': 'http://purl.obolibrary.org/obo/RO_0002573', 'lbl': 'has modifier', 'meta': {'basicPropertyValues': [{'pred': 'http://www.geneontology.org/formats/oboInOwl#shorthand', 'val': 'qualifier'}], 'comments': ['placeholder relation to indicate normality/abnormality.'], 'xrefs': [{'val': 'RO:0002180'}]}}\n",
-      "2023-01-13 12:49:23,063 hpotk.ontology.load.obographs._load DEBUG : Creating the edge list\n",
-      "2023-01-13 12:49:23,147 hpotk.ontology.load.obographs._load DEBUG : Building ontology graph\n",
-      "2023-01-13 12:49:23,147 hpotk.graph._factory DEBUG : Creating ontology graph from 21071 edges\n",
-      "2023-01-13 12:49:23,164 hpotk.graph._factory DEBUG : Found root HP:0000001\n",
-      "2023-01-13 12:49:23,193 hpotk.graph._factory DEBUG : Extracted 16810 nodes\n",
-      "2023-01-13 12:49:23,194 hpotk.graph._factory DEBUG : Building a connectivity matrix\n",
-      "2023-01-13 12:49:39,401 hpotk.graph._factory DEBUG : Assembling immutable connectivity matrix\n",
-      "2023-01-13 12:49:39,406 hpotk.graph._factory DEBUG : Finalizing the ontology graph\n",
-      "2023-01-13 12:49:39,416 hpotk.ontology.load.obographs._load DEBUG : Assemblying the ontology\n"
-     ]
-    }
-   ],
-   "source": [
-    "\n",
-    "\n",
-    "o: Ontology = load_ontology('https://raw.githubusercontent.com/obophenotype/human-phenotype-ontology/master/hp.json')"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 3,
-   "id": "f4a2e644",
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "We got 17 phenopackets\n"
-     ]
-    }
-   ],
-   "source": [
-    "phenopacket_dir = \"/Users/robinp/GIT/hrmd/notebooks/TRPM3/phenopackets\"\n",
-    "ingestor = PhenopacketIngestor(indir=phenopacket_dir)\n",
-    "patient_d = ingestor.get_patient_dictionary()\n",
-    "print(f\"We got {len(patient_d)} phenopackets\")"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 4,
-   "id": "8073b8b1",
-   "metadata": {},
-   "outputs": [
-    {
-     "ename": "AttributeError",
-     "evalue": "'DetailedSupplTable' object has no attribute '_total_counts_propagated'",
-     "output_type": "error",
-     "traceback": [
-      "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
-      "\u001b[0;31mAttributeError\u001b[0m                            Traceback (most recent call last)",
-      "Cell \u001b[0;32mIn[4], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m table \u001b[38;5;241m=\u001b[39m \u001b[43mDetailedSupplTable\u001b[49m\u001b[43m(\u001b[49m\u001b[43mpatient_d\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mpatient_d\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mhp_ontology\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mo\u001b[49m\u001b[43m)\u001b[49m\n",
-      "File \u001b[0;32m~/GIT/pyphetools/pyphetools/output/detailed_suppl_table.py:45\u001b[0m, in \u001b[0;36mDetailedSupplTable.__init__\u001b[0;34m(self, patient_d, hp_ontology)\u001b[0m\n\u001b[1;32m     43\u001b[0m         \u001b[38;5;28;01mcontinue\u001b[39;00m\n\u001b[1;32m     44\u001b[0m     \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[0;32m---> 45\u001b[0m         \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_total_counts_propagated\u001b[49m[hp_id\u001b[38;5;241m.\u001b[39mvalue] \u001b[38;5;241m+\u001b[39m\u001b[38;5;241m=\u001b[39m \u001b[38;5;241m1\u001b[39m\n\u001b[1;32m     46\u001b[0m variant_d \u001b[38;5;241m=\u001b[39m pat\u001b[38;5;241m.\u001b[39mget_variant_d()\n\u001b[1;32m     47\u001b[0m \u001b[38;5;28;01mfor\u001b[39;00m var \u001b[38;5;129;01min\u001b[39;00m variant_d\u001b[38;5;241m.\u001b[39mkeys():\n",
-      "\u001b[0;31mAttributeError\u001b[0m: 'DetailedSupplTable' object has no attribute '_total_counts_propagated'"
-     ]
-    }
-   ],
-   "source": [
-    "table = DetailedSupplTable(patient_d=patient_d, hp_ontology=o)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "0386ee44",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "str(type(patient_d))"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "f78c13b0",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "isinstance(patient_d, dict)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "65147fba",
-   "metadata": {},
-   "outputs": [],
-   "source": []
-  }
- ],
- "metadata": {
-  "kernelspec": {
-   "display_name": "venv2",
-   "language": "python",
-   "name": "venv2"
-  },
-  "language_info": {
-   "codemirror_mode": {
-    "name": "ipython",
-    "version": 3
-   },
-   "file_extension": ".py",
-   "mimetype": "text/x-python",
-   "name": "python",
-   "nbconvert_exporter": "python",
-   "pygments_lexer": "ipython3",
-   "version": "3.10.6"
-  }
- },
- "nbformat": 4,
- "nbformat_minor": 5
-}
diff --git a/notebooks/quality_control.ipynb b/notebooks/quality_control.ipynb
deleted file mode 100644
index cb564928..00000000
--- a/notebooks/quality_control.ipynb
+++ /dev/null
@@ -1,1301 +0,0 @@
-{
- "cells": [
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Quality control"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 1,
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "pyphetools version 0.8.3\n"
-     ]
-    }
-   ],
-   "source": [
-    "from pyphetools.visualization import PhenopacketIngestor\n",
-    "from pyphetools.validation import ContentValidator\n",
-    "import pyphetools\n",
-    "print(f\"pyphetools version {pyphetools.__version__}\")"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 6,
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "[pyphetools] Ingested 7 GA4GH phenopackets.\n"
-     ]
-    }
-   ],
-   "source": [
-    "indir = \"../../phenopacket-store/phenopackets/ANKH/\"\n",
-    "ingestor = PhenopacketIngestor(indir=indir)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 7,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "phenopacket_d = ingestor.get_phenopacket_dictionary()"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 8,
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "PMID_20358596_patient_A id: \"PMID_20358596_patient_A\"\n",
-      "subject {\n",
-      "  id: \"patient A\"\n",
-      "  time_at_last_encounter {\n",
-      "    age {\n",
-      "      iso8601duration: \"P12Y\"\n",
-      "    }\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000316\"\n",
-      "    label: \"Hypertelorism\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0001742\"\n",
-      "    label: \"Nasal congestion\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0430025\"\n",
-      "    label: \"Bilateral facial palsy\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0011968\"\n",
-      "    label: \"Feeding difficulties\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0002795\"\n",
-      "    label: \"Abnormal respiratory system physiology\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0004437\"\n",
-      "    label: \"Cranial hyperostosis\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000405\"\n",
-      "    label: \"Conductive hearing impairment\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0010628\"\n",
-      "    label: \"Facial palsy\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000689\"\n",
-      "    label: \"Dental malocclusion\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000365\"\n",
-      "    label: \"Hearing impairment\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0031428\"\n",
-      "    label: \"Increased circulating osteocalcin level\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0003155\"\n",
-      "    label: \"Elevated circulating alkaline phosphatase concentration\"\n",
-      "  }\n",
-      "}\n",
-      "interpretations {\n",
-      "  id: \"patient A\"\n",
-      "  progress_status: SOLVED\n",
-      "  diagnosis {\n",
-      "    disease {\n",
-      "      id: \"OMIM:123000\"\n",
-      "      label: \"Craniometaphyseal dysplasia\"\n",
-      "    }\n",
-      "    genomic_interpretations {\n",
-      "      subject_or_biosample_id: \"patient A\"\n",
-      "      interpretation_status: CAUSATIVE\n",
-      "      variant_interpretation {\n",
-      "        variation_descriptor {\n",
-      "          id: \"var_WKbjRgFyreyQDDIKQZoeHCfqI\"\n",
-      "          gene_context {\n",
-      "            value_id: \"HGNC:15492\"\n",
-      "            symbol: \"ANKH\"\n",
-      "          }\n",
-      "          expressions {\n",
-      "            syntax: \"hgvs.c\"\n",
-      "            value: \"NM_054027.6:c.1015T>C\"\n",
-      "          }\n",
-      "          expressions {\n",
-      "            syntax: \"hgvs.g\"\n",
-      "            value: \"NC_000005.10:g.14716832A>G\"\n",
-      "          }\n",
-      "          vcf_record {\n",
-      "            genome_assembly: \"hg38\"\n",
-      "            chrom: \"chr5\"\n",
-      "            pos: 14716832\n",
-      "            ref: \"A\"\n",
-      "            alt: \"G\"\n",
-      "          }\n",
-      "          molecule_context: genomic\n",
-      "          allelic_state {\n",
-      "            id: \"GENO:0000135\"\n",
-      "            label: \"heterozygous\"\n",
-      "          }\n",
-      "        }\n",
-      "      }\n",
-      "    }\n",
-      "  }\n",
-      "}\n",
-      "meta_data {\n",
-      "  created {\n",
-      "    seconds: 1696123168\n",
-      "    nanos: 359586954\n",
-      "  }\n",
-      "  created_by: \"ORCID:0000-0002-0736-9199\"\n",
-      "  resources {\n",
-      "    id: \"geno\"\n",
-      "    name: \"Genotype Ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/geno.owl\"\n",
-      "    version: \"2022-03-05\"\n",
-      "    namespace_prefix: \"GENO\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/GENO_\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"hgnc\"\n",
-      "    name: \"HUGO Gene Nomenclature Committee\"\n",
-      "    url: \"https://www.genenames.org\"\n",
-      "    version: \"06/01/23\"\n",
-      "    namespace_prefix: \"HGNC\"\n",
-      "    iri_prefix: \"https://www.genenames.org/data/gene-symbol-report/#!/hgnc_id/\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"omim\"\n",
-      "    name: \"An Online Catalog of Human Genes and Genetic Disorders\"\n",
-      "    url: \"https://www.omim.org\"\n",
-      "    version: \"January 4, 2023\"\n",
-      "    namespace_prefix: \"OMIM\"\n",
-      "    iri_prefix: \"https://www.omim.org/entry/\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"so\"\n",
-      "    name: \"Sequence types and features ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/so.obo\"\n",
-      "    version: \"2021-11-22\"\n",
-      "    namespace_prefix: \"SO\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/SO_\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"hp\"\n",
-      "    name: \"human phenotype ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/hp.owl\"\n",
-      "    version: \"2023-09-01\"\n",
-      "    namespace_prefix: \"HP\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/HP_\"\n",
-      "  }\n",
-      "  phenopacket_schema_version: \"2.0\"\n",
-      "  external_references {\n",
-      "    id: \"PMID:20358596\"\n",
-      "    reference: \"https://pubmed.ncbi.nlm.nih.gov/20358596\"\n",
-      "    description: \"Three novel mutations in the ANK membrane protein cause craniometaphyseal dysplasia with variable conductive hearing loss\"\n",
-      "  }\n",
-      "}\n",
-      "\n",
-      "PMID_22647861_41-year-old_woman id: \"PMID_22647861_41-year-old_woman\"\n",
-      "subject {\n",
-      "  id: \"41-year-old woman\"\n",
-      "  time_at_last_encounter {\n",
-      "    age {\n",
-      "      iso8601duration: \"P41Y\"\n",
-      "    }\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0030840\"\n",
-      "    label: \"Ankle pain\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0005017\"\n",
-      "    label: \"Polyarticular chondrocalcinosis\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0003419\"\n",
-      "    label: \"Low back pain\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0005195\"\n",
-      "    label: \"Polyarticular arthropathy\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0002148\"\n",
-      "    label: \"Hypophosphatemia\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0003165\"\n",
-      "    label: \"Elevated circulating parathyroid hormone level\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0002150\"\n",
-      "    label: \"Hypercalciuria\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0003109\"\n",
-      "    label: \"Hyperphosphaturia\"\n",
-      "  }\n",
-      "}\n",
-      "interpretations {\n",
-      "  id: \"41-year-old woman\"\n",
-      "  progress_status: SOLVED\n",
-      "  diagnosis {\n",
-      "    disease {\n",
-      "      id: \"OMIM:118600\"\n",
-      "      label: \"Chondrocalcinosis 2\"\n",
-      "    }\n",
-      "    genomic_interpretations {\n",
-      "      subject_or_biosample_id: \"41-year-old woman\"\n",
-      "      interpretation_status: CAUSATIVE\n",
-      "      variant_interpretation {\n",
-      "        variation_descriptor {\n",
-      "          id: \"var_FhVPDYgmzFckqiKYfDWJWYtwe\"\n",
-      "          gene_context {\n",
-      "            value_id: \"HGNC:15492\"\n",
-      "            symbol: \"ANKH\"\n",
-      "          }\n",
-      "          expressions {\n",
-      "            syntax: \"hgvs.c\"\n",
-      "            value: \"NM_054027.4:c.13C>T\"\n",
-      "          }\n",
-      "          expressions {\n",
-      "            syntax: \"hgvs.g\"\n",
-      "            value: \"NC_000005.10:g.14871435G>A\"\n",
-      "          }\n",
-      "          vcf_record {\n",
-      "            genome_assembly: \"hg38\"\n",
-      "            chrom: \"chr5\"\n",
-      "            pos: 14871435\n",
-      "            ref: \"G\"\n",
-      "            alt: \"A\"\n",
-      "          }\n",
-      "          molecule_context: genomic\n",
-      "          allelic_state {\n",
-      "            id: \"GENO:0000135\"\n",
-      "            label: \"heterozygous\"\n",
-      "          }\n",
-      "        }\n",
-      "      }\n",
-      "    }\n",
-      "  }\n",
-      "}\n",
-      "meta_data {\n",
-      "  created {\n",
-      "    seconds: 1696123188\n",
-      "    nanos: 132802009\n",
-      "  }\n",
-      "  created_by: \"ORCID:0000-0002-0736-9199\"\n",
-      "  resources {\n",
-      "    id: \"geno\"\n",
-      "    name: \"Genotype Ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/geno.owl\"\n",
-      "    version: \"2022-03-05\"\n",
-      "    namespace_prefix: \"GENO\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/GENO_\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"hgnc\"\n",
-      "    name: \"HUGO Gene Nomenclature Committee\"\n",
-      "    url: \"https://www.genenames.org\"\n",
-      "    version: \"06/01/23\"\n",
-      "    namespace_prefix: \"HGNC\"\n",
-      "    iri_prefix: \"https://www.genenames.org/data/gene-symbol-report/#!/hgnc_id/\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"omim\"\n",
-      "    name: \"An Online Catalog of Human Genes and Genetic Disorders\"\n",
-      "    url: \"https://www.omim.org\"\n",
-      "    version: \"January 4, 2023\"\n",
-      "    namespace_prefix: \"OMIM\"\n",
-      "    iri_prefix: \"https://www.omim.org/entry/\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"so\"\n",
-      "    name: \"Sequence types and features ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/so.obo\"\n",
-      "    version: \"2021-11-22\"\n",
-      "    namespace_prefix: \"SO\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/SO_\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"hp\"\n",
-      "    name: \"human phenotype ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/hp.owl\"\n",
-      "    version: \"2023-09-01\"\n",
-      "    namespace_prefix: \"HP\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/HP_\"\n",
-      "  }\n",
-      "  phenopacket_schema_version: \"2.0\"\n",
-      "  external_references {\n",
-      "    id: \"PMID:22647861\"\n",
-      "    reference: \"https://pubmed.ncbi.nlm.nih.gov/22647861\"\n",
-      "    description: \"Novel ANKH amino terminus mutation (Pro5Ser) associated with early-onset calcium pyrophosphate disease with associated phosphaturia\"\n",
-      "  }\n",
-      "}\n",
-      "\n",
-      "PMID_20186813_6-year-old_male_patient id: \"PMID_20186813_6-year-old_male_patient\"\n",
-      "subject {\n",
-      "  id: \"6-year-old male patient\"\n",
-      "  time_at_last_encounter {\n",
-      "    age {\n",
-      "      iso8601duration: \"P12Y\"\n",
-      "    }\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0003196\"\n",
-      "    label: \"Short nose\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000407\"\n",
-      "    label: \"Sensorineural hearing impairment\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000572\"\n",
-      "    label: \"Visual loss\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0011856\"\n",
-      "    label: \"Pica\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0100255\"\n",
-      "    label: \"Metaphyseal dysplasia\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0100774\"\n",
-      "    label: \"Hyperostosis\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0011856\"\n",
-      "    label: \"Pica\"\n",
-      "  }\n",
-      "}\n",
-      "interpretations {\n",
-      "  id: \"6-year-old male patient\"\n",
-      "  progress_status: SOLVED\n",
-      "  diagnosis {\n",
-      "    disease {\n",
-      "      id: \"OMIM:123000\"\n",
-      "      label: \"Craniometaphyseal dysplasia\"\n",
-      "    }\n",
-      "    genomic_interpretations {\n",
-      "      subject_or_biosample_id: \"6-year-old male patient\"\n",
-      "      interpretation_status: CAUSATIVE\n",
-      "      variant_interpretation {\n",
-      "        variation_descriptor {\n",
-      "          id: \"var_zRzegykpHEmywYkijfvOFEtcC\"\n",
-      "          gene_context {\n",
-      "            value_id: \"HGNC:15492\"\n",
-      "            symbol: \"ANKH\"\n",
-      "          }\n",
-      "          expressions {\n",
-      "            syntax: \"hgvs.c\"\n",
-      "            value: \"NM_054027.6:c.942_953del\"\n",
-      "          }\n",
-      "          expressions {\n",
-      "            syntax: \"hgvs.g\"\n",
-      "            value: \"NC_000005.10:g.14741885_14741896del\"\n",
-      "          }\n",
-      "          vcf_record {\n",
-      "            genome_assembly: \"hg38\"\n",
-      "            chrom: \"chr5\"\n",
-      "            pos: 14741884\n",
-      "            ref: \"TGTGTTGCTCGTG\"\n",
-      "            alt: \"T\"\n",
-      "          }\n",
-      "          molecule_context: genomic\n",
-      "          allelic_state {\n",
-      "            id: \"GENO:0000135\"\n",
-      "            label: \"heterozygous\"\n",
-      "          }\n",
-      "        }\n",
-      "      }\n",
-      "    }\n",
-      "  }\n",
-      "}\n",
-      "meta_data {\n",
-      "  created {\n",
-      "    seconds: 1696123149\n",
-      "    nanos: 677453041\n",
-      "  }\n",
-      "  created_by: \"ORCID:0000-0002-0736-9199\"\n",
-      "  resources {\n",
-      "    id: \"geno\"\n",
-      "    name: \"Genotype Ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/geno.owl\"\n",
-      "    version: \"2022-03-05\"\n",
-      "    namespace_prefix: \"GENO\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/GENO_\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"hgnc\"\n",
-      "    name: \"HUGO Gene Nomenclature Committee\"\n",
-      "    url: \"https://www.genenames.org\"\n",
-      "    version: \"06/01/23\"\n",
-      "    namespace_prefix: \"HGNC\"\n",
-      "    iri_prefix: \"https://www.genenames.org/data/gene-symbol-report/#!/hgnc_id/\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"omim\"\n",
-      "    name: \"An Online Catalog of Human Genes and Genetic Disorders\"\n",
-      "    url: \"https://www.omim.org\"\n",
-      "    version: \"January 4, 2023\"\n",
-      "    namespace_prefix: \"OMIM\"\n",
-      "    iri_prefix: \"https://www.omim.org/entry/\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"so\"\n",
-      "    name: \"Sequence types and features ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/so.obo\"\n",
-      "    version: \"2021-11-22\"\n",
-      "    namespace_prefix: \"SO\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/SO_\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"hp\"\n",
-      "    name: \"human phenotype ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/hp.owl\"\n",
-      "    version: \"2023-09-01\"\n",
-      "    namespace_prefix: \"HP\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/HP_\"\n",
-      "  }\n",
-      "  phenopacket_schema_version: \"2.0\"\n",
-      "  external_references {\n",
-      "    id: \"PMID:20186813\"\n",
-      "    reference: \"https://pubmed.ncbi.nlm.nih.gov/20186813\"\n",
-      "    description: \"Novel ANKH mutation in a patient with sporadic craniometaphyseal dysplasia\"\n",
-      "  }\n",
-      "}\n",
-      "\n",
-      "PMID_22150416_patient_1 id: \"PMID_22150416_patient_1\"\n",
-      "subject {\n",
-      "  id: \"patient 1\"\n",
-      "  time_at_last_encounter {\n",
-      "    age {\n",
-      "      iso8601duration: \"P1Y5M\"\n",
-      "    }\n",
-      "  }\n",
-      "  sex: FEMALE\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0002781\"\n",
-      "    label: \"Upper airway obstruction\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0010628\"\n",
-      "    label: \"Facial palsy\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0002694\"\n",
-      "    label: \"Sclerosis of skull base\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0003072\"\n",
-      "    label: \"Hypercalcemia\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000365\"\n",
-      "    label: \"Hearing impairment\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0025405\"\n",
-      "    label: \"Visual fixation instability\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000577\"\n",
-      "    label: \"Exotropia\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000648\"\n",
-      "    label: \"Optic atrophy\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0001548\"\n",
-      "    label: \"Overgrowth\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000639\"\n",
-      "    label: \"Nystagmus\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0100658\"\n",
-      "    label: \"Cellulitis\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000238\"\n",
-      "    label: \"Hydrocephalus\"\n",
-      "  }\n",
-      "  excluded: true\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000256\"\n",
-      "    label: \"Macrocephaly\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0003155\"\n",
-      "    label: \"Elevated circulating alkaline phosphatase concentration\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0002148\"\n",
-      "    label: \"Hypophosphatemia\"\n",
-      "  }\n",
-      "}\n",
-      "interpretations {\n",
-      "  id: \"patient 1\"\n",
-      "  progress_status: SOLVED\n",
-      "  diagnosis {\n",
-      "    disease {\n",
-      "      id: \"OMIM:123000\"\n",
-      "      label: \"Craniometaphyseal dysplasia\"\n",
-      "    }\n",
-      "    genomic_interpretations {\n",
-      "      subject_or_biosample_id: \"patient 1\"\n",
-      "      interpretation_status: CAUSATIVE\n",
-      "      variant_interpretation {\n",
-      "        variation_descriptor {\n",
-      "          id: \"var_ynhALZSmrslEsJeZrdvgiAgii\"\n",
-      "          gene_context {\n",
-      "            value_id: \"HGNC:15492\"\n",
-      "            symbol: \"ANKH\"\n",
-      "          }\n",
-      "          expressions {\n",
-      "            syntax: \"hgvs.c\"\n",
-      "            value: \"NM_054027.6:c.854_871del\"\n",
-      "          }\n",
-      "          expressions {\n",
-      "            syntax: \"hgvs.g\"\n",
-      "            value: \"NC_000005.10:g.14745915_14745932del\"\n",
-      "          }\n",
-      "          vcf_record {\n",
-      "            genome_assembly: \"hg38\"\n",
-      "            chrom: \"chr5\"\n",
-      "            pos: 14745913\n",
-      "            ref: \"CCGTATGGCATGTGACCCA\"\n",
-      "            alt: \"C\"\n",
-      "          }\n",
-      "          molecule_context: genomic\n",
-      "          allelic_state {\n",
-      "            id: \"GENO:0000135\"\n",
-      "            label: \"heterozygous\"\n",
-      "          }\n",
-      "        }\n",
-      "      }\n",
-      "    }\n",
-      "  }\n",
-      "}\n",
-      "meta_data {\n",
-      "  created {\n",
-      "    seconds: 1696123202\n",
-      "    nanos: 143004894\n",
-      "  }\n",
-      "  created_by: \"ORCID:0000-0002-0736-9199\"\n",
-      "  resources {\n",
-      "    id: \"geno\"\n",
-      "    name: \"Genotype Ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/geno.owl\"\n",
-      "    version: \"2022-03-05\"\n",
-      "    namespace_prefix: \"GENO\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/GENO_\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"hgnc\"\n",
-      "    name: \"HUGO Gene Nomenclature Committee\"\n",
-      "    url: \"https://www.genenames.org\"\n",
-      "    version: \"06/01/23\"\n",
-      "    namespace_prefix: \"HGNC\"\n",
-      "    iri_prefix: \"https://www.genenames.org/data/gene-symbol-report/#!/hgnc_id/\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"omim\"\n",
-      "    name: \"An Online Catalog of Human Genes and Genetic Disorders\"\n",
-      "    url: \"https://www.omim.org\"\n",
-      "    version: \"January 4, 2023\"\n",
-      "    namespace_prefix: \"OMIM\"\n",
-      "    iri_prefix: \"https://www.omim.org/entry/\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"so\"\n",
-      "    name: \"Sequence types and features ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/so.obo\"\n",
-      "    version: \"2021-11-22\"\n",
-      "    namespace_prefix: \"SO\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/SO_\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"hp\"\n",
-      "    name: \"human phenotype ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/hp.owl\"\n",
-      "    version: \"2023-09-01\"\n",
-      "    namespace_prefix: \"HP\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/HP_\"\n",
-      "  }\n",
-      "  phenopacket_schema_version: \"2.0\"\n",
-      "  external_references {\n",
-      "    id: \"PMID:22150416\"\n",
-      "    reference: \"https://pubmed.ncbi.nlm.nih.gov/22150416\"\n",
-      "    description: \"Two novel large ANKH deletion mutations in sporadic cases with craniometaphyseal dysplasia\"\n",
-      "  }\n",
-      "}\n",
-      "\n",
-      "PMID_22150416_patient_2 id: \"PMID_22150416_patient_2\"\n",
-      "subject {\n",
-      "  id: \"patient 2\"\n",
-      "  time_at_last_encounter {\n",
-      "    age {\n",
-      "      iso8601duration: \"P5Y\"\n",
-      "    }\n",
-      "  }\n",
-      "  sex: FEMALE\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0002684\"\n",
-      "    label: \"Thickened calvaria\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0001730\"\n",
-      "    label: \"Progressive hearing impairment\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000365\"\n",
-      "    label: \"Hearing impairment\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000256\"\n",
-      "    label: \"Macrocephaly\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000337\"\n",
-      "    label: \"Broad forehead\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000316\"\n",
-      "    label: \"Hypertelorism\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000506\"\n",
-      "    label: \"Telecanthus\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0011225\"\n",
-      "    label: \"Epiblepharon\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0430028\"\n",
-      "    label: \"Hyperplasia of the maxilla\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0010628\"\n",
-      "    label: \"Facial palsy\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000358\"\n",
-      "    label: \"Posteriorly rotated ears\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0003155\"\n",
-      "    label: \"Elevated circulating alkaline phosphatase concentration\"\n",
-      "  }\n",
-      "}\n",
-      "interpretations {\n",
-      "  id: \"patient 2\"\n",
-      "  progress_status: SOLVED\n",
-      "  diagnosis {\n",
-      "    disease {\n",
-      "      id: \"OMIM:123000\"\n",
-      "      label: \"Craniometaphyseal dysplasia\"\n",
-      "    }\n",
-      "    genomic_interpretations {\n",
-      "      subject_or_biosample_id: \"patient 2\"\n",
-      "      interpretation_status: CAUSATIVE\n",
-      "      variant_interpretation {\n",
-      "        variation_descriptor {\n",
-      "          id: \"var_iuoEoNgvwrnTkLpOGBucZTYdP\"\n",
-      "          gene_context {\n",
-      "            value_id: \"HGNC:15492\"\n",
-      "            symbol: \"ANKH\"\n",
-      "          }\n",
-      "          expressions {\n",
-      "            syntax: \"hgvs.c\"\n",
-      "            value: \"NM_054027.6:c.1179_1190del\"\n",
-      "          }\n",
-      "          expressions {\n",
-      "            syntax: \"hgvs.g\"\n",
-      "            value: \"NC_000005.10:g.14713621_14713632del\"\n",
-      "          }\n",
-      "          vcf_record {\n",
-      "            genome_assembly: \"hg38\"\n",
-      "            chrom: \"chr5\"\n",
-      "            pos: 14713618\n",
-      "            ref: \"GGTTTTCTTCAGT\"\n",
-      "            alt: \"G\"\n",
-      "          }\n",
-      "          molecule_context: genomic\n",
-      "        }\n",
-      "      }\n",
-      "    }\n",
-      "  }\n",
-      "}\n",
-      "meta_data {\n",
-      "  created {\n",
-      "    seconds: 1696123203\n",
-      "    nanos: 186205863\n",
-      "  }\n",
-      "  created_by: \"ORCID:0000-0002-0736-9199\"\n",
-      "  resources {\n",
-      "    id: \"geno\"\n",
-      "    name: \"Genotype Ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/geno.owl\"\n",
-      "    version: \"2022-03-05\"\n",
-      "    namespace_prefix: \"GENO\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/GENO_\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"hgnc\"\n",
-      "    name: \"HUGO Gene Nomenclature Committee\"\n",
-      "    url: \"https://www.genenames.org\"\n",
-      "    version: \"06/01/23\"\n",
-      "    namespace_prefix: \"HGNC\"\n",
-      "    iri_prefix: \"https://www.genenames.org/data/gene-symbol-report/#!/hgnc_id/\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"omim\"\n",
-      "    name: \"An Online Catalog of Human Genes and Genetic Disorders\"\n",
-      "    url: \"https://www.omim.org\"\n",
-      "    version: \"January 4, 2023\"\n",
-      "    namespace_prefix: \"OMIM\"\n",
-      "    iri_prefix: \"https://www.omim.org/entry/\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"so\"\n",
-      "    name: \"Sequence types and features ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/so.obo\"\n",
-      "    version: \"2021-11-22\"\n",
-      "    namespace_prefix: \"SO\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/SO_\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"hp\"\n",
-      "    name: \"human phenotype ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/hp.owl\"\n",
-      "    version: \"2023-09-01\"\n",
-      "    namespace_prefix: \"HP\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/HP_\"\n",
-      "  }\n",
-      "  phenopacket_schema_version: \"2.0\"\n",
-      "  external_references {\n",
-      "    id: \"PMID:22150416\"\n",
-      "    reference: \"https://pubmed.ncbi.nlm.nih.gov/22150416\"\n",
-      "    description: \"Two novel large ANKH deletion mutations in sporadic cases with craniometaphyseal dysplasia\"\n",
-      "  }\n",
-      "}\n",
-      "\n",
-      "PMID_20358596_patient_C id: \"PMID_20358596_patient_C\"\n",
-      "subject {\n",
-      "  id: \"patient C\"\n",
-      "  time_at_last_encounter {\n",
-      "    age {\n",
-      "      iso8601duration: \"P43Y\"\n",
-      "    }\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000452\"\n",
-      "    label: \"Choanal stenosis\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000316\"\n",
-      "    label: \"Hypertelorism\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0100774\"\n",
-      "    label: \"Hyperostosis\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000689\"\n",
-      "    label: \"Dental malocclusion\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000680\"\n",
-      "    label: \"Delayed eruption of primary teeth\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0002753\"\n",
-      "    label: \"Thin bony cortex\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0003016\"\n",
-      "    label: \"Metaphyseal widening\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000405\"\n",
-      "    label: \"Conductive hearing impairment\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0002684\"\n",
-      "    label: \"Thickened calvaria\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000365\"\n",
-      "    label: \"Hearing impairment\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000360\"\n",
-      "    label: \"Tinnitus\"\n",
-      "  }\n",
-      "}\n",
-      "interpretations {\n",
-      "  id: \"patient C\"\n",
-      "  progress_status: SOLVED\n",
-      "  diagnosis {\n",
-      "    disease {\n",
-      "      id: \"OMIM:123000\"\n",
-      "      label: \"Craniometaphyseal dysplasia\"\n",
-      "    }\n",
-      "    genomic_interpretations {\n",
-      "      subject_or_biosample_id: \"patient C\"\n",
-      "      interpretation_status: CAUSATIVE\n",
-      "      variant_interpretation {\n",
-      "        variation_descriptor {\n",
-      "          id: \"var_aUSnGtprfgKHNhpAyntdQbWub\"\n",
-      "          gene_context {\n",
-      "            value_id: \"HGNC:15492\"\n",
-      "            symbol: \"ANKH\"\n",
-      "          }\n",
-      "          expressions {\n",
-      "            syntax: \"hgvs.c\"\n",
-      "            value: \"NM_054027.6:c.1001T>G\"\n",
-      "          }\n",
-      "          expressions {\n",
-      "            syntax: \"hgvs.g\"\n",
-      "            value: \"NC_000005.10:g.14741837A>C\"\n",
-      "          }\n",
-      "          vcf_record {\n",
-      "            genome_assembly: \"hg38\"\n",
-      "            chrom: \"chr5\"\n",
-      "            pos: 14741837\n",
-      "            ref: \"A\"\n",
-      "            alt: \"C\"\n",
-      "          }\n",
-      "          molecule_context: genomic\n",
-      "          allelic_state {\n",
-      "            id: \"GENO:0000135\"\n",
-      "            label: \"heterozygous\"\n",
-      "          }\n",
-      "        }\n",
-      "      }\n",
-      "    }\n",
-      "  }\n",
-      "}\n",
-      "meta_data {\n",
-      "  created {\n",
-      "    seconds: 1696123170\n",
-      "    nanos: 42814016\n",
-      "  }\n",
-      "  created_by: \"ORCID:0000-0002-0736-9199\"\n",
-      "  resources {\n",
-      "    id: \"geno\"\n",
-      "    name: \"Genotype Ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/geno.owl\"\n",
-      "    version: \"2022-03-05\"\n",
-      "    namespace_prefix: \"GENO\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/GENO_\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"hgnc\"\n",
-      "    name: \"HUGO Gene Nomenclature Committee\"\n",
-      "    url: \"https://www.genenames.org\"\n",
-      "    version: \"06/01/23\"\n",
-      "    namespace_prefix: \"HGNC\"\n",
-      "    iri_prefix: \"https://www.genenames.org/data/gene-symbol-report/#!/hgnc_id/\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"omim\"\n",
-      "    name: \"An Online Catalog of Human Genes and Genetic Disorders\"\n",
-      "    url: \"https://www.omim.org\"\n",
-      "    version: \"January 4, 2023\"\n",
-      "    namespace_prefix: \"OMIM\"\n",
-      "    iri_prefix: \"https://www.omim.org/entry/\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"so\"\n",
-      "    name: \"Sequence types and features ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/so.obo\"\n",
-      "    version: \"2021-11-22\"\n",
-      "    namespace_prefix: \"SO\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/SO_\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"hp\"\n",
-      "    name: \"human phenotype ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/hp.owl\"\n",
-      "    version: \"2023-09-01\"\n",
-      "    namespace_prefix: \"HP\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/HP_\"\n",
-      "  }\n",
-      "  phenopacket_schema_version: \"2.0\"\n",
-      "  external_references {\n",
-      "    id: \"PMID:20358596\"\n",
-      "    reference: \"https://pubmed.ncbi.nlm.nih.gov/20358596\"\n",
-      "    description: \"Three novel mutations in the ANK membrane protein cause craniometaphyseal dysplasia with variable conductive hearing loss\"\n",
-      "  }\n",
-      "}\n",
-      "\n",
-      "PMID_20358596_patient_B id: \"PMID_20358596_patient_B\"\n",
-      "subject {\n",
-      "  id: \"patient B\"\n",
-      "  time_at_last_encounter {\n",
-      "    age {\n",
-      "      iso8601duration: \"P23Y\"\n",
-      "    }\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0012799\"\n",
-      "    label: \"Unilateral facial palsy\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000678\"\n",
-      "    label: \"Dental crowding\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000256\"\n",
-      "    label: \"Macrocephaly\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000407\"\n",
-      "    label: \"Sensorineural hearing impairment\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0002694\"\n",
-      "    label: \"Sclerosis of skull base\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0100774\"\n",
-      "    label: \"Hyperostosis\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000648\"\n",
-      "    label: \"Optic atrophy\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0001742\"\n",
-      "    label: \"Nasal congestion\"\n",
-      "  }\n",
-      "  excluded: true\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0003016\"\n",
-      "    label: \"Metaphyseal widening\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0000303\"\n",
-      "    label: \"Mandibular prognathia\"\n",
-      "  }\n",
-      "}\n",
-      "phenotypic_features {\n",
-      "  type {\n",
-      "    id: \"HP:0100033\"\n",
-      "    label: \"Tics\"\n",
-      "  }\n",
-      "}\n",
-      "interpretations {\n",
-      "  id: \"patient B\"\n",
-      "  progress_status: SOLVED\n",
-      "  diagnosis {\n",
-      "    disease {\n",
-      "      id: \"OMIM:123000\"\n",
-      "      label: \"Craniometaphyseal dysplasia\"\n",
-      "    }\n",
-      "    genomic_interpretations {\n",
-      "      subject_or_biosample_id: \"patient B\"\n",
-      "      interpretation_status: CAUSATIVE\n",
-      "      variant_interpretation {\n",
-      "        variation_descriptor {\n",
-      "          id: \"var_wbwBUWhCewEkiHFrSuByNbDLW\"\n",
-      "          gene_context {\n",
-      "            value_id: \"HGNC:15492\"\n",
-      "            symbol: \"ANKH\"\n",
-      "          }\n",
-      "          expressions {\n",
-      "            syntax: \"hgvs.c\"\n",
-      "            value: \"NM_054027.6:c.1172T>C\"\n",
-      "          }\n",
-      "          expressions {\n",
-      "            syntax: \"hgvs.g\"\n",
-      "            value: \"NC_000005.10:g.14713637A>G\"\n",
-      "          }\n",
-      "          vcf_record {\n",
-      "            genome_assembly: \"hg38\"\n",
-      "            chrom: \"chr5\"\n",
-      "            pos: 14713637\n",
-      "            ref: \"A\"\n",
-      "            alt: \"G\"\n",
-      "          }\n",
-      "          molecule_context: genomic\n",
-      "          allelic_state {\n",
-      "            id: \"GENO:0000135\"\n",
-      "            label: \"heterozygous\"\n",
-      "          }\n",
-      "        }\n",
-      "      }\n",
-      "    }\n",
-      "  }\n",
-      "}\n",
-      "meta_data {\n",
-      "  created {\n",
-      "    seconds: 1696123169\n",
-      "    nanos: 248134851\n",
-      "  }\n",
-      "  created_by: \"ORCID:0000-0002-0736-9199\"\n",
-      "  resources {\n",
-      "    id: \"geno\"\n",
-      "    name: \"Genotype Ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/geno.owl\"\n",
-      "    version: \"2022-03-05\"\n",
-      "    namespace_prefix: \"GENO\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/GENO_\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"hgnc\"\n",
-      "    name: \"HUGO Gene Nomenclature Committee\"\n",
-      "    url: \"https://www.genenames.org\"\n",
-      "    version: \"06/01/23\"\n",
-      "    namespace_prefix: \"HGNC\"\n",
-      "    iri_prefix: \"https://www.genenames.org/data/gene-symbol-report/#!/hgnc_id/\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"omim\"\n",
-      "    name: \"An Online Catalog of Human Genes and Genetic Disorders\"\n",
-      "    url: \"https://www.omim.org\"\n",
-      "    version: \"January 4, 2023\"\n",
-      "    namespace_prefix: \"OMIM\"\n",
-      "    iri_prefix: \"https://www.omim.org/entry/\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"so\"\n",
-      "    name: \"Sequence types and features ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/so.obo\"\n",
-      "    version: \"2021-11-22\"\n",
-      "    namespace_prefix: \"SO\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/SO_\"\n",
-      "  }\n",
-      "  resources {\n",
-      "    id: \"hp\"\n",
-      "    name: \"human phenotype ontology\"\n",
-      "    url: \"http://purl.obolibrary.org/obo/hp.owl\"\n",
-      "    version: \"2023-09-01\"\n",
-      "    namespace_prefix: \"HP\"\n",
-      "    iri_prefix: \"http://purl.obolibrary.org/obo/HP_\"\n",
-      "  }\n",
-      "  phenopacket_schema_version: \"2.0\"\n",
-      "  external_references {\n",
-      "    id: \"PMID:20358596\"\n",
-      "    reference: \"https://pubmed.ncbi.nlm.nih.gov/20358596\"\n",
-      "    description: \"Three novel mutations in the ANK membrane protein cause craniometaphyseal dysplasia with variable conductive hearing loss\"\n",
-      "  }\n",
-      "}\n",
-      "\n"
-     ]
-    }
-   ],
-   "source": [
-    "for k, v in phenopacket_d.items():\n",
-    "    print(k,v)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": []
-  }
- ],
- "metadata": {
-  "kernelspec": {
-   "display_name": "ppt_venv",
-   "language": "python",
-   "name": "ppt_venv"
-  },
-  "language_info": {
-   "codemirror_mode": {
-    "name": "ipython",
-    "version": 3
-   },
-   "file_extension": ".py",
-   "mimetype": "text/x-python",
-   "name": "python",
-   "nbconvert_exporter": "python",
-   "pygments_lexer": "ipython3",
-   "version": "3.11.3"
-  }
- },
- "nbformat": 4,
- "nbformat_minor": 2
-}
diff --git a/notebooks/table_converter.ipynb b/notebooks/table_converter.ipynb
deleted file mode 100644
index f65a9112..00000000
--- a/notebooks/table_converter.ipynb
+++ /dev/null
@@ -1,428 +0,0 @@
-{
- "cells": [
-  {
-   "cell_type": "markdown",
-   "id": "4259d498",
-   "metadata": {},
-   "source": [
-    "<h1>Generate table from collection of phenopackets</h1>\n",
-    "<p>A common task for the analysis of a cohort of individuals with pathogenic variants in a given gene is to generate a table with a summary of the findings. The pyphetool package has functionality to ingest a collection of phenopakcets and to generate several different kinds of tables that may be useful for publications of supplementary material sections.</p>"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 1,
-   "id": "75a6c549",
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "Using pyphetools version 0.5.7\n"
-     ]
-    }
-   ],
-   "source": [
-    "import phenopackets as php\n",
-    "from google.protobuf.json_format import MessageToDict, MessageToJson\n",
-    "from google.protobuf.json_format import Parse, ParseDict\n",
-    "import pandas as pd\n",
-    "import os, sys\n",
-    "pd.set_option('display.max_colwidth', None) # show entire column contents, important!\n",
-    "pd.set_option('display.max_rows', None)\n",
-    "from collections import defaultdict\n",
-    "\n",
-    "from phenopackets import Phenopacket\n",
-    "from google.protobuf.json_format import Parse\n",
-    "import json\n",
-    "import hpotk\n",
-    "\n",
-    "\n",
-    "from pyphetools.visualization import *\n",
-    "\n",
-    "import importlib.metadata\n",
-    "__version__ = importlib.metadata.version(\"pyphetools\")\n",
-    "print(f\"Using pyphetools version {__version__}\")"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 2,
-   "id": "9c3a2add-de5a-450e-a3e8-f82f3e75556f",
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "[pyphetools] Ingested 20 GA4GH phenopackets.\n"
-     ]
-    }
-   ],
-   "source": [
-    "dr = \"/home/robinp/GIT/phenopacket-store/notebooks/MAPK8IP3/phenopackets\"\n",
-    "ingestor = PhenopacketIngestor(indir=dr)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 3,
-   "id": "eaa1fabe",
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "We got 20 phenopackets\n"
-     ]
-    }
-   ],
-   "source": [
-    "patient_d = ingestor.get_phenopacket_dictionary()\n",
-    "print(f\"We got {len(patient_d)} phenopackets\")"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 4,
-   "id": "d89f63aa",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "from hpotk.ontology import Ontology\n",
-    "from hpotk.ontology.load.obographs import load_ontology\n",
-    "if os.path.isfile('hpo_data/hp.json'):\n",
-    "    hpo_ontology = load_ontology('hpo_data/hp.json')\n",
-    "else:\n",
-    "    hpo_ontology = load_ontology('https://raw.githubusercontent.com/obophenotype/human-phenotype-ontology/master/hp.json')"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 5,
-   "id": "8f6060d1-074f-471b-a48e-22375042570d",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "from IPython.display import HTML, display"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 6,
-   "id": "8f6a5045",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "pplist = list(patient_d.values())"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 7,
-   "id": "74a0a7bf",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "table = PhenopacketTable(phenopacket_list = pplist)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 8,
-   "id": "1dfcb2dd",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "text/html": [
-       "<table style=\"border: 2px solid black;\">\n",
-       "\n",
-       "<caption>20 phenopackets - PMID:30612693 (n=13); PMID:pending (n=1); PMID:30945334 (n=5); PMID:34321325 (n=1)</caption>\n",
-       "\n",
-       "<tr>\n",
-       "            <th>Individual</th>\n",
-       "            <th>Disease</th>\n",
-       "            <th>Genotype</th>\n",
-       "            <th>Phenotypic features</th>\n",
-       "        </tr>\n",
-       "        \n",
-       "<tr>\n",
-       "<td>11 (FEMALE; P11Y)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.3439C>T (heterozygous)</td>\n",
-       "<td class=\"table-data\">Spasticity (HP:0001257); Intellectual disability, moderate (HP:0002342); Thin corpus callosum (HP:0033725); Lateral ventricle dilatation (HP:0006956); Cerebral visual impairment (HP:0100704); Global developmental delay (HP:0001263)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>3 (MALE; P4Y)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.111C>G (heterozygous)</td>\n",
-       "<td class=\"table-data\">Intellectual disability, moderate (HP:0002342); Nystagmus (HP:0000639); Hypotonia (HP:0001252); Global developmental delay (HP:0001263)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>12 (FEMALE; P4Y6M)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.3439C>T (heterozygous)</td>\n",
-       "<td class=\"table-data\">Cerebral palsy (HP:0100021); Spastic diplegia (HP:0001264); Intellectual disability, severe (HP:0010864); Periventricular leukomalacia (HP:0006970); Dysplastic corpus callosum (HP:0006989); Myopic astigmatism (HP:0500041); Global developmental delay (HP:0001263)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>Individual A (FEMALE; )</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.1735C>T (heterozygous)</td>\n",
-       "<td class=\"table-data\">Nuchal cord (HP:0012498); Motor deterioration (HP:0002333); Microcephaly (HP:0000252); Spastic diplegia (HP:0001264); Cerebral palsy (HP:0100021); Global developmental delay (HP:0001263); Delayed ability to walk (HP:0031936); Delayed speech and language development (HP:0000750); Cerebellar hypoplasia (HP:0001321); Cerebral hypoplasia (HP:0006872); Thin corpus callosum (HP:0033725); Hypoplasia of the pons (HP:0012110); Reduced cerebral white matter volume (HP:0034295); Periventricular leukomalacia (HP:0006970)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>2 (MALE; P4Y)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.79G>T (heterozygous)</td>\n",
-       "<td class=\"table-data\">Ataxia (HP:0001251); Intellectual disability, severe (HP:0010864); Hypotonia (HP:0001252); Global developmental delay (HP:0001263)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>Individual 1 (MALE; P29Y)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.1735C>T (heterozygous)</td>\n",
-       "<td class=\"table-data\">Motor delay (HP:0001270); Delayed ability to walk (HP:0031936); Spastic diplegia (HP:0001264); Cerebral atrophy (HP:0002059); Delayed CNS myelination (HP:0002188); Thin upper lip vermilion (HP:0000219); Round face (HP:0000311); Short stature (HP:0004322); Obesity (HP:0001513); Precocious puberty (HP:0000826); Intellectual disability, severe (HP:0010864); Delayed speech and language development (HP:0000750); Loss of ambulation (HP:0002505)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>6 (FEMALE; P9Y)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.1334T>C (heterozygous)</td>\n",
-       "<td class=\"table-data\">Intellectual disability, mild (HP:0001256); Perisylvian polymicrogyria (HP:0012650); Hypotonia (HP:0001252); Global developmental delay (HP:0001263)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>4 (MALE; P7Y6M)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.1201G>A (heterozygous)</td>\n",
-       "<td class=\"table-data\">Intellectual disability, mild (HP:0001256); Hearing impairment (HP:0000365); Hypertelorism (HP:0000316); Protruding ear (HP:0000411); Hypodontia (HP:0000668); Finger clinodactyly (HP:0040019); Synophrys (HP:0000664); Encopresis (HP:0040183); Autism (HP:0000717); Global developmental delay (HP:0001263)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>Patient S12 (MALE; )</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.45C>G (heterozygous)</td>\n",
-       "<td class=\"table-data\">Intellectual disability (HP:0001249); Spastic triplegia (HP:0034183)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>Individual 5 (FEMALE; P5Y)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001374425.1:c.272G>A (heterozygous)</td>\n",
-       "<td class=\"table-data\">Motor delay (HP:0001270); Persistent head lag (HP:0032988); Delayed ability to sit (HP:0025336); Delayed ability to walk (HP:0031936); Spastic diplegia (HP:0001264); Autistic behavior (HP:0000729); Infantile muscular hypotonia (HP:0008947); Cerebral atrophy (HP:0002059); Delayed CNS myelination (HP:0002188); Prominent nasal bridge (HP:0000426); Thin upper lip vermilion (HP:0000219); Round face (HP:0000311); Intellectual disability, severe (HP:0010864); Absent speech (HP:0001344); Thick eyebrow (HP:0000574); Upslanted palpebral fissure (HP:0000582); Anteverted nares (HP:0000463); Short philtrum (HP:0000322)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>9 (FEMALE; P6Y)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.1735C>T (heterozygous)</td>\n",
-       "<td class=\"table-data\">Spasticity (HP:0001257); Upper motor neuron dysfunction (HP:0002493); Lower limb muscle weakness (HP:0007340); Intellectual disability, moderate (HP:0002342); Thin corpus callosum (HP:0033725); Hypoplasia of the brainstem (HP:0002365); Polymicrogyria (HP:0002126); Reduced cerebral white matter volume (HP:0034295); Pica (HP:0011856); Syringomyelia (HP:0003396); Small hand (HP:0200055); Hypotonia (HP:0001252); Seizure (HP:0001250); Global developmental delay (HP:0001263)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>1 (MALE; P14Y8M)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.65del (heterozygous)</td>\n",
-       "<td class=\"table-data\">Ataxia (HP:0001251); Intellectual disability, moderate (HP:0002342); Cerebellar atrophy (HP:0001272); Emotional lability (HP:0000712); Scoliosis (HP:0002650); Autism (HP:0000717); Hypotonia (HP:0001252); Global developmental delay (HP:0001263)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>Individual 3 (MALE; P16Y)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.1735C>T (heterozygous)</td>\n",
-       "<td class=\"table-data\">Motor delay (HP:0001270); Delayed ability to sit (HP:0025336); Delayed ability to walk (HP:0031936); Spastic diplegia (HP:0001264); Prominent nasal bridge (HP:0000426); Thin upper lip vermilion (HP:0000219); Short stature (HP:0004322); Obesity (HP:0001513); Intellectual disability, profound (HP:0002187); Delayed speech and language development (HP:0000750); Loss of ambulation (HP:0002505)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>Individual 2 (FEMALE; P27Y)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.1735C>T (heterozygous)</td>\n",
-       "<td class=\"table-data\">Motor delay (HP:0001270); Delayed ability to roll over (HP:0032989); Delayed ability to walk (HP:0031936); Spastic diplegia (HP:0001264); Cerebral atrophy (HP:0002059); Delayed CNS myelination (HP:0002188); Thin upper lip vermilion (HP:0000219); Round face (HP:0000311); Short stature (HP:0004322); Obesity (HP:0001513); Precocious puberty (HP:0000826); Intellectual disability, severe (HP:0010864); Delayed speech and language development (HP:0000750); Loss of ambulation (HP:0002505)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>5 (MALE; P10Y)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.1334T>C (heterozygous)</td>\n",
-       "<td class=\"table-data\">Intellectual disability, moderate (HP:0002342); Perisylvian polymicrogyria (HP:0012650); Microdontia (HP:0000691); Scoliosis (HP:0002650); Hypotonia (HP:0001252); Seizure (HP:0001250); Global developmental delay (HP:0001263)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>7 (FEMALE; P3Y)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.1577G>A (heterozygous)</td>\n",
-       "<td class=\"table-data\">Intellectual disability, mild (HP:0001256); Global developmental delay (HP:0001263)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>Individual 4 (MALE; P5Y)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.3439C>T (heterozygous)</td>\n",
-       "<td class=\"table-data\">Motor delay (HP:0001270); Persistent head lag (HP:0032988); Delayed ability to roll over (HP:0032989); Delayed ability to sit (HP:0025336); Delayed ability to walk (HP:0031936); Autistic behavior (HP:0000729); Infantile muscular hypotonia (HP:0008947); Cerebral atrophy (HP:0002059); Prominent nasal bridge (HP:0000426); Thin upper lip vermilion (HP:0000219); Round face (HP:0000311); Short stature (HP:0004322); Intellectual disability, severe (HP:0010864); Absent speech (HP:0001344); Delayed gross motor development (HP:0002194)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>13 (FEMALE; P19Y)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.3439C>T (heterozygous)</td>\n",
-       "<td class=\"table-data\">Oromotor apraxia (HP:0007301); Poor coordination (HP:0002370); Unsteady gait (HP:0002317); Intellectual disability, moderate (HP:0002342); Gastroesophageal reflux (HP:0002020); Hepatomegaly (HP:0002240); Long face (HP:0000276); Thick eyebrow (HP:0000574); Narrow palpebral fissure (HP:0045025); Strabismus (HP:0000486); Prominent nasal bridge (HP:0000426); Anteverted nares (HP:0000463); Short philtrum (HP:0000322); Narrow mouth (HP:0000160); High palate (HP:0000218); Truncal obesity (HP:0001956); Slender finger (HP:0001238); Kyphosis (HP:0002808); Hyperlordosis (HP:0003307); Scoliosis (HP:0002650); Genu valgum (HP:0002857); Hypotonia (HP:0001252); Cerebral visual impairment (HP:0100704); Global developmental delay (HP:0001263)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>8 (FEMALE; P5Y)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.1735C>T (heterozygous)</td>\n",
-       "<td class=\"table-data\">Spastic paraplegia (HP:0001258); Intellectual disability, severe (HP:0010864); CNS hypomyelination (HP:0003429); Thin corpus callosum (HP:0033725); Full cheeks (HP:0000293); Long philtrum (HP:0000343); Hypotonia (HP:0001252); Seizure (HP:0001250); Global developmental delay (HP:0001263)</td>\n",
-       "</tr>\n",
-       "<tr>\n",
-       "<td>10 (MALE; P4Y)</ts>\n",
-       "<td>Neurodevelopmental disorder with or without variable brain abnormalities (OMIM:618443)</ts>\n",
-       "<td>NM_001318852.2:c.2985C>G (heterozygous)</td>\n",
-       "<td class=\"table-data\">Intellectual disability, moderate (HP:0002342); Hypotonia (HP:0001252); Seizure (HP:0001250); Global developmental delay (HP:0001263)</td>\n",
-       "</tr>\n",
-       "</table>\n"
-      ],
-      "text/plain": [
-       "<IPython.core.display.HTML object>"
-      ]
-     },
-     "metadata": {},
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "display(HTML(table.to_html()))"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 9,
-   "id": "d4e1465e",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "detailed_table = DetailedSupplTable(patient_d=patient_d, hp_ontology=hpo_ontology)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 10,
-   "id": "916ffbcd-6c3d-4205-9eda-d9942bd005d9",
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "text/html": [
-       "<table style=\"border: 2px solid black;\">\n",
-       "\n",
-       "<tr><th>HPO term</th><th>PMID:30612693</th><th>PMID:30945334</th><th>PMID:pending</th><th>PMID:34321325</th></tr>\n",
-       "<tr><td>Global developmental delay (HP:0001263)</td><td>13/13 (100.0%)</td><td>0/5 (0.0%)</td><td>1/1 (100.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Hypotonia (HP:0001252)</td><td>9/13 (69.2%)</td><td>0/5 (0.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Intellectual disability, moderate (HP:0002342)</td><td>7/13 (53.8%)</td><td>0/5 (0.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Intellectual disability, severe (HP:0010864)</td><td>3/13 (23.1%)</td><td>4/5 (80.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Spastic diplegia (HP:0001264)</td><td>1/13 (7.7%)</td><td>4/5 (80.0%)</td><td>1/1 (100.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Delayed ability to walk (HP:0031936)</td><td>0/13 (0.0%)</td><td>5/5 (100.0%)</td><td>1/1 (100.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Motor delay (HP:0001270)</td><td>0/13 (0.0%)</td><td>5/5 (100.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Thin upper lip vermilion (HP:0000219)</td><td>0/13 (0.0%)</td><td>5/5 (100.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Thin corpus callosum (HP:0033725)</td><td>3/13 (23.1%)</td><td>0/5 (0.0%)</td><td>1/1 (100.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Delayed speech and language development (HP:0000750)</td><td>0/13 (0.0%)</td><td>3/5 (60.0%)</td><td>1/1 (100.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Cerebral atrophy (HP:0002059)</td><td>0/13 (0.0%)</td><td>4/5 (80.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Round face (HP:0000311)</td><td>0/13 (0.0%)</td><td>4/5 (80.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Short stature (HP:0004322)</td><td>0/13 (0.0%)</td><td>4/5 (80.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Prominent nasal bridge (HP:0000426)</td><td>1/13 (7.7%)</td><td>3/5 (60.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Seizure (HP:0001250)</td><td>4/13 (30.8%)</td><td>0/5 (0.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Delayed CNS myelination (HP:0002188)</td><td>0/13 (0.0%)</td><td>3/5 (60.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Obesity (HP:0001513)</td><td>0/13 (0.0%)</td><td>3/5 (60.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Loss of ambulation (HP:0002505)</td><td>0/13 (0.0%)</td><td>3/5 (60.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Intellectual disability, mild (HP:0001256)</td><td>3/13 (23.1%)</td><td>0/5 (0.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Delayed ability to sit (HP:0025336)</td><td>0/13 (0.0%)</td><td>3/5 (60.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Scoliosis (HP:0002650)</td><td>3/13 (23.1%)</td><td>0/5 (0.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Spasticity (HP:0001257)</td><td>2/13 (15.4%)</td><td>0/5 (0.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Cerebral visual impairment (HP:0100704)</td><td>2/13 (15.4%)</td><td>0/5 (0.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Cerebral palsy (HP:0100021)</td><td>1/13 (7.7%)</td><td>0/5 (0.0%)</td><td>1/1 (100.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Periventricular leukomalacia (HP:0006970)</td><td>1/13 (7.7%)</td><td>0/5 (0.0%)</td><td>1/1 (100.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Reduced cerebral white matter volume (HP:0034295)</td><td>1/13 (7.7%)</td><td>0/5 (0.0%)</td><td>1/1 (100.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Ataxia (HP:0001251)</td><td>2/13 (15.4%)</td><td>0/5 (0.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Precocious puberty (HP:0000826)</td><td>0/13 (0.0%)</td><td>2/5 (40.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Perisylvian polymicrogyria (HP:0012650)</td><td>2/13 (15.4%)</td><td>0/5 (0.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Autism (HP:0000717)</td><td>2/13 (15.4%)</td><td>0/5 (0.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Persistent head lag (HP:0032988)</td><td>0/13 (0.0%)</td><td>2/5 (40.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Autistic behavior (HP:0000729)</td><td>0/13 (0.0%)</td><td>2/5 (40.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Infantile muscular hypotonia (HP:0008947)</td><td>0/13 (0.0%)</td><td>2/5 (40.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Absent speech (HP:0001344)</td><td>0/13 (0.0%)</td><td>2/5 (40.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Thick eyebrow (HP:0000574)</td><td>1/13 (7.7%)</td><td>1/5 (20.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Anteverted nares (HP:0000463)</td><td>1/13 (7.7%)</td><td>1/5 (20.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Short philtrum (HP:0000322)</td><td>1/13 (7.7%)</td><td>1/5 (20.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "<tr><td>Delayed ability to roll over (HP:0032989)</td><td>0/13 (0.0%)</td><td>2/5 (40.0%)</td><td>0/1 (0.0%)</td><td>0/1 (0.0%)</td></tr>\n",
-       "\n",
-       "</table>\n"
-      ],
-      "text/plain": [
-       "<IPython.core.display.HTML object>"
-      ]
-     },
-     "metadata": {},
-     "output_type": "display_data"
-    }
-   ],
-   "source": [
-    "display(HTML(detailed_table.get_html_table_by_pmid(min_count=1)))"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "36d4039f-c344-45f9-9cf9-14583b0efc70",
-   "metadata": {},
-   "outputs": [],
-   "source": []
-  }
- ],
- "metadata": {
-  "kernelspec": {
-   "display_name": "Python 3 (ipykernel)",
-   "language": "python",
-   "name": "python3"
-  },
-  "language_info": {
-   "codemirror_mode": {
-    "name": "ipython",
-    "version": 3
-   },
-   "file_extension": ".py",
-   "mimetype": "text/x-python",
-   "name": "python",
-   "nbconvert_exporter": "python",
-   "pygments_lexer": "ipython3",
-   "version": "3.10.12"
-  }
- },
- "nbformat": 4,
- "nbformat_minor": 5
-}
diff --git a/src/pyphetools/__init__.py b/src/pyphetools/__init__.py
index 3518fbe7..4300353b 100644
--- a/src/pyphetools/__init__.py
+++ b/src/pyphetools/__init__.py
@@ -1,12 +1,14 @@
 # top level
 from . import creation
+from . import pp
 from . import visualization
 from . import validation
 
-__version__ = "0.9.62"
+__version__ = "0.9.66"
 
 __all__ = [
     "creation",
+    "pp",
     "visualization",
     "validation"
 ]
diff --git a/src/pyphetools/creation/age_of_death_mapper.py b/src/pyphetools/creation/age_of_death_mapper.py
index 3383b6e0..70d63984 100644
--- a/src/pyphetools/creation/age_of_death_mapper.py
+++ b/src/pyphetools/creation/age_of_death_mapper.py
@@ -1,10 +1,13 @@
-import phenopackets as PPKt
 
 import pandas as pd
 from typing import Optional
 from .age_isoformater import AgeIsoFormater
 from .constants import Constants
 
+from pyphetools.pp.v202 import VitalStatus as pptVitalStatus
+from pyphetools.pp.v202 import TimeElement as pptTimeElement
+from pyphetools.pp.v202 import Age as pptAge
+
 
 class AgeOfDeathColumnMapper:
 
@@ -23,7 +26,7 @@ def __init__(self, column_name, string_to_iso_d=None) -> None:
         self._column_name = column_name
         self._string_to_iso_d = string_to_iso_d
 
-    def map_cell_to_vital_status(self, cell_contents) -> Optional[PPKt.VitalStatus]:
+    def map_cell_to_vital_status(self, cell_contents) -> Optional[pptVitalStatus]:
 
         """
         Map a single cell of the table
@@ -35,10 +38,10 @@ def map_cell_to_vital_status(self, cell_contents) -> Optional[PPKt.VitalStatus]:
         contents = str(cell_contents)
         if contents not in self._string_to_iso_d:
             return None
-        iso_age = self._string_to_iso_d.get(contents)
-        vstatus = PPKt.VitalStatus()
-        vstatus.status = PPKt.VitalStatus.DECEASED
-        vstatus.time_of_death.age.iso8601duration = iso_age
+        # Wrap the Age (iso8601) in a TimeElement.
+        iso_age = pptAge(self._string_to_iso_d.get(contents))
+        telem = pptTimeElement(iso_age)
+        vstatus = pptVitalStatus(status=pptVitalStatus.Status.DECEASED, time_of_death=telem)
         return vstatus
 
     @property
diff --git a/src/pyphetools/creation/case_template_encoder.py b/src/pyphetools/creation/case_template_encoder.py
index 6370fbdf..750b430d 100644
--- a/src/pyphetools/creation/case_template_encoder.py
+++ b/src/pyphetools/creation/case_template_encoder.py
@@ -17,6 +17,8 @@
 
 
 # The following constants are identical with the constants used in the  Excel template
+from pyphetools.pp.v202 import VitalStatus
+
 AGE_OF_ONSET_FIELDNAME = "age_of_onset"
 AGE_AT_LAST_ENCOUNTER_FIELDNAME = "age_at_last_encounter"
 
@@ -74,6 +76,8 @@ def __init__(self, h1:str, h2:str, ntr=False):
         self._is_ntr = ntr
         if h1.endswith(" "):
             self._error = f"Error - HPO label ends with whitespace: \”{h1}\""
+        elif isinstance(h2, float):
+            raise ValueError(f"second header was parsed as float which usually means it was empty: h1=\"{h1}\", h2=\"{h2}\"")
         elif h2.startswith("HP:") and len(h2) != 10:
             self._error = f"Error - Malformed HPO id: \”{h2}\" ({h1})"
         elif not h2.startswith("HP:"):
@@ -178,7 +182,7 @@ def columntype(self):
 
 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"}
+                "age_at_last_encounter", "deceased", "sex"}
 
 # note that the allele_2 field is option
 REQUIRED_H1_FIELDS = ["PMID", "title", "individual_id",	"comment", "disease_id", "disease_label",
@@ -188,6 +192,12 @@ def columntype(self):
 REQUIRED_H2_FIELDS = ["CURIE", "str",	"str", "optional", "CURIE", "str", "CURIE",	"str",
                     "str","str","str","optional",  "age", "age", "M:F:O:U", "na"]
 
+OPTIONAL_H1_FIELDS = REQUIRED_H1_FIELDS
+OPTIONAL_H1_FIELDS.insert(14, "deceased")
+OPTIONAL_H2_FIELDS = REQUIRED_H2_FIELDS
+OPTIONAL_H2_FIELDS.insert(14, "yes/no/na")
+
+
 class CaseTemplateEncoder:
     """Class to encode data from user-provided Excel template.
 
@@ -216,13 +226,20 @@ def __init__(self, df:pd.DataFrame, hpo_cr:HpoConceptRecognizer, created_by:str,
             raise ValueError("headers are different lengths. Check template file for correctness.")
         # check headers are well formed
         idx = 0
-        for i in range(len(REQUIRED_H1_FIELDS)):
-            if idx == ALLELE_2_IDX and header_1[idx] != REQUIRED_H1_FIELDS[idx]:
+        ## The deceased field is optional
+        if "deceased" in header_1:
+            required_h1 = OPTIONAL_H1_FIELDS
+            required_h2 = OPTIONAL_H2_FIELDS
+        else:
+            required_h1 = REQUIRED_H1_FIELDS
+            required_h2 = REQUIRED_H2_FIELDS
+        for i in range(len(required_h1)):
+            if idx == ALLELE_2_IDX and header_1[idx] != required_h1[idx]:
                 idx += 1 # skip optional index
-            if header_1[idx] != REQUIRED_H1_FIELDS[idx]:
-                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]}\"")
+            if header_1[idx] != required_h1[idx]:
+                raise ValueError(f"Malformed header 1 field at index {idx}. Expected \"{required_h1[idx]}\" but got \"{header_1[idx]}\"")
+            if header_2[idx] != required_h2[idx]:
+                raise ValueError(f"Malformed header 2 field at index {idx}. Expected \"{required_h2[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)
@@ -251,6 +268,9 @@ def  _process_header(self, header_1:List, header_2:List, hpo_cr:HpoConceptRecogn
         for i in range(self._n_columns):
             h1 = header_1[i]
             h2 = header_2[i]
+            if isinstance(h1, float) or len(h1) == 0:
+                raise ValueError(f"Error: Empty column header at column {i}")
+
             if h1 == "HPO":
                 in_hpo_range = True
                 index_to_decoder_d[i] = NullEncoder()
@@ -260,6 +280,8 @@ def  _process_header(self, header_1:List, header_2:List, hpo_cr:HpoConceptRecogn
             elif not in_hpo_range:
                 if h1 in EXPECTED_HEADERS:
                     index_to_decoder_d[i] = DataEncoder(h1=h1, h2=h2)
+                elif h1 == "deceased":
+                    index_to_decoder_d[i] = DataEncoder(h1=h1, h2=h2)
                 else:
                     raise ValueError(f"Malformed template header at column {i}: \"{h1}\"")
             elif in_hpo_range:
@@ -352,8 +374,20 @@ def _parse_individual(self, row:pd.Series):
             encounter_age = PyPheToolsAge.get_age(encounter_age)
         else:
             encounter_age = NoneAge("na")
+        vitStat = None
+        if "deceased" in data_items:
+            decsd = data_items.get("deceased")
+            if encounter_age.is_valid():
+                timeelem = encounter_age.to_ga4gh_time_element()
+                vitStat = VitalStatus(status=VitalStatus.Status.DECEASED, time_of_death=timeelem)
+            else:
+                vitStat = VitalStatus(status=VitalStatus.Status.DECEASED)
         disease_id = data_items.get("disease_id")
         disease_label = data_items.get("disease_label")
+        # common error -- e.g. PMID: 3000312 or OMIM: 600123 (whitespace after colon)
+        for item in [pmid, disease_id]:
+            if " " in item:
+                raise ValueError(f"Found illegal whitespace in {item}. Please remove it and try again")
         disease = Disease(disease_id=disease_id, disease_label=disease_label)
         return Individual(individual_id=individual_id,
                             citation=citation,
@@ -361,6 +395,7 @@ def _parse_individual(self, row:pd.Series):
                             sex=sex,
                             age_of_onset=onset_age,
                             age_at_last_encounter=encounter_age,
+                            vital_status=vitStat,
                             disease=disease)
 
     def _debug_row(self, target_idx:int, row:pd.Series):
diff --git a/src/pyphetools/creation/cohort_encoder.py b/src/pyphetools/creation/cohort_encoder.py
index e26ac47a..2040be94 100644
--- a/src/pyphetools/creation/cohort_encoder.py
+++ b/src/pyphetools/creation/cohort_encoder.py
@@ -5,6 +5,7 @@
 from .abstract_encoder import AbstractEncoder
 from .age_column_mapper import AgeColumnMapper
 from .citation import Citation
+from .column_mapper import ColumnMapper
 from .constants import Constants
 from .disease import Disease
 from .hpo_cr import HpoConceptRecognizer
@@ -49,16 +50,16 @@ class CohortEncoder(AbstractEncoder):
     """
 
     def __init__(self,
-                df,
+                df:pd.DataFrame,
                 hpo_cr: HpoConceptRecognizer,
-                column_mapper_list,
-                individual_column_name,
+                column_mapper_list:List[ColumnMapper],
+                individual_column_name:str,
                 metadata,
-                age_of_onset_mapper=AgeColumnMapper.not_provided(),
-                age_at_last_encounter_mapper=AgeColumnMapper.not_provided(),
-                sexmapper=SexColumnMapper.not_provided(),
-                variant_mapper=None,
-                delimiter=None):
+                age_of_onset_mapper:AgeColumnMapper=AgeColumnMapper.not_provided(),
+                age_at_last_encounter_mapper:AgeColumnMapper=AgeColumnMapper.not_provided(),
+                sexmapper:SexColumnMapper=SexColumnMapper.not_provided(),
+                variant_mapper:VariantColumnMapper=None,
+                delimiter:str=None):
         """Constructor
         """
         super().__init__(metadata=metadata)
diff --git a/src/pyphetools/creation/import_template.py b/src/pyphetools/creation/import_template.py
index fd702b1a..77178786 100644
--- a/src/pyphetools/creation/import_template.py
+++ b/src/pyphetools/creation/import_template.py
@@ -114,9 +114,11 @@ def _get_allelic_requirement(df:pd.DataFrame):
 
 
 
-    def import_phenopackets_from_template(self, deletions:typing.Set[str]=set(),
+    def import_phenopackets_from_template(self,
+                                          deletions:typing.Set[str]=set(),
                                         duplications:typing.Set[str]=set(),
-                                        inversions:typing.Set[str]=set()):
+                                        inversions:typing.Set[str]=set(),
+                                        hemizygous:bool=False):
         """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
@@ -128,11 +130,13 @@ def import_phenopackets_from_template(self, deletions:typing.Set[str]=set(),
         :type duplications: (typing.Set[str], optional
         :param inversions: Strings (identical to entries in the templates) that represent inversions.
         :type inversions: (typing.Set[str], optional
+        :param hemizygous: Set this to true for X-chromosomal recessive conditions in which the genotype of affected males is hemizygous
+        :type hemizygous: bool
         :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 CaseTemplateEncoder
         from pyphetools.creation import VariantManager
         from pyphetools.validation import CohortValidator
         parser = HpoParser(hpo_json_file=self._hp_json)
@@ -167,9 +171,11 @@ def import_phenopackets_from_template(self, deletions:typing.Set[str]=set(),
                     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)
+        vman.add_variants_to_individuals(individuals, hemizygous=hemizygous)
         all_req = TemplateImporter._get_allelic_requirement(df)
         cvalidator = CohortValidator(cohort=individuals, ontology=hpo_ontology, min_hpo=1, allelic_requirement=all_req)
+        if cvalidator.n_removed_individuals() > 0:
+            print(f"Removed {cvalidator.n_removed_individuals()} individuals with unfixable errors")
         ef_individuals = cvalidator.get_error_free_individual_list()
         encoder.output_individuals_as_phenopackets(individual_list=ef_individuals)
         return individuals, cvalidator
diff --git a/src/pyphetools/creation/individual.py b/src/pyphetools/creation/individual.py
index 55b6b920..b0027650 100644
--- a/src/pyphetools/creation/individual.py
+++ b/src/pyphetools/creation/individual.py
@@ -10,7 +10,7 @@
 from .hgvs_variant import Variant
 from .metadata import MetaData, Resource
 from .pyphetools_age import PyPheToolsAge, NoneAge, IsoAge
-
+from ..pp.v202 import VitalStatus
 
 class Individual:
     """
@@ -37,6 +37,7 @@ def __init__(self,
                 sex:str=Constants.NOT_PROVIDED,
                 age_of_onset:str=NoneAge("na"),
                 age_at_last_encounter:str=NoneAge("na"),
+                vital_status:VitalStatus=None,
                 interpretation_list:List[PPKt.VariantInterpretation]=None,
                 disease:Disease=None):
         """Constructor
@@ -57,6 +58,7 @@ def __init__(self,
         #if not isinstance(age_at_last_encounter, PyPheToolsAge):
         #    raise ValueError(f"age_at_last_encounter argument must be PyPheToolsAge but was {type(age_of_onset)}")
         self._age_at_last_encounter = age_at_last_encounter
+        self._vital_status = vital_status
         if hpo_terms is None:
             self._hpo_terms = list()
         else:
@@ -192,7 +194,9 @@ def set_citation(self, citation:Citation):
         """
         self._citation = citation
 
-    def set_vital_status(self, vstatus:PPKt.VitalStatus):
+    def set_vital_status(self, vstatus:VitalStatus):
+        if not isinstance(vstatus, VitalStatus):
+            raise ValueError(f"vstatus argument must be pyphetools.pp.v202.VitalStatus but was{type(vstatus)}")
         self._vital_status = vstatus
 
     def get_phenopacket_id(self, phenopacket_id=None) -> str:
@@ -287,7 +291,8 @@ def to_ga4gh_phenopacket(self, metadata, phenopacket_id=None) -> PPKt.Phenopacke
         elif self._sex == Constants.UNKNOWN_SEX_SYMBOL:
             php.subject.sex = PPKt.Sex.UNKNOWN_SEX
         if self._vital_status is not None:
-            php.subject.vital_status.CopyFrom(self._vital_status)
+            vs = self._vital_status.to_message()
+            php.subject.vital_status.CopyFrom(vs)
         disease_object = self._get_disease_object()
         php.diseases.append(disease_object)
         if isinstance(self._hpo_terms, list):
diff --git a/src/pyphetools/creation/mixed_cohort_encoder.py b/src/pyphetools/creation/mixed_cohort_encoder.py
index 3db9ebeb..c24df55e 100644
--- a/src/pyphetools/creation/mixed_cohort_encoder.py
+++ b/src/pyphetools/creation/mixed_cohort_encoder.py
@@ -60,6 +60,7 @@ class MixedCohortEncoder(AbstractEncoder):
     def __init__(self,
                 df,
                 hpo_cr,
+                hpo_ontology,
                 column_mapper_list,
                 individual_column_name,
                 disease_id_mapper,
@@ -95,7 +96,7 @@ def __init__(self,
         self._title_column = title_column
         self._variant_mapper = variant_mapper
         self._delimiter = delimiter
-        MixedCohortEncoder.HPO_VERSION = hpo_cr.get_hpo_ontology().version
+        MixedCohortEncoder.HPO_VERSION = hpo_ontology.version
 
 
     def get_individuals(self) -> List[Individual]:
@@ -179,7 +180,7 @@ def get_individuals(self) -> List[Individual]:
             cite = Citation(pmid=pmid, title=title)
             indi = Individual(individual_id=individual_id,
                                 sex=sex,
-                                age=age,
+                                age_at_last_encounter=age,
                                 hpo_terms=hpo_terms,
                                 citation=cite,
                                 interpretation_list=interpretation_list,
diff --git a/src/pyphetools/creation/simple_column_mapper.py b/src/pyphetools/creation/simple_column_mapper.py
index 1f92ba2d..81caf9fb 100644
--- a/src/pyphetools/creation/simple_column_mapper.py
+++ b/src/pyphetools/creation/simple_column_mapper.py
@@ -1,6 +1,7 @@
 from .hp_term import HpTerm
 from .column_mapper import ColumnMapper
 from .hpo_cr import HpoConceptRecognizer
+from .pyphetools_age import IsoAge
 from typing import List
 import pandas as pd
 import re
@@ -66,7 +67,7 @@ def map_cell(self, cell_contents) -> List[HpTerm]:
         contents = cell_contents.strip()
         # first check if the cell contents represent a valid iso8601, which represents age of onset.
         if ColumnMapper.is_valid_iso8601(contents):
-            return [HpTerm(hpo_id=self._hpo_id, label=self._hpo_label, onset=contents)]
+            return [HpTerm(hpo_id=self._hpo_id, label=self._hpo_label, onset=IsoAge(contents))]
         if contents in self._observed:
             return [HpTerm(hpo_id=self._hpo_id, label=self._hpo_label)]
         elif contents in self._excluded:
diff --git a/src/pyphetools/pp/__init__.py b/src/pyphetools/pp/__init__.py
new file mode 100644
index 00000000..108561af
--- /dev/null
+++ b/src/pyphetools/pp/__init__.py
@@ -0,0 +1,13 @@
+"""
+A package with strongly typed Phenopacket Schema types and the code for I/O and validation.
+"""
+
+from . import parse
+from . import v202
+from ._timestamp import Timestamp
+
+__all__ = [
+    'parse',
+    'v202',
+    'Timestamp',
+]
diff --git a/src/pyphetools/pp/_api.py b/src/pyphetools/pp/_api.py
new file mode 100644
index 00000000..4ca22a20
--- /dev/null
+++ b/src/pyphetools/pp/_api.py
@@ -0,0 +1,18 @@
+import abc
+
+from .parse import Serializable, Deserializable, FromProtobuf, ToProtobuf
+
+
+# TODO:
+# - There is a problem with map<string, string> because it is represented as a JSON object
+#    and the case of keys will be changed during write.
+# - We do not validate the values, just their presence and type
+
+
+class MessageMixin(Serializable, Deserializable, FromProtobuf, ToProtobuf,
+                   metaclass=abc.ABCMeta):
+
+    # MANDATORY
+    @abc.abstractmethod
+    def __eq__(self, other):
+        pass
diff --git a/src/pyphetools/pp/_timestamp.py b/src/pyphetools/pp/_timestamp.py
new file mode 100644
index 00000000..568add28
--- /dev/null
+++ b/src/pyphetools/pp/_timestamp.py
@@ -0,0 +1,173 @@
+import math
+import typing
+from datetime import datetime, timezone
+
+from google.protobuf.message import Message
+from google.protobuf.timestamp_pb2 import Timestamp as PbTimestamp
+
+from .parse import ToProtobuf, FromProtobuf
+
+
+class Timestamp(ToProtobuf, FromProtobuf):
+    """
+    This `Timestamp` implementation is functionally equivalent to protobuf's timestamp.
+
+    Per protobuf API documentation, *A Timestamp represents a point in time
+    independent of any time zone or local calendar, encoded as a count of seconds
+    and fractions of seconds at nanosecond resolution.
+    The count is relative to an epoch at UTC midnight on January 1, 1970,
+    in the proleptic Gregorian calendar which extends the Gregorian calendar
+    backwards to year one.*
+
+    Consult the `Phenopacket Schema <https://phenopacket-schema.readthedocs.io/en/latest/timestamp.html>`_ documentation
+    for more information.
+
+    **Examples**
+
+    Here we show how to create a `Timestamp` from various inputs.
+
+    >>> from pyphetools.pp import Timestamp
+
+    Let's create a timestamp from a date time string:
+
+    >>> ts = Timestamp.from_str('1970-01-01T00:00:30Z')
+    >>> ts.seconds, ts.nanos
+    (30, 0)
+
+    Note, we indicate that the timestamp is in UTC by adding `Z` suffix.
+
+    We can also create a timestamp from a local time.
+    Let's create the same `Timestamp` but now in Eastern Daylight Time (EDT)
+    which is 4 hours behind UTC:
+
+    >>> ts_local = Timestamp.from_str('1969-12-31T20:00:30-04:00')
+    >>> assert ts_local == ts
+
+    We can also create timestamp from a datetime object:
+
+    >>> from datetime import datetime, date, time, timezone
+    >>> d = date(1970, 1, 1)
+    >>> t = time(0, 0, 30)
+    >>> dt = datetime.combine(d, t, tzinfo=timezone.utc)
+    >>> ts_dt = Timestamp.from_datetime(dt)
+    >>> assert ts_dt == ts
+
+    Last, we can create timestamp directly from seconds and nanoseconds:
+
+    >>> ts_raw = Timestamp(30, 0)
+    >>> assert ts_raw == ts
+
+    and we can convert the timestamp to a UTC date time string:
+
+    >>> ts_raw.as_str()
+    '1970-01-01T00:00:30Z'
+    """
+
+    def __init__(
+            self,
+            seconds: int,
+            nanos: int,
+    ):
+        # Seconds can be positive or negative,
+        # The negative seconds represent the timestamps prior the UNIX epoch.
+        self._seconds = seconds
+        self._nanos = nanos  # TODO: check that nanos is a positive `int`
+
+    @property
+    def seconds(self) -> int:
+        return self._seconds
+
+    @seconds.setter
+    def seconds(self, value: int):
+        self._seconds = value
+
+    @property
+    def nanos(self) -> int:
+        return self._nanos
+
+    @nanos.setter
+    def nanos(self, value: int):
+        self._nanos = value
+
+    def as_datetime(self) -> datetime:
+        """
+        Convert timestamp into Python's datetime object.
+
+        The datetime is always in UTC.
+
+        **Example**
+
+        >>> from pyphetools.pp import Timestamp
+        >>> ts = Timestamp(10, 500)
+        >>> dt = ts.as_datetime()
+
+        Now we can access the datetime components:
+
+        >>> dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second
+        (1970, 1, 1, 0, 0, 10)
+
+        including the time zone:
+
+        >>> dt.tzname()
+        'UTC'
+        """
+        return datetime.fromtimestamp(self._seconds + (self._nanos / 10 ** 9), tz=timezone.utc)
+
+    def as_str(self, fmt: str = '%Y-%m-%dT%H:%M:%SZ') -> str:
+        """
+        Convert timestamp into a date time string.
+
+        **Example**
+
+        >>> from pyphetools.pp import Timestamp
+        >>> ts = Timestamp(0, 500_000)
+        >>> ts.as_str()
+        '1970-01-01T00:00:00Z'
+
+        We can use different formatting:
+
+        >>> ts.as_str('%Y-%m-%dT%H:%M:%S.%f%Z')
+        '1970-01-01T00:00:00.000500UTC'
+        """
+        return self.as_datetime().strftime(fmt)
+
+    @staticmethod
+    def from_str(val: str, fmt: str = '%Y-%m-%dT%H:%M:%S%z'):
+        """
+        Create `Timestamp` from a date time string.
+
+        :param val: the date time `str`.
+        :param fmt: the date time format string.
+        """
+        dt = datetime.strptime(val, fmt)
+        return Timestamp.from_datetime(dt)
+
+    @staticmethod
+    def from_datetime(dt: datetime):
+        fractional, integral = math.modf(dt.timestamp())
+        return Timestamp(seconds=int(integral), nanos=int(fractional * 1_000_000))
+
+    def to_message(self) -> Message:
+        return PbTimestamp(seconds=self._seconds, nanos=self._nanos)
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return PbTimestamp
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, PbTimestamp):
+            return Timestamp(
+                seconds=msg.seconds,
+                nanos=msg.nanos,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Timestamp) \
+            and self._seconds == other._seconds \
+            and self._nanos == other._nanos
+
+    def __repr__(self):
+        return f'Timestamp(seconds={self._seconds}, nanos={self._nanos})'
diff --git a/src/pyphetools/pp/parse/__init__.py b/src/pyphetools/pp/parse/__init__.py
new file mode 100644
index 00000000..1c7e71dd
--- /dev/null
+++ b/src/pyphetools/pp/parse/__init__.py
@@ -0,0 +1,14 @@
+"""
+A package to support the (de)serialization of phenopacket schema elements to/from various formats,
+such as JSON, YAML, and protobuf.
+"""
+from . import json
+from ._io import Serializer, Serializable, Deserializer, Deserializable
+from ._io import extract_message_scalar, extract_message_sequence, extract_oneof_scalar
+from ._pb import FromProtobuf, ToProtobuf
+from ._pb import extract_pb_message_scalar, extract_pb_message_seq, extract_pb_oneof_scalar
+
+__all__ = [
+    'Serializer', 'Serializable', 'Deserializer', 'Deserializable',
+    'FromProtobuf', 'ToProtobuf',
+]
diff --git a/src/pyphetools/pp/parse/_io.py b/src/pyphetools/pp/parse/_io.py
new file mode 100644
index 00000000..ef9545b7
--- /dev/null
+++ b/src/pyphetools/pp/parse/_io.py
@@ -0,0 +1,236 @@
+import abc
+import enum
+import io
+import typing
+
+
+class Serializable(metaclass=abc.ABCMeta):
+    """
+    `Serializable` is a mixin class for data classes composed of a hierarchy of primitive types, such as `bool`, `int`,
+    `float`, or `str`, or other `Serializable` objects.
+
+    `Serializable` knows how to convert the data into a hierarchy composed of primitive types
+    or Python compound types such as `list` and `dict`. The hierarchy is an intermediate step before serializing
+    into a data format such as JSON, YAML or others using a :class:Serializer`.
+
+    The mixin requirements include a single method: :func:`field_names` and the other functionality then comes for free.
+    """
+
+    _PRIMITIVES = {
+        bool, int, float, str,
+    }
+
+    @staticmethod
+    @abc.abstractmethod
+    def field_names() -> typing.Iterable[str]:
+        """
+        Get an iterable with the data class field names.
+        """
+        pass
+
+    def to_dict(self,
+                out: typing.MutableMapping[str, typing.Any]):
+        """
+        Write itself into a dictionary composed of primitive or Python compound types.
+        """
+        for name in self.field_names():
+            field = getattr(self, name)
+            Serializable._put_field_to_mapping(name, field, out)
+
+    @staticmethod
+    def _put_field_to_mapping(
+            name: str,
+            field: typing.Optional[typing.Any],
+            out: typing.MutableMapping[str, typing.Any],
+    ):
+        if field is None:
+            pass
+        elif type(field) in Serializable._PRIMITIVES:
+            out[name] = field
+        elif isinstance(field, typing.Sequence):
+            seq = []
+            for subfield in field:
+                Serializable._put_field_to_sequence(subfield, seq)
+            out[name] = seq
+        elif isinstance(field, typing.Mapping):
+            val = {}
+            for k, v in field.items():
+                Serializable._put_field_to_mapping(k, v, val)
+            out[name] = val
+        elif isinstance(field, Serializable):
+            val = {}
+            field.to_dict(val)
+            out[name] = val
+        elif isinstance(field, enum.Enum):
+            out[name] = field.name
+        elif hasattr(field, 'seconds') and hasattr(field, 'nanos') and hasattr(field, 'as_str') and callable(field.as_str):
+            # This quack *exactly* as a Timestamp!
+            out[name] = field.as_str()
+        else:
+            raise ValueError(f'Unexpected field {field}')
+
+    @staticmethod
+    def _put_field_to_sequence(
+            field: typing.Optional[typing.Any],
+            out: typing.MutableSequence[typing.Any],
+    ):
+        if field is None:
+            pass
+        elif type(field) in Serializable._PRIMITIVES:
+            out.append(field)
+        elif isinstance(field, Serializable):
+            val = {}
+            for field_name in field.field_names():
+                sub = getattr(field, field_name)
+                Serializable._put_field_to_mapping(field_name, sub, val)
+            out.append(val)
+        elif isinstance(field, typing.Mapping):
+            val = {}
+            for k, v in field.items():
+                Serializable._put_field_to_mapping(k, v, val)
+            out.append(val)
+        elif isinstance(field, enum.Enum):
+            out.append(field.name)
+        elif hasattr(field, 'seconds') and hasattr(field, 'nanos') and hasattr(field, 'as_str') and callable(field.as_str):
+            # This quack *exactly* as a Timestamp!
+            out.append(field.as_str())
+        else:
+            # We should not have to process a sequence within a sequence.
+            raise ValueError(f'Unexpected field {field}')
+
+
+class Serializer(metaclass=abc.ABCMeta):
+    """
+    `Serializer` serializes a :class:`Serializable` object into a format such as JSON, YAML, or others.
+
+    The format depends on the serializer subclass.
+    """
+
+    @abc.abstractmethod
+    def serialize(
+            self,
+            val: Serializable,
+            fp: typing.IO,
+    ):
+        """
+        Serialize a value `val` into the provided IO object `fp`.
+        """
+        pass
+
+
+E = typing.TypeVar('E', bound=enum.Enum)
+"""
+A type that is a subclass of :class:`enumEnum`.
+"""
+
+
+class Deserializable(metaclass=abc.ABCMeta):
+    """
+    `Deserializable` knows how to initialize itself
+    based on a `dict` with intermediate Python representation.
+
+    See :class:`Serializable` for more info.
+    """
+
+    @classmethod
+    @abc.abstractmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        # Can raise if a required field is missing
+        pass
+
+    @classmethod
+    @abc.abstractmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        # May not be implemented if the class includes a field with oneof Protobuf semantics!
+        pass
+
+    @classmethod
+    def _all_required_fields_are_present(
+            cls,
+            values: typing.Mapping[str, typing.Any]
+    ) -> bool:
+        return all(field in values for field in cls.required_fields())
+
+    @classmethod
+    def _complain_about_missing_field(
+            cls,
+            values: typing.Mapping[str, typing.Any]
+    ):
+        missing = tuple(filter(lambda f: f not in values, cls.required_fields()))
+        raise ValueError(f'{cls.__name__}: missing {len(missing)} required field(s): {missing}')
+
+    @staticmethod
+    def _extract_optional_field(
+            key: str,
+            vals: typing.Mapping[str, typing.Any],
+    ) -> typing.Optional[typing.Any]:
+        return vals[key] if key in vals else None
+
+    @staticmethod
+    def _extract_enum_field(
+            key: str,
+            clz: typing.Type[E],
+            vals: typing.Mapping[str, typing.Any],
+    ) -> typing.Optional[E]:
+        return clz[vals[key]] if key in vals else None
+
+
+D = typing.TypeVar('D', bound=Deserializable)
+"""
+A type that is a subclass of :class:`Deserializable`.
+"""
+
+
+def extract_oneof_scalar(
+        clsd: typing.Mapping[str, typing.Type[D]],
+        vals: typing.Mapping[str, typing.Any],
+) -> typing.Optional[D]:
+    for key, cls in clsd.items():
+        scalar = extract_message_scalar(key, cls, vals)
+        if scalar is not None:
+            return scalar
+    return None
+
+
+def extract_message_scalar(
+        key: str,
+        cls: typing.Type[D],
+        vals: typing.Mapping[str, typing.Any],
+) -> typing.Optional[D]:
+    return cls.from_dict(vals[key]) if key in vals else None
+
+
+def extract_message_sequence(
+        key: str,
+        cls: typing.Type[D],
+        vals: typing.Mapping[str, typing.Any],
+) -> typing.Optional[typing.Sequence[D]]:
+    if key in vals:
+        val = vals[key]
+        if not isinstance(val, typing.Sequence):
+            raise ValueError('Bug')  # TODO: improve error handling
+        else:
+            return [cls.from_dict(item) for item in val]
+    else:
+        return None
+
+
+class Deserializer(metaclass=abc.ABCMeta):
+    """
+    `Deserializer` decodes a :class:`Deserializable` class from a `str` or text IO handle.
+    """
+
+    @abc.abstractmethod
+    def deserialize(
+            self,
+            fp: typing.Union[str, io.TextIOBase],
+            clz: typing.Type[D],
+    ) -> D:
+        """
+        Decode an instance of deserializable class from the input `fp`.
+
+        :param fp: input to decode either as a `str` or a text IO handle.
+        :param clz: type of the class to be created from the input.
+        :returns: a new instance of `D` with attributes set based on the `fp`.
+        """
+        pass
diff --git a/src/pyphetools/pp/parse/_pb.py b/src/pyphetools/pp/parse/_pb.py
new file mode 100644
index 00000000..84d55c53
--- /dev/null
+++ b/src/pyphetools/pp/parse/_pb.py
@@ -0,0 +1,149 @@
+import abc
+import typing
+
+from google.protobuf.message import Message
+
+
+class ToProtobuf(metaclass=abc.ABCMeta):
+    """
+    A mixin for data classes that can encode themselves into some protobuf bytes.
+
+    **Example**
+
+    Let's create an example subject:
+
+    >>> from pyphetools.pp.v202 import Individual
+    >>> i = Individual(id='example.id', alternate_ids=['other', 'identifiers'])
+
+    Now we can serialize the individual into a file or, for the purpose of this test, into :class:`io.BytesIO` buffer:
+
+    >>> import io
+    >>> buf = io.BytesIO()
+
+    >>> i.dump_pb(buf)
+
+    >>> buf.getvalue()
+    b'\\n\\nexample.id\\x12\\x05other\\x12\\x0bidentifiers'
+    """
+
+    @abc.abstractmethod
+    def to_message(self) -> Message:
+        """
+        Get a protobuf representation of the class.
+        """
+        pass
+
+    def dump_pb(self, fp: typing.BinaryIO):
+        """
+        Write the protobuf representation of the class into the provided `fp` byte handle.
+        """
+        msg = self.to_message()
+        fp.write(msg.SerializeToString())
+
+
+class FromProtobuf(metaclass=abc.ABCMeta):
+    """
+    A mixin for data classes/types that can be created from some protobuf bytes.
+
+    In general, the deserialization first constructs an intermediate Protobuf :class:`Message`. The message is then
+    mapped into the actual class.
+
+    **Example**
+
+    Let's load example protobuf file at the following location:
+
+    >>> import os
+    >>> fpath_pb = os.path.join('test', 'data', 'pp', 'retinoblastoma.pb')
+
+
+    The file contains a v2.0.2 phenopacket. Let's import the `Phenopacket` class and load the file:
+
+    >>> from pyphetools.pp.v202 import Phenopacket
+    >>> with open(fpath_pb, 'rb') as fh:
+    ...   pp = Phenopacket.from_pb(fh)
+
+    Now we have a phenopacket that we can work with:
+
+    >>> pp.id
+    'example.retinoblastoma.phenopacket.id'
+
+    """
+
+    @classmethod
+    @abc.abstractmethod
+    def message_type(cls) -> typing.Type[Message]:
+        """
+        Get the type of the protobuf element that this class can be decoded from.
+        """
+        pass
+
+    @classmethod
+    @abc.abstractmethod
+    def from_message(cls, msg: Message):
+        """
+        Decode the message into a new instance of this class.
+        """
+        pass
+
+    @classmethod
+    def from_pb(
+            cls,
+            fp: typing.BinaryIO,
+    ):
+        """
+        Create an instance from some bytes read from `pb`.
+        The bytes are expected to correspond to the state of the message.
+        """
+        msg_type = cls.message_type()
+        msg = msg_type()
+        msg.MergeFromString(fp.read())
+
+        return cls.from_message(msg)
+
+    @classmethod
+    def complain_about_incompatible_msg_type(
+            cls,
+            msg: Message,
+    ):
+        """
+        A utility method for user-friendly complaint regarding attempting to decode from incompatible type.
+        """
+        raise ValueError(f'Cannot decode {cls} from {type(msg)}')
+
+
+FP = typing.TypeVar('FP', bound=FromProtobuf)
+"""
+A type that is a subclass of :class:`FromProtobuf`.
+"""
+
+
+def extract_pb_oneof_scalar(
+        oneof_group: str,
+        clsd: typing.Mapping[str, typing.Type[FP]],
+        msg: Message,
+) -> typing.Optional[FP]:
+    key = msg.WhichOneof(oneof_group)
+    if key is not None:
+        cls = clsd[key]
+        return extract_pb_message_scalar(key, cls, msg)
+    else:
+        return None
+
+
+def extract_pb_message_scalar(
+        key: str,
+        cls: typing.Type[FP],
+        msg: Message,
+) -> typing.Optional[FP]:
+    if msg.HasField(key):
+        return cls.from_message(getattr(msg, key))
+    else:
+        return None
+
+
+def extract_pb_message_seq(
+        key: str,
+        cls: typing.Type[FP],
+        msg: Message,
+) -> typing.Iterable[FP]:
+    return (cls.from_message(i) for i in getattr(msg, key))
diff --git a/src/pyphetools/pp/parse/_util.py b/src/pyphetools/pp/parse/_util.py
new file mode 100644
index 00000000..718a3ee2
--- /dev/null
+++ b/src/pyphetools/pp/parse/_util.py
@@ -0,0 +1,124 @@
+import re
+import typing
+
+
+class CaseConverter:
+    # Not part of the public API!
+
+    def __init__(self):
+        self._camel_to_snake = re.compile(r'(?<!^)(?=[A-Z])')
+
+
+    def camel_to_snake(self, payload: str) -> str:
+        return self._camel_to_snake.sub('_', payload).lower()
+
+    @staticmethod
+    def snake_to_camel(payload: str) -> str:
+        tokens = payload.split('_')
+        if len(tokens) == 0:
+            return ''
+        elif len(tokens) == 1:
+            return payload
+        else:
+            return tokens[0] + ''.join(map(lambda s: s.title(), tokens[1:]))
+
+
+PS_v202_BLACKLIST = (
+    'file_attributes', 'individual_to_file_identifiers', 'individual_to_sample_identifiers',
+    'fileAttributes', 'individualToFileIdentifiers', 'individualToSampleIdentifiers',
+)
+
+
+class HierarchicalKeyMapper:
+    """
+    `HierarchicalKeyMapper` takes a sequence or a mapping of primitive Python types e.g. lists of dicts, or dicts of dicts,
+    and applies a remapping function `func` on the mapping keys, unless the enclosing element key is in a blacklist.
+
+    The mapper takes either a sequence of things via :attr:`remap_sequence` or :attr:`remap_mapping` and applies `func`
+    recursively.
+
+    .. note::
+
+      This is *not* a definitive solution, it only works with Phenopacket Schema where we only have `map<string, string>`.
+
+    :param blacklist: an iterable of `str` with names of keys whose sub-keys should not be subject
+      of the remapping function.
+    """
+
+    _PRIMITIVES = {
+        bool, int, float, str,
+    }
+
+    @staticmethod
+    def ps_v202_mapper():
+        """
+        Create a key mapper for remapping elements of Phenopacket Schema `v2.0.2`.
+        """
+        return HierarchicalKeyMapper(PS_v202_BLACKLIST)
+
+    def __init__(
+            self,
+            blacklist: typing.Iterable[str],
+    ):
+        self._blist = set(blacklist)
+
+    def remap_sequence(
+            self,
+            func: typing.Callable[[str], str],
+            vals: typing.Sequence[typing.Any],
+            ) -> typing.Sequence[typing.Any]:
+        return self._do_sequence(func, vals)
+
+    def remap_mapping(
+            self,
+            func: typing.Callable[[str], str],
+            vals: typing.Mapping[str, typing.Any],
+    ) -> typing.Mapping[str, typing.Any]:
+        return self._do_mapping(func, vals, update=True)
+
+    def _do_mapping(
+            self,
+            func: typing.Callable[[str], str],
+            vals: typing.Mapping[str, typing.Any],
+            update: bool = True,
+    ) -> typing.Mapping[str, typing.Any]:
+        out = {}
+
+        for key, val in vals.items():
+            if type(val) in HierarchicalKeyMapper._PRIMITIVES:
+                new_key = func(key) if update else key
+            else:
+                new_key = func(key) if update else key
+                if isinstance(val, typing.Sequence):
+                    val = self._do_sequence(func, val)
+                elif isinstance(val, typing.Mapping):
+                    update_next = key not in self._blist
+                    val = self._do_mapping(func, val, update_next)
+                else:
+                    raise ValueError(f'Value under {key} was neither a primitive nor a sequence or mapping: {type(val)}')
+
+            out[new_key] = val
+
+        return out
+
+    def _do_sequence(
+            self,
+            func: typing.Callable[[str], str],
+            vals: typing.Sequence[typing.Any],
+    ) -> typing.Sequence[typing.Any]:
+        out = []
+
+        for i, val in enumerate(vals):
+            if type(val) in HierarchicalKeyMapper._PRIMITIVES:
+                pass
+            else:
+                if isinstance(val, typing.Sequence):
+                    val = self._do_sequence(func, val)
+                elif isinstance(val, typing.Mapping):
+                    val = self._do_mapping(func, val, update=True)
+                else:
+                    raise ValueError(f'Value #{i} was neither a primitive nor a sequence or mapping: {type(val)}')
+
+            out.append(val)
+
+        return out
diff --git a/src/pyphetools/pp/parse/json/__init__.py b/src/pyphetools/pp/parse/json/__init__.py
new file mode 100644
index 00000000..319d0d15
--- /dev/null
+++ b/src/pyphetools/pp/parse/json/__init__.py
@@ -0,0 +1,9 @@
+"""
+A package for loading and storing Phenopacket Schema elements in JSON format.
+"""
+
+from ._json import JsonSerializer, JsonDeserializer
+
+__all__ = [
+    'JsonSerializer', 'JsonDeserializer',
+]
diff --git a/src/pyphetools/pp/parse/json/_json.py b/src/pyphetools/pp/parse/json/_json.py
new file mode 100644
index 00000000..e1976ff0
--- /dev/null
+++ b/src/pyphetools/pp/parse/json/_json.py
@@ -0,0 +1,97 @@
+import io
+import json
+import typing
+
+from .._io import Serializer, Serializable, Deserializer, D
+from .._util import CaseConverter, HierarchicalKeyMapper
+
+_PRIMITIVES = {
+    bool, int, float, str,
+}
+
+
+def map_dict_keys_case(
+        func: typing.Callable[[str], str],
+        vals: typing.Union[typing.Mapping[str, typing.Any], typing.Sequence[typing.Any], typing.Any],
+) -> typing.Union[typing.Mapping[str, typing.Any], typing.Any]:
+    if type(vals) in _PRIMITIVES:
+        return vals
+
+    if isinstance(vals, typing.Mapping):
+        out = {}
+        for key, val in vals.items():
+            new_key = func(key)
+            if type(val) in _PRIMITIVES:
+                new_val = val
+            elif isinstance(val, typing.Sequence):
+                new_val = [map_dict_keys_case(func, e) for e in val]
+            elif isinstance(val, typing.Mapping):
+                new_val = map_dict_keys_case(func, val)
+            else:
+                new_val = val
+
+            out[new_key] = new_val
+
+        return out
+    else:
+        raise ValueError('Bug')
+
+
+class JsonSerializer(Serializer):
+    """
+    A serializer to format :class:`Serializable` objects into JSON format.
+
+    The serializer will convert the field names from snake case used in Python into camel case
+    (e.g. `file_attributes` -> `fileAttributes`).
+    For the purpose of working with Phenopacket Schema, the serializer will not convert the map keys,
+    (e.g. keys of `file_attributes` map).
+
+    See :func:`json.dump` for the accepted keyword arguments.
+    """
+
+    def __init__(
+            self,
+            **kwargs
+    ):
+        self._kwargs = kwargs
+        self._case_converter = CaseConverter()
+        self._key_mapper = HierarchicalKeyMapper.ps_v202_mapper()
+
+    def serialize(self, val: Serializable, fp: typing.IO):
+        out = {}
+        val.to_dict(out)
+        mapped = self._key_mapper.remap_mapping(self._case_converter.snake_to_camel, out)
+        json.dump(mapped, fp, **self._kwargs)
+
+
+class JsonDeserializer(Deserializer):
+    """
+    A deserializer to load :class:`Deserializable` objects from JSON format.
+
+    The deserializer supports reading a JSON with keys
+    in both *snake case* (e.g. `file_attributes`) and *camel case* (e.g. `fileAttributes`).
+    """
+
+    def __init__(self):
+        self._case_converter = CaseConverter()
+        self._key_mapper = HierarchicalKeyMapper.ps_v202_mapper()
+
+    def deserialize(
+            self,
+            fp: typing.Union[str, io.TextIOBase],
+            clz: typing.Type[D]) -> D:
+        val = self._decode_json_content(fp)
+
+        mapped = self._key_mapper.remap_mapping(self._case_converter.camel_to_snake, val)
+        return clz.from_dict(mapped)
+
+    @staticmethod
+    def _decode_json_content(fp: typing.Union[str, io.TextIOBase]):
+
+        if isinstance(fp, str):
+            val = json.loads(fp)
+        elif isinstance(fp, io.TextIOBase):
+            val = json.load(fp)
+        else:
+            raise ValueError(f'`fp` must be a `str` or `io.TextIOBase` but was {type(fp)}')
+        return val
diff --git a/src/pyphetools/pp/v202/__init__.py b/src/pyphetools/pp/v202/__init__.py
new file mode 100644
index 00000000..4c00565b
--- /dev/null
+++ b/src/pyphetools/pp/v202/__init__.py
@@ -0,0 +1,35 @@
+"""
+A package with types corresponding to the `v2.0.2` release of the Phenopacket Schema.
+"""
+
+# The private package members structure mimic the structure of the protobuf files of the `phenopacket-schema`.
+
+from ._base import OntologyClass, ExternalReference, Evidence, Procedure
+# We re-export Timestamp
+from ._base import GestationalAge, Age, AgeRange, TimeInterval, TimeElement, Timestamp, File
+from ._biosample import Biosample
+from ._gene_descriptor import GeneDescriptor
+from ._individual import Individual, KaryotypicSex, Sex, VitalStatus
+from ._interpretation import AcmgPathogenicityClassification, TherapeuticActionability, VariantInterpretation
+from ._interpretation import GenomicInterpretation, Diagnosis, Interpretation
+from ._measurement import ReferenceRange, Quantity, TypedQuantity, ComplexValue, Value, Measurement
+from ._medical_action import TherapeuticRegimen, RadiationTherapy, DrugType, DoseInterval, Treatment, MedicalAction
+from ._phenotypic_feature import PhenotypicFeature
+from ._disease import Disease
+from ._meta_data import MetaData, Resource, Update
+from ._phenopackets import Phenopacket
+from ._vrsatile import Expression, Extension, VcfRecord, MoleculeContext, VariationDescriptor
+
+__all__ = [
+    'Phenopacket',
+    'Individual', 'VitalStatus', 'Sex', 'KaryotypicSex',
+    'GeneDescriptor', 'AcmgPathogenicityClassification', 'TherapeuticActionability', 'VariantInterpretation',
+    'GenomicInterpretation', 'Diagnosis', 'Interpretation',
+    'ReferenceRange', 'Quantity', 'TypedQuantity', 'ComplexValue', 'Value', 'Measurement',
+    'TherapeuticRegimen', 'RadiationTherapy', 'DrugType', 'DoseInterval', 'Treatment', 'MedicalAction',
+    'Expression', 'Extension', 'VcfRecord', 'MoleculeContext', 'VariationDescriptor',
+    'PhenotypicFeature', 'Disease', 'Biosample',
+    'MetaData', 'Resource', 'Update',
+    'OntologyClass', 'ExternalReference', 'Evidence', 'Procedure', 'GestationalAge', 'Age', 'AgeRange', 'TimeInterval',
+    'TimeElement', 'Timestamp', 'File',
+]
diff --git a/src/pyphetools/pp/v202/_base.py b/src/pyphetools/pp/v202/_base.py
new file mode 100644
index 00000000..87295f37
--- /dev/null
+++ b/src/pyphetools/pp/v202/_base.py
@@ -0,0 +1,870 @@
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from .._api import MessageMixin
+from .._timestamp import Timestamp
+from ..parse import extract_message_scalar, extract_pb_message_scalar, extract_oneof_scalar, extract_pb_oneof_scalar
+
+
+class OntologyClass(MessageMixin):
+    """
+    `OntologyClass` represents classes (terms) from ontologies, and is used in many places throughout
+    the Phenopacket standard. It is a simple, two element message that represents the `identifier` and the `label`
+    of an ontology class.
+
+    >>> from pyphetools.pp.v202 import OntologyClass
+
+    >>> oc = OntologyClass(id='HP:0001250', label='Seizure')
+    >>> oc.id
+    'HP:0001250'
+    >>> oc.label
+    'Seizure'
+
+    :param id: a `str` with a CURIE-style identifier (e.g. `HP:0001250`).
+    :param label: a `str` with a human-readable class name (e.g. `Seizure`).
+    """
+
+    def __init__(
+            self,
+            id: str,
+            label: str,
+    ):
+        self._id = id
+        self._label = label
+
+    @property
+    def id(self) -> str:
+        """
+        Get a `str` with the ontology class identifier.
+        """
+        return self._id
+
+    @property
+    def label(self) -> str:
+        """
+        Get a `str` with the human-readable class name.
+        """
+        return self._label
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'id', 'label'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'id', 'label'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return OntologyClass(
+                id=values['id'],
+                label=values['label'],
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.OntologyClass(
+            id=self._id,
+            label=self._label,
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.OntologyClass
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.OntologyClass):
+            return OntologyClass(
+                id=msg.id,
+                label=msg.label,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, OntologyClass) \
+            and self._id == other._id \
+            and self._label == other._label
+
+    def __repr__(self):
+        return f'OntologyClass(id={self._id}, label={self._label})'
+
+
+class ExternalReference(MessageMixin):
+
+    def __init__(
+            self,
+            id: typing.Optional[str] = None,
+            reference: typing.Optional[str] = None,
+            description: typing.Optional[str] = None,
+    ):
+        self._id = id
+        self._reference = reference
+        self._description = description
+
+    @property
+    def id(self) -> str:
+        return self._id
+
+    @id.setter
+    def id(self, value: str):
+        self._id = value
+
+    @property
+    def reference(self) -> str:
+        return self._reference
+
+    @reference.setter
+    def reference(self, value: str):
+        self._reference = value
+
+    @reference.deleter
+    def reference(self):
+        self._reference = None
+
+    @property
+    def description(self) -> str:
+        return self._description
+
+    @description.setter
+    def description(self, value: str):
+        self._description = value
+
+    @description.deleter
+    def description(self):
+        self._description = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'id', 'reference', 'description'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return ()
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return ExternalReference(
+                id=MessageMixin._extract_optional_field('id', values),
+                reference=MessageMixin._extract_optional_field('reference', values),
+                description=MessageMixin._extract_optional_field('description', values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.ExternalReference(
+            id=None if self._id is None else self._id,
+            reference=None if self._reference is None else self._reference,
+            description=None if self._description is None else self._description,
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.ExternalReference
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.ExternalReference):
+            return ExternalReference(
+                id=None if msg.id == '' else msg.id,
+                reference=None if msg.reference == '' else msg.reference,
+                description=None if msg.description == '' else msg.description,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, ExternalReference) \
+            and self._id == other._id \
+            and self._reference == other._reference \
+            and self._description == other._description
+
+    def __repr__(self):
+        return f'ExternalReference(id={self._id}, reference={self._reference}, description={self._description})'
+
+
+class Evidence(MessageMixin):
+
+    def __init__(
+            self,
+            evidence_code: OntologyClass,
+            reference: typing.Optional[ExternalReference] = None,
+    ):
+        self._evidence_code = evidence_code
+        self._reference = reference
+
+    @property
+    def evidence_code(self) -> OntologyClass:
+        return self._evidence_code
+
+    @evidence_code.setter
+    def evidence_code(self, value: OntologyClass):
+        self._evidence_code = value
+
+    @property
+    def reference(self) -> typing.Optional[ExternalReference]:
+        return self._reference
+
+    @reference.setter
+    def reference(self, value: typing.Optional[ExternalReference]):
+        self._reference = value
+
+    @reference.deleter
+    def reference(self):
+        self._reference = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'evidence_code', 'reference'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'evidence_code',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Evidence(
+                evidence_code=extract_message_scalar('evidence_code', OntologyClass, values),
+                reference=extract_message_scalar('reference', ExternalReference, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.Evidence(
+            evidence_code=self._evidence_code.to_message(),
+            reference=self._reference.to_message(),
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Evidence
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.Evidence):
+            return Evidence(
+                evidence_code=extract_pb_message_scalar('evidence_code', OntologyClass, msg),
+                reference=extract_pb_message_scalar('reference', ExternalReference, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Evidence) \
+            and self._evidence_code == other._evidence_code \
+            and self._reference == other._reference
+
+    def __repr__(self):
+        return f'Evidence(evidence_code={self._evidence_code}, reference={self._reference})'
+
+
+class GestationalAge(MessageMixin):
+
+    def __init__(
+            self,
+            weeks: int,
+            days: typing.Optional[int] = None,
+    ):
+        # TODO: validate
+        self._weeks = weeks
+        self._days = days
+
+    @property
+    def weeks(self) -> int:
+        return self._weeks
+
+    @weeks.setter
+    def weeks(self, value: int):
+        self._weeks = value
+
+    @property
+    def days(self) -> typing.Optional[int]:
+        return self._days
+
+    @days.setter
+    def days(self, value: typing.Optional[int]):
+        self._days = value
+
+    @days.deleter
+    def days(self):
+        self._days = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'weeks', 'days',
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'weeks',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            weeks = values['weeks']
+            days = values['days'] if 'days' in values else None
+            return GestationalAge(
+                weeks=weeks,
+                days=days,
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.GestationalAge(
+            weeks=self._weeks,
+            days=self._days,
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.GestationalAge
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.GestationalAge):
+            return GestationalAge(
+                weeks=msg.weeks,
+                days=msg.days,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, GestationalAge) \
+            and self._weeks == other._weeks \
+            and self._days == other._days
+
+    def __repr__(self):
+        return f'GestationalAge(weeks={self._weeks}, days={self._days})'
+
+
+class Age(MessageMixin):
+
+    def __init__(
+            self,
+            iso8601duration: str,
+    ):
+        self._iso8601duration = iso8601duration
+
+    @property
+    def iso8601duration(self) -> str:
+        return self._iso8601duration
+
+    @iso8601duration.setter
+    def iso8601duration(self, value: str):
+        self._iso8601duration = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'iso8601duration',
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'iso8601duration',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Age(iso8601duration=values['iso8601duration'])
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.Age(
+            iso8601duration=self._iso8601duration,
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Age
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.Age):
+            return Age(
+                iso8601duration=msg.iso8601duration,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Age) \
+            and self._iso8601duration == other._iso8601duration
+
+    def __repr__(self):
+        return f'Age(iso8601duration={self._iso8601duration})'
+
+
+class AgeRange(MessageMixin):
+
+    def __init__(
+            self,
+            start: Age,
+            end: Age,
+    ):
+        self._start = start
+        self._end = end
+
+    @property
+    def start(self):
+        return self._start
+
+    @start.setter
+    def start(self, value: Age):
+        self._start = value
+
+    @property
+    def end(self) -> Age:
+        return self._end
+
+    @end.setter
+    def end(self, value: Age):
+        self._end = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'start', 'end'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'start', 'end'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return AgeRange(
+                start=Age.from_dict(values['start']),
+                end=Age.from_dict(values['end']),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.AgeRange(
+            start=self._start.to_message(),
+            end=self._end.to_message(),
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.AgeRange
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.AgeRange):
+            return AgeRange(
+                start=extract_pb_message_scalar('start', Age, msg),
+                end=extract_pb_message_scalar('end', Age, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, AgeRange) \
+            and self._start == other._start \
+            and self._end == other._end
+
+    def __repr__(self):
+        return f'AgeRange(start={self._start}, end={self._end})'
+
+
+class TimeInterval(MessageMixin):
+
+    def __init__(
+            self,
+            start: Timestamp,
+            end: Timestamp,
+    ):
+        self._start = start
+        self._end = end
+
+    @property
+    def start(self) -> Timestamp:
+        return self._start
+
+    @start.setter
+    def start(self, value: Timestamp):
+        self._start = value
+
+    @property
+    def end(self) -> Timestamp:
+        return self._end
+
+    @end.setter
+    def end(self, value: Timestamp):
+        self._end = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'start', 'end'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'start', 'end'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return TimeInterval(
+                start=Timestamp.from_str(values['start']),
+                end=Timestamp.from_str(values['end']),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.TimeInterval(
+            start=self._start.to_message(),
+            end=self._end.to_message(),
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.TimeInterval
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.TimeInterval):
+            return TimeInterval(
+                start=extract_pb_message_scalar('start', Timestamp, msg),
+                end=extract_pb_message_scalar('end', Timestamp, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, TimeInterval) \
+            and self._start == other._start \
+            and self._end == other._end
+
+    def __repr__(self):
+        return f'TimeInterval(start={self._start}, end={self._end})'
+
+
+class TimeElement(MessageMixin):
+    """
+    TODO: better description
+    """
+    _ONEOF_ELEMENT = {
+        'gestational_age': GestationalAge, 'age': Age, 'age_range': AgeRange,
+        'ontology_class': OntologyClass, 'timestamp': Timestamp, 'interval': TimeInterval,
+    }
+
+    def __init__(
+            self,
+            element: typing.Union[GestationalAge, Age, AgeRange, OntologyClass, Timestamp, TimeInterval]
+    ):
+        self._element = element
+
+    @property
+    def element(self) -> typing.Union[GestationalAge, Age, AgeRange, OntologyClass, Timestamp, TimeInterval]:
+        return self._element
+
+    @property
+    def age(self) -> typing.Optional[Age]:
+        return self._element if isinstance(self._element, Age) else None
+
+    @age.setter
+    def age(self, value: Age):
+        self._element = value
+
+    @property
+    def age_range(self) -> typing.Optional[AgeRange]:
+        return self._element if isinstance(self._element, AgeRange) else None
+
+    @age_range.setter
+    def age_range(self, value: AgeRange):
+        self._element = value
+
+    @property
+    def ontology_class(self) -> OntologyClass:
+        return self._element if isinstance(self._element, OntologyClass) else None
+
+    @ontology_class.setter
+    def ontology_class(self, value: OntologyClass):
+        self._element = value
+
+    @property
+    def timestamp(self) -> Timestamp:
+        return self._element if isinstance(self._element, Timestamp) else None
+
+    @timestamp.setter
+    def timestamp(self, value: Timestamp):
+        self._element = value
+
+    @property
+    def interval(self) -> TimeInterval:
+        return self._element if isinstance(self._element, TimeInterval) else None
+
+    @interval.setter
+    def interval(self, value: TimeInterval):
+        self._element = value
+
+    @property
+    def gestational_age(self) -> typing.Optional[GestationalAge]:
+        return self._element if isinstance(self._element, GestationalAge) else None
+
+    @gestational_age.setter
+    def gestational_age(self, value: GestationalAge):
+        self._element = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'gestational_age', 'age', 'age_range', 'ontology_class', 'timestamp', 'interval'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        raise NotImplementedError('Should not be called!')
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if any(field in values for field in cls._ONEOF_ELEMENT):
+            return TimeElement(
+                element=extract_oneof_scalar(cls._ONEOF_ELEMENT, values)
+            )
+        else:
+            raise ValueError(
+                f'Missing one of required fields: '
+                f'`{"|".join(cls._ONEOF_ELEMENT)}`: {values}'
+            )
+
+    def to_message(self) -> Message:
+        te = pp202.TimeElement()
+
+        val = self._element.to_message()
+        if isinstance(self._element, Age):
+            te.age.CopyFrom(val)
+        elif isinstance(self._element, AgeRange):
+            te.age_range.CopyFrom(val)
+        elif isinstance(self._element, OntologyClass):
+            te.ontology_class.CopyFrom(val)
+        elif isinstance(self._element, Timestamp):
+            te.timestamp.CopyFrom(val)
+        elif isinstance(self._element, TimeInterval):
+            te.interval.CopyFrom(val)
+        elif isinstance(self._element, GestationalAge):
+            te.gestational_age.CopyFrom(val)
+        else:
+            raise ValueError('Bug')
+
+        return te
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.TimeElement
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return TimeElement(
+                element=extract_pb_oneof_scalar(
+                    'element',
+                    {
+                        'gestational_age': GestationalAge, 'age': Age, 'age_range': AgeRange,
+                        'ontology_class': OntologyClass, 'timestamp': Timestamp, 'interval': TimeInterval,
+                    }, msg)
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, TimeElement) and self._element == other._element
+
+    def __repr__(self):
+        return f'TimeElement(element={self._element})'
+
+
+class Procedure(MessageMixin):
+
+    def __init__(
+            self,
+            code: OntologyClass,
+            body_site: typing.Optional[OntologyClass] = None,
+            performed: typing.Optional[TimeElement] = None,
+    ):
+        self._code = code
+        self._body_site = body_site
+        self._performed = performed
+
+    @property
+    def code(self) -> OntologyClass:
+        return self._code
+
+    @code.setter
+    def code(self, value: OntologyClass):
+        self._code = value
+
+    @property
+    def body_site(self) -> typing.Optional[OntologyClass]:
+        return self._body_site
+
+    @body_site.setter
+    def body_site(self, value: typing.Optional[OntologyClass]):
+        self._body_site = value
+
+    @body_site.deleter
+    def body_site(self):
+        self._body_site = None
+
+    @property
+    def performed(self) -> typing.Optional[TimeElement]:
+        return self._performed
+
+    @performed.setter
+    def performed(self, value: typing.Optional[TimeElement]):
+        self._performed = value
+
+    @performed.deleter
+    def performed(self):
+        self._performed = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'code', 'body_site', 'performed'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'code',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Procedure(
+                code=extract_message_scalar('code', OntologyClass, values),
+                body_site=extract_message_scalar('body_site', OntologyClass, values),
+                performed=extract_message_scalar('performed', TimeElement, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        procedure = pp202.Procedure(
+            code=self._code.to_message(),
+        )
+
+        if self._body_site is not None:
+            procedure.body_site.CopyFrom(self._body_site.to_message())
+
+        if self._performed is not None:
+            procedure.performed.CopyFrom(self._performed.to_message())
+
+        return procedure
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Procedure
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.Procedure):
+            return Procedure(
+                code=extract_pb_message_scalar('code', OntologyClass, msg),
+                body_site=extract_pb_message_scalar('body_site', OntologyClass, msg),
+                performed=extract_pb_message_scalar('performed', TimeElement, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Procedure) \
+            and self._code == other._code \
+            and self._body_site == other._body_site \
+            and self._performed == other._performed
+
+    def __repr__(self):
+        return f'Procedure(code={self._code}, body_site={self._body_site}, performed={self._performed})'
+
+
+class File(MessageMixin):
+
+    def __init__(
+            self,
+            uri: str,
+            individual_to_file_identifiers: typing.Optional[typing.Mapping[str, str]] = None,
+            file_attributes: typing.Optional[typing.Mapping[str, str]] = None,
+    ):
+        self._uri = uri
+        self._individual_to_file_identifiers = dict() \
+            if individual_to_file_identifiers is None \
+            else dict(individual_to_file_identifiers)
+        self._file_attributes = dict() if file_attributes is None else dict(file_attributes)
+
+    @property
+    def uri(self) -> str:
+        return self._uri
+
+    @uri.setter
+    def uri(self, value: str):
+        self._uri = value
+
+    @property
+    def individual_to_file_identifiers(self) -> typing.MutableMapping[str, str]:
+        return self._individual_to_file_identifiers
+
+    @property
+    def file_attributes(self) -> typing.MutableMapping[str, str]:
+        return self._file_attributes
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'uri', 'individual_to_file_identifiers', 'file_attributes'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'uri',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return File(
+                uri=values['uri'],
+                individual_to_file_identifiers=values['individual_to_file_identifiers'],
+                file_attributes=values['file_attributes'],
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        file = pp202.File(uri=self._uri)
+        for k, v in self._individual_to_file_identifiers.items():
+            file.individual_to_file_identifiers[k] = v
+
+        for k, v in self._file_attributes.items():
+            file.file_attributes[k] = v
+
+        return file
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.File
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.File):
+            return File(
+                uri=msg.uri,
+                individual_to_file_identifiers=msg.individual_to_file_identifiers,
+                file_attributes=msg.file_attributes,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, File) and self._uri == other._uri \
+            and self._individual_to_file_identifiers == other._individual_to_file_identifiers \
+            and self._file_attributes == other._file_attributes
+
+    def __repr__(self):
+        return 'File(' \
+               f'uri={self._uri},' \
+               f' individual_to_file_identifiers={self._individual_to_file_identifiers},' \
+               f' file_attributes={self._file_attributes})'
diff --git a/src/pyphetools/pp/v202/_biosample.py b/src/pyphetools/pp/v202/_biosample.py
new file mode 100644
index 00000000..d6a4d373
--- /dev/null
+++ b/src/pyphetools/pp/v202/_biosample.py
@@ -0,0 +1,420 @@
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from ._base import OntologyClass, TimeElement, File, Procedure
+from ._measurement import Measurement
+from ._phenotypic_feature import PhenotypicFeature
+from .._api import MessageMixin
+from ..parse import extract_message_scalar, extract_message_sequence, extract_pb_message_scalar, extract_pb_message_seq
+
+
+class Biosample(MessageMixin):
+
+    def __init__(
+            self,
+            id: str,
+            individual_id: typing.Optional[str] = None,
+            derived_from_id: typing.Optional[str] = None,
+            description: typing.Optional[str] = None,
+            sampled_tissue: typing.Optional[OntologyClass] = None,
+            sample_type: typing.Optional[OntologyClass] = None,
+            phenotypic_features: typing.Optional[typing.Iterable[PhenotypicFeature]] = None,
+            measurements: typing.Optional[typing.Iterable[Measurement]] = None,
+            taxonomy: typing.Optional[OntologyClass] = None,
+            time_of_collection: typing.Optional[TimeElement] = None,
+            histological_diagnosis: typing.Optional[OntologyClass] = None,
+            tumor_progression: typing.Optional[OntologyClass] = None,
+            tumor_grade: typing.Optional[OntologyClass] = None,
+            pathological_stage: typing.Optional[OntologyClass] = None,
+            pathological_tnm_finding: typing.Optional[typing.Iterable[OntologyClass]] = None,
+            diagnostic_markers: typing.Optional[typing.Iterable[OntologyClass]] = None,
+            procedure: typing.Optional[Procedure] = None,
+            files: typing.Optional[typing.Iterable[File]] = None,
+            material_sample: typing.Optional[OntologyClass] = None,
+            sample_processing: typing.Optional[OntologyClass] = None,
+            sample_storage: typing.Optional[OntologyClass] = None,
+    ):
+        self._id = id
+        self._individual_id = individual_id
+        self._derived_from_id = derived_from_id
+        self._description = description
+        self._sampled_tissue = sampled_tissue
+        self._sample_type = sample_type
+        self._phenotypic_features = [] if phenotypic_features is None else list(phenotypic_features)
+        self._measurements = [] if measurements is None else list(measurements)
+        self._taxonomy = taxonomy
+        self._time_of_collection = time_of_collection
+        self._histological_diagnosis = histological_diagnosis
+        self._tumor_progression = tumor_progression
+        self._tumor_grade = tumor_grade
+        self._pathological_stage = pathological_stage
+        self._pathological_tnm_finding = [] if pathological_tnm_finding is None else list(pathological_tnm_finding)
+        self._diagnostic_markers = [] if diagnostic_markers is None else list(diagnostic_markers)
+        self._procedure = procedure
+        self._files = [] if files is None else list(files)
+        self._material_sample = material_sample
+        self._sample_processing = sample_processing
+        self._sample_storage = sample_storage
+
+    @property
+    def id(self) -> str:
+        return self._id
+
+    @id.setter
+    def id(self, value: str):
+        self._id = value
+
+    @property
+    def individual_id(self) -> typing.Optional[str]:
+        return self._individual_id
+
+    @individual_id.setter
+    def individual_id(self, value: str):
+        self._individual_id = value
+
+    @individual_id.deleter
+    def individual_id(self):
+        self._individual_id = None
+
+    @property
+    def derived_from_id(self) -> typing.Optional[str]:
+        return self._derived_from_id
+
+    @derived_from_id.setter
+    def derived_from_id(self, value: str):
+        self._derived_from_id = value
+
+    @derived_from_id.deleter
+    def derived_from_id(self):
+        self._derived_from_id = None
+
+    @property
+    def description(self) -> typing.Optional[str]:
+        return self._description
+
+    @description.setter
+    def description(self, value: str):
+        self._description = value
+
+    @description.deleter
+    def description(self):
+        self._description = None
+
+    @property
+    def sampled_tissue(self) -> typing.Optional[OntologyClass]:
+        return self._sampled_tissue
+
+    @sampled_tissue.setter
+    def sampled_tissue(self, value: OntologyClass):
+        self._sampled_tissue = value
+
+    @sampled_tissue.deleter
+    def sampled_tissue(self):
+        self._sampled_tissue = None
+
+    @property
+    def sample_type(self) -> typing.Optional[OntologyClass]:
+        return self._sample_type
+
+    @sample_type.setter
+    def sample_type(self, value: OntologyClass):
+        self._sample_type = value
+
+    @sample_type.deleter
+    def sample_type(self):
+        self._sample_type = None
+
+    @property
+    def phenotypic_features(self) -> typing.MutableSequence[PhenotypicFeature]:
+        return self._phenotypic_features
+
+    @property
+    def measurements(self) -> typing.MutableSequence[Measurement]:
+        return self._measurements
+
+    @property
+    def taxonomy(self) -> typing.Optional[OntologyClass]:
+        return self._taxonomy
+
+    @taxonomy.setter
+    def taxonomy(self, value: OntologyClass):
+        self._taxonomy = value
+
+    @taxonomy.deleter
+    def taxonomy(self):
+        self._taxonomy = None
+
+    @property
+    def time_of_collection(self) -> typing.Optional[TimeElement]:
+        return self._time_of_collection
+
+    @time_of_collection.setter
+    def time_of_collection(self, value: TimeElement):
+        self._time_of_collection = value
+
+    @time_of_collection.deleter
+    def time_of_collection(self):
+        self._time_of_collection = None
+
+    @property
+    def histological_diagnosis(self) -> typing.Optional[OntologyClass]:
+        return self._histological_diagnosis
+
+    @histological_diagnosis.setter
+    def histological_diagnosis(self, value: OntologyClass):
+        self._histological_diagnosis = value
+
+    @histological_diagnosis.deleter
+    def histological_diagnosis(self):
+        self._histological_diagnosis = None
+
+    @property
+    def tumor_progression(self) -> typing.Optional[OntologyClass]:
+        return self._tumor_progression
+
+    @tumor_progression.setter
+    def tumor_progression(self, value: OntologyClass):
+        self._tumor_progression = value
+
+    @tumor_progression.deleter
+    def tumor_progression(self):
+        self._tumor_progression = None
+
+    @property
+    def tumor_grade(self) -> typing.Optional[OntologyClass]:
+        return self._tumor_grade
+
+    @tumor_grade.setter
+    def tumor_grade(self, value: OntologyClass):
+        self._tumor_grade = value
+
+    @tumor_grade.deleter
+    def tumor_grade(self):
+        self._tumor_grade = None
+
+    @property
+    def pathological_stage(self) -> typing.Optional[OntologyClass]:
+        return self._pathological_stage
+
+    @pathological_stage.setter
+    def pathological_stage(self, value: OntologyClass):
+        self._pathological_stage = value
+
+    @pathological_stage.deleter
+    def pathological_stage(self):
+        self._pathological_stage = None
+
+    @property
+    def pathological_tnm_finding(self) -> typing.MutableSequence[OntologyClass]:
+        return self._pathological_tnm_finding
+
+    @property
+    def diagnostic_markers(self) -> typing.MutableSequence[OntologyClass]:
+        return self._diagnostic_markers
+
+    @property
+    def procedure(self) -> typing.Optional[Procedure]:
+        return self._procedure
+
+    @procedure.setter
+    def procedure(self, value: Procedure):
+        self._procedure = value
+
+    @procedure.deleter
+    def procedure(self):
+        self._procedure = None
+
+    @property
+    def files(self) -> typing.MutableSequence[File]:
+        return self._files
+
+    @property
+    def material_sample(self) -> typing.Optional[OntologyClass]:
+        return self._material_sample
+
+    @material_sample.setter
+    def material_sample(self, value: OntologyClass):
+        self._material_sample = value
+
+    @material_sample.deleter
+    def material_sample(self):
+        self._material_sample = None
+
+    @property
+    def sample_processing(self) -> typing.Optional[OntologyClass]:
+        return self._sample_processing
+
+    @sample_processing.setter
+    def sample_processing(self, value: OntologyClass):
+        self._sample_processing = value
+
+    @sample_processing.deleter
+    def sample_processing(self):
+        self._sample_processing = None
+
+    @property
+    def sample_storage(self) -> typing.Optional[OntologyClass]:
+        return self._sample_storage
+
+    @sample_storage.setter
+    def sample_storage(self, value: OntologyClass):
+        self._sample_storage = value
+
+    @sample_storage.deleter
+    def sample_storage(self):
+        self._sample_storage = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return (
+            'id', 'individual_id', 'derived_from_id', 'description', 'sampled_tissue', 'sample_type',
+            'phenotypic_features', 'measurements',
+            'taxonomy', 'time_of_collection', 'histological_diagnosis', 'tumor_progression', 'tumor_grade',
+            'pathological_stage', 'pathological_tnm_finding', 'diagnostic_markers', 'procedure',
+            'files', 'material_sample', 'sample_processing', 'sample_storage',
+        )
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'id',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Biosample(
+                id=values['id'],
+                individual_id=values['individual_id'] if 'individual_id' in values else None,
+                derived_from_id=values['derived_from_id'] if 'derived_from_id' in values else None,
+                description=values['description'] if 'description' in values else None,
+                sampled_tissue=extract_message_scalar('sampled_tissue', OntologyClass, values),
+                sample_type=extract_message_scalar('sample_type', OntologyClass, values),
+                phenotypic_features=extract_message_sequence('phenotypic_features', PhenotypicFeature, values),
+                measurements=extract_message_sequence('measurements', Measurement, values),
+                taxonomy=extract_message_scalar('taxonomy', OntologyClass, values),
+                time_of_collection=extract_message_scalar('time_of_collection', OntologyClass, values),
+                histological_diagnosis=extract_message_scalar('histological_diagnosis', OntologyClass, values),
+                tumor_progression=extract_message_scalar('tumor_progression', OntologyClass, values),
+                tumor_grade=extract_message_scalar('tumor_grade', OntologyClass, values),
+                pathological_stage=extract_message_scalar('pathological_stage', OntologyClass, values),
+                pathological_tnm_finding=extract_message_sequence('pathological_tnm_finding', OntologyClass, values),
+                diagnostic_markers=extract_message_sequence('diagnostic_markers', OntologyClass, values),
+                procedure=extract_message_scalar('procedure', Procedure, values),
+                files=extract_message_sequence('files', File, values),
+                material_sample=extract_message_scalar('material_sample', OntologyClass, values),
+                sample_processing=extract_message_scalar('sample_processing', OntologyClass, values),
+                sample_storage=extract_message_scalar('sample_storage', OntologyClass, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        biosample = pp202.Biosample(id=self._id)
+
+        if self._individual_id is not None:
+            biosample.individual_id = self._individual_id
+
+        if self._derived_from_id is not None:
+            biosample.derived_from_id = self._derived_from_id
+
+        if self._description is not None:
+            biosample.description = self._description
+
+        # Set optional compound singular & sequence fields
+        for name in (
+                'sampled_tissue', 'sample_type', 'phenotypic_features', 'measurements',
+                'taxonomy', 'time_of_collection', 'histological_diagnosis', 'tumor_progression', 'tumor_grade',
+                'pathological_stage', 'pathological_tnm_finding', 'diagnostic_markers', 'procedure',
+                'files', 'material_sample', 'sample_processing', 'sample_storage',
+        ):
+            field = getattr(self, name)
+            if field is not None:
+                target = getattr(biosample, name)
+                if isinstance(field, typing.List):
+                    target.extend(item.to_message() for item in field)
+                else:
+                    target.CopyFrom(field.to_message())
+
+        return biosample
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Biosample
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Biosample(
+                id=msg.id,
+                individual_id=None if msg.individual_id == '' else msg.individual_id,
+                derived_from_id=None if msg.derived_from_id == '' else msg.derived_from_id,
+                description=None if msg.description == '' else msg.description,
+                sampled_tissue=extract_pb_message_scalar('sampled_tissue', OntologyClass, msg),
+                sample_type=extract_pb_message_scalar('sample_type', OntologyClass, msg),
+                phenotypic_features=extract_pb_message_seq('phenotypic_features', PhenotypicFeature, msg),
+                measurements=extract_pb_message_seq('measurements', Measurement, msg),
+                taxonomy=extract_pb_message_scalar('taxonomy', OntologyClass, msg),
+                time_of_collection=extract_pb_message_scalar('time_of_collection', OntologyClass, msg),
+                histological_diagnosis=extract_pb_message_scalar('histological_diagnosis', OntologyClass, msg),
+                tumor_progression=extract_pb_message_scalar('tumor_progression', OntologyClass, msg),
+                tumor_grade=extract_pb_message_scalar('tumor_grade', OntologyClass, msg),
+                pathological_stage=extract_pb_message_scalar('pathological_stage', OntologyClass, msg),
+                pathological_tnm_finding=extract_pb_message_seq('pathological_tnm_finding', OntologyClass, msg),
+                diagnostic_markers=extract_pb_message_seq('diagnostic_markers', OntologyClass, msg),
+                procedure=extract_pb_message_scalar('procedure', Procedure, msg),
+                files=extract_pb_message_seq('files', File, msg),
+                material_sample=extract_pb_message_scalar('material_sample', OntologyClass, msg),
+                sample_processing=extract_pb_message_scalar('sample_processing', OntologyClass, msg),
+                sample_storage=extract_pb_message_scalar('sample_storage', OntologyClass, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Biosample) \
+            and self._id == other._id \
+            and self._individual_id == other._individual_id \
+            and self._derived_from_id == other._derived_from_id \
+            and self._description == other._description \
+            and self._sampled_tissue == other._sampled_tissue \
+            and self._sample_type == other._sample_type \
+            and self._phenotypic_features == other._phenotypic_features \
+            and self._measurements == other._measurements \
+            and self._taxonomy == other._taxonomy \
+            and self._time_of_collection == other._time_of_collection \
+            and self._histological_diagnosis == other._histological_diagnosis \
+            and self._tumor_progression == other._tumor_progression \
+            and self._tumor_grade == other._tumor_grade \
+            and self._pathological_stage == other._pathological_stage \
+            and self._pathological_tnm_finding == other._pathological_tnm_finding \
+            and self._diagnostic_markers == other._diagnostic_markers \
+            and self._procedure == other._procedure \
+            and self._files == other._files \
+            and self._material_sample == other._material_sample \
+            and self._sample_processing == other._sample_processing \
+            and self._sample_storage == other._sample_storage
+
+    def __repr__(self):
+        return f'Biosample(' \
+               f'id={self._id}, ' \
+               f'individual_id={self._individual_id}, ' \
+               f'derived_from_id={self._derived_from_id}, ' \
+               f'description={self._description}, ' \
+               f'sampled_tissue={self._sampled_tissue}, ' \
+               f'sample_type={self._sample_type}, ' \
+               f'phenotypic_features={self._phenotypic_features}, ' \
+               f'measurements={self._measurements}, ' \
+               f'taxonomy={self._taxonomy}, ' \
+               f'time_of_collection={self._time_of_collection}, ' \
+               f'histological_diagnosis={self._histological_diagnosis}, ' \
+               f'tumor_progression={self._tumor_progression}, ' \
+               f'tumor_grade={self._tumor_grade}, ' \
+               f'pathological_stage={self._pathological_stage}, ' \
+               f'pathological_tnm_finding={self._pathological_tnm_finding}, ' \
+               f'diagnostic_markers={self._diagnostic_markers}, ' \
+               f'procedure={self._procedure}, ' \
+               f'files={self._files}, ' \
+               f'material_sample={self._material_sample}, ' \
+               f'sample_processing={self._sample_processing}, ' \
+               f'sample_storage={self._sample_storage})'
diff --git a/src/pyphetools/pp/v202/_disease.py b/src/pyphetools/pp/v202/_disease.py
new file mode 100644
index 00000000..22a0baca
--- /dev/null
+++ b/src/pyphetools/pp/v202/_disease.py
@@ -0,0 +1,189 @@
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from ._base import OntologyClass, TimeElement
+from .._api import MessageMixin
+from ..parse import extract_message_scalar, extract_message_sequence, extract_pb_message_scalar, extract_pb_message_seq
+
+
+class Disease(MessageMixin):
+
+    def __init__(
+            self,
+            term: OntologyClass,
+            excluded: bool = False,
+            onset: typing.Optional[TimeElement] = None,
+            resolution: typing.Optional[TimeElement] = None,
+            disease_stage: typing.Optional[typing.Iterable[OntologyClass]] = None,
+            clinical_tnm_finding: typing.Optional[typing.Iterable[OntologyClass]] = None,
+            primary_site: typing.Optional[OntologyClass] = None,
+            laterality: typing.Optional[OntologyClass] = None,
+    ):
+        self._term = term
+        self._excluded = excluded
+        self._onset = onset
+        self._resolution = resolution
+        self._disease_stage = [] if disease_stage is None else list(disease_stage)
+        self._clinical_tnm_finding = [] if clinical_tnm_finding is None else list(clinical_tnm_finding)
+        self._primary_site = primary_site
+        self._laterality = laterality
+
+    @property
+    def term(self) -> OntologyClass:
+        return self._term
+
+    @term.setter
+    def term(self, value: OntologyClass):
+        self._term = value
+
+    @property
+    def excluded(self) -> bool:
+        return self._excluded
+
+    @excluded.setter
+    def excluded(self, value: bool):
+        self._excluded = value
+
+    @property
+    def onset(self) -> typing.Optional[TimeElement]:
+        return self._onset
+
+    @onset.setter
+    def onset(self, value: TimeElement):
+        self._onset = value
+
+    @onset.deleter
+    def onset(self):
+        self._onset = None
+
+    @property
+    def resolution(self) -> typing.Optional[TimeElement]:
+        return self._resolution
+
+    @resolution.setter
+    def resolution(self, value: TimeElement):
+        self._resolution = value
+
+    @resolution.deleter
+    def resolution(self):
+        self._resolution = None
+
+    @property
+    def disease_stage(self) -> typing.MutableSequence[OntologyClass]:
+        return self._disease_stage
+
+    @property
+    def clinical_tnm_finding(self) -> typing.MutableSequence[OntologyClass]:
+        return self._clinical_tnm_finding
+
+    @property
+    def primary_site(self) -> typing.Optional[OntologyClass]:
+        return self._primary_site
+
+    @primary_site.setter
+    def primary_site(self, value: OntologyClass):
+        self._primary_site = value
+
+    @primary_site.deleter
+    def primary_site(self):
+        self._primary_site = None
+
+    @property
+    def laterality(self) -> typing.Optional[OntologyClass]:
+        return self._laterality
+
+    @laterality.setter
+    def laterality(self, value: OntologyClass):
+        self._laterality = value
+
+    @laterality.deleter
+    def laterality(self):
+        self._laterality = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'term', 'excluded', 'onset', 'resolution', 'disease_stage', 'clinical_tnm_finding', 'primary_site', 'laterality'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'term',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Disease(
+                term=extract_message_scalar('term', OntologyClass, values),
+                excluded=values['excluded'] if 'excluded' in values else False,
+                onset=extract_message_scalar('onset', TimeElement, values),
+                resolution=extract_message_scalar('resolution', TimeElement, values),
+                disease_stage=extract_message_sequence('disease_stage', OntologyClass, values),
+                clinical_tnm_finding=extract_message_sequence('clinical_tnm_finding', OntologyClass, values),
+                primary_site=extract_message_scalar('primary_site', OntologyClass, values),
+                laterality=extract_message_scalar('laterality', OntologyClass, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        disease = pp202.Disease(term=self._term.to_message(), excluded=self._excluded)
+
+        if self._onset is not None:
+            disease.onset.CopyFrom(self._onset.to_message())
+
+        if self._resolution is not None:
+            disease.resolution.CopyFrom(self._resolution.to_message())
+
+        disease.disease_stage.extend(ds.to_message() for ds in self._disease_stage)
+        disease.clinical_tnm_finding.extend(tnm.to_message() for tnm in self._clinical_tnm_finding)
+
+        if self._primary_site is not None:
+            disease.primary_site.CopyFrom(self._primary_site.to_message())
+
+        if self._laterality is not None:
+            disease.laterality.CopyFrom(self._laterality.to_message())
+
+        return disease
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Disease
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Disease(
+                term=extract_pb_message_scalar('term', OntologyClass, msg),
+                excluded=msg.excluded,
+                onset=extract_pb_message_scalar('onset', TimeElement, msg),
+                resolution=extract_pb_message_scalar('resolution', TimeElement, msg),
+                disease_stage=extract_pb_message_seq('disease_stage', OntologyClass, msg),
+                clinical_tnm_finding=extract_pb_message_seq('clinical_tnm_finding', OntologyClass, msg),
+                primary_site=extract_pb_message_scalar('primary_site', OntologyClass, msg),
+                laterality=extract_pb_message_scalar('laterality', OntologyClass, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Disease) \
+            and self._term == other._term \
+            and self._excluded == other._excluded \
+            and self._onset == other._onset \
+            and self._resolution == other._resolution \
+            and self._disease_stage == other._disease_stage \
+            and self._clinical_tnm_finding == other._clinical_tnm_finding \
+            and self._primary_site == other._primary_site \
+            and self._laterality == other._laterality
+
+    def __repr__(self):
+        return (f'Disease(term={self._term}, '
+                f'excluded={self._excluded}, '
+                f'onset={self._onset}, '
+                f'resolution={self._resolution}, '
+                f'disease_stage={self._disease_stage}, '
+                f'clinical_tnm_finding={self._clinical_tnm_finding}, '
+                f'primary_site={self._primary_site}, '
+                f'laterality={self._laterality}'
+                ')')
diff --git a/src/pyphetools/pp/v202/_gene_descriptor.py b/src/pyphetools/pp/v202/_gene_descriptor.py
new file mode 100644
index 00000000..e039e525
--- /dev/null
+++ b/src/pyphetools/pp/v202/_gene_descriptor.py
@@ -0,0 +1,135 @@
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from .._api import MessageMixin
+
+
+class GeneDescriptor(MessageMixin):
+
+    def __init__(
+            self,
+            value_id: str,
+            symbol: str,
+            description: typing.Optional[str] = None,
+            alternate_ids: typing.Optional[typing.Iterable[str]] = None,
+            xrefs: typing.Optional[typing.Iterable[str]] = None,
+            alternate_symbols: typing.Optional[typing.Iterable[str]] = None,
+    ):
+        self._value_id = value_id
+        self._symbol = symbol
+        self._description = description
+        self._alternate_ids = [] if alternate_ids is None else list(alternate_ids)
+        self._xrefs = [] if xrefs is None else list(xrefs)
+        self._alternate_symbols = [] if alternate_symbols is None else list(alternate_symbols)
+
+    @property
+    def value_id(self) -> str:
+        return self._value_id
+
+    @value_id.setter
+    def value_id(self, value: str):
+        self._value_id = value
+
+    @property
+    def symbol(self) -> str:
+        return self._symbol
+
+    @symbol.setter
+    def symbol(self, value: str):
+        self._symbol = value
+
+    @property
+    def description(self) -> str:
+        return self._description
+
+    @description.setter
+    def description(self, value: str):
+        self._description = value
+
+    @description.deleter
+    def description(self):
+        self._description = None
+
+    @property
+    def alternate_ids(self) -> typing.MutableSequence[str]:
+        return self._alternate_ids
+
+    @property
+    def xrefs(self) -> typing.MutableSequence[str]:
+        return self._xrefs
+
+    @property
+    def alternate_symbols(self) -> typing.MutableSequence[str]:
+        return self._alternate_symbols
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'value_id', 'symbol', 'description', 'alternate_ids', 'xrefs', 'alternate_symbols',
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'value_id', 'symbol',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return GeneDescriptor(
+                value_id=values['value_id'],
+                symbol=values['symbol'],
+                description=values.get('description', None),
+                alternate_ids=values.get('alternate_ids', None),
+                xrefs=values.get('xrefs', None),
+                alternate_symbols=values.get('alternate_symbols', None),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        gene_descriptor = pp202.GeneDescriptor(value_id=self._value_id, symbol=self._symbol)
+
+        if self._description is not None:
+            gene_descriptor.description = self._description
+
+        gene_descriptor.alternate_ids.extend(self._alternate_ids)
+        gene_descriptor.xrefs.extend(self._xrefs)
+        gene_descriptor.alternate_symbols.extend(self._alternate_symbols)
+
+        return gene_descriptor
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.GeneDescriptor
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return GeneDescriptor(
+                value_id=msg.value_id,
+                symbol=msg.symbol,
+                description=None if msg.description == '' else msg.description,
+                alternate_ids=msg.alternate_ids,
+                xrefs=msg.xrefs,
+                alternate_symbols=msg.alternate_symbols,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, GeneDescriptor) \
+            and self._value_id == other._value_id \
+            and self._symbol == other._symbol \
+            and self._description == other._description \
+            and self._alternate_ids == other._alternate_ids \
+            and self._xrefs == other._xrefs \
+            and self._alternate_symbols == other._alternate_symbols
+
+    def __repr__(self):
+        return f'GeneDescriptor(' \
+               f'value_id={self._value_id}, ' \
+               f'symbol={self._symbol}, ' \
+               f'description={self._description}, ' \
+               f'alternate_ids={self._alternate_ids}, ' \
+               f'xrefs={self._xrefs}, ' \
+               f'alternate_symbols={self._alternate_symbols})'
diff --git a/src/pyphetools/pp/v202/_individual.py b/src/pyphetools/pp/v202/_individual.py
new file mode 100644
index 00000000..3804e9f9
--- /dev/null
+++ b/src/pyphetools/pp/v202/_individual.py
@@ -0,0 +1,416 @@
+import enum
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from ._base import TimeElement, OntologyClass
+from .._api import MessageMixin
+from .._timestamp import Timestamp
+from ..parse import extract_message_scalar, extract_pb_message_scalar
+
+
+class Sex(enum.Enum):
+    """
+    An enumeration used to represent the sex of an individual. This element does not represent gender identity
+    or :class:`KaryotypicSex`, but instead represents typical “phenotypic sex”, as would be determined
+    by a midwife or physician at birth.
+    """
+    # The `int` values correspond to protobuf fields in Phenopacket Schema.
+
+    UNKNOWN_SEX = 0
+    """
+    Not assessed or not available. 
+    Maps to `NCIT:C17998 <https://www.ebi.ac.uk/ols/ontologies/ncit/terms?iri=http%3A%2F%2Fpurl.obolibrary.org%2Fobo%2FNCIT_C17998>`_
+    """
+
+    FEMALE = 1
+    """
+    Female sex. 
+    Maps to `NCIT:C46113 <https://www.ebi.ac.uk/ols/ontologies/ncit/terms?iri=http%3A%2F%2Fpurl.obolibrary.org%2Fobo%2FNCIT_C46113>`_.
+    """
+
+    MALE = 2
+    """
+    Male sex. 
+    Maps to `NCIT:C46112 <https://www.ebi.ac.uk/ols/ontologies/ncit/terms?iri=http%3A%2F%2Fpurl.obolibrary.org%2Fobo%2FNCIT_C46112>`_
+    """
+
+    OTHER_SEX = 3
+    """
+    It is not possible to accurately assess the applicability of `MALE`/`FEMALE`. 
+    Maps to `NCIT:C45908 <https://www.ebi.ac.uk/ols/ontologies/ncit/terms?iri=http%3A%2F%2Fpurl.obolibrary.org%2Fobo%2FNCIT_C45908>`_
+    """
+
+
+class KaryotypicSex(enum.Enum):
+
+    # The `int` values correspond to protobuf fields in Phenopacket Schema.
+    UNKNOWN_KARYOTYPE = 0
+    XX = 1
+    XY = 2
+    XO = 3
+    XXY = 4
+    XXX = 5
+    XXYY = 6
+    XXXY = 7
+    XXXX = 8
+    XYY = 9
+    OTHER_KARYOTYPE = 10
+
+
+class VitalStatus(MessageMixin):
+    """
+    TODO: add docs.
+    """
+
+    class Status(enum.Enum):
+        """
+        TODO: add docs.
+        """
+        # The `int` values correspond to protobuf fields in Phenopacket Schema.
+
+        UNKNOWN_STATUS = 0
+        ALIVE = 1
+        DECEASED = 2
+
+    def __init__(
+            self,
+            status: Status,
+            time_of_death: typing.Optional[TimeElement] = None,
+            cause_of_death: typing.Optional[OntologyClass] = None,
+            survival_time_in_days: typing.Optional[int] = None,
+    ):
+        self._status = status
+        self._time_of_death = time_of_death
+        self._cause_of_death = cause_of_death
+        self._survival_time_in_days = survival_time_in_days
+
+    @property
+    def status(self) -> Status:
+        return self._status
+
+    @status.setter
+    def status(self, value: Status):
+        self._status = value
+
+    @property
+    def time_of_death(self) -> typing.Optional[TimeElement]:
+        return self._time_of_death
+
+    @time_of_death.setter
+    def time_of_death(self, value: typing.Optional[TimeElement]):
+        self._time_of_death = value
+
+    @time_of_death.deleter
+    def time_of_death(self):
+        self._time_of_death = None
+
+    @property
+    def cause_of_death(self) -> typing.Optional[OntologyClass]:
+        return self._cause_of_death
+
+    @cause_of_death.setter
+    def cause_of_death(self, value: typing.Optional[OntologyClass]):
+        self._cause_of_death = value
+
+    @cause_of_death.deleter
+    def cause_of_death(self):
+        self._cause_of_death = None
+
+    @property
+    def survival_time_in_days(self) -> typing.Optional[int]:
+        return self._survival_time_in_days
+
+    @survival_time_in_days.setter
+    def survival_time_in_days(self, value: typing.Optional[int]):
+        self._survival_time_in_days = value
+
+    @survival_time_in_days.deleter
+    def survival_time_in_days(self):
+        self._survival_time_in_days = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'status', 'time_of_death', 'cause_of_death', 'survival_time_in_days'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'status',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return VitalStatus(
+                status=MessageMixin._extract_enum_field('status', VitalStatus.Status, values),
+                time_of_death=extract_message_scalar('time_of_death', TimeElement, values),
+                cause_of_death=extract_message_scalar('cause_of_death', OntologyClass, values),
+                survival_time_in_days=int(values['survival_time_in_days']) if 'survival_time_in_days' in values else None,
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        vs = pp202.VitalStatus(
+            status=pp202.VitalStatus.Status.Value(self._status.name),
+        )
+
+        if self._time_of_death is not None:
+            vs.time_of_death.CopyFrom(self._time_of_death.to_message())
+
+
+        if self._cause_of_death is not None:
+            vs.cause_of_death.CopyFrom(self._cause_of_death.to_message())
+
+        if self._survival_time_in_days is not None:
+            vs.survival_time_in_days = self._survival_time_in_days
+
+        return vs
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.VitalStatus
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.VitalStatus):
+            return VitalStatus(
+                status=VitalStatus.Status(msg.status),
+                time_of_death=extract_pb_message_scalar('time_of_death', TimeElement, msg),
+                cause_of_death=extract_pb_message_scalar('cause_of_death', OntologyClass, msg),
+                survival_time_in_days=None if msg.survival_time_in_days == 0 else msg.survival_time_in_days,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, VitalStatus) \
+            and self._status == other._status \
+            and self._time_of_death == other._time_of_death \
+            and self._cause_of_death == other._cause_of_death \
+            and self._survival_time_in_days == other._survival_time_in_days
+
+    def __repr__(self):
+        return f'VitalStatus(' \
+               f'status={self._status}, ' \
+               f'time_of_death={self._time_of_death}, ' \
+               f'cause_of_death={self._cause_of_death}, ' \
+               f'survival_time_in_days={self._survival_time_in_days}' \
+               ')'
+
+
+class Individual(MessageMixin):
+
+    def __init__(
+            self,
+            id: str,
+            alternate_ids: typing.Optional[typing.Iterable[str]] = None,
+            date_of_birth: typing.Optional[Timestamp] = None,
+            time_at_last_encounter: typing.Optional[TimeElement] = None,
+            vital_status: typing.Optional[VitalStatus] = None,
+            sex: typing.Optional[Sex] = None,
+            karyotypic_sex: typing.Optional[KaryotypicSex] = None,
+            gender: typing.Optional[OntologyClass] = None,
+            taxonomy: typing.Optional[OntologyClass] = None,
+    ):
+        # TODO: validate
+        self._id = id
+        self._alt_ids = [] if alternate_ids is None else list(alternate_ids)
+        self._date_of_birth = date_of_birth
+        self._time_at_last_encounter = time_at_last_encounter
+        self._vital_status = vital_status
+        self._sex = sex
+        self._karyotypic_sex = karyotypic_sex
+        self._gender = gender
+        self._taxonomy = taxonomy
+
+    @property
+    def id(self) -> typing.Optional[str]:
+        return self._id
+
+    @property
+    def alternate_ids(self) -> typing.MutableSequence[str]:
+        return self._alt_ids
+
+    @property
+    def date_of_birth(self) -> typing.Optional[Timestamp]:
+        return self._date_of_birth
+
+    @date_of_birth.setter
+    def date_of_birth(self, value: typing.Optional[Timestamp]):
+        self._date_of_birth = value
+
+    @date_of_birth.deleter
+    def date_of_birth(self):
+        self._date_of_birth = None
+
+    @property
+    def time_at_last_encounter(self) -> typing.Optional[TimeElement]:
+        return self._time_at_last_encounter
+
+    @time_at_last_encounter.setter
+    def time_at_last_encounter(self, value: typing.Optional[TimeElement]):
+        self._time_at_last_encounter = value
+
+    @time_at_last_encounter.deleter
+    def time_at_last_encounter(self):
+        self._time_at_last_encounter = None
+
+    @property
+    def vital_status(self) -> typing.Optional[VitalStatus]:
+        return self._vital_status
+
+    @vital_status.setter
+    def vital_status(self, value: typing.Optional[VitalStatus]):
+        self._vital_status = value
+
+    @vital_status.deleter
+    def vital_status(self):
+        self._vital_status = None
+
+    @property
+    def sex(self) -> typing.Optional[Sex]:
+        return self._sex
+
+    @sex.setter
+    def sex(self, value: typing.Optional[Sex]):
+        self._sex = value
+
+    @sex.deleter
+    def sex(self):
+        self._sex = None
+
+    @property
+    def karyotypic_sex(self) -> typing.Optional[KaryotypicSex]:
+        return self._karyotypic_sex
+
+    @karyotypic_sex.setter
+    def karyotypic_sex(self, value: typing.Optional[KaryotypicSex]):
+        self._karyotypic_sex = value
+
+    @karyotypic_sex.deleter
+    def karyotypic_sex(self):
+        self._karyotypic_sex = None
+
+    @property
+    def gender(self) -> typing.Optional[OntologyClass]:
+        return self._gender
+
+    @gender.setter
+    def gender(self, value: typing.Optional[OntologyClass]):
+        self._gender = value
+
+    @gender.deleter
+    def gender(self):
+        self._gender = None
+
+    @property
+    def taxonomy(self) -> typing.Optional[OntologyClass]:
+        return self._taxonomy
+
+    @taxonomy.setter
+    def taxonomy(self, value: typing.Optional[OntologyClass]):
+        self._taxonomy = value
+
+    @taxonomy.deleter
+    def taxonomy(self):
+        self._taxonomy = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'id', 'alternate_ids', 'date_of_birth', 'time_at_last_encounter', 'vital_status', 'sex', 'karyotypic_sex', 'gender', 'taxonomy'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'id',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Individual(
+                id=values['id'],
+                alternate_ids=MessageMixin._extract_optional_field('alternate_ids', values),
+                date_of_birth=extract_message_scalar('date_of_birth', Timestamp, values),
+                time_at_last_encounter=extract_message_scalar('time_at_last_encounter', TimeElement, values),
+                vital_status=extract_message_scalar('vital_status', VitalStatus, values),
+                sex=MessageMixin._extract_enum_field('sex', Sex, values),
+                karyotypic_sex=MessageMixin._extract_enum_field('karyotypic_sex', KaryotypicSex, values),
+                gender=extract_message_scalar('gender', OntologyClass, values),
+                taxonomy=extract_message_scalar('taxonomy', OntologyClass, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        i = pp202.Individual(
+            id=self._id,
+        )
+
+        if len(self._alt_ids) != 0:
+            i.alternate_ids.extend(self._alt_ids)
+
+        if self._date_of_birth is not None:
+            i.time_at_last_encounter.CopyFrom(self._date_of_birth.to_message())
+
+        if self._time_at_last_encounter is not None:
+            i.time_at_last_encounter.CopyFrom(self._time_at_last_encounter.to_message())
+
+        if self._vital_status is not None:
+            i.vital_status.CopyFrom(self._vital_status.to_message())
+
+        if self._sex is not None:
+            i.sex = pp202.Sex.Value(self._sex.name)
+
+        if self._karyotypic_sex is not None:
+            i.karyotypic_sex = pp202.KaryotypicSex.Value(self._karyotypic_sex.name)
+
+        if self._gender is not None:
+            i.gender.CopyFrom(self._gender.to_message())
+
+        if self._taxonomy is not None:
+            i.taxonomy.CopyFrom(self._taxonomy.to_message())
+
+        return i
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Individual
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.Individual):
+            return Individual(
+                id=msg.id,
+                alternate_ids=msg.alternate_ids,
+                date_of_birth=extract_pb_message_scalar('date_of_birth', Timestamp, msg),
+                time_at_last_encounter=extract_pb_message_scalar('time_at_last_encounter', TimeElement, msg),
+                sex=Sex(msg.sex),
+                karyotypic_sex=KaryotypicSex(msg.karyotypic_sex),
+                vital_status=extract_pb_message_scalar('vital_status', VitalStatus, msg),
+                gender=extract_pb_message_scalar('gender', OntologyClass, msg),
+                taxonomy=extract_pb_message_scalar('taxonomy', OntologyClass, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Individual) \
+            and self._id == other._id \
+            and self._alt_ids == other._alt_ids \
+            and self._date_of_birth == other._date_of_birth \
+            and self._time_at_last_encounter == other._time_at_last_encounter \
+            and self._sex == other._sex \
+            and self._karyotypic_sex == other._karyotypic_sex \
+            and self._gender == other._gender \
+            and self._taxonomy == other._taxonomy
+
+    def __repr__(self):
+        return f'Individual(id={self._id},' \
+               f' alternate_ids={self._alt_ids},' \
+               f' date_of_birth={self._date_of_birth},' \
+               f' time_at_last_encounter={self._time_at_last_encounter},' \
+               f' sex={self._sex},' \
+               f' karyotypic_sex={self._karyotypic_sex},' \
+               f' gender={self._gender},' \
+               f' taxonomy={self._taxonomy},' \
+               ')'
diff --git a/src/pyphetools/pp/v202/_interpretation.py b/src/pyphetools/pp/v202/_interpretation.py
new file mode 100644
index 00000000..aca746c3
--- /dev/null
+++ b/src/pyphetools/pp/v202/_interpretation.py
@@ -0,0 +1,500 @@
+import enum
+
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from ._base import OntologyClass
+from ._vrsatile import VariationDescriptor
+from ._gene_descriptor import GeneDescriptor
+from .._api import MessageMixin
+from ..parse import extract_message_scalar, extract_message_sequence, extract_pb_message_scalar, extract_pb_message_seq
+
+
+class AcmgPathogenicityClassification(enum.Enum):
+    NOT_PROVIDED = 0
+    BENIGN = 1
+    LIKELY_BENIGN = 2
+    UNCERTAIN_SIGNIFICANCE = 3
+    LIKELY_PATHOGENIC = 4
+    PATHOGENIC = 5
+
+
+class TherapeuticActionability(enum.Enum):
+    UNKNOWN_ACTIONABILITY = 0
+    NOT_ACTIONABLE = 1
+    ACTIONABLE = 2
+
+
+class VariantInterpretation(MessageMixin):
+
+    def __init__(
+            self,
+            acmg_pathogenicity_classification: AcmgPathogenicityClassification,
+            therapeutic_actionability: TherapeuticActionability,
+            variation_descriptor: VariationDescriptor,
+    ):
+        self._acmg_pathogenicity_classification = acmg_pathogenicity_classification
+        self._therapeutic_actionability = therapeutic_actionability
+        self._variation_descriptor = variation_descriptor
+
+    @property
+    def acmg_pathogenicity_classification(self) -> AcmgPathogenicityClassification:
+        return self._acmg_pathogenicity_classification
+
+    @acmg_pathogenicity_classification.setter
+    def acmg_pathogenicity_classification(self, value: AcmgPathogenicityClassification):
+        self._acmg_pathogenicity_classification = value
+
+    @property
+    def therapeutic_actionability(self) -> TherapeuticActionability:
+        return self._therapeutic_actionability
+
+    @therapeutic_actionability.setter
+    def therapeutic_actionability(self, value: TherapeuticActionability):
+        self._therapeutic_actionability = value
+
+    @property
+    def variation_descriptor(self) -> VariationDescriptor:
+        return self._variation_descriptor
+
+    @variation_descriptor.setter
+    def variation_descriptor(self, value: VariationDescriptor):
+        self._variation_descriptor = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'acmg_pathogenicity_classification', 'therapeutic_actionability', 'variation_descriptor'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'acmg_pathogenicity_classification', 'therapeutic_actionability', 'variation_descriptor'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return VariantInterpretation(
+                acmg_pathogenicity_classification=MessageMixin._extract_enum_field(
+                    'acmg_pathogenicity_classification', AcmgPathogenicityClassification, values),
+                therapeutic_actionability=MessageMixin._extract_enum_field(
+                    'therapeutic_actionability', TherapeuticActionability, values),
+                variation_descriptor=extract_message_scalar('variation_descriptor', VariationDescriptor, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.VariantInterpretation(
+            acmg_pathogenicity_classification=pp202.AcmgPathogenicityClassification.Value(
+                self._acmg_pathogenicity_classification.name
+            ),
+            therapeutic_actionability=pp202.TherapeuticActionability.Value(
+                self._therapeutic_actionability.name
+            ),
+            variation_descriptor=self._variation_descriptor.to_message(),
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.VariantInterpretation
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.VariantInterpretation):
+            return VariantInterpretation(
+                acmg_pathogenicity_classification=AcmgPathogenicityClassification(
+                    msg.acmg_pathogenicity_classification),
+                therapeutic_actionability=TherapeuticActionability(msg.therapeutic_actionability),
+                variation_descriptor=extract_pb_message_scalar('variation_descriptor', VariationDescriptor, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, VariantInterpretation) \
+            and self._acmg_pathogenicity_classification == other._acmg_pathogenicity_classification \
+            and self._therapeutic_actionability == other._therapeutic_actionability \
+            and self._variation_descriptor == other._variation_descriptor
+
+    def __repr__(self):
+        return f'VariantInterpretation(acmg_pathogenicity_classification={self._acmg_pathogenicity_classification}, ' \
+               f'therapeutic_actionability={self._therapeutic_actionability}, ' \
+               f'variation_descriptor={self._variation_descriptor})'
+
+
+class GenomicInterpretation(MessageMixin):
+    class InterpretationStatus(enum.Enum):
+        UNKNOWN_STATUS = 0
+        REJECTED = 1
+        CANDIDATE = 2
+        CONTRIBUTORY = 3
+        CAUSATIVE = 4
+
+    def __init__(
+            self,
+            subject_or_biosample_id: str,
+            interpretation_status: InterpretationStatus,
+            gene_descriptor: typing.Optional[GeneDescriptor] = None,
+            variant_interpretation: typing.Optional[VariantInterpretation] = None,
+    ):
+        self._subject_or_biosample_id = subject_or_biosample_id
+        self._interpretation_status = interpretation_status
+        one_ofs = (gene_descriptor, variant_interpretation)
+        if sum(1 for arg in one_ofs if arg is not None) != 1:
+            cnt = sum(1 for arg in one_ofs if arg is not None)
+            raise ValueError(
+                f'GenomicInterpretation must be provided with exactly 1 argument but {cnt} arguments were provided!')
+
+        if gene_descriptor is not None:
+            self._discriminant = 0
+            self._call = gene_descriptor
+        elif variant_interpretation is not None:
+            self._discriminant = 1
+            self._call = variant_interpretation
+        else:
+            raise ValueError('Bug')  # TODO: wording
+
+    @property
+    def subject_or_biosample_id(self) -> str:
+        return self._subject_or_biosample_id
+
+    @subject_or_biosample_id.setter
+    def subject_or_biosample_id(self, value: str):
+        self._subject_or_biosample_id = value
+
+    @property
+    def interpretation_status(self) -> InterpretationStatus:
+        return self._interpretation_status
+
+    @interpretation_status.setter
+    def interpretation_status(self, value: InterpretationStatus):
+        self._interpretation_status = value
+
+    @property
+    def gene_descriptor(self) -> typing.Optional[GeneDescriptor]:
+        return self._call if self._discriminant == 0 else None
+
+    @gene_descriptor.setter
+    def gene_descriptor(self, value: GeneDescriptor):
+        self._discriminant = 0
+        self._call = value
+
+    @property
+    def variant_interpretation(self) -> typing.Optional[VariantInterpretation]:
+        return self._call if self._discriminant == 1 else None
+
+    @variant_interpretation.setter
+    def variant_interpretation(self, value: VariantInterpretation):
+        self._discriminant = 1
+        self._call = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'subject_or_biosample_id', 'interpretation_status', 'gene_descriptor', 'variant_interpretation'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'subject_or_biosample_id', 'interpretation_status',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            if 'gene_descriptor' in values:
+                assert 'variant_interpretation' not in values, \
+                    'Variant interpretation must be unset when Gene descriptor is set!'
+                return GenomicInterpretation(
+                    subject_or_biosample_id=values['subject_or_biosample_id'],
+                    interpretation_status=MessageMixin._extract_enum_field(
+                        'interpretation_status', GenomicInterpretation.InterpretationStatus, values
+                    ),
+                    gene_descriptor=extract_message_scalar('gene_descriptor', GeneDescriptor, values),
+                )
+
+            elif 'variant_interpretation' in values:
+                assert 'gene_descriptor' not in values, \
+                    'Gene descriptor must be unset when Variant interpretation is set!'
+                return GenomicInterpretation(
+                    subject_or_biosample_id=values['subject_or_biosample_id'],
+                    interpretation_status=MessageMixin._extract_enum_field(
+                        'interpretation_status', GenomicInterpretation.InterpretationStatus, values
+                    ),
+                    variant_interpretation=extract_message_scalar(
+                        'variant_interpretation', VariantInterpretation, values
+                    ),
+                )
+
+            else:
+                raise ValueError('Either `gene_descriptor` or `variant_interpretation` must be set!')
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        msg = pp202.GenomicInterpretation(
+            subject_or_biosample_id=self._subject_or_biosample_id,
+            interpretation_status=pp202.GenomicInterpretation.InterpretationStatus.Value(
+                self._interpretation_status.name
+            ),
+        )
+
+        val = self._call.to_message()
+        if self._discriminant == 0:
+            msg.gene_descriptor.CopyFrom(val)
+        elif self._discriminant == 1:
+            msg.variant_interpretation.CopyFrom(val)
+        else:
+            raise ValueError(f'Invalid discriminant {self._discriminant}')
+
+        return msg
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.GenomicInterpretation
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.GenomicInterpretation):
+            subject_or_biosample_id = msg.subject_or_biosample_id
+            interpretation_status = GenomicInterpretation.InterpretationStatus(msg.interpretation_status)
+
+            case = msg.WhichOneof('call')
+            if case == 'gene_descriptor':
+                return GenomicInterpretation(
+                    subject_or_biosample_id=subject_or_biosample_id,
+                    interpretation_status=interpretation_status,
+                    gene_descriptor=extract_pb_message_scalar(
+                        'gene_descriptor', GeneDescriptor, msg
+                    ),
+                )
+            elif case == 'variant_interpretation':
+                return GenomicInterpretation(
+                    subject_or_biosample_id=subject_or_biosample_id,
+                    interpretation_status=interpretation_status,
+                    variant_interpretation=extract_pb_message_scalar(
+                        'variant_interpretation', VariantInterpretation, msg
+                    ),
+                )
+            else:
+                raise ValueError(f'Unknown one of field set {case}')
+
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, GenomicInterpretation) \
+            and self._subject_or_biosample_id == other._subject_or_biosample_id \
+            and self._interpretation_status == other._interpretation_status \
+            and self._discriminant == other._discriminant \
+            and self._call == other._call
+
+    def __repr__(self):
+        if self._discriminant == 0:
+            val = f'gene_descriptor={self._call}'
+        elif self._discriminant == 1:
+            val = f'variant_interpretation={self._call}'
+        else:
+            raise ValueError(f'Invalid discriminant {self._discriminant}')
+
+        return f'GenomicInterpretation(' \
+               f'subject_or_biosample_id={self._subject_or_biosample_id}, ' \
+               f'interpretation_status={self._interpretation_status}, ' \
+               f'{val})'
+
+
+class Diagnosis(MessageMixin):
+
+    def __init__(
+            self,
+            disease: OntologyClass,
+            genomic_interpretations: typing.Optional[typing.Iterable[GenomicInterpretation]] = None,
+    ):
+        self._disease = disease
+        self._genomic_interpretations = [] if genomic_interpretations is None else list(genomic_interpretations)
+
+    @property
+    def disease(self) -> OntologyClass:
+        return self._disease
+
+    @disease.setter
+    def disease(self, value: OntologyClass):
+        self._disease = value
+
+    @property
+    def genomic_interpretations(self) -> typing.MutableSequence[GenomicInterpretation]:
+        return self._genomic_interpretations
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'disease', 'genomic_interpretations'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'disease',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Diagnosis(
+                disease=extract_message_scalar('disease', OntologyClass, values),
+                genomic_interpretations=extract_message_sequence(
+                    'genomic_interpretations', GenomicInterpretation, values
+                ),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.Diagnosis(
+            disease=self._disease.to_message(),
+            genomic_interpretations=(gi.to_message() for gi in self._genomic_interpretations),
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Diagnosis
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.Diagnosis):
+            return Diagnosis(
+                disease=extract_pb_message_scalar('disease', OntologyClass, msg),
+                genomic_interpretations=extract_pb_message_seq('genomic_interpretations', GenomicInterpretation, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Diagnosis) \
+            and self._disease == other._disease \
+            and self._genomic_interpretations == other._genomic_interpretations
+
+    def __repr__(self):
+        return f'Diagnosis(' \
+               f'disease={self._disease}' \
+               f'genomic_interpretations={self._genomic_interpretations})'
+
+
+class Interpretation(MessageMixin):
+    class ProgressStatus(enum.Enum):
+        UNKNOWN_PROGRESS = 0
+        IN_PROGRESS = 1
+        COMPLETED = 2
+        SOLVED = 3
+        UNSOLVED = 4
+
+    def __init__(
+            self,
+            id: str,
+            progress_status: ProgressStatus,
+            diagnosis: typing.Optional[Diagnosis] = None,
+            summary: typing.Optional[str] = None,
+    ):
+        self._id = id
+        self._progress_status = progress_status
+        self._diagnosis = diagnosis
+        self._summary = summary
+
+    @property
+    def id(self) -> str:
+        return self._id
+
+    @id.setter
+    def id(self, value: str):
+        self._id = value
+
+    @property
+    def progress_status(self) -> ProgressStatus:
+        return self._progress_status
+
+    @progress_status.setter
+    def progress_status(self, value: ProgressStatus):
+        self._progress_status = value
+
+    @property
+    def diagnosis(self) -> typing.Optional[Diagnosis]:
+        return self._diagnosis
+
+    @diagnosis.setter
+    def diagnosis(self, value: Diagnosis):
+        self._diagnosis = value
+
+    @diagnosis.deleter
+    def diagnosis(self):
+        self._diagnosis = None
+
+    @property
+    def summary(self) -> typing.Optional[str]:
+        return self._summary
+
+    @summary.setter
+    def summary(self, value: str):
+        self._summary = value
+
+    @summary.deleter
+    def summary(self):
+        self._summary = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'id', 'progress_status', 'diagnosis', 'summary'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'id', 'progress_status',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Interpretation(
+                id=values['id'],
+                progress_status=MessageMixin._extract_enum_field('progress_status', Interpretation.ProgressStatus,
+                                                                 values),
+                diagnosis=extract_message_scalar('diagnosis', Diagnosis, values),
+                summary=MessageMixin._extract_optional_field('summary', values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        i = pp202.Interpretation(
+            id=self._id,
+            progress_status=pp202.Interpretation.ProgressStatus.Value(self._progress_status.name),
+        )
+
+        if self._diagnosis is not None:
+            i.diagnosis.CopyFrom(self._diagnosis.to_message())
+
+        if self._summary is not None:
+            i.summary = self._summary
+
+        return i
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Interpretation
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.Interpretation):
+            return Interpretation(
+                id=msg.id,
+                progress_status=Interpretation.ProgressStatus(msg.progress_status),
+                diagnosis=extract_pb_message_scalar('diagnosis', Diagnosis, msg),
+                summary=None if msg.summary == '' else msg.summary,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Interpretation) \
+            and self._id == other._id \
+            and self._progress_status == other._progress_status \
+            and self._diagnosis == other._diagnosis \
+            and self._summary == other._summary
+
+    def __repr__(self):
+        return f'Interpretation(' \
+               f'id={self._id}, ' \
+               f'progress_status={self._progress_status}, ' \
+               f'diagnosis={self._diagnosis}, ' \
+               f'summary={self._summary})'
diff --git a/src/pyphetools/pp/v202/_measurement.py b/src/pyphetools/pp/v202/_measurement.py
new file mode 100644
index 00000000..50a2c5f8
--- /dev/null
+++ b/src/pyphetools/pp/v202/_measurement.py
@@ -0,0 +1,550 @@
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from .._api import MessageMixin
+from ._base import OntologyClass, Procedure, TimeElement
+from ..parse import extract_message_scalar, extract_message_sequence, extract_oneof_scalar
+from ..parse import extract_pb_message_scalar, extract_pb_message_seq, extract_pb_oneof_scalar
+
+
+class ReferenceRange(MessageMixin):
+
+    def __init__(
+            self,
+            unit: OntologyClass,
+            low: float,
+            high: float,
+    ):
+        self._unit = unit
+        self._low = low
+        self._high = high
+
+    @property
+    def unit(self) -> OntologyClass:
+        return self._unit
+
+    @unit.setter
+    def unit(self, value: OntologyClass):
+        self._unit = value
+
+    @property
+    def low(self) -> float:
+        return self._low
+
+    @low.setter
+    def low(self, value: float):
+        self._low = value
+
+    @property
+    def high(self) -> float:
+        return self._high
+
+    @high.setter
+    def high(self, value: float):
+        self._high = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'unit', 'low', 'high'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'unit', 'low', 'high'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return ReferenceRange(
+                unit=extract_message_scalar('unit', OntologyClass, values),
+                low=values['low'],
+                high=values['high'],
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.ReferenceRange(
+            unit=self._unit.to_message(),
+            low=self._low,
+            high=self._high,
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.ReferenceRange
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return ReferenceRange(
+                unit=extract_pb_message_scalar('unit', OntologyClass, msg),
+                low=msg.low,
+                high=msg.high,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, ReferenceRange) \
+            and self._unit == other._unit \
+            and self._low == other._low \
+            and self._high == other._high
+
+    def __repr__(self):
+        return f'ReferenceRange(unit={self._unit}, low={self._low}, high={self._high})'
+
+
+class Quantity(MessageMixin):
+
+    def __init__(
+            self,
+            unit: OntologyClass,
+            value: float,
+            reference_range: typing.Optional[ReferenceRange] = None,
+    ):
+        self._unit = unit
+        self._value = value
+        self._reference_range = reference_range
+
+    @property
+    def unit(self) -> OntologyClass:
+        return self._unit
+
+    @unit.setter
+    def unit(self, value: OntologyClass):
+        self._unit = value
+
+    @property
+    def value(self) -> float:
+        return self._value
+
+    @value.setter
+    def value(self, value: float):
+        self._value = value
+
+    @property
+    def reference_range(self) -> typing.Optional[ReferenceRange]:
+        return self._reference_range
+
+    @reference_range.setter
+    def reference_range(self, value: ReferenceRange):
+        self._reference_range = value
+
+    @reference_range.deleter
+    def reference_range(self):
+        self._reference_range = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'unit', 'value', 'reference_range'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'unit', 'value'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Quantity(
+                unit=extract_message_scalar('unit', OntologyClass, values),
+                value=values['value'],
+                reference_range=extract_message_scalar('reference_range', ReferenceRange, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Quantity
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Quantity(
+                unit=extract_pb_message_scalar('unit', OntologyClass, msg),
+                value=msg.value,
+                reference_range=extract_pb_message_scalar('reference_range', ReferenceRange, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def to_message(self) -> Message:
+        quantity = pp202.Quantity(unit=self._unit.to_message(), value=self._value)
+
+        if self._reference_range is not None:
+            quantity.reference_range.CopyFrom(self._reference_range.to_message())
+
+        return quantity
+
+    def __eq__(self, other):
+        return isinstance(other, Quantity) \
+            and self._unit == other._unit \
+            and self._value == other._value \
+            and self._reference_range == other._reference_range
+
+    def __repr__(self):
+        return f'Quantity(unit={self._unit}, value={self._value}, reference_range={self._reference_range})'
+
+
+class TypedQuantity(MessageMixin):
+
+    def __init__(
+            self,
+            type: OntologyClass,
+            quantity: Quantity,
+    ):
+        self._type = type
+        self._quantity = quantity
+
+    @property
+    def type(self) -> OntologyClass:
+        return self._type
+
+    @type.setter
+    def type(self, value: OntologyClass):
+        self._type = value
+
+    @property
+    def quantity(self) -> Quantity:
+        return self._quantity
+
+    @quantity.setter
+    def quantity(self, value: Quantity):
+        self._quantity = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'type', 'quantity'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'type', 'quantity'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return TypedQuantity(
+                type=extract_message_scalar('type', OntologyClass, values),
+                quantity=extract_message_scalar('quantity', Quantity, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.TypedQuantity(
+            type=self._type.to_message(),
+            quantity=self._quantity.to_message(),
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.TypedQuantity
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return TypedQuantity(
+                type=extract_pb_message_scalar('type', OntologyClass, msg),
+                quantity=extract_pb_message_scalar('quantity', Quantity, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, TypedQuantity) \
+            and self._type == other._type \
+            and self._quantity == other._quantity
+
+    def __repr__(self):
+        return f'TypedQuantity(type={self._type}, quantity={self._quantity})'
+
+
+class ComplexValue(MessageMixin):
+
+    def __init__(
+            self,
+            typed_quantities: typing.Iterable[TypedQuantity],
+    ):
+        self._typed_quantities = list(typed_quantities)
+        if len(self._typed_quantities) < 0:
+            raise ValueError(f'At least 1 typed quantity must be present!')
+
+    @property
+    def typed_quantities(self) -> typing.MutableSequence[TypedQuantity]:
+        return self._typed_quantities
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'typed_quantities',
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'typed_quantities',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return ComplexValue(
+                typed_quantities=extract_message_sequence('typed_quantities', TypedQuantity, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.ComplexValue
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return ComplexValue(
+                typed_quantities=extract_pb_message_seq('typed_quantities', TypedQuantity, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def to_message(self) -> Message:
+        cv = pp202.ComplexValue()
+        cv.typed_quantities.extend(m.to_message() for m in self._typed_quantities)
+        return cv
+
+    def __eq__(self, other):
+        return isinstance(other, ComplexValue) \
+            and self._typed_quantities == other._typed_quantities
+
+    def __repr__(self):
+        return f'ComplexValue(typed_quantities={self._typed_quantities})'
+
+
+class Value(MessageMixin):
+    _ONEOF_VALUE = {'quantity': Quantity, 'ontology_class': OntologyClass}
+
+    def __init__(
+            self,
+            value: typing.Union[Quantity, OntologyClass],
+    ):
+        self._value = value
+
+    @property
+    def value(self) -> typing.Union[Quantity, OntologyClass]:
+        return self._value
+
+    @property
+    def quantity(self) -> typing.Optional[Quantity]:
+        return self._value if isinstance(self._value, Quantity) else None
+
+    @quantity.setter
+    def quantity(self, value: Quantity):
+        self._value = value
+
+    @property
+    def ontology_class(self) -> typing.Optional[OntologyClass]:
+        return self._value if isinstance(self._value, OntologyClass) else None
+
+    @ontology_class.setter
+    def ontology_class(self, value: OntologyClass):
+        self._value = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'quantity', 'ontology_class'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        raise NotImplementedError('Should not be called!')
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if any(field in values for field in cls._ONEOF_VALUE):
+            return Value(
+                value=extract_oneof_scalar(cls._ONEOF_VALUE, values),
+            )
+        else:
+            raise ValueError(f'Missing one of required fields: `quantity|ontology_class`: {values}')
+
+    def to_message(self) -> Message:
+        v = pp202.Value()
+
+        if isinstance(self._value, Quantity):
+            v.quantity.CopyFrom(self._value.to_message())
+        elif isinstance(self._value, OntologyClass):
+            v.ontology_class.CopyFrom(self._value.to_message())
+        else:
+            raise ValueError('Bug')
+
+        return v
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Value
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Value(
+                value=extract_pb_oneof_scalar('value', cls._ONEOF_VALUE, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Value) and self._value == other._value
+
+    def __repr__(self):
+        return f'Value(value={self._value})'
+
+
+class Measurement(MessageMixin):
+    _ONEOF_MEASUREMENT_VALUE = {'value': Value, 'complex_value': ComplexValue}
+
+    def __init__(
+            self,
+            assay: OntologyClass,
+            measurement_value: typing.Union[Value, ComplexValue],
+            description: typing.Optional[str] = None,
+            time_observed: typing.Optional[TimeElement] = None,
+            procedure: typing.Optional[Procedure] = None,
+    ):
+        self._assay = assay
+        self._measurement_value = measurement_value
+        self._description = description
+        self._time_observed = time_observed
+        self._procedure = procedure
+
+    @property
+    def assay(self) -> OntologyClass:
+        return self._assay
+
+    @assay.setter
+    def assay(self, value: OntologyClass):
+        self._assay = value
+
+    @property
+    def measurement_value(self) -> typing.Union[Value, ComplexValue]:
+        return self._measurement_value
+
+    @property
+    def value(self) -> typing.Optional[Value]:
+        return self._measurement_value if isinstance(self._measurement_value, Value) else None
+
+    @property
+    def complex_value(self) -> typing.Optional[ComplexValue]:
+        return self._measurement_value if isinstance(self._measurement_value, ComplexValue) else None
+
+    @property
+    def description(self) -> typing.Optional[str]:
+        return self._description
+
+    @description.setter
+    def description(self, value: str):
+        self._description = value
+
+    @description.deleter
+    def description(self):
+        self._description = None
+
+    @property
+    def time_observed(self) -> typing.Optional[TimeElement]:
+        return self._time_observed
+
+    @time_observed.setter
+    def time_observed(self, value: TimeElement):
+        self._time_observed = value
+
+    @time_observed.deleter
+    def time_observed(self):
+        self._time_observed = None
+
+    @property
+    def procedure(self) -> typing.Optional[Procedure]:
+        return self._procedure
+
+    @procedure.setter
+    def procedure(self, value: Procedure):
+        self._procedure = value
+
+    @procedure.deleter
+    def procedure(self):
+        self._procedure = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'assay', 'value', 'complex_value', 'description', 'time_observed', 'procedure'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        raise NotImplementedError('Should not be called!')
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if 'assay' in values and any(field in values for field in cls._ONEOF_MEASUREMENT_VALUE):
+            return Measurement(
+                assay=extract_message_scalar('assay', OntologyClass, values),
+                measurement_value=extract_oneof_scalar(cls._ONEOF_MEASUREMENT_VALUE, values),
+                description=values.get('description', None),
+                time_observed=extract_message_scalar('time_observed', TimeElement, values),
+                procedure=extract_message_scalar('procedure', Procedure, values),
+            )
+        else:
+            raise ValueError(f'Missing one of required fields: `assay, value|complex_value` {values}')
+
+    def to_message(self) -> Message:
+        m = pp202.Measurement(
+            assay=self._assay.to_message(),
+        )
+
+        if isinstance(self._measurement_value, Value):
+            m.value.CopyFrom(self._measurement_value.to_message())
+        elif isinstance(self._measurement_value, ComplexValue):
+            m.complex_value.CopyFrom(self._measurement_value.to_message())
+        else:
+            raise ValueError('Bug')
+
+        if self._description is not None:
+            m.description = self._description
+
+        if self._time_observed is not None:
+            m.time_observed.CopyFrom(self._time_observed.to_message())
+
+        if self._procedure is not None:
+            m.procedure.CopyFrom(self._procedure.to_message())
+
+        return m
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Measurement
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Measurement(
+                assay=extract_pb_message_scalar('assay', OntologyClass, msg),
+                measurement_value=extract_pb_oneof_scalar(
+                    'measurement_value',
+                    cls._ONEOF_MEASUREMENT_VALUE,
+                    msg),
+                description=None if msg.description == '' else msg.description,
+                time_observed=extract_pb_message_scalar('time_observed', TimeElement, msg),
+                procedure=extract_pb_message_scalar('procedure', Procedure, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Measurement) \
+            and self._assay == other._assay \
+            and self._measurement_value == other._measurement_value \
+            and self._description == other._description \
+            and self._time_observed == other._time_observed \
+            and self._procedure == other._procedure
+
+    def __repr__(self):
+        return f'Measurement(' \
+               f'assay={self._assay}, ' \
+               f'measurement_value={self._measurement_value}, ' \
+               f'description={self._description}, ' \
+               f'time_observed={self._time_observed}, ' \
+               f'procedure={self._procedure})'
diff --git a/src/pyphetools/pp/v202/_medical_action.py b/src/pyphetools/pp/v202/_medical_action.py
new file mode 100644
index 00000000..0f460f23
--- /dev/null
+++ b/src/pyphetools/pp/v202/_medical_action.py
@@ -0,0 +1,704 @@
+import enum
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from ._base import OntologyClass, TimeElement, ExternalReference, TimeInterval, Procedure
+from ._measurement import Quantity
+
+from .._api import MessageMixin
+from ..parse import extract_message_scalar, extract_message_sequence, extract_oneof_scalar
+from ..parse import extract_pb_message_scalar, extract_pb_message_seq, extract_pb_oneof_scalar
+
+
+class TherapeuticRegimen(MessageMixin):
+    _ONEOF_IDENTIFIER = {'external_reference': ExternalReference, 'ontology_class': OntologyClass}
+
+    class RegimenStatus(enum.Enum):
+        UNKNOWN_STATUS = 0
+        STARTED = 1
+        COMPLETED = 2
+        DISCONTINUED = 3
+
+    def __init__(
+            self,
+            identifier: typing.Union[ExternalReference, OntologyClass],
+            regimen_status: RegimenStatus,
+            start_time: typing.Optional[TimeElement] = None,
+            end_time: typing.Optional[TimeElement] = None,
+    ):
+        self._identifier = identifier
+        self._regimen_status = regimen_status
+        self._start_time = start_time
+        self._end_time = end_time
+
+    @property
+    def identifier(self) -> typing.Union[ExternalReference, OntologyClass]:
+        return self._identifier
+
+    @property
+    def external_reference(self) -> typing.Optional[ExternalReference]:
+        return self._identifier if isinstance(self._identifier, ExternalReference) else None
+
+    @external_reference.setter
+    def external_reference(self, value: ExternalReference):
+        self._identifier = value
+
+    @property
+    def ontology_class(self) -> typing.Optional[OntologyClass]:
+        return self._identifier if isinstance(self._identifier, OntologyClass) else None
+
+    @ontology_class.setter
+    def ontology_class(self, value: OntologyClass):
+        self._identifier = value
+
+    @property
+    def regimen_status(self) -> RegimenStatus:
+        return self._regimen_status
+
+    @regimen_status.setter
+    def regimen_status(self, value: RegimenStatus):
+        self._regimen_status = value
+
+    @property
+    def start_time(self) -> typing.Optional[TimeElement]:
+        return self._start_time
+
+    @start_time.setter
+    def start_time(self, value: TimeElement):
+        self._start_time = value
+
+    @start_time.deleter
+    def start_time(self):
+        self._start_time = None
+
+    @property
+    def end_time(self) -> typing.Optional[TimeElement]:
+        return self._end_time
+
+    @end_time.setter
+    def end_time(self, value: TimeElement):
+        self._end_time = value
+
+    @end_time.deleter
+    def end_time(self):
+        self._end_time = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'external_reference', 'ontology_class', 'regimen_status', 'start_time', 'end_time'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        raise NotImplementedError('Should not be called!')
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if 'regimen_status' in values and any(field in values for field in cls._ONEOF_IDENTIFIER):
+            return TherapeuticRegimen(
+                identifier=extract_oneof_scalar(cls._ONEOF_IDENTIFIER, values),
+                regimen_status=MessageMixin._extract_enum_field(
+                    'regimen_status', TherapeuticRegimen.RegimenStatus, values,
+                ),
+                start_time=extract_message_scalar('start_time', TimeElement, values),
+                end_time=extract_message_scalar('end_time', TimeElement, values),
+            )
+        else:
+            raise ValueError(
+                f'Missing one of required fields: `external_reference|ontology_class, regimen_status` {values}'
+            )
+
+    def to_message(self) -> Message:
+        tr = pp202.TherapeuticRegimen(
+            regimen_status=pp202.TherapeuticRegimen.RegimenStatus.Value(self._regimen_status.name),
+        )
+
+        if isinstance(self._identifier, ExternalReference):
+            tr.external_reference.CopyFrom(self._identifier.to_message())
+        elif isinstance(self._identifier, OntologyClass):
+            tr.ontology_class.CopyFrom(self._identifier.to_message())
+        else:
+            raise ValueError('Bug')
+
+        if self._start_time is not None:
+            tr.start_time.CopyFrom(self._start_time.to_message())
+
+        if self._end_time is not None:
+            tr.end_time.CopyFrom(self._end_time.to_message())
+
+        return tr
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.TherapeuticRegimen
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return TherapeuticRegimen(
+                identifier=extract_pb_oneof_scalar('identifier', cls._ONEOF_IDENTIFIER, msg),
+                regimen_status=TherapeuticRegimen.RegimenStatus(msg.regimen_status),
+                start_time=extract_pb_message_scalar('start_time', TimeElement, msg),
+                end_time=extract_pb_message_scalar('end_time', TimeElement, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, TherapeuticRegimen) \
+            and self._identifier == other._identifier \
+            and self._regimen_status == other._regimen_status \
+            and self._start_time == other._start_time \
+            and self._end_time == other._end_time
+
+    def __repr__(self):
+        return f'TherapeuticRegimen(identifier={self._identifier}, ' \
+               f'regimen_status={self._regimen_status}, ' \
+               f'start_time={self._start_time}, ' \
+               f'end_time={self._end_time})'
+
+
+class RadiationTherapy(MessageMixin):
+
+    def __init__(
+            self,
+            modality: OntologyClass,
+            body_site: OntologyClass,
+            dosage: int,
+            fractions: int,
+    ):
+        self._modality = modality
+        self._body_site = body_site
+        self._dosage = dosage
+        self._fractions = fractions
+
+    @property
+    def modality(self) -> OntologyClass:
+        return self._modality
+
+    @modality.setter
+    def modality(self, value: OntologyClass):
+        self._modality = value
+
+    @property
+    def body_site(self) -> OntologyClass:
+        return self._body_site
+
+    @body_site.setter
+    def body_site(self, value: OntologyClass):
+        self._body_site = value
+
+    @property
+    def dosage(self) -> int:
+        return self._dosage
+
+    @dosage.setter
+    def dosage(self, value: int):
+        self._dosage = value
+
+    @property
+    def fractions(self) -> int:
+        return self._fractions
+
+    @fractions.setter
+    def fractions(self, value: int):
+        self._fractions = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'modality', 'body_site', 'dosage', 'fractions'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'modality', 'body_site', 'dosage', 'fractions'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return RadiationTherapy(
+                modality=extract_message_scalar('modality', OntologyClass, values),
+                body_site=extract_message_scalar('body_site', OntologyClass, values),
+                dosage=values['dosage'],
+                fractions=values['fractions'],
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.RadiationTherapy(
+            modality=self._modality.to_message(),
+            body_site=self._body_site.to_message(),
+            dosage=self._dosage,
+            fractions=self._fractions,
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.RadiationTherapy
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.RadiationTherapy):
+            return RadiationTherapy(
+                modality=extract_pb_message_scalar('modality', OntologyClass, msg),
+                body_site=extract_pb_message_scalar('body_site', OntologyClass, msg),
+                dosage=msg.dosage,
+                fractions=msg.fractions,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, RadiationTherapy) \
+            and self._modality == other._modality \
+            and self._body_site == other._body_site \
+            and self._dosage == other._dosage \
+            and self._fractions == other._fractions
+
+    def __repr__(self):
+        return f'RadiationTherapy(modality={self._modality}, ' \
+               f'body_site={self._modality}, ' \
+               f'dosage={self._body_site}, ' \
+               f'fractions={self._dosage})'
+
+
+class DrugType(enum.Enum):
+    UNKNOWN_DRUG_TYPE = 0
+    PRESCRIPTION = 1
+    EHR_MEDICATION_LIST = 2
+    ADMINISTRATION_RELATED_TO_PROCEDURE = 3
+
+
+class DoseInterval(MessageMixin):
+
+    def __init__(
+            self,
+            quantity: Quantity,
+            schedule_frequency: OntologyClass,
+            interval: TimeInterval,
+    ):
+        self._quantity = quantity
+        self._schedule_frequency = schedule_frequency
+        self._interval = interval
+
+    @property
+    def quantity(self) -> Quantity:
+        return self._quantity
+
+    @quantity.setter
+    def quantity(self, value: Quantity):
+        self._quantity = value
+
+    @property
+    def schedule_frequency(self) -> OntologyClass:
+        return self._schedule_frequency
+
+    @schedule_frequency.setter
+    def schedule_frequency(self, value: OntologyClass):
+        self._schedule_frequency = value
+
+    @property
+    def interval(self) -> TimeInterval:
+        return self._interval
+
+    @interval.setter
+    def interval(self, value: TimeInterval):
+        self._interval = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'quantity', 'schedule_frequency', 'interval'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'quantity', 'schedule_frequency', 'interval'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return DoseInterval(
+                quantity=extract_message_scalar('quantity', Quantity, values),
+                schedule_frequency=extract_message_scalar('schedule_frequency', OntologyClass, values),
+                interval=extract_message_scalar('interval', TimeInterval, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.DoseInterval(
+            quantity=self._quantity.to_message(),
+            schedule_frequency=self._schedule_frequency.to_message(),
+            interval=self._interval.to_message(),
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.DoseInterval
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.DoseInterval):
+            return DoseInterval(
+                quantity=extract_pb_message_scalar('quantity', Quantity, msg),
+                schedule_frequency=extract_pb_message_scalar('schedule_frequency', OntologyClass, msg),
+                interval=extract_pb_message_scalar('interval', TimeInterval, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, DoseInterval) \
+            and self._quantity == other._quantity \
+            and self._schedule_frequency == other._schedule_frequency \
+            and self._interval == other._interval
+
+    def __repr__(self):
+        return f'DoseInterval(quantity={self._quantity}, ' \
+               f'schedule_frequency={self._schedule_frequency}, ' \
+               f'interval={self._interval})'
+
+
+class Treatment(MessageMixin):
+
+    def __init__(
+            self,
+            agent: OntologyClass,
+            route_of_administration: typing.Optional[OntologyClass] = None,
+            dose_intervals: typing.Optional[typing.Iterable[DoseInterval]] = None,
+            drug_type: typing.Optional[DrugType] = None,
+            cumulative_dose: typing.Optional[Quantity] = None,
+    ):
+        self._agent = agent
+        self._route_of_administration = route_of_administration
+        self._dose_intervals = [] if dose_intervals is None else list(dose_intervals)
+        self._drug_type = DrugType.UNKNOWN_DRUG_TYPE if drug_type is None else drug_type
+        self._cumulative_dose = cumulative_dose
+
+    @property
+    def agent(self) -> OntologyClass:
+        return self._agent
+
+    @agent.setter
+    def agent(self, value: OntologyClass):
+        self._agent = value
+
+    @property
+    def route_of_administration(self) -> typing.Optional[OntologyClass]:
+        return self._route_of_administration
+
+    @route_of_administration.setter
+    def route_of_administration(self, value: OntologyClass):
+        self._route_of_administration = value
+
+    @route_of_administration.deleter
+    def route_of_administration(self):
+        self._route_of_administration = None
+
+    @property
+    def dose_intervals(self) -> typing.MutableSequence[DoseInterval]:
+        return self._dose_intervals
+
+    @property
+    def drug_type(self) -> typing.Optional[DrugType]:
+        return self._drug_type
+
+    @drug_type.setter
+    def drug_type(self, value: DrugType):
+        self._drug_type = value
+
+    @drug_type.deleter
+    def drug_type(self):
+        self._drug_type = None
+
+    @property
+    def cumulative_dose(self) -> typing.Optional[Quantity]:
+        return self._cumulative_dose
+
+    @cumulative_dose.setter
+    def cumulative_dose(self, value: Quantity):
+        self._cumulative_dose = value
+
+    @cumulative_dose.deleter
+    def cumulative_dose(self):
+        self._cumulative_dose = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'agent', 'route_of_administration', 'dose_intervals', 'drug_type', 'cumulative_dose'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'agent',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Treatment(
+                agent=extract_message_scalar('agent', OntologyClass, values),
+                route_of_administration=extract_message_scalar('route_of_administration', OntologyClass, values),
+                dose_intervals=extract_message_sequence('dose_intervals', DoseInterval, values),
+                drug_type=MessageMixin._extract_enum_field('drug_type', DrugType, values),
+                cumulative_dose=extract_message_scalar('cumulative_dose', Quantity, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Treatment
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.Treatment):
+            return Treatment(
+                agent=extract_pb_message_scalar('agent', OntologyClass, msg),
+                route_of_administration=extract_pb_message_scalar('route_of_administration', OntologyClass, msg),
+                dose_intervals=extract_pb_message_seq('dose_intervals', DoseInterval, msg),
+                drug_type=DrugType(msg.drug_type),
+                cumulative_dose=extract_pb_message_scalar('cumulative_dose', Quantity, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def to_message(self) -> Message:
+        t = pp202.Treatment(agent=self._agent.to_message(), )
+
+        if self._route_of_administration is not None:
+            t.route_of_administration.CopyFrom(self._route_of_administration.to_message())
+
+        t.dose_intervals.extend(di.to_message() for di in self._dose_intervals)
+
+        if self._drug_type is None:
+            t.drug_type = pp202.DrugType.Value(DrugType.UNKNOWN_DRUG_TYPE.name)
+        else:
+            t.drug_type = pp202.DrugType.Value(self._drug_type.name)
+
+        if self._cumulative_dose is not None:
+            t.cumulative_dose.CopyFrom(self._cumulative_dose.to_message())
+
+        return t
+
+    def __eq__(self, other):
+        return isinstance(other, Treatment) \
+            and self._agent == other._agent \
+            and self._route_of_administration == other._route_of_administration \
+            and self._dose_intervals == other._dose_intervals \
+            and self._drug_type == other._drug_type \
+            and self._cumulative_dose == other._cumulative_dose
+
+    def __repr__(self):
+        return f'Treatment(agent={self._agent}, ' \
+               f'route_of_administration={self._route_of_administration}, ' \
+               f'dose_intervals={self._dose_intervals}, ' \
+               f'drug_type={self._drug_type}, ' \
+               f'cumulative_dose={self._cumulative_dose})'
+
+
+class MedicalAction(MessageMixin):
+    _CLS_ACTION = {
+        'procedure': Procedure, 'treatment': Treatment,
+        'radiation_therapy': RadiationTherapy,
+        'therapeutic_regimen': TherapeuticRegimen,
+    }
+
+    def __init__(
+            self,
+            action: typing.Union[Procedure, Treatment, RadiationTherapy, TherapeuticRegimen],
+            treatment_target: typing.Optional[OntologyClass] = None,
+            treatment_intent: typing.Optional[OntologyClass] = None,
+            response_to_treatment: typing.Optional[OntologyClass] = None,
+            adverse_events: typing.Optional[typing.Iterable[OntologyClass]] = None,
+            treatment_termination_reason: typing.Optional[OntologyClass] = None,
+    ):
+        self._action = action
+        self._treatment_target = treatment_target
+        self._treatment_intent = treatment_intent
+        self._response_to_treatment = response_to_treatment
+        self._adverse_events = [] if adverse_events is None else list(adverse_events)
+        self._treatment_termination_reason = treatment_termination_reason
+
+    @property
+    def action(self) -> typing.Union[Procedure, Treatment, RadiationTherapy, TherapeuticRegimen]:
+        return self._action
+
+    @property
+    def procedure(self) -> Procedure:
+        return self._action if isinstance(self._action, Procedure) else None
+
+    @procedure.setter
+    def procedure(self, value: Procedure):
+        self._action = value
+
+    @property
+    def treatment(self) -> Treatment:
+        return self._action if isinstance(self._action, Treatment) else None
+
+    @treatment.setter
+    def treatment(self, value: Treatment):
+        self._action = value
+
+    @property
+    def radiation_therapy(self) -> RadiationTherapy:
+        return self._action if isinstance(self._action, RadiationTherapy) else None
+
+    @radiation_therapy.setter
+    def radiation_therapy(self, value: RadiationTherapy):
+        self._action = value
+
+    @property
+    def therapeutic_regimen(self) -> TherapeuticRegimen:
+        return self._action if isinstance(self._action, TherapeuticRegimen) else None
+
+    @therapeutic_regimen.setter
+    def therapeutic_regimen(self, value: TherapeuticRegimen):
+        self._action = value
+
+    @property
+    def treatment_target(self) -> typing.Optional[OntologyClass]:
+        return self._treatment_target
+
+    @treatment_target.setter
+    def treatment_target(self, value: OntologyClass):
+        self._treatment_target = value
+
+    @treatment_target.deleter
+    def treatment_target(self):
+        self._treatment_target = None
+
+    @property
+    def treatment_intent(self) -> typing.Optional[OntologyClass]:
+        return self._treatment_intent
+
+    @treatment_intent.setter
+    def treatment_intent(self, value: OntologyClass):
+        self._treatment_intent = value
+
+    @treatment_intent.deleter
+    def treatment_intent(self):
+        self._treatment_intent = None
+
+    @property
+    def response_to_treatment(self) -> typing.Optional[OntologyClass]:
+        return self._response_to_treatment
+
+    @response_to_treatment.setter
+    def response_to_treatment(self, value: OntologyClass):
+        self._response_to_treatment = value
+
+    @response_to_treatment.deleter
+    def response_to_treatment(self):
+        self._response_to_treatment = None
+
+    @property
+    def adverse_events(self) -> typing.MutableSequence[OntologyClass]:
+        return self._adverse_events
+
+    @property
+    def treatment_termination_reason(self) -> typing.Optional[OntologyClass]:
+        return self._treatment_termination_reason
+
+    @treatment_termination_reason.setter
+    def treatment_termination_reason(self, value: OntologyClass):
+        self._treatment_termination_reason = value
+
+    @treatment_termination_reason.deleter
+    def treatment_termination_reason(self):
+        self._treatment_termination_reason = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return (
+            'procedure', 'treatment', 'radiation_therapy', 'therapeutic_regimen',
+            'treatment_target', 'treatment_intent', 'response_to_treatment', 'adverse_events',
+            'treatment_termination_reason',
+        )
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        raise NotImplementedError('Should not be called!')
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if any(field in values for field in cls._CLS_ACTION):
+            return MedicalAction(
+                action=extract_oneof_scalar(cls._CLS_ACTION, values),
+                treatment_target=extract_message_scalar('treatment_target', OntologyClass, values),
+                treatment_intent=extract_message_scalar('treatment_intent', OntologyClass, values),
+                response_to_treatment=extract_message_scalar('response_to_treatment', OntologyClass, values),
+                adverse_events=extract_message_sequence('adverse_events', OntologyClass, values),
+                treatment_termination_reason=extract_message_scalar(
+                    'treatment_termination_reason', OntologyClass, values),
+            )
+        else:
+            raise ValueError(
+                f'Missing one of required fields: `procedure|treatment|radiation_therapy|therapeutic_regimen` {values}'
+            )
+
+    def to_message(self) -> Message:
+        m = pp202.MedicalAction()
+
+        if isinstance(self._action, Procedure):
+            m.procedure.CopyFrom(self._action.to_message())
+        elif isinstance(self._action, Treatment):
+            m.treatment.CopyFrom(self._action.to_message())
+        elif isinstance(self._action, RadiationTherapy):
+            m.radiation_therapy.CopyFrom(self._action.to_message())
+        elif isinstance(self._action, TherapeuticRegimen):
+            m.therapeutic_regimen.CopyFrom(self._action.to_message())
+        else:
+            raise ValueError('Bug')
+
+        if self._treatment_target is not None:
+            m.treatment_target.CopyFrom(self._treatment_target.to_message())
+
+        if self._treatment_intent is not None:
+            m.treatment_intent.CopyFrom(self._treatment_intent.to_message())
+
+        if self._response_to_treatment is not None:
+            m.response_to_treatment.CopyFrom(self._response_to_treatment.to_message())
+
+        m.adverse_events.extend(a.to_message() for a in self._adverse_events)
+
+        if self._treatment_termination_reason is not None:
+            m.treatment_termination_reason.CopyFrom(self._treatment_termination_reason.to_message())
+
+        return m
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.MedicalAction
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return MedicalAction(
+                action=extract_pb_oneof_scalar('action', cls._CLS_ACTION, msg),
+                treatment_target=extract_pb_message_scalar('treatment_target', OntologyClass, msg),
+                treatment_intent=extract_pb_message_scalar('treatment_intent', OntologyClass, msg),
+                response_to_treatment=extract_pb_message_scalar('response_to_treatment', OntologyClass,
+                                                                msg),
+                adverse_events=extract_pb_message_seq('adverse_events', OntologyClass, msg),
+                treatment_termination_reason=extract_pb_message_scalar(
+                    'treatment_termination_reason', OntologyClass, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, MedicalAction) \
+            and self._action == other._action \
+            and self._treatment_target == other._treatment_target \
+            and self._treatment_intent == other._treatment_intent \
+            and self._response_to_treatment == other._response_to_treatment \
+            and self._adverse_events == other._adverse_events \
+            and self._treatment_termination_reason == other._treatment_termination_reason
+
+    def __repr__(self):
+        return f'MedicalAction(' \
+               f'action={self._action}, ' \
+               f'treatment_target={self._treatment_target}, ' \
+               f'treatment_intent={self._treatment_intent}, ' \
+               f'response_to_treatment={self._response_to_treatment}, ' \
+               f'adverse_events={self._adverse_events}, ' \
+               f'treatment_termination_reason={self._treatment_termination_reason})'
diff --git a/src/pyphetools/pp/v202/_meta_data.py b/src/pyphetools/pp/v202/_meta_data.py
new file mode 100644
index 00000000..50709dde
--- /dev/null
+++ b/src/pyphetools/pp/v202/_meta_data.py
@@ -0,0 +1,390 @@
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from ._base import ExternalReference
+from .._api import MessageMixin
+from .._timestamp import Timestamp
+from ..parse import extract_message_scalar, extract_message_sequence, extract_pb_message_scalar, extract_pb_message_seq
+
+
+class Resource(MessageMixin):
+
+    def __init__(
+            self,
+            id: str,
+            name: str,
+            url: str,
+            version: str,
+            namespace_prefix: str,
+            iri_prefix: str,
+    ):
+        self._id = id
+        self._name = name
+        self._url = url
+        self._version = version
+        self._namespace_prefix = namespace_prefix
+        self._iri_prefix = iri_prefix
+
+    @property
+    def id(self) -> str:
+        return self._id
+
+    @id.setter
+    def id(self, value: str):
+        self._id = value
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @name.setter
+    def name(self, value: str):
+        self._name = value
+
+    @property
+    def url(self) -> str:
+        return self._url
+
+    @url.setter
+    def url(self, value: str):
+        self._url = value
+
+    @property
+    def version(self) -> str:
+        return self._version
+
+    @version.setter
+    def version(self, value: str):
+        self._version = value
+
+    @property
+    def namespace_prefix(self) -> str:
+        return self._namespace_prefix
+
+    @namespace_prefix.setter
+    def namespace_prefix(self, value: str):
+        self._namespace_prefix = value
+
+    @property
+    def iri_prefix(self) -> str:
+        return self._iri_prefix
+
+    @iri_prefix.setter
+    def iri_prefix(self, value: str):
+        self._iri_prefix = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'id', 'name', 'url', 'version', 'namespace_prefix', 'iri_prefix'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'id', 'name', 'url', 'version', 'namespace_prefix', 'iri_prefix'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Resource(
+                id=values['id'],
+                name=values['name'],
+                url=values['url'],
+                version=values['version'],
+                namespace_prefix=values['namespace_prefix'],
+                iri_prefix=values['iri_prefix'],
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Resource
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Resource(
+                id=msg.id,
+                name=msg.name,
+                url=msg.url,
+                version=msg.version,
+                namespace_prefix=msg.namespace_prefix,
+                iri_prefix=msg.iri_prefix,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def to_message(self) -> Message:
+        return pp202.Resource(
+            id=self._id,
+            name=self._name,
+            url=self._url,
+            version=self._version,
+            namespace_prefix=self._namespace_prefix,
+            iri_prefix=self._iri_prefix,
+        )
+
+    def __eq__(self, other):
+        return isinstance(other, Resource) \
+            and self._id == other._id \
+            and self._name == other._name \
+            and self._url == other._url \
+            and self._version == other._version \
+            and self._namespace_prefix == other._namespace_prefix \
+            and self._iri_prefix == other._iri_prefix
+
+    def __repr__(self):
+        return (f'Resource(id={self._id}, '
+                f'name={self._name}, '
+                f'url={self._url}, '
+                f'version={self._version}, '
+                f'namespace_prefix={self._namespace_prefix}, '
+                f'iri_prefix={self._iri_prefix})')
+
+
+class Update(MessageMixin):
+
+    def __init__(
+            self,
+            timestamp: Timestamp,
+            updated_by: typing.Optional[str] = None,
+            comment: typing.Optional[str] = None,
+    ):
+        self._timestamp = timestamp
+        self._updated_by = updated_by
+        self._comment = comment
+
+    @property
+    def timestamp(self) -> Timestamp:
+        return self._timestamp
+
+    @timestamp.setter
+    def timestamp(self, value: Timestamp):
+        self._timestamp = value
+
+    @property
+    def updated_by(self) -> str:
+        return self._updated_by
+
+    @updated_by.setter
+    def updated_by(self, value: str):
+        self._updated_by = value
+
+    @updated_by.deleter
+    def updated_by(self):
+        self._updated_by = None
+
+    @property
+    def comment(self) -> str:
+        return self._comment
+
+    @comment.setter
+    def comment(self, value: str):
+        self._comment = value
+
+    @comment.deleter
+    def comment(self):
+        self._comment = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'timestamp', 'updated_by', 'comment'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'timestamp',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Update(
+                timestamp=extract_message_scalar('timestamp', Timestamp, values),
+                updated_by=values.get('updated_by', None),
+                comment=values.get('comment', None),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Update
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Update(
+                timestamp=extract_pb_message_scalar('timestamp', Timestamp, msg),
+                updated_by=None if msg.updated_by == '' else msg.updated_by,
+                comment=None if msg.comment == '' else msg.comment,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def to_message(self) -> Message:
+        update = pp202.Update(timestamp=self._timestamp.to_message(), )
+
+        if self._updated_by is not None:
+            update.updated_by = self._updated_by
+
+        if self._comment is not None:
+            update.comment = self._comment
+
+        return update
+
+    def __eq__(self, other):
+        return isinstance(other, Update) \
+            and self._timestamp == other._timestamp \
+            and self._updated_by == other._updated_by \
+            and self._comment == other._comment
+
+    def __repr__(self):
+        return f'Update(timestamp={self._timestamp}, updated_by={self._updated_by}, comment={self._comment})'
+
+
+class MetaData(MessageMixin):
+    # TODO: this entire class must be implemented!
+
+    def __init__(
+            self,
+            created: Timestamp,
+            created_by: str,
+            submitted_by: typing.Optional[str] = None,
+            resources: typing.Optional[typing.Iterable[Resource]] = None,
+            updates: typing.Optional[typing.Iterable[Update]] = None,
+            phenopacket_schema_version: str = '2.0.2',
+            external_references: typing.Optional[typing.Iterable[ExternalReference]] = None,
+    ):
+        self._created = created
+        self._created_by = created_by
+        self._submitted_by = submitted_by
+        self._resources = [] if resources is None else list(resources)
+        self._updates = [] if updates is None else list(updates)
+        self._phenopacket_schema_version = phenopacket_schema_version
+        self._external_references = [] if external_references is None else list(external_references)
+
+    @property
+    def created(self) -> Timestamp:
+        return self._created
+
+    @created.setter
+    def created(self, value: Timestamp):
+        self._created = value
+
+    @property
+    def created_by(self) -> str:
+        return self._created_by
+
+    @created_by.setter
+    def created_by(self, value: str):
+        self._created_by = value
+
+    @property
+    def submitted_by(self) -> typing.Optional[str]:
+        return self._submitted_by
+
+    @submitted_by.setter
+    def submitted_by(self, value: typing.Optional[str]):
+        self._submitted_by = value
+
+    @submitted_by.deleter
+    def submitted_by(self):
+        self._submitted_by = None
+
+    @property
+    def resources(self) -> typing.MutableSequence[Resource]:
+        return self._resources
+
+    @property
+    def updates(self) -> typing.MutableSequence[Update]:
+        return self._updates
+
+    @property
+    def phenopacket_schema_version(self) -> str:
+        return self._phenopacket_schema_version
+
+    @phenopacket_schema_version.setter
+    def phenopacket_schema_version(self, value: str):
+        self._phenopacket_schema_version = value
+
+    @property
+    def external_references(self) -> typing.MutableSequence[ExternalReference]:
+        return self._external_references
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'created', 'created_by', 'submitted_by', 'resources', 'updates', 'phenopacket_schema_version', 'external_references'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'created', 'created_by', 'phenopacket_schema_version',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return MetaData(
+                created=Timestamp.from_str(values['created']),
+                created_by=values['created_by'],
+                submitted_by=values.get('submitted_by', None),
+                resources=extract_message_sequence('resources', Resource, values),
+                updates=extract_message_sequence('updates', Update, values),
+                phenopacket_schema_version=values['phenopacket_schema_version'],
+                external_references=extract_message_sequence('external_references', ExternalReference, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        meta_data = pp202.MetaData(
+            created=self._created.to_message(),
+            created_by=self._created_by,
+            phenopacket_schema_version=self._phenopacket_schema_version,
+        )
+
+        if self._submitted_by is not None:
+            meta_data.submitted_by = self._submitted_by
+
+        meta_data.resources.extend(r.to_message() for r in self._resources)
+        meta_data.resources.extend(u.to_message() for u in self._updates)
+        meta_data.external_references.extend(er.to_message() for er in self._external_references)
+
+        return meta_data
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.MetaData
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.MetaData):
+            return MetaData(
+                created=extract_pb_message_scalar('created', Timestamp, msg),
+                created_by=msg.created_by,
+                submitted_by=None if msg.submitted_by == '' else msg.submitted_by,
+                resources=extract_pb_message_seq('resources', Resource, msg),
+                updates=extract_pb_message_seq('updates', Update, msg),
+                phenopacket_schema_version=msg.phenopacket_schema_version,
+                external_references=extract_pb_message_seq('external_references', ExternalReference, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, MetaData) \
+            and self._created == other._created \
+            and self._created_by == other._created_by \
+            and self._submitted_by == other._submitted_by \
+            and self._resources == other._resources \
+            and self._updates == other._updates \
+            and self._phenopacket_schema_version == other._phenopacket_schema_version \
+            and self._external_references == other._external_references
+
+    def __repr__(self):
+        return (f'MetaData('
+                f'created={self._created}, '
+                f'created_by={self._created_by}, '
+                f'submitted_by={self._submitted_by}, '
+                f'resources={self._resources}, '
+                f'updates={self._updates}, '
+                f'phenopacket_schema_version={self._phenopacket_schema_version}, '
+                f'external_references={self._external_references})')
diff --git a/src/pyphetools/pp/v202/_phenopackets.py b/src/pyphetools/pp/v202/_phenopackets.py
new file mode 100644
index 00000000..2c4229e1
--- /dev/null
+++ b/src/pyphetools/pp/v202/_phenopackets.py
@@ -0,0 +1,191 @@
+import typing
+
+import phenopackets as pp202
+
+from google.protobuf.message import Message
+
+from ._base import File
+from ._biosample import Biosample
+from ._individual import Individual
+from ._interpretation import Interpretation
+from ._measurement import Measurement
+from ._medical_action import MedicalAction
+from ._phenotypic_feature import PhenotypicFeature
+from ._disease import Disease
+from ._meta_data import MetaData
+from .._api import MessageMixin
+from ..parse import extract_message_scalar, extract_message_sequence, extract_pb_message_scalar, extract_pb_message_seq
+
+
+class Phenopacket(MessageMixin):
+
+    def __init__(
+            self,
+            id: str,
+            meta_data: MetaData,
+            subject: typing.Optional[Individual] = None,
+            phenotypic_features: typing.Optional[typing.Iterable[PhenotypicFeature]] = None,
+            measurements: typing.Optional[typing.Iterable[Measurement]] = None,
+            biosamples: typing.Optional[typing.Iterable[Biosample]] = None,
+            interpretations: typing.Optional[typing.Iterable[Interpretation]] = None,
+            diseases: typing.Optional[typing.Iterable[Disease]] = None,
+            medical_actions: typing.Optional[typing.Iterable[MedicalAction]] = None,
+            files: typing.Optional[typing.Iterable[File]] = None,
+    ):
+        self._id = id
+        self._subject = subject
+        self._phenotypic_features = [] if phenotypic_features is None else list(phenotypic_features)
+        self._measurements = [] if measurements is None else list(measurements)
+        self._biosamples = [] if biosamples is None else list(biosamples)
+        self._interpretations = [] if interpretations is None else list(interpretations)
+        self._diseases = [] if diseases is None else list(diseases)
+        self._medical_actions = [] if medical_actions is None else list(medical_actions)
+        self._files = [] if files is None else list(files)
+        self._meta_data = meta_data
+
+    @property
+    def id(self) -> str:
+        return self._id
+
+    @id.setter
+    def id(self, value: str):
+        self._id = value
+
+    @property
+    def subject(self) -> typing.Optional[Individual]:
+        return self._subject
+
+    @subject.setter
+    def subject(self, value: Individual):
+        self._subject = value
+
+    @subject.deleter
+    def subject(self):
+        self._subject = None
+
+    @property
+    def phenotypic_features(self) -> typing.MutableSequence[PhenotypicFeature]:
+        return self._phenotypic_features
+
+    @property
+    def measurements(self) -> typing.MutableSequence[Measurement]:
+        return self._measurements
+
+    @property
+    def biosamples(self) -> typing.MutableSequence[Biosample]:
+        return self._biosamples
+
+    @property
+    def interpretations(self) -> typing.MutableSequence[Interpretation]:
+        return self._interpretations
+
+    @property
+    def diseases(self) -> typing.MutableSequence[Disease]:
+        return self._diseases
+
+    @property
+    def medical_actions(self) -> typing.MutableSequence[MedicalAction]:
+        return self._medical_actions
+
+    @property
+    def files(self) -> typing.MutableSequence[File]:
+        return self._files
+
+    @property
+    def meta_data(self) -> MetaData:
+        return self._meta_data
+
+    @meta_data.setter
+    def meta_data(self, value: MetaData):
+        self._meta_data = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return (
+            'id', 'subject', 'phenotypic_features', 'measurements', 'biosamples',
+            'interpretations', 'diseases', 'medical_actions', 'files', 'meta_data',
+        )
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'id', 'meta_data'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Phenopacket(
+                id=values['id'],
+                subject=extract_message_scalar('subject', Individual, values),
+                phenotypic_features=extract_message_sequence('phenotypic_features', PhenotypicFeature, values),
+                measurements=extract_message_sequence('measurements', Measurement, values),
+                biosamples=extract_message_sequence('biosamples', Biosample, values),
+                interpretations=extract_message_sequence('interpretations', Interpretation, values),
+                diseases=extract_message_sequence('diseases', Disease, values),
+                medical_actions=extract_message_sequence('medical_actions', MedicalAction, values),
+                files=extract_message_sequence('files', File, values),
+                meta_data=extract_message_scalar('meta_data', MetaData, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.Phenopacket(
+            id=self._id,
+            subject=self._subject.to_message(),
+            phenotypic_features=(pf.to_message() for pf in self._phenotypic_features),
+            measurements=(m.to_message() for m in self._measurements),
+            biosamples=(b.to_message() for b in self._biosamples),
+            interpretations=(i.to_message() for i in self._interpretations),
+            diseases=(d.to_message() for d in self._diseases),
+            medical_actions=(ma.to_message() for ma in self._medical_actions),
+            files=(f.to_message() for f in self._files),
+            meta_data=self._meta_data.to_message(),
+        )
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Phenopacket
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, pp202.Phenopacket):
+            return Phenopacket(
+                id=msg.id,
+                subject=extract_pb_message_scalar('subject', Individual, msg),
+                phenotypic_features=extract_pb_message_seq('phenotypic_features', PhenotypicFeature, msg),
+                measurements=extract_pb_message_seq('measurements', Measurement, msg),
+                biosamples=extract_pb_message_seq('biosamples', Biosample, msg),
+                interpretations=extract_pb_message_seq('interpretations', Interpretation, msg),
+                diseases=extract_pb_message_seq('diseases', Disease, msg),
+                medical_actions=extract_pb_message_seq('medical_actions', MedicalAction, msg),
+                files=extract_pb_message_seq('files', File, msg),
+                meta_data=extract_pb_message_scalar('meta_data', MetaData, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Phenopacket) \
+            and self._id == other._id \
+            and self._subject == other._subject \
+            and self._phenotypic_features == other._phenotypic_features \
+            and self._measurements == other._measurements \
+            and self._biosamples == other._biosamples \
+            and self._interpretations == other._interpretations \
+            and self._diseases == other._diseases \
+            and self._medical_actions == other._medical_actions \
+            and self._files == other._files \
+            and self._meta_data == other._meta_data
+
+    def __repr__(self):
+        return f'Phenopacket(' \
+               f'id={self._id}, ' \
+               f'subject={self._subject}, ' \
+               f'phenotypic_features={self._phenotypic_features}, ' \
+               f'measurements={self._measurements}, ' \
+               f'biosamples={self._biosamples}, ' \
+               f'interpretations={self._interpretations}, ' \
+               f'diseases={self._diseases}, ' \
+               f'medical_actions={self._medical_actions}, ' \
+               f'files={self._files}, ' \
+               f'meta_data={self._meta_data})'
diff --git a/src/pyphetools/pp/v202/_phenotypic_feature.py b/src/pyphetools/pp/v202/_phenotypic_feature.py
new file mode 100644
index 00000000..d42c1160
--- /dev/null
+++ b/src/pyphetools/pp/v202/_phenotypic_feature.py
@@ -0,0 +1,191 @@
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from ._base import OntologyClass, TimeElement, Evidence
+from .._api import MessageMixin
+from ..parse import extract_message_scalar, extract_message_sequence, extract_pb_message_scalar, extract_pb_message_seq
+
+
+class PhenotypicFeature(MessageMixin):
+
+    def __init__(
+            self,
+            type: OntologyClass,
+            excluded: bool = False,
+            description: typing.Optional[str] = None,
+            severity: typing.Optional[OntologyClass] = None,
+            modifiers: typing.Optional[typing.Iterable[OntologyClass]] = None,
+            onset: typing.Optional[TimeElement] = None,
+            resolution: typing.Optional[TimeElement] = None,
+            evidence: typing.Optional[typing.Iterable[Evidence]] = None,
+    ):
+        self._type = type
+        self._excluded = excluded
+        self._description = description
+        self._severity = severity
+        self._modifiers = [] if modifiers is None else list(modifiers)
+        self._onset = onset
+        self._resolution = resolution
+        self._evidence = [] if evidence is None else list(evidence)
+
+    @property
+    def type(self) -> OntologyClass:
+        return self._type
+
+    @type.setter
+    def type(self, value: OntologyClass):
+        self._type = value
+
+    @property
+    def excluded(self) -> bool:
+        return self._excluded
+
+    @excluded.setter
+    def excluded(self, value: bool):
+        self._excluded = value
+
+    @property
+    def description(self) -> typing.Optional[str]:
+        return self._description
+
+    @description.setter
+    def description(self, value: str):
+        self._description = value
+
+    @description.deleter
+    def description(self):
+        self._description = None
+
+    @property
+    def severity(self) -> typing.Optional[OntologyClass]:
+        return self._severity
+
+    @severity.setter
+    def severity(self, value: OntologyClass):
+        self._severity = value
+
+    @severity.deleter
+    def severity(self):
+        self._severity = None
+
+    @property
+    def modifiers(self) -> typing.MutableSequence[OntologyClass]:
+        return self._modifiers
+
+    @property
+    def onset(self) -> typing.Optional[TimeElement]:
+        return self._onset
+
+    @onset.setter
+    def onset(self, value: TimeElement):
+        self._onset = value
+
+    @onset.deleter
+    def onset(self):
+        self._onset = None
+
+    @property
+    def resolution(self) -> typing.Optional[TimeElement]:
+        return self._resolution
+
+    @resolution.setter
+    def resolution(self, value: TimeElement):
+        self._resolution = value
+
+    @resolution.deleter
+    def resolution(self):
+        self._resolution = None
+
+    @property
+    def evidence(self) -> typing.MutableSequence[Evidence]:
+        return self._evidence
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'type', 'excluded', 'description', 'severity', 'modifiers', 'onset', 'resolution', 'evidence'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'type',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return PhenotypicFeature(
+                type=extract_message_scalar('type', OntologyClass, values),
+                excluded=values['excluded'] if 'excluded' in values else False,
+                description=values['description'] if 'description' in values else None,
+                severity=extract_message_scalar('severity', OntologyClass, values),
+                modifiers=extract_message_sequence('modifiers', OntologyClass, values),
+                onset=extract_message_scalar('onset', TimeElement, values),
+                resolution=extract_message_scalar('resolution', TimeElement, values),
+                evidence=extract_message_sequence('evidence', Evidence, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        pf = pp202.PhenotypicFeature(
+            type=self._type.to_message(), excluded=self._excluded,
+        )
+
+        if self._description is not None:
+            pf.description = self._description
+
+        if self._severity is not None:
+            pf.severity.CopyFrom(self._severity.to_message())
+
+        pf.modifiers.extend(m.to_message() for m in self._modifiers)
+
+        if self._onset is not None:
+            pf.onset.CopyFrom(self._onset.to_message())
+
+        if self._resolution is not None:
+            pf.resolution.CopyFrom(self._resolution.to_message())
+
+        pf.evidence.extend(e.to_message() for e in self._evidence)
+
+        return pf
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.PhenotypicFeature
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return PhenotypicFeature(
+                type=extract_pb_message_scalar('type', OntologyClass, msg),
+                excluded=msg.excluded,
+                description=None if msg.description == '' else msg.description,
+                severity=extract_pb_message_scalar('severity', OntologyClass, msg),
+                modifiers=extract_pb_message_seq('modifiers', OntologyClass, msg),
+                onset=extract_pb_message_scalar('onset', TimeElement, msg),
+                resolution=extract_pb_message_scalar('resolution', TimeElement, msg),
+                evidence=extract_pb_message_seq('evidence', Evidence, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, PhenotypicFeature) \
+            and self._type == other._type \
+            and self._excluded == other._excluded \
+            and self._description == other._description \
+            and self._severity == other._severity \
+            and self._modifiers == other._modifiers \
+            and self._onset == other._onset \
+            and self._resolution == other._resolution \
+            and self._evidence == other._evidence
+
+    def __repr__(self):
+        return (f'PhenotypicFeature(type={self._type}, '
+                f'excluded={self._excluded}, '
+                f'description={self._description}, '
+                f'severity={self._severity}, '
+                f'modifiers={self._modifiers}, '
+                f'onset={self._onset}, '
+                f'resolution={self._resolution}, '
+                f'evidence={self._evidence})')
diff --git a/src/pyphetools/pp/v202/_vrsatile.py b/src/pyphetools/pp/v202/_vrsatile.py
new file mode 100644
index 00000000..5798c8b4
--- /dev/null
+++ b/src/pyphetools/pp/v202/_vrsatile.py
@@ -0,0 +1,657 @@
+import enum
+import typing
+
+import phenopackets as pp202
+from google.protobuf.message import Message
+
+from ._gene_descriptor import GeneDescriptor
+from ._base import OntologyClass
+
+from .._api import MessageMixin
+from ..parse import extract_message_scalar, extract_message_sequence, extract_pb_message_scalar, extract_pb_message_seq
+
+
+class Expression(MessageMixin):
+
+    def __init__(
+            self,
+            syntax: str,
+            value: str,
+            version: typing.Optional[str] = None,
+    ):
+        self._syntax = syntax
+        self._value = value
+        self._version = version
+
+    @property
+    def syntax(self) -> str:
+        return self._syntax
+
+    @syntax.setter
+    def syntax(self, value: str):
+        self._syntax = value
+
+    @property
+    def value(self) -> str:
+        return self._value
+
+    @value.setter
+    def value(self, value: str):
+        self._value = value
+
+    @property
+    def version(self) -> typing.Optional[str]:
+        return self._version
+
+    @version.setter
+    def version(self, value: str):
+        self._version = value
+
+    @version.deleter
+    def version(self):
+        self._version = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'syntax', 'value', 'version'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'syntax', 'value',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Expression(
+                syntax=values['syntax'],
+                value=values['value'],
+                version=values.get('version', None),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        expression = pp202.Expression(syntax=self._syntax, value=self._value)
+
+        if self._version is not None:
+            expression.version = self._version
+
+        return expression
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Expression
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Expression(
+                syntax=msg.syntax,
+                value=msg.value,
+                version=None if msg.version == '' else msg.version,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Expression) \
+            and self._syntax == other._syntax \
+            and self._value == other._value \
+            and self._version == other._version
+
+    def __repr__(self):
+        return 'Expression(' \
+               f'syntax={self._syntax}, ' \
+               f'value={self._value}, ' \
+               f'version={self._version})'
+
+
+class Extension(MessageMixin):
+
+    def __init__(
+            self,
+            name: str,
+            value: str,
+    ):
+        self._name = name
+        self._value = value
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @name.setter
+    def name(self, value: str):
+        self._name = value
+
+    @property
+    def value(self) -> str:
+        return self._value
+
+    @value.setter
+    def value(self, value: str):
+        self._value = value
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'name', 'value'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'name', 'value'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return Extension(name=values['name'], value=values['value'])
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        return pp202.Extension(name=self._name, value=self._value)
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.Extension
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return Extension(
+                name=msg.name,
+                value=msg.value,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, Extension) and self._name == other._name and self._value == other._value
+
+    def __repr__(self):
+        return f'Extension(name={self._name}, value={self._value})'
+
+
+class VcfRecord(MessageMixin):
+
+    def __init__(
+            self,
+            genome_assembly: str,
+            chrom: str,
+            pos: int,
+            ref: str,
+            alt: str,
+            id: typing.Optional[str] = None,
+            qual: typing.Optional[str] = None,
+            filter: typing.Optional[str] = None,
+            info: typing.Optional[str] = None,
+    ):
+        self._genome_assembly = genome_assembly
+        self._chrom = chrom
+        self._pos = pos
+        self._ref = ref
+        self._alt = alt
+        self._id = id
+        self._qual = qual
+        self._filter = filter
+        self._info = info
+
+    @property
+    def genome_assembly(self) -> str:
+        return self._genome_assembly
+
+    @genome_assembly.setter
+    def genome_assembly(self, value: str):
+        self._genome_assembly = value
+
+    @property
+    def chrom(self) -> str:
+        return self._chrom
+
+    @chrom.setter
+    def chrom(self, value: str):
+        self._chrom = value
+
+    @property
+    def pos(self) -> int:
+        return self._pos
+
+    @pos.setter
+    def pos(self, value: int):
+        self._pos = value
+
+    @property
+    def ref(self) -> str:
+        return self._ref
+
+    @ref.setter
+    def ref(self, value: str):
+        self._ref = value
+
+    @property
+    def alt(self) -> str:
+        return self._alt
+
+    @alt.setter
+    def alt(self, value: str):
+        self._alt = value
+
+    @property
+    def id(self) -> typing.Optional[str]:
+        return self._id
+
+    @id.setter
+    def id(self, value: str):
+        self._id = value
+
+    @id.deleter
+    def id(self):
+        self._id = None
+
+    @property
+    def qual(self) -> typing.Optional[str]:
+        return self._qual
+
+    @qual.setter
+    def qual(self, value: str):
+        self._qual = value
+
+    @qual.deleter
+    def qual(self):
+        self._qual = None
+
+    @property
+    def filter(self) -> typing.Optional[str]:
+        return self._filter
+
+    @filter.setter
+    def filter(self, value: str):
+        self._filter = value
+
+    @filter.deleter
+    def filter(self):
+        self._filter = None
+
+    @property
+    def info(self) -> typing.Optional[str]:
+        return self._info
+
+    @info.setter
+    def info(self, value: str):
+        self._info = value
+
+    @info.deleter
+    def info(self):
+        self._info = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return 'genome_assembly', 'chrom', 'pos', 'ref', 'alt', 'id', 'qual', 'filter', 'info'
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'genome_assembly', 'chrom', 'pos', 'ref', 'alt'
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return VcfRecord(
+                genome_assembly=values['genome_assembly'],
+                chrom=values['chrom'],
+                pos=values['pos'],
+                ref=values['ref'],
+                alt=values['alt'],
+                id=MessageMixin._extract_optional_field('id', values),
+                qual=MessageMixin._extract_optional_field('qual', values),
+                filter=MessageMixin._extract_optional_field('filter', values),
+                info=MessageMixin._extract_optional_field('info', values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        vcf = pp202.VcfRecord(
+            genome_assembly=self._genome_assembly,
+            chrom=self._chrom,
+            pos=self._pos,
+            ref=self._ref,
+            alt=self._alt,
+        )
+
+        if self._id is not None:
+            vcf.id = self._id
+
+        if self._qual is not None:
+            vcf.qual = self._qual
+
+        if self._filter is not None:
+            vcf.filter = self._filter
+
+        if self._info is not None:
+            vcf.info = self._info
+
+        return vcf
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.VcfRecord
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return VcfRecord(
+                genome_assembly=msg.genome_assembly,
+                chrom=msg.chrom,
+                pos=msg.pos,
+                ref=msg.ref,
+                alt=msg.alt,
+                id=None if msg.id == '' else msg.id,
+                qual=None if msg.qual == '' else msg.qual,
+                filter=None if msg.filter == '' else msg.filter,
+                info=None if msg.info == '' else msg.info,
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, VcfRecord) \
+            and self._genome_assembly == other._genome_assembly \
+            and self._chrom == other._chrom \
+            and self._pos == other._pos \
+            and self._ref == other._ref \
+            and self._alt == other._alt \
+            and self._id == other._id \
+            and self._qual == other._qual \
+            and self._filter == other._filter \
+            and self._info == other._info
+
+    def __repr__(self):
+        return f'VcfRecord(genome_assembly={self._genome_assembly}, ' \
+               f'chrom={self._chrom}, ' \
+               f'pos={self._pos}, ' \
+               f'ref={self._ref}, ' \
+               f'alt={self._alt}, ' \
+               f'id={self._id}, ' \
+               f'qual={self._qual}, ' \
+               f'filter={self._filter}, ' \
+               f'info={self._info})'
+
+
+class MoleculeContext(enum.Enum):
+    unspecified_molecule_context = 0
+    genomic = 1
+    transcript = 2
+    protein = 3
+
+
+class VariationDescriptor(MessageMixin):
+
+    def __init__(
+            self,
+            id: str,
+            molecule_context: MoleculeContext,
+            # variation: Variation  # TODO: implement
+            label: typing.Optional[str] = None,
+            description: typing.Optional[str] = None,
+            gene_context: typing.Optional[GeneDescriptor] = None,
+            expressions: typing.Optional[typing.Iterable[Expression]] = None,
+            vcf_record: typing.Optional[VcfRecord] = None,
+            xrefs: typing.Optional[typing.Iterable[str]] = None,
+            alternate_labels: typing.Optional[typing.Iterable[str]] = None,
+            extensions: typing.Optional[typing.Iterable[Extension]] = None,
+            structural_type: typing.Optional[OntologyClass] = None,
+            vrs_ref_allele_seq: typing.Optional[str] = None,
+            allelic_state: typing.Optional[OntologyClass] = None,
+    ):
+        self._id = id
+        self._molecule_context = molecule_context
+        self._label = label
+        self._description = description
+        self._gene_context = gene_context
+        self._expressions = [] if expressions is None else list(expressions)
+        self._vcf_record = vcf_record
+        self._xrefs = [] if xrefs is None else list(xrefs)
+        self._alternate_labels = [] if alternate_labels is None else list(alternate_labels)
+        self._extensions = [] if extensions is None else list(extensions)
+        self._structural_type = structural_type
+        self._vrs_ref_allele_seq = vrs_ref_allele_seq
+        self._allelic_state = allelic_state
+
+    @property
+    def id(self) -> str:
+        return self._id
+
+    @id.setter
+    def id(self, value: str):
+        self._id = value
+
+    @property
+    def molecule_context(self) -> MoleculeContext:
+        return self._molecule_context
+
+    @molecule_context.setter
+    def molecule_context(self, value: MoleculeContext):
+        self._molecule_context = value
+
+    @property
+    def label(self) -> typing.Optional[str]:
+        return self._label
+
+    @label.setter
+    def label(self, value: str):
+        self._label = value
+
+    @label.deleter
+    def label(self):
+        self._label = None
+
+    @property
+    def description(self) -> typing.Optional[str]:
+        return self._description
+
+    @description.setter
+    def description(self, value: str):
+        self._description = value
+
+    @description.deleter
+    def description(self):
+        self._description = None
+
+    @property
+    def gene_context(self) -> typing.Optional[GeneDescriptor]:
+        return self._gene_context
+
+    @gene_context.setter
+    def gene_context(self, value: GeneDescriptor):
+        self._gene_context = value
+
+    @gene_context.deleter
+    def gene_context(self):
+        self._gene_context = None
+
+    @property
+    def expressions(self) -> typing.MutableSequence[Expression]:
+        return self._expressions
+
+    @property
+    def vcf_record(self) -> typing.Optional[VcfRecord]:
+        return self._vcf_record
+
+    @vcf_record.setter
+    def vcf_record(self, value: VcfRecord):
+        self._vcf_record = value
+
+    @vcf_record.deleter
+    def vcf_record(self):
+        self._vcf_record = None
+
+    @property
+    def xrefs(self) -> typing.MutableSequence[str]:
+        return self._xrefs
+
+    @property
+    def alternate_labels(self) -> typing.MutableSequence[str]:
+        return self._alternate_labels
+
+    @property
+    def extensions(self) -> typing.MutableSequence[Extension]:
+        return self._extensions
+
+    @property
+    def structural_type(self) -> typing.Optional[OntologyClass]:
+        return self._structural_type
+
+    @structural_type.setter
+    def structural_type(self, value: OntologyClass):
+        self._structural_type = value
+
+    @structural_type.deleter
+    def structural_type(self):
+        self._structural_type = None
+
+    @property
+    def vrs_ref_allele_seq(self) -> typing.Optional[str]:
+        return self._vrs_ref_allele_seq
+
+    @vrs_ref_allele_seq.setter
+    def vrs_ref_allele_seq(self, value: str):
+        self._vrs_ref_allele_seq = value
+
+    @vrs_ref_allele_seq.deleter
+    def vrs_ref_allele_seq(self):
+        self._vrs_ref_allele_seq = None
+
+    @property
+    def allelic_state(self) -> typing.Optional[OntologyClass]:
+        return self._allelic_state
+
+    @allelic_state.setter
+    def allelic_state(self, value: OntologyClass):
+        self._allelic_state = value
+
+    @allelic_state.deleter
+    def allelic_state(self):
+        self._allelic_state = None
+
+    @staticmethod
+    def field_names() -> typing.Iterable[str]:
+        return (
+            'id', 'molecule_context', 'label', 'description', 'gene_context', 'expressions', 'vcf_record', 'xrefs',
+            'alternate_labels', 'extensions', 'structural_type', 'vrs_ref_allele_seq', 'allelic_state',
+        )
+
+    @classmethod
+    def required_fields(cls) -> typing.Sequence[str]:
+        return 'id', 'molecule_context',
+
+    @classmethod
+    def from_dict(cls, values: typing.Mapping[str, typing.Any]):
+        if cls._all_required_fields_are_present(values):
+            return VariationDescriptor(
+                id=values['id'],
+                molecule_context=MessageMixin._extract_enum_field('molecule_context', MoleculeContext, values),
+                label=values.get('label', None),
+                description=values.get('description', None),
+                gene_context=extract_message_scalar('gene_context', GeneDescriptor, values),
+                expressions=extract_message_sequence('expressions', Expression, values),
+                vcf_record=extract_message_scalar('vcf_record', VcfRecord, values),
+                xrefs=values.get('xrefs', None),
+                alternate_labels=values.get('alternate_labels', None),
+                extensions=extract_message_sequence('extensions', Extension, values),
+                structural_type=extract_message_scalar('structural_type', OntologyClass, values),
+                vrs_ref_allele_seq=values.get('vrs_ref_allele_seq', None),
+                allelic_state=extract_message_scalar('allelic_state', OntologyClass, values),
+            )
+        else:
+            cls._complain_about_missing_field(values)
+
+    def to_message(self) -> Message:
+        vd = pp202.VariationDescriptor(
+            id=self._id,
+            molecule_context=pp202.MoleculeContext.Value(self._molecule_context.name),
+        )
+
+        if self._label is not None:
+            vd.label = self._label
+
+        if self._description is not None:
+            vd.description = self._description
+
+        if self._gene_context is not None:
+            vd.gene_context.CopyFrom(self._gene_context.to_message())
+
+        vd.expressions.extend(e.to_message() for e in self._expressions)
+
+        if self._vcf_record is not None:
+            vd.vcf_record.CopyFrom(self._vcf_record.to_message())
+
+        vd.xrefs.extend(self._xrefs)
+        vd.alternate_labels.extend(self._alternate_labels)
+        vd.extensions.extend(e.to_message() for e in self._extensions)
+
+        if self._structural_type is not None:
+            vd.structural_type.CopyFrom(self._structural_type.to_message())
+
+        if self._vrs_ref_allele_seq is not None:
+            self.vrs_ref_allele_seq = self._vrs_ref_allele_seq
+
+        if self._allelic_state is not None:
+            vd.allelic_state.CopyFrom(self._allelic_state.to_message())
+
+        return vd
+
+    @classmethod
+    def message_type(cls) -> typing.Type[Message]:
+        return pp202.VariationDescriptor
+
+    @classmethod
+    def from_message(cls, msg: Message):
+        if isinstance(msg, cls.message_type()):
+            return VariationDescriptor(
+                id=msg.id,
+                molecule_context=MoleculeContext(msg.molecule_context),
+                label=None if msg.label == '' else msg.label,
+                description=None if msg.description == '' else msg.description,
+                gene_context=extract_pb_message_scalar('gene_context', GeneDescriptor, msg),
+                expressions=extract_pb_message_seq('expressions', Expression, msg),
+                vcf_record=extract_pb_message_scalar('vcf_record', VcfRecord, msg),
+                xrefs=msg.xrefs,
+                alternate_labels=msg.alternate_labels,
+                extensions=extract_pb_message_seq('extensions', Extension, msg),
+                structural_type=extract_pb_message_scalar('structural_type', OntologyClass, msg),
+                vrs_ref_allele_seq=None if msg.vrs_ref_allele_seq == '' else msg.vrs_ref_allele_seq,
+                allelic_state=extract_pb_message_scalar('allelic_state', OntologyClass, msg),
+            )
+        else:
+            cls.complain_about_incompatible_msg_type(msg)
+
+    def __eq__(self, other):
+        return isinstance(other, VariationDescriptor) \
+            and self._id == other._id \
+            and self._molecule_context == other._molecule_context \
+            and self._label == other._label \
+            and self._description == other._description \
+            and self._gene_context == other._gene_context \
+            and self._expressions == other._expressions \
+            and self._vcf_record == other._vcf_record \
+            and self._xrefs == other._xrefs \
+            and self._alternate_labels == other._alternate_labels \
+            and self._extensions == other._extensions \
+            and self._structural_type == other._structural_type \
+            and self._vrs_ref_allele_seq == other._vrs_ref_allele_seq \
+            and self._allelic_state == other._allelic_state
+
+    def __repr__(self):
+        return 'VariationDescriptor(' \
+               f'id={self._id}, ' \
+               f'molecule_context={self._molecule_context}, ' \
+               f'label={self._label}, ' \
+               f'description={self._description}, ' \
+               f'gene_context={self._gene_context}, ' \
+               f'expressions={self._expressions}, ' \
+               f'vcf_record={self._vcf_record}, ' \
+               f'xrefs={self._xrefs}, ' \
+               f'alternate_labels={self._alternate_labels}, ' \
+               f'extensions={self._extensions}, ' \
+               f'structural_type={self._structural_type}, ' \
+               f'vrs_ref_allele_seq={self._vrs_ref_allele_seq}, ' \
+               f'allelic_state={self._allelic_state})'


Test patch:
diff --git a/pytest.ini b/pytest.ini
index df3eb518..75c0714d 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,2 +1,7 @@
 [pytest]
 addopts = --doctest-modules
+
+; We want pytest to look for both test files prefixed with underscore `_` and "regular* test files.
+; The unittest files, the test files located in the source code, are prepended with underscore
+; to preclude their inclusion into API reference.
+python_files = _test*.py test*.py
diff --git a/src/pyphetools/pp/parse/_test_util.py b/src/pyphetools/pp/parse/_test_util.py
new file mode 100644
index 00000000..6980b337
--- /dev/null
+++ b/src/pyphetools/pp/parse/_test_util.py
@@ -0,0 +1,105 @@
+import pytest
+
+from ._util import CaseConverter
+from ._util import HierarchicalKeyMapper
+
+
+class TestCaseConverter:
+
+    @pytest.fixture(scope='class')
+    def converter(self) -> CaseConverter:
+        return CaseConverter()
+
+    @pytest.mark.parametrize(
+        'payload, expected',
+        [
+            ('', ''),
+            ('a', 'a'),
+            ('A', 'a'),
+            ('AbcDef', 'abc_def'),
+            ('abcDef', 'abc_def'),
+            ('ABcDef', 'a_bc_def'),
+            ('ABCDEF', 'a_b_c_d_e_f'),
+
+            # The snake case is, however, left untouched.
+            ('abc_def', 'abc_def'),
+            ('abc_def_g_h', 'abc_def_g_h'),
+        ]
+    )
+    def test_camel_to_snake(
+            self,
+            converter: CaseConverter,
+            payload: str,
+            expected: str,
+    ):
+        actual = converter.camel_to_snake(payload)
+
+        assert actual == expected
+
+    @pytest.mark.parametrize(
+        'payload, expected',
+        [
+            ('', ''),
+            ('a', 'a'),
+            ('a_', 'a'),
+            ('a_b_', 'aB'),
+            ('abc', 'abc'),
+            ('a_b_c', 'aBC'),
+            ('abc_def', 'abcDef'),
+            ('a_bc_def', 'aBcDef'),
+            ('a_b_c_d_e_f', 'aBCDEF'),
+        ]
+    )
+    def test_snake_to_camel(
+            self,
+            converter: CaseConverter,
+            payload: str,
+            expected: str,
+    ):
+        actual = converter.snake_to_camel(payload)
+
+        assert actual == expected
+
+
+class TestHierarchicalKeyMapper:
+
+    @pytest.fixture
+    def mapper(self) -> HierarchicalKeyMapper:
+        return HierarchicalKeyMapper((
+            'file_attributes', 'individual_to_file_identifiers',
+            'fileAttributes', 'individualToFileIdentifiers',
+        ))
+
+    @pytest.fixture
+    def case_converter(self) -> CaseConverter:
+        return CaseConverter()
+
+    def test_remap(
+            self,
+            mapper: HierarchicalKeyMapper,
+            case_converter: CaseConverter,
+    ):
+        payload = {
+          "files": [
+            {
+              "uri": "file://data/germlineWgs.vcf.gz",
+              "individual_to_file_identifiers": {"proband A": "sample1", "proband B": "sample2", },
+              "file_attributes": {"genome_assembly": "GRCh38", "file_format": "VCF", }
+            }, {
+              "uri": "file://data/somaticWgs.vcf.gz",
+              "individual_to_file_identifiers": {"proband A": "sample1", "proband B": "sample2", },
+              "file_attributes": { "genome_assembly": "GRCh38", "file_format": "VCF", }
+            }
+          ]
+        }
+
+        remapped = mapper.remap_mapping(case_converter.snake_to_camel, payload)
+
+        # We remapped the fields
+        assert all('individualToFileIdentifiers' in f for f in remapped['files'])
+        assert all('fileAttributes' in f for f in remapped['files'])
+
+        # but not their sub-elements.
+        for file in remapped['files']:
+            assert all(x in file['individualToFileIdentifiers'] for x in ('proband A', 'proband B'))
+            assert all(x in file['fileAttributes'] for x in ('genome_assembly', 'file_format'))
diff --git a/src/pyphetools/pp/v202/_test_individual.py b/src/pyphetools/pp/v202/_test_individual.py
new file mode 100644
index 00000000..17df65cb
--- /dev/null
+++ b/src/pyphetools/pp/v202/_test_individual.py
@@ -0,0 +1,19 @@
+import pytest
+
+from ._individual import Sex
+
+
+@pytest.mark.parametrize(
+    'left, right, expected',
+    [
+        (Sex.MALE, Sex.MALE, True),
+        (Sex.FEMALE, Sex.FEMALE, True),
+        (Sex.OTHER_SEX, Sex.OTHER_SEX, True),
+        (Sex.UNKNOWN_SEX, Sex.UNKNOWN_SEX, True),
+
+        (Sex.FEMALE, Sex.MALE, False),
+        (Sex.MALE, Sex.FEMALE, False),
+    ]
+)
+def test_sex_eq(left: Sex, right: Sex, expected: bool):
+    assert (left == right) == expected
diff --git a/test/conftest.py b/test/conftest.py
index e0fe872d..686695a1 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -18,3 +18,18 @@ def fpath_hpo(fpath_data: str) -> str:
 @pytest.fixture(scope='session')
 def hpo(fpath_hpo: str) -> hpotk.Ontology:
     return hpotk.load_ontology(fpath_hpo)
+
+
+@pytest.fixture(scope='session')
+def fpath_pp(fpath_data: str) -> str:
+    return os.path.join(fpath_data, 'pp')
+
+
+@pytest.fixture(scope='session')
+def fpath_retinoblastoma_json(fpath_pp: str) -> str:
+    """
+    Path to a JSON file with retinoblastoma phenopacket.
+
+    Note: the phenopacket is pulled from *Phenopacket tools*.
+    """
+    return os.path.join(fpath_pp, 'retinoblastoma.json')
diff --git a/test/data/pp/retinoblastoma.json b/test/data/pp/retinoblastoma.json
new file mode 100644
index 00000000..6f2edd32
--- /dev/null
+++ b/test/data/pp/retinoblastoma.json
@@ -0,0 +1,519 @@
+{
+  "id": "example.retinoblastoma.phenopacket.id",
+  "subject": {
+    "id": "proband A",
+    "alternateIds": ["some", "other", "identifiers"],
+    "timeAtLastEncounter": {
+      "age": {
+        "iso8601duration": "P6M"
+      }
+    },
+    "sex": "FEMALE",
+    "karyotypicSex": "XX",
+    "vitalStatus": {
+      "status": "DECEASED",
+      "timeOfDeath": {
+        "age": {
+          "iso8601duration": "P1Y"
+        }
+      },
+      "causeOfDeath": {
+        "id": "NCIT:C7541",
+        "label": "Retinoblastoma"
+      },
+      "survivalTimeInDays": 180
+    },
+    "taxonomy": {
+        "id": "NCBITaxon:9606",
+        "label": "Homo sapiens"
+      }
+  },
+  "phenotypicFeatures": [{
+    "type": {
+      "id": "HP:0030084",
+      "label": "Clinodactyly"
+    },
+    "modifiers": [{
+      "id": "HP:0012834",
+      "label": "Right"
+    }],
+    "onset": {
+      "age": {
+        "iso8601duration": "P3M"
+      }
+    }
+  }, {
+    "type": {
+      "id": "HP:0000555",
+      "label": "Leukocoria"
+    },
+    "modifiers": [{
+      "id": "HP:0012835",
+      "label": "Left"
+    }],
+    "onset": {
+      "age": {
+        "iso8601duration": "P4M"
+      }
+    }
+  }, {
+    "type": {
+      "id": "HP:0000486",
+      "label": "Strabismus"
+    },
+    "modifiers": [{
+      "id": "HP:0012835",
+      "label": "Left"
+    }],
+    "onset": {
+      "age": {
+        "iso8601duration": "P5M15D"
+      }
+    }
+  }, {
+    "type": {
+      "id": "HP:0000541",
+      "label": "Retinal detachment"
+    },
+    "modifiers": [{
+      "id": "HP:0012835",
+      "label": "Left"
+    }],
+    "onset": {
+      "age": {
+        "iso8601duration": "P6M"
+      }
+    }
+  }],
+  "measurements": [{
+    "assay": {
+      "id": "LOINC:79893-4",
+      "label": "Left eye Intraocular pressure"
+    },
+    "value": {
+      "quantity": {
+        "unit": {
+          "id": "UCUM:mm[Hg]",
+          "label": "millimetres of mercury"
+        },
+        "value": 25.0,
+        "referenceRange": {
+          "unit": {
+            "id": "LOINC:56844-4",
+            "label": "Intraocular pressure of Eye"
+          },
+          "low": 10.0,
+          "high": 21.0
+        }
+      }
+    },
+    "timeObserved": {
+      "age": {
+        "iso8601duration": "P6M"
+      }
+    }
+  }, {
+    "assay": {
+      "id": "LOINC:79892-6",
+      "label": "Right eye Intraocular pressure"
+    },
+    "value": {
+      "quantity": {
+        "unit": {
+          "id": "UCUM:mm[Hg]",
+          "label": "millimetres of mercury"
+        },
+        "value": 15.0,
+        "referenceRange": {
+          "unit": {
+            "id": "LOINC:56844-4",
+            "label": "Intraocular pressure of Eye"
+          },
+          "low": 10.0,
+          "high": 21.0
+        }
+      }
+    },
+    "timeObserved": {
+      "age": {
+        "iso8601duration": "P6M"
+      }
+    }
+  }],
+  "biosamples": [{
+    "id": "biosample.1",
+    "sampledTissue": {
+      "id": "UBERON:0000970",
+      "label": "eye"
+    },
+    "phenotypicFeatures": [{
+      "type": {
+        "id": "NCIT:C35941",
+        "label": "Flexner-Wintersteiner Rosette Formation"
+      }
+    }, {
+      "type": {
+        "id": "NCIT:C132485",
+        "label": "Apoptosis and Necrosis"
+      }
+    }],
+    "measurements": [{
+      "assay": {
+        "id": "LOINC:33728-7",
+        "label": "Size.maximum dimension in Tumor"
+      },
+      "value": {
+        "quantity": {
+          "unit": {
+            "id": "UCUM:mm",
+            "label": "millimeter"
+          },
+          "value": 15.0
+        }
+      },
+      "timeObserved": {
+        "age": {
+          "iso8601duration": "P8M2W"
+        }
+      }
+    }],
+    "tumorProgression": {
+      "id": "NCIT:C8509",
+      "label": "Primary Neoplasm"
+    },
+    "pathologicalTnmFinding": [{
+      "id": "NCIT:C140720",
+      "label": "Retinoblastoma pT3 TNM Finding v8"
+    }, {
+      "id": "NCIT:C140711",
+      "label": "Retinoblastoma pN0 TNM Finding v8"
+    }],
+    "procedure": {
+      "code": {
+        "id": "NCIT:C48601",
+        "label": "Enucleation"
+      },
+      "bodySite": {
+        "id": "UBERON:0004548",
+        "label": "left eye"
+      },
+      "performed": {
+        "age": {
+          "iso8601duration": "P8M2W"
+        }
+      }
+    },
+    "files": [{
+      "uri": "file://data/fileSomaticWgs.vcf.gz",
+      "individualToFileIdentifiers": {
+        "biosample.1": "specimen.1"
+      },
+      "fileAttributes": {
+        "fileFormat": "VCF",
+        "genomeAssembly": "GRCh38"
+      }
+    }]
+  }],
+  "interpretations": [{
+    "id": "interpretation.id",
+    "progressStatus": "SOLVED",
+    "diagnosis": {
+      "disease": {
+        "id": "NCIT:C7541",
+        "label": "Retinoblastoma"
+      },
+      "genomicInterpretations": [{
+        "subjectOrBiosampleId": "proband A",
+        "interpretationStatus": "CAUSATIVE",
+        "variantInterpretation": {
+          "acmgPathogenicityClassification": "PATHOGENIC",
+          "therapeuticActionability": "ACTIONABLE",
+          "variationDescriptor": {
+            "id": "example-cnv",
+            "moleculeContext": "genomic",
+            "variation": {
+              "copyNumber": {
+                "derivedSequenceExpression": {
+                  "location": {
+                    "sequenceId": "refseq:NC_000013.14",
+                    "sequenceInterval": {
+                      "startNumber": {
+                        "value": "25981249"
+                      },
+                      "endNumber": {
+                        "value": "61706822"
+                      }
+                    }
+                  }
+                },
+                "number": {
+                  "value": "1"
+                }
+              }
+            },
+            "extensions": [{
+              "name": "mosaicism",
+              "value": "40.0%"
+            }]
+          }
+        }
+      }, {
+        "subjectOrBiosampleId": "biosample.1",
+        "interpretationStatus": "CAUSATIVE",
+        "variantInterpretation": {
+          "acmgPathogenicityClassification": "PATHOGENIC",
+          "therapeuticActionability": "ACTIONABLE",
+          "variationDescriptor": {
+            "id": "rs121913300",
+            "variation": {
+              "allele": {
+                "sequenceLocation": {
+                  "sequenceId": "refseq:NC_000013.11",
+                  "sequenceInterval": {
+                    "startNumber": {
+                      "value": "48367511"
+                    },
+                    "endNumber": {
+                      "value": "48367512"
+                    }
+                  }
+                },
+                "literalSequenceExpression": {
+                  "sequence": "T"
+                }
+              }
+            },
+            "label": "RB1 c.958C\u003eT (p.Arg320Ter)",
+            "geneContext": {
+              "valueId": "HGNC:9884",
+              "symbol": "RB1"
+            },
+            "expressions": [{
+              "syntax": "hgvs.c",
+              "value": "NM_000321.2:c.958C\u003eT"
+            }, {
+              "syntax": "transcript_reference",
+              "value": "NM_000321.2"
+            }],
+            "vcfRecord": {
+              "genomeAssembly": "GRCh38",
+              "chrom": "NC_000013.11",
+              "pos": 48367512,
+              "ref": "C",
+              "alt": "T"
+            },
+            "extensions": [{
+              "name": "allele-frequency",
+              "value": "25.0%"
+            }],
+            "moleculeContext": "genomic",
+            "allelicState": {
+              "id": "GENO:0000135",
+              "label": "heterozygous"
+            }
+          }
+        }
+      }]
+    }
+  }],
+  "diseases": [{
+    "term": {
+      "id": "NCIT:C7541",
+      "label": "Retinoblastoma"
+    },
+    "onset": {
+      "age": {
+        "iso8601duration": "P4M"
+      }
+    },
+    "diseaseStage": [{
+      "id": "LOINC:LA24739-7",
+      "label": "Group E"
+    }],
+    "clinicalTnmFinding": [{
+      "id": "NCIT:C140678",
+      "label": "Retinoblastoma cM0 TNM Finding v8"
+    }],
+    "primarySite": {
+      "id": "UBERON:0004548",
+      "label": "left eye"
+    }
+  }],
+  "medicalActions": [{
+    "treatment": {
+      "agent": {
+        "id": "DrugCentral:1678",
+        "label": "melphalan"
+      },
+      "routeOfAdministration": {
+        "id": "NCIT:C38222",
+        "label": "Intraarterial Route of Administration"
+      },
+      "doseIntervals": [{
+        "quantity": {
+          "unit": {
+            "id": "UCUM:mg.kg-1",
+            "label": "milligram per kilogram"
+          },
+          "value": 0.4
+        },
+        "scheduleFrequency": {
+          "id": "NCIT:C64576",
+          "label": "Once"
+        },
+        "interval": {
+          "start": "2020-09-02T00:00:00Z",
+          "end": "2020-09-02T00:00:00Z"
+        }
+      }]
+    },
+    "treatmentTarget": {
+      "id": "NCIT:C7541",
+      "label": "Retinoblastoma"
+    },
+    "treatmentIntent": {
+      "id": "NCIT:C62220",
+      "label": "Cure"
+    },
+    "adverseEvents": [{
+      "id": "HP:0025637",
+      "label": "Vasospasm"
+    }],
+    "treatmentTerminationReason": {
+      "id": "NCIT:C41331",
+      "label": "Adverse Event"
+    }
+  }, {
+    "therapeuticRegimen": {
+      "ontologyClass": {
+        "id": "NCIT:C10894",
+        "label": "Carboplatin/Etoposide/Vincristine"
+      },
+      "startTime": {
+        "age": {
+          "iso8601duration": "P7M"
+        }
+      },
+      "endTime": {
+        "age": {
+          "iso8601duration": "P8M"
+        }
+      },
+      "regimenStatus": "COMPLETED"
+    },
+    "treatmentTarget": {
+      "id": "NCIT:C7541",
+      "label": "Retinoblastoma"
+    },
+    "treatmentIntent": {
+      "id": "NCIT:C62220",
+      "label": "Cure"
+    }
+  }, {
+    "procedure": {
+      "code": {
+        "id": "NCIT:C48601",
+        "label": "Enucleation"
+      },
+      "bodySite": {
+        "id": "UBERON:0004548",
+        "label": "left eye"
+      },
+      "performed": {
+        "age": {
+          "iso8601duration": "P8M2W"
+        }
+      }
+    },
+    "treatmentTarget": {
+      "id": "NCIT:C7541",
+      "label": "Retinoblastoma"
+    },
+    "treatmentIntent": {
+      "id": "NCIT:C62220",
+      "label": "Cure"
+    }
+  }],
+  "files": [{
+    "uri": "file://data/germlineWgs.vcf.gz",
+    "individualToFileIdentifiers": {
+      "proband A": "sample1",
+      "proband B": "sample2"
+    },
+    "fileAttributes": {
+      "fileFormat": "VCF",
+      "genomeAssembly": "GRCh38"
+    }
+  }, {
+    "uri": "file://data/somaticWgs.vcf.gz",
+    "individualToFileIdentifiers": {
+      "proband A": "sample1",
+      "proband B": "sample2"
+    },
+    "fileAttributes": {
+      "fileFormat": "VCF",
+      "genomeAssembly": "GRCh38"
+    }
+  }],
+  "metaData": {
+    "created": "2021-05-14T10:35:00Z",
+    "createdBy": "anonymous biocurator",
+    "resources": [{
+      "id": "hp",
+      "name": "human phenotype ontology",
+      "url": "http://purl.obolibrary.org/obo/hp.owl",
+      "version": "2021-08-02",
+      "namespacePrefix": "HP",
+      "iriPrefix": "http://purl.obolibrary.org/obo/HP_"
+    },
+      {
+        "id": "geno",
+        "name": "Genotype Ontology",
+        "url": "http://purl.obolibrary.org/obo/geno.owl",
+        "version": "2020-03-08",
+        "namespacePrefix": "GENO",
+        "iriPrefix": "http://purl.obolibrary.org/obo/GENO_"
+      },
+      {
+        "id": "ncit",
+        "name": "NCI Thesaurus",
+        "url": "http://purl.obolibrary.org/obo/ncit.owl",
+        "version": "VERSION",
+        "namespacePrefix": "NCIT",
+        "iriPrefix": "http://purl.obolibrary.org/obo/NCIT_"
+      },
+      {
+        "id": "uberon",
+        "name": "Uber-anatomy ontology",
+        "url": "http://purl.obolibrary.org/obo/uberon.owl",
+        "version": "VERSION",
+        "namespacePrefix": "UBERON",
+        "iriPrefix": "http://purl.obolibrary.org/obo/UBERON_"
+      },
+      {
+        "id": "loinc",
+        "name": "Logical Observation Identifiers Names and Codes",
+        "url": "https://loinc.org",
+        "version": "VERSION",
+        "namespacePrefix": "LOINC",
+        "iriPrefix": "https://loinc.org"
+      },
+      {
+        "id": "drugcentral",
+        "name": "Drug Central",
+        "url": "https://drugcentral.org/",
+        "version": "VERSION",
+        "namespacePrefix": "DrugCentral",
+        "iriPrefix": "https://drugcentral.org/drugcard"
+      },
+      {
+        "id": "ucum",
+        "name": "Unified Code for Units of Measure",
+        "url": "https://ucum.org",
+        "version": "2.1",
+        "namespacePrefix": "UCUM",
+        "iriPrefix": "https://ucum.org/"
+      }],
+    "phenopacketSchemaVersion": "2.0.0"
+  }
+}
\ No newline at end of file
diff --git a/test/data/pp/retinoblastoma.pb b/test/data/pp/retinoblastoma.pb
new file mode 100644
index 00000000..ad894fc5
Binary files /dev/null and b/test/data/pp/retinoblastoma.pb differ
diff --git a/test/pp/__init__.py b/test/pp/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/test/pp/conftest.py b/test/pp/conftest.py
new file mode 100644
index 00000000..8eb94244
--- /dev/null
+++ b/test/pp/conftest.py
@@ -0,0 +1,388 @@
+import typing
+
+import pytest
+
+from pyphetools.pp.v202 import *
+
+
+@pytest.fixture(scope='package')
+def retinoblastoma(
+        individual: Individual,
+        phenotypic_features: typing.Sequence[PhenotypicFeature],
+        measurements: typing.Sequence[Measurement],
+        biosamples: typing.Sequence[Biosample],
+        interpretations: typing.Sequence[Interpretation],
+        diseases: typing.Sequence[Disease],
+        medical_actions: typing.Sequence[MedicalAction],
+        files: typing.Sequence[File],
+        meta_data: MetaData,
+) -> Phenopacket:
+    """
+    Phenopacket with the same values that we can find in `test/data/pp/retinoblastoma.json`.
+    """
+    return Phenopacket(
+        id='example.retinoblastoma.phenopacket.id',
+        subject=individual,
+        phenotypic_features=phenotypic_features,
+        measurements=measurements,
+        biosamples=biosamples,
+        interpretations=interpretations,
+        diseases=diseases,
+        medical_actions=medical_actions,
+        files=files,
+        meta_data=meta_data,
+    )
+
+
+@pytest.fixture(scope='package')
+def individual() -> Individual:
+    """
+    `Individual` from the `retinoblastoma` phenopacket.
+    """
+    return Individual(
+        id='proband A',
+        alternate_ids=('some', 'other', 'identifiers'),
+        time_at_last_encounter=TimeElement(
+            element=Age(
+                iso8601duration='P6M',
+            )
+        ),
+        vital_status=VitalStatus(
+            status=VitalStatus.Status.DECEASED,
+            time_of_death=TimeElement(element=Age(iso8601duration='P1Y')),
+            cause_of_death=OntologyClass(id='NCIT:C7541', label='Retinoblastoma'),
+            survival_time_in_days=180,
+        ),
+        sex=Sex.FEMALE,
+        karyotypic_sex=KaryotypicSex.XX,
+        taxonomy=OntologyClass(id='NCBITaxon:9606', label='Homo sapiens')
+    )
+
+
+@pytest.fixture(scope='package')
+def phenotypic_features() -> typing.Sequence[PhenotypicFeature]:
+    return (
+        PhenotypicFeature(
+            type=OntologyClass(id='HP:0030084', label='Clinodactyly'),
+            modifiers=(OntologyClass(id='HP:0012834', label='Right'),),
+            onset=TimeElement(element=Age(iso8601duration='P3M')),
+        ),
+        PhenotypicFeature(
+            type=OntologyClass(id='HP:0000555', label='Leukocoria'),
+            modifiers=(OntologyClass(id='HP:0012835', label='Left'),),
+            onset=TimeElement(element=Age(iso8601duration='P4M')),
+        ),
+        PhenotypicFeature(
+            type=OntologyClass(id='HP:0000486', label='Strabismus'),
+            modifiers=(OntologyClass(id='HP:0012835', label='Left'),),
+            onset=TimeElement(element=Age(iso8601duration='P5M15D')),
+        ),
+        PhenotypicFeature(
+            type=OntologyClass(id='HP:0000541', label='Retinal detachment'),
+            modifiers=(OntologyClass(id='HP:0012835', label='Left'),),
+            onset=TimeElement(element=Age(iso8601duration='P6M')),
+        ),
+    )
+
+
+@pytest.fixture(scope='package')
+def measurements() -> typing.Sequence[Measurement]:
+    return (
+        Measurement(
+            assay=OntologyClass(id='LOINC:79893-4', label='Left eye Intraocular pressure'),
+            measurement_value=Value(
+                value=Quantity(
+                    unit=OntologyClass(id='UCUM:mm[Hg]', label='millimetres of mercury'),
+                    value=25.0,
+                    reference_range=ReferenceRange(
+                        unit=OntologyClass(id='LOINC:56844-4', label='Intraocular pressure of Eye'),
+                        low=10.0,
+                        high=21.0,
+                    ),
+                ),
+            ),
+            time_observed=TimeElement(
+                element=Age(iso8601duration='P6M'),
+            ),
+        ),
+        Measurement(
+            assay=OntologyClass(id='LOINC:79892-6', label='Right eye Intraocular pressure'),
+            measurement_value=Value(
+                value=Quantity(
+                    unit=OntologyClass(id='UCUM:mm[Hg]', label='millimetres of mercury'),
+                    value=15.0,
+                    reference_range=ReferenceRange(
+                        unit=OntologyClass(id='LOINC:56844-4', label='Intraocular pressure of Eye'),
+                        low=10.0,
+                        high=21.0,
+                    ),
+                ),
+            ),
+            time_observed=TimeElement(
+                element=Age(iso8601duration='P6M'),
+            ),
+        ),
+    )
+
+
+@pytest.fixture(scope='package')
+def diseases() -> typing.Sequence[Disease]:
+    return (
+        Disease(
+            term=OntologyClass(id='NCIT:C7541', label='Retinoblastoma'),
+            onset=TimeElement(element=Age(iso8601duration='P4M')),
+            disease_stage=(OntologyClass(id='LOINC:LA24739-7', label='Group E'),),
+            clinical_tnm_finding=(OntologyClass(id='NCIT:C140678', label='Retinoblastoma cM0 TNM Finding v8'),),
+            primary_site=OntologyClass(id='UBERON:0004548', label='left eye'),
+        ),
+    )
+
+
+@pytest.fixture(scope='package')
+def interpretations() -> typing.Sequence[Interpretation]:
+    return (
+        Interpretation(
+            id='interpretation.id',
+            progress_status=Interpretation.ProgressStatus.SOLVED,
+            diagnosis=Diagnosis(
+                disease=OntologyClass(id='NCIT:C7541', label='Retinoblastoma'),
+                genomic_interpretations=(
+                    GenomicInterpretation(
+                        subject_or_biosample_id='proband A',
+                        interpretation_status=GenomicInterpretation.InterpretationStatus.CAUSATIVE,
+                        variant_interpretation=VariantInterpretation(
+                            acmg_pathogenicity_classification=AcmgPathogenicityClassification.PATHOGENIC,
+                            therapeutic_actionability=TherapeuticActionability.ACTIONABLE,
+                            variation_descriptor=VariationDescriptor(
+                                id='example-cnv',
+                                molecule_context=MoleculeContext.genomic,
+                                # TODO: variation
+                                extensions=(
+                                    Extension(name='mosaicism', value='40.0%'),
+                                ),
+                            )
+                        )
+                    ),
+                    GenomicInterpretation(
+                        subject_or_biosample_id='biosample.1',
+                        interpretation_status=GenomicInterpretation.InterpretationStatus.CAUSATIVE,
+                        variant_interpretation=VariantInterpretation(
+                            acmg_pathogenicity_classification=AcmgPathogenicityClassification.PATHOGENIC,
+                            therapeutic_actionability=TherapeuticActionability.ACTIONABLE,
+                            variation_descriptor=VariationDescriptor(
+                                id='rs121913300',
+                                molecule_context=MoleculeContext.genomic,
+                                # TODO: variation
+                                label='RB1 c.958C>T (p.Arg320Ter)',
+                                gene_context=GeneDescriptor(value_id='HGNC:9884', symbol='RB1'),
+                                expressions=(
+                                    Expression(syntax='hgvs.c', value='NM_000321.2:c.958C>T'),
+                                    Expression(syntax='transcript_reference', value='NM_000321.2'),
+                                ),
+                                vcf_record=VcfRecord(
+                                    genome_assembly="GRCh38",
+                                    chrom="NC_000013.11",
+                                    pos=48367512,
+                                    ref="C",
+                                    alt="T",
+                                ),
+                                extensions=(
+                                    Extension(name='allele-frequency', value='25.0%'),
+                                ),
+                                allelic_state=OntologyClass(id='GENO:0000135', label='heterozygous'),
+                            )
+                        )
+                    )
+                ),
+            ),
+        ),
+    )
+
+
+@pytest.fixture(scope='package')
+def biosamples() -> typing.Sequence[Biosample]:
+    return (
+        Biosample(
+            id='biosample.1',
+            sampled_tissue=OntologyClass(id='UBERON:0000970', label='eye'),
+            phenotypic_features=(
+                PhenotypicFeature(type=OntologyClass(id='NCIT:C35941', label='Flexner-Wintersteiner Rosette Formation')),
+                PhenotypicFeature(type=OntologyClass(id='NCIT:C132485', label='Apoptosis and Necrosis')),
+            ),
+            measurements=(
+                Measurement(
+                    assay=OntologyClass(id='LOINC:33728-7', label='Size.maximum dimension in Tumor'),
+                    measurement_value=Value(
+                        value=Quantity(
+                            unit=OntologyClass(id='UCUM:mm', label='millimeter'),
+                            value=15.0,
+                        )
+                    ),
+                    time_observed=TimeElement(
+                        element=Age(iso8601duration='P8M2W')
+                    )
+                ),
+            ),
+            tumor_progression=OntologyClass(id='NCIT:C8509', label='Primary Neoplasm'),
+            pathological_tnm_finding=(
+                OntologyClass(id='NCIT:C140720', label='Retinoblastoma pT3 TNM Finding v8'),
+                OntologyClass(id='NCIT:C140711', label='Retinoblastoma pN0 TNM Finding v8'),
+            ),
+            procedure=Procedure(
+                code=OntologyClass(id='NCIT:C48601', label='Enucleation'),
+                body_site=OntologyClass(id='UBERON:0004548', label='left eye'),
+                performed=TimeElement(element=Age(iso8601duration='P8M2W'))
+            ),
+            files=(
+                File(
+                    uri='file://data/fileSomaticWgs.vcf.gz',
+                    individual_to_file_identifiers={
+                        'biosample.1': 'specimen.1',
+                    },
+                    file_attributes={
+                        'fileFormat': 'VCF',
+                        'genomeAssembly': 'GRCh38',
+                    },
+                ),
+            ),
+        ),
+    )
+
+
+@pytest.fixture(scope='package')
+def medical_actions() -> typing.Sequence[MedicalAction]:
+    return (
+        MedicalAction(
+            action=Treatment(
+                agent=OntologyClass(id='DrugCentral:1678', label='melphalan'),
+                route_of_administration=OntologyClass(id='NCIT:C38222', label='Intraarterial Route of Administration'),
+                dose_intervals=(
+                    DoseInterval(
+                        quantity=Quantity(
+                            unit=OntologyClass(id='UCUM:mg.kg-1', label='milligram per kilogram'),
+                            value=0.4,
+                        ),
+                        schedule_frequency=OntologyClass(id='NCIT:C64576', label='Once'),
+                        interval=TimeInterval(
+                            start=Timestamp.from_str('2020-09-02T00:00:00Z'),
+                            end=Timestamp.from_str('2020-09-02T00:00:00Z'),
+                        )
+                    ),
+                )
+            ),
+            treatment_target=OntologyClass(id='NCIT:C7541', label='Retinoblastoma'),
+            treatment_intent=OntologyClass(id='NCIT:C62220', label='Cure'),
+            adverse_events=(
+                OntologyClass(id='HP:0025637', label='Vasospasm'),
+            ),
+            treatment_termination_reason=OntologyClass(id='NCIT:C41331', label='Adverse Event')
+        ),
+        MedicalAction(
+            action=TherapeuticRegimen(
+                identifier=OntologyClass(id='NCIT:C10894', label='Carboplatin/Etoposide/Vincristine'),
+                start_time=TimeElement(
+                    element=Age(iso8601duration='P7M'),
+                ),
+                end_time=TimeElement(
+                    element=Age(iso8601duration='P8M'),
+                ),
+                regimen_status=TherapeuticRegimen.RegimenStatus.COMPLETED,
+            ),
+            treatment_target=OntologyClass(id='NCIT:C7541', label='Retinoblastoma'),
+            treatment_intent=OntologyClass(id='NCIT:C62220', label='Cure'),
+        ),
+        MedicalAction(
+            action=Procedure(
+                code=OntologyClass(id='NCIT:C48601', label='Enucleation'),
+                body_site=OntologyClass(id='UBERON:0004548', label='left eye'),
+                performed=TimeElement(element=Age(iso8601duration='P8M2W'))
+            ),
+            treatment_target=OntologyClass(id='NCIT:C7541', label='Retinoblastoma'),
+            treatment_intent=OntologyClass(id='NCIT:C62220', label='Cure'),
+        ),
+    )
+
+
+@pytest.fixture(scope='package')
+def files() -> typing.Sequence[File]:
+    return (
+        File(
+            uri='file://data/germlineWgs.vcf.gz',
+            individual_to_file_identifiers={
+                'proband A': 'sample1',
+                'proband B': 'sample2'
+            },
+            file_attributes={
+                'fileFormat': 'VCF',
+                'genomeAssembly': 'GRCh38',
+            },
+        ),
+        File(
+            uri='file://data/somaticWgs.vcf.gz',
+            individual_to_file_identifiers={
+                'proband A': 'sample1',
+                'proband B': 'sample2'
+            },
+            file_attributes={
+                'fileFormat': 'VCF',
+                'genomeAssembly': 'GRCh38',
+            },
+        ),
+    )
+
+
+@pytest.fixture(scope='package')
+def meta_data() -> MetaData:
+    """
+    `MetaData` from the `retinoblastoma` phenopacket.
+    """
+    return MetaData(
+        created=Timestamp.from_str('2021-05-14T10:35:00Z'),
+        created_by='anonymous biocurator',
+        submitted_by=None,
+        resources=(
+            Resource(
+                id='hp', name='human phenotype ontology',
+                url='http://purl.obolibrary.org/obo/hp.owl',
+                version='2021-08-02', namespace_prefix='HP',
+                iri_prefix='http://purl.obolibrary.org/obo/HP_'
+            ),
+            Resource(
+                id='geno', name='Genotype Ontology',
+                url='http://purl.obolibrary.org/obo/geno.owl',
+                version='2020-03-08', namespace_prefix='GENO',
+                iri_prefix='http://purl.obolibrary.org/obo/GENO_'
+            ),
+            Resource(
+                id='ncit', name='NCI Thesaurus',
+                url='http://purl.obolibrary.org/obo/ncit.owl',
+                version='VERSION', namespace_prefix='NCIT',
+                iri_prefix='http://purl.obolibrary.org/obo/NCIT_'
+            ),
+            Resource(
+                id='uberon', name='Uber-anatomy ontology',
+                url='http://purl.obolibrary.org/obo/uberon.owl',
+                version='VERSION', namespace_prefix='UBERON',
+                iri_prefix='http://purl.obolibrary.org/obo/UBERON_'
+            ),
+            Resource(
+                id='loinc', name='Logical Observation Identifiers Names and Codes',
+                url='https://loinc.org',
+                version='VERSION', namespace_prefix='LOINC',
+                iri_prefix='https://loinc.org'
+            ),
+            Resource(
+                id='drugcentral', name='Drug Central',
+                url='https://drugcentral.org/',
+                version='VERSION', namespace_prefix='DrugCentral',
+                iri_prefix='https://drugcentral.org/drugcard'
+            ),
+            Resource(
+                id='ucum', name='Unified Code for Units of Measure',
+                url='https://ucum.org',
+                version='2.1', namespace_prefix='UCUM',
+                iri_prefix='https://ucum.org/'
+            ),
+        ),
+        phenopacket_schema_version='2.0.0',
+    )
diff --git a/test/pp/parse/__init__.py b/test/pp/parse/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/test/pp/parse/test_json.py b/test/pp/parse/test_json.py
new file mode 100644
index 00000000..f9cbbf3a
--- /dev/null
+++ b/test/pp/parse/test_json.py
@@ -0,0 +1,45 @@
+import io
+
+import pytest
+
+from pyphetools.pp.parse.json import JsonSerializer, JsonDeserializer
+from pyphetools.pp.v202 import *
+
+
+class TestJsonSerializers:
+
+    @pytest.fixture
+    def serializer(self) -> JsonSerializer:
+        return JsonSerializer(indent=2)
+
+    @pytest.fixture
+    def deserializer(self) -> JsonDeserializer:
+        return JsonDeserializer()
+
+    def test_deserialize(
+            self,
+            fpath_retinoblastoma_json: str,
+            retinoblastoma: Phenopacket,
+            deserializer: JsonDeserializer,
+    ):
+        with open(fpath_retinoblastoma_json) as fh:
+            pp = deserializer.deserialize(fh, Phenopacket)
+
+        assert pp == retinoblastoma
+
+    def test_round_trip(
+            self,
+            retinoblastoma: Phenopacket,
+            serializer: JsonSerializer,
+            deserializer: JsonDeserializer,
+    ):
+        # Serialize
+        buf = io.StringIO()
+        serializer.serialize(retinoblastoma, buf)
+
+        # Deserialize
+        buf.seek(0)  # Rewind to allow reading from the buffer.
+        out = deserializer.deserialize(buf, Phenopacket)
+
+        # Compare
+        assert out == retinoblastoma
diff --git a/test/pp/parse/test_pb.py b/test/pp/parse/test_pb.py
new file mode 100644
index 00000000..3ba46ba9
--- /dev/null
+++ b/test/pp/parse/test_pb.py
@@ -0,0 +1,48 @@
+import os
+
+import pytest
+
+from pyphetools.pp.v202 import *
+
+import io
+
+
+class TestProtobuf:
+    """
+    Test that we can write a :class:`Phenopacket` to protobuf representation and get the same content back.
+    """
+
+    @pytest.fixture(scope='class')
+    def fpath_retinoblastoma_pb(self, fpath_pp: str) -> str:
+        return os.path.join(fpath_pp, 'retinoblastoma.pb')
+
+    @pytest.mark.skip('Only run upon changes in the retinoblastoma Phenopacket')
+    def test_dump_retinoblastoma_pb(
+            self,
+            retinoblastoma: Phenopacket,
+            fpath_retinoblastoma_pb: str,
+    ):
+        with open(fpath_retinoblastoma_pb, 'wb') as fh:
+            retinoblastoma.dump_pb(fh)
+
+    def test_round_trip_to_protobuf_and_back(
+            self,
+            retinoblastoma: Phenopacket,
+    ):
+        buf = io.BytesIO()
+
+        retinoblastoma.dump_pb(buf)
+        buf.seek(0)
+
+        actual = Phenopacket.from_pb(buf)
+        assert actual == retinoblastoma
+
+    def test_from_pb(
+            self,
+            retinoblastoma: Phenopacket,
+            fpath_retinoblastoma_pb: str,
+    ):
+        with open(fpath_retinoblastoma_pb, 'rb') as fh:
+            actual = Phenopacket.from_pb(fh)
+
+        assert actual == retinoblastoma
diff --git a/test/pp/test_mixin.py b/test/pp/test_mixin.py
new file mode 100644
index 00000000..c85d2a9e
--- /dev/null
+++ b/test/pp/test_mixin.py
@@ -0,0 +1,27 @@
+from pyphetools.pp.v202 import Individual, Phenopacket
+
+
+class TestMessageMixin:
+
+    def test_from_dict(self, individual: Individual):
+        payload = {
+            'id': 'example',
+            'alternate_ids': ('other', 'yet', 'something'),
+            'time_at_last_encounter': {
+                'age': {'iso8601duration': 'P11Y6M'}
+            }
+        }
+
+        individual = Individual.from_dict(payload)
+
+        assert individual.id == 'example'
+        assert individual.alternate_ids == ['other', 'yet', 'something']
+        assert individual.time_at_last_encounter.age.iso8601duration == 'P11Y6M'
+
+    def test_round_trip(self, retinoblastoma: Phenopacket):
+        out = {}
+        retinoblastoma.to_dict(out)
+
+        other = Phenopacket.from_dict(out)
+
+        assert other == retinoblastoma
diff --git a/test/pp/test_serialization.py b/test/pp/test_serialization.py
new file mode 100644
index 00000000..f66d2782
--- /dev/null
+++ b/test/pp/test_serialization.py
@@ -0,0 +1,64 @@
+
+from google.protobuf.message import Message
+
+from pyphetools.pp.v202 import *
+
+
+class TestSerializable:
+
+    def test_to_message__no_compound_fields(self):
+        # Test a message with no compound fields.
+        oc = OntologyClass(id='HP:0001250', label='Seizure')
+        msg = oc.to_message()
+
+        assert isinstance(msg, Message)
+        assert msg.id == 'HP:0001250'
+        assert msg.label == 'Seizure'
+
+    def test_to_message__with_a_compound_field(self):
+        # Test a message with a compound field.
+        age_range = AgeRange(start=Age(iso8601duration='P1Y'), end=Age(iso8601duration='P2Y'))
+        msg = age_range.to_message()
+
+        assert isinstance(msg, Message)
+        assert isinstance(msg.start, Message)
+        assert msg.start.iso8601duration == 'P1Y'
+
+        assert isinstance(msg.end, Message)
+        assert msg.end.iso8601duration == 'P2Y'
+
+    def test_to_message__map_field(self):
+        # Test a message that has a map field.
+        file = File(uri='file://path/to/some/file.bam',)
+        file.individual_to_file_identifiers['Jim'] = 'jimmy'
+        file.individual_to_file_identifiers['Fred'] = 'freddy'
+
+        file.file_attributes['genomeAssembly'] = 'GRCh38'
+        file.file_attributes['fileFormat'] = 'BAM'
+
+        msg = file.to_message()
+
+        assert isinstance(msg, Message)
+        assert msg.uri == 'file://path/to/some/file.bam'
+
+        assert msg.individual_to_file_identifiers['Jim'] == 'jimmy'
+        assert msg.individual_to_file_identifiers['Fred'] == 'freddy'
+
+        assert msg.file_attributes['genomeAssembly'] == 'GRCh38'
+        assert msg.file_attributes['fileFormat'] == 'BAM'
+
+    def test_to_message__oneof_field(self):
+        te = TimeElement(element=Age(iso8601duration='P1Y'))
+
+        msg = te.to_message()
+
+        assert isinstance(msg, Message)
+        assert msg.age.iso8601duration == 'P1Y'
+
+        # Try other one-of case
+        te = TimeElement(element=GestationalAge(weeks=6, days=4))
+
+        msg = te.to_message()
+        assert isinstance(msg, Message)
+        assert msg.gestational_age.weeks == 6
+        assert msg.gestational_age.days == 4
diff --git a/test/pp/test_timestamp.py b/test/pp/test_timestamp.py
new file mode 100644
index 00000000..0731fd86
--- /dev/null
+++ b/test/pp/test_timestamp.py
@@ -0,0 +1,59 @@
+import pytest
+
+from pyphetools.pp import Timestamp
+
+
+class TestTimestamp:
+
+    @pytest.mark.parametrize(
+        'left, right, expected',
+        [
+            ('1970-01-01T00:00:01Z', '1970-01-01T00:00:01Z', True),
+            ('1970-01-01T10:00:00Z', '1970-01-01T06:00:00-04:00', True),  # EDT is 4 hrs behind UTC
+        ]
+    )
+    def test_time_zones(
+            self,
+            left: str, right: str, expected: bool):
+        left = Timestamp.from_str(left)
+        right = Timestamp.from_str(right)
+        assert (left == right) == expected
+
+    @pytest.mark.parametrize(
+        'dt, seconds, nanos',
+        [
+            ('1970-01-01T00:00:00Z', 0, 0),
+            ('1970-01-01T04:00:00+04:00', 0, 0),
+        ]
+    )
+    def test_from_str(
+            self,
+            dt: str, seconds: int, nanos: int,
+    ):
+        ts = Timestamp.from_str(dt)
+
+        assert ts.seconds == seconds
+        assert ts.nanos == nanos
+
+    @pytest.mark.parametrize(
+        'seconds, nanos, expected',
+        [
+            (0, 0, '1970-01-01T00:00:00Z'),
+            (10, 0, '1970-01-01T00:00:10Z'),
+            (-10, 0, '1969-12-31T23:59:50Z'),
+            (-10, 0, '1969-12-31T23:59:50Z'),
+        ]
+    )
+    def test_as_str(
+            self,
+            seconds: int, nanos: int, expected: str):
+        ts = Timestamp(seconds, nanos)
+        assert ts.as_str() == expected
+
+    def test_nanos(self):
+        ts = Timestamp(10, 10_000)
+        assert ts.seconds == 10
+        assert ts.nanos == 10_000
+
+        assert ts.as_str() == '1970-01-01T00:00:10Z'
+        assert ts.as_str(fmt='%Y-%m-%dT%H:%M:%S.%fZ') == '1970-01-01T00:00:10.000010Z'
diff --git a/test/test_vital_status.py b/test/test_vital_status.py
index 5a3a36d1..efe55db0 100644
--- a/test/test_vital_status.py
+++ b/test/test_vital_status.py
@@ -2,7 +2,7 @@
 from pyphetools.creation import AgeOfDeathColumnMapper, Individual, MetaData
 import phenopackets as PPKt
 
-
+from pyphetools.pp.v202 import VitalStatus as pptVitalStatus
 
 class TestVitalStatus(unittest.TestCase):
 
@@ -16,8 +16,8 @@ def test_vs1(self):
         self.assertIsNone(vstatus)
         vstatus = mapper.map_cell_to_vital_status("7 months")
         self.assertIsNotNone(vstatus)
-        self.assertTrue(isinstance(vstatus, PPKt.VitalStatus))
-        self.assertEqual(PPKt.VitalStatus.DECEASED, vstatus.status)
+        self.assertTrue(isinstance(vstatus, pptVitalStatus))
+        self.assertEqual(pptVitalStatus.Status.DECEASED, vstatus.status)
         self.assertEqual("P7M", vstatus.time_of_death.age.iso8601duration)
 
     def test_vs2(self):
@@ -28,8 +28,8 @@ def test_vs2(self):
         mapper = AgeOfDeathColumnMapper(column_name='test', string_to_iso_d=aod_d)
         vstatus = mapper.map_cell_to_vital_status("10 months")
         self.assertIsNotNone(vstatus)
-        self.assertTrue(isinstance(vstatus, PPKt.VitalStatus))
-        self.assertEqual(PPKt.VitalStatus.DECEASED, vstatus.status)
+        self.assertTrue(isinstance(vstatus, pptVitalStatus))
+        self.assertEqual(pptVitalStatus.Status.DECEASED, vstatus.status)
         self.assertEqual("P10M", vstatus.time_of_death.age.iso8601duration)
 
     def test_individual_with_vs(self):