Metadata-Version: 2.4
Name: GeneyxPgxParser
Version: 0.1.7
Summary: Convert PharmCAT / pbStarPhase / PyPGx / ONT / Thermo PharmacoScan PGx output into the Geneyx PGx JSON format.
Author: Bar Cohen
Author-email: Bar Cohen <bar@geneyx.com>
License: MIT License
        
        Copyright (c) 2026 Geneyx
        
        Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Project-URL: homepage, https://github.com/geneyx/GeneyxPgxParser
Project-URL: repository, https://github.com/geneyx/GeneyxPgxParser
Project-URL: documentation, https://geneyx.com/docs
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
Requires-Python: <3.15,>=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# GeneyxPgxParser

Converts pharmacogenomics caller output — PharmCAT, pbStarPhase, PyPGx, the
ONT PGx workflow, a Thermo Fisher PharmacoScan report, or a plain
Gene/Diplotype table — into the JSON format Geneyx Analysis consumes for PGx
reporting.

Install it next to your own PacBio, ONT or array pipeline, run it on the
output, and upload the result to Geneyx.

```bash
pip install GeneyxPgxParser
geneyx-pgx-parser HG002.pharmcat.report.json HG002.pbstarphase.json --output-json HG002.geneyx-pgx-results.json
```

## Output format

A JSON array, one entry per gene - **every gene the input files call**,
nothing dropped:

```json
[
  {
    "Id": "CYP2C19",
    "Name": "CYP2C19",
    "StarAllele": "*1/*1",
    "Activity": "Normal Metabolizer",
    "InReport": true,
    "ReportNote": null,
    "HaplotypeAlleles": null
  },
  {
    "Id": "IFNL3",
    "Name": "IFNL3",
    "StarAllele": "Reference/rs12979860",
    "Activity": "Unfavorable Response",
    "InReport": false,
    "ReportNote": "report dataset v2.9.0-PGX-Data-90genes-2026-10-11 has no drug for IFNL3",
    "HaplotypeAlleles": [
      ["Reference"],
      ["rs12979860", "rs8099917", "rs12980275"]
    ]
  },
  {
    "Id": "HLA-DRB1",
    "Name": "HLA-DRB1",
    "StarAllele": "*04:01/*07:01",
    "Activity": null,
    "InReport": false,
    "ReportNote": "HLA-DRB1 is not a gene of report dataset v2.9.0-PGX-Data-90genes-2026-10-11",
    "HaplotypeAlleles": null
  }
]
```

- **`Id`** / **`Name`** — the gene symbol. Both are required; the Geneyx report
  keys its result set on `Id` and filters on `Name`.
- **`StarAllele`** — the **diplotype** (`*1/*2`), its two alleles in PyPGx's
  order whichever order the caller wrote them in (see _Merging rules_).
- **`Activity`** — the **phenotype** / metaboliser status (`Normal Metabolizer`),
  or `null` when it is not known.
- **`InReport`** — whether the Geneyx PGx report will show this result: `false`
  when the gene is not in the report's dataset, has no drug there, or its
  `StarAllele` is not one of the report's diplotypes for the gene.
- **`ReportNote`** — why not, when `InReport` is `false`; when it is `true`, the
  phenotype the report shows if it differs from `Activity` (or `Activity` is
  `null`), else `null`.
- **`HaplotypeAlleles`** — every allele the caller found on each of the two
  haplotypes, in the caller's order, or `null`. Only PyPGx lists them
  (`Haplotype1`/`Haplotype2`); its `Genotype` names each haplotype by one
  allele only, the first in its allele table, so in the IFNL3 example above
  rs8099917 and rs12980275 were called but appear nowhere else. `StarAllele`
  is unchanged by it: the report looks results up by that exact string.
  Two things to know when showing it. The lists are in PyPGx's phase order
  (`Haplotype1`, `Haplotype2`), which need not be `StarAllele`'s order:
  CYP2C19 `*1/*17` can come with `[["*17", "*1"], ["*1"]]`. And a list holds
  every allele whose definition the haplotype matches, so an allele that
  another contains appears beside it (`*1` with `*17`). A gene PyPGx calls
  from copy number alone (GSTT1, UGT2B17) has empty haplotype cells and `null`.

These names are matched case-sensitively on the Geneyx side. Do not rename them.
`InReport`, `ReportNote` and `HaplotypeAlleles` are additions the Geneyx side
does not read yet; its JSON reader skips members it does not know, so they are
safe to carry.

A second file, `<output>.provenance.json`, is written alongside the result. It
records the caller versions, the mapping-table versions used, per-gene warnings,
and any term that had no Geneyx mapping. It is not part of the upload contract —
it exists so a report can be traced back to what produced it. A gene's warnings
also say when the Geneyx PGx report cannot show its `StarAllele` (the report
looks a result up by that exact string), or shows a different phenotype for it.
Each gene also carries `vendorDiplotype`: the winning caller's diplotype exactly
as it wrote it (`*2.001/*4.015` from pbStarPhase, a Thermo token, an HLA allele
with all its fields), before any rule turned it into `StarAllele`. The suballele
is the only place two `*4` chromosomes can later be told apart, and PharmVar
assigns function at the core level, so it is kept here and never matched on.
Each gene also carries `coverageCaveats`: what the caller could not see
for it, which the reported call does not rule out - PharmCAT's
`Missing_Positions` / `Uncallable_Haplotypes` (the matcher TSV) and
`uncalledHaplotypes` / variants without a call (the JSON), Thermo's
`Unknown Call` alternatives (GAN-9404). For NA19207 that is CYP2C9 `*6/*25/*85`,
G6PD (9 variants), NUDT15 `*6/*9/*18`, DPYD `*3/*7` and RYR1. The result is
unchanged by them. The sidecar's top level has `samples`, the sample each
input file names (PharmCAT's title, the matcher TSV's `Sample` column, the
ONT CYP2D6 report's `Sample` row, PyPGx's first column, Thermo's CEL file),
and `warnings` for the run: a sample-ID mismatch between the files, or
between a file and the sample its name is for (NA19207's matcher TSV says
`7NA19207`). Nothing is dropped for it.
A PyPGx gene also carries `phaseExtended`: `true` when a called allele has
every defining variant absent from the 1KGP panel PyPGx phases with, so Beagle
dropped them and PyPGx placed the allele by its phase-extension heuristic
(`pypgx_panel_gaps.json`, a measured list per PyPGx release); `false` for any
other PyPGx call; `null` for a caller whose phase is not PyPGx's. The result
is unchanged by it.

## Supported inputs

| Input                       | Detected by               | Provides                                                                    |
| --------------------------- | ------------------------- | --------------------------------------------------------------------------- |
| `*.pharmcat.report.json`    | name or `pharmcatVersion` | diplotypes, phenotypes, tool + data versions (PharmCAT 2.x and 3.x)         |
| `*.pharmcat.phenotype.json` | name or `geneReports`     | diplotypes, phenotypes                                                      |
| `*.pbstarphase.json`        | name or `gene_details`    | diplotypes; alone (PacBio WDL v4), the primary caller with PyPGx phenotypes |
| `*.pbstarphase.tsv`         | `.tsv` header             | as the JSON (PacBio v4 summary)                                             |
| `<GENE>.tsv` (PyPGx)        | `.tsv` header             | diplotype + phenotype, one gene per file                                    |
| `*.pharmcat.tsv` (ONT)      | `.tsv` header             | diplotypes only (PharmCAT matcher; no CYP2D6)                               |
| `CYP2D6/report.tsv` (ONT)   | `Sample` first row        | CYP2D6 diplotype only                                                       |
| `*_phenotype.rpt` (Thermo)  | `.rpt` or `Known Call`    | diplotype + phenotype (PharmacoScan plate report; `--sample`)               |
| `*.csv` / `*.json` table    | fallback                  | Gene + Diplotype (+ optional Phenotype)                                     |

The minimal table is the upload format: a header with **Gene** and **Diplotype**
is enough. Column names are matched loosely (`gene`, `geneSymbol`, `symbol`;
`diplotype`, `genotype`, `starAllele`).

## Why two files are better than one

PharmCAT only reports genes that have a CPIC or DPWG guideline. In a PacBio
HG002 run, pbStarPhase called 35 genes and PharmCAT reported 22. Passing the
pbStarPhase summary alongside the PharmCAT output adds the other 13 — NAT2,
CYP1A2, CYP2A13, CYP2C8 and the HLA genes beyond HLA-A/B — with a diplotype and
no phenotype; `InReport` says which of them the report can show.

Where both files describe the same gene, PharmCAT wins: it normalises
PharmVar sub-alleles (`*4.015`) to the core alleles (`*4`) that Geneyx uses.

### pbStarPhase on its own (PacBio WDL v4)

The HiFi human WGS WDL v4 dropped PharmCAT, so a v4 run has only the
pbStarPhase output. Then pbStarPhase is the **primary** caller: every gene it
calls is reported, sub-alleles stripped and names put in the Geneyx (PyPGx)
vocabulary, and `Activity` is PyPGx's phenotype for the diplotype — what a
PyPGx run would report. On HG002 that is all 35 genes, 17 with an Activity.
Up to 0.1.5 the parser treated pbStarPhase as PharmCAT's supplement even when
it came alone, and a v4 sample came out with 4 genes and no Activity. With
PharmCAT in the run, PharmCAT's call wins for the genes it reports, and
pbStarPhase's phenotype is never attached to PharmCAT's diplotype.

## ONT PGx output

The ONT PGx workflow writes its calls to two files, and both are needed:
`<sample>.pharmcat.tsv` holds every gene except CYP2D6, and
`CYP2D6/report.tsv` holds CYP2D6. Pass both, and they merge into one array:

```bash
geneyx-pgx-parser NA19207.pharmcat.tsv NA19207/CYP2D6/report.tsv --output-json NA19207.geneyx-pgx-results.json
```

The workflow runs only PharmCAT's allele matcher, not its phenotyper, so
neither file carries a phenotype. The Activity is looked up in CPIC's own
diplotype table - the table PharmCAT's phenotyper reads - packaged as
`Mappings/cpic_diplotypes.json`. The lookup tries both allele orders, writes
three or more copies the way CPIC does (`*2x3` -> `*2x≥3`), and falls back to
the haplotype columns where the matcher TSV abbreviates (CFTR). Which CPIC key
answered, and its activity score, go in the provenance sidecar.

DPYD is usually unphased: PharmCAT lists the variants it found rather than a
diplotype, and they are reported as-is, `;`-separated
(`c.85T>C (*9A);c.557A>G`). Two variants can only be one on each strand, so
the pair is looked up as a diplotype; three or more need CPIC's lowest-two
rule and are left without an Activity rather than guessed.

Genes the table cannot answer keep `Activity: null`: CYP3A4 and CYP4F2 have
no CPIC diplotype table, G6PD needs the sample's sex, and IFNL3 / VKORC1 are
genotype-guided as on every other path.

`--input-dir` does not descend into `CYP2D6/`, so name the report explicitly.

### Keeping the CPIC table current

CPIC revises its tables in place - SLCO1B1 `*41` went from uncertain to no
function on 2025-09-23, so NA19207's `*37/*41` is `Decreased Function` here but
`Indeterminate` from PharmCAT 2.15.5, whose data predates the change.

The table is kept current in [pgx-metadata](#mapping-tables), where
`Test_CpicLiveTable.py` fetches CPIC and fails while the table disagrees
with it, listing every changed, added and removed diplotype and every
phenotype term with no Geneyx term yet. The refreshed table reaches this
package as a new mapping bundle.

### Running PharmCAT's phenotyper instead

The same Activity can come from running the rest of PharmCAT on the
workflow's `<sample>.match.json`, with the CYP2D6 call handed back to it as an
outside call. Use the same PharmCAT version as the workflow (wf-pgx v0.1.7
ships 2.15.5):

```bash
geneyx-pgx-parser NA19207/CYP2D6/report.tsv --pharmcat-outside-call NA19207.outside.tsv
pharmcat -phenotyper -pi NA19207.match.json -po NA19207.outside.tsv -reporter -reporterJson -o out -bf NA19207
geneyx-pgx-parser out/NA19207.report.json --output-json NA19207.geneyx-pgx-results.json
```

The `report.json` is the same file the PacBio path produces, and PharmCAT's
phenotypes are used as-is - no CPIC lookup. Unphased DPYD keeps its
`;`-separated variants as StarAllele and takes the phenotype PharmCAT gives
the pair.

## Thermo Fisher PharmacoScan

Axiom Analysis Suite's `apt-pgx-translation` writes one `*_phenotype.rpt` per
**plate**, so the file holds every sample on it while the Geneyx result is per
sample. Name the sample, by CEL file name or its stem:

```bash
geneyx-pgx-parser 2026-02-18-PharmacoScan24F_phenotype.rpt --sample HG03225 --output-json HG03225.geneyx-pgx-results.json
```

A file with one sample needs no `--sample`; a file with several and no choice
is refused with the list of samples it holds (GAN-8867).

What the parser does with the report:

- **`Known Call` is the diplotype; Activity is PyPGx's phenotype for it.**
  Thermo's `Phenotype Call` is only a cross-check; see below.
- **Allele names are translated** token by token through
  `thermo_alleles.json`, built from the SNP-level Thermo ↔ PyPGx dictionary on
  GAN-8867 and the diplotypes the Geneyx model was seen to accept. `WT`, `c.=`
  and `rs2231142G` become `Reference`; `*28+60+80+93` becomes `*80+*28`; a
  male's G6PD `Null` becomes `MALE`; `MTRNR1 Null/m.=` becomes `MT-RNR1
Reference/Reference`. The table names Geneyx core alleles, so a PyPGx compound such as
  `*10+*4` is written `*4`.
- **A token the table does not know is kept as written** and listed in the
  sidecar's `unmappedTerms`. It is a genomic observation the Geneyx model does
  not name yet, not a vocabulary term that could pass for one: RYR1
  `c.6178G>T`, a CPIC Level A malignant-hyperthermia variant, is reported as
  `c.6178G>T/Reference` rather than vanishing into `Reference/Reference`.
- **Ambiguous phase is reported, not resolved.** A `,`-separated `Known Call`
  (`*1/*14,*4/*37`) is Thermo saying it could not phase the sample; the
  candidates are emitted `;`-separated, like unphased DPYD on the ONT path,
  and the gene is flagged in the sidecar. Candidates that name the same Geneyx
  diplotype (`*1A/*7A,*1B/*7A+1B` → `*1/*7`) collapse to it.
- **Copy-number gains are kept as `(*1/*10)xN`** with a warning. Thermo does
  not say which haplotype is duplicated, so nothing is guessed.
- **Rows with no `Known Call`** (`UNDH`, `CN_HybridLoss`) are written as
  no-calls (`StarAllele: null`), with the interpretation code and the
  `Unknown Call` candidates in the provenance. Up to 0.1.7 they were left out.
- **`Unknown Call` beside a `Known Call` is a coverage caveat.** Control1's
  CYP2D6 `*1/*29` stays `*1/*29` NM; Thermo's alternatives with a haplotype
  the chip cannot resolve (`*2/UNK,*34/UNK,...`) go to the sidecar's
  `coverageCaveats` for the gene, and weigh nothing (GAN-9404).
- **A copy-number code the `Known Call` leaves out** (`CN_HybridGain` on
  `*2/*4`) keeps the call and adds a warning that it may not describe every
  gene copy.
- **Every gene the chip calls is reported** (ALDH2, FMO3, TNF, UGT1A9 …), with
  `InReport` `false` where the Geneyx PGx report has nothing for it. Only
  `CYP2CRS12777823` is left out: it is a carrier for the rs12777823 warfarin
  variant rather than a gene (`ignored_genes` in `thermo_alleles.json`). On the
  24F panel that is 31 of the 32 reported genes.

### Where `Activity` comes from: PyPGx's phenotype step

The Geneyx report is built on PyPGx. A Thermo report has a diplotype but not
PyPGx's phenotype for it, so that step is run here: once `Known Call` is
translated into Geneyx (PyPGx) allele names, `Activity` is what PyPGx's
`predict_phenotype` gives that diplotype — read from
`Mappings/pypgx_phenotypes.json`, which pgx-metadata generates from the PyPGx
release the pipeline runs (0.27.0).
A Thermo sample therefore reads as a PyPGx sample with the same diplotype
would:

- **Diplotype genes** (CYP2C19, TPMT, SLCO1B1, G6PD, CFTR, RYR1, …) take
  PyPGx's diplotype table; a diplotype it does not list is `Indeterminate`,
  as in PyPGx.
- **Score genes** (CYP2D6, CYP2C9, DPYD) add the two alleles' activity scores
  — copies multiply, `+` tandems add — and take PyPGx's ranges; an allele of
  uncertain function is `Indeterminate`.
- **Genes PyPGx gives no phenotype** (NAT1/NAT2, COMT, CYP1A2, GSTM1, VKORC1,
  …) get no Activity, as a PyPGx run leaves them.
- **No guessing.** An unresolved phase (`*1/*14;*4/*37`) or an unplaced
  copy-number gain (`(*1/*7)xN`, read with every copy count that could change
  the answer, not only two or three) is reported only when every possible reading
  gives PyPGx the same phenotype; a diplotype with an allele the Geneyx model
  cannot name gets none. The provenance sidecar says which and why.

Thermo's own `Phenotype Call` (`NM`, `NF`, `MHS`, …) is converted per gene
through `phenotype_calls` in `thermo_alleles.json` and **only compared**: where
it differs from PyPGx, the gene carries a warning naming both. They differ
often — Thermo's research-use metabolizer table is coarser than PyPGx's (CFTR
`NF` vs PyPGx `Indeterminate`, G6PD `Deficient` vs `Deficient with CNSHA`,
RYR1 `Indeterminate` vs `Malignant Hyperthermia Susceptibility` for
c.14387A>G) — which is why it is not the reported value. The reconciliation
report lists every such difference on its `Thermo vs PyPGx` sheet.

Worth knowing when reading the output: for the twelve panel genes whose Geneyx
model carries only an `Indeterminate` activity (COMT, CYP1A2, CYP2A6, CYP2C8,
CYP2E1, CYP3A4, CYP3A7, CYP4F2, GSTP1, NAT1, NAT2, UGT2B7), the report will
return Indeterminate however good the diplotype is: PyPGx has no phenotype
for them, and Thermo's own call is not used. That is a gap in the Geneyx model's activity tables,
not in this parser, and it is the same gap GAN-8867 records for GSTM1.

### Checking the Thermo tables against PyPGx

Both halves of `thermo_alleles.json` (allele tokens by variant content, and
every metabolizer diplotype's Activity) are checked against the PyPGx release
the Geneyx pipeline runs by `tools/thermo_reconcile.py` in pgx-metadata,
together with Thermo's library files and PyPGx's tables it reads, kept
byte-exact under `sources/` there. A new Thermo library or PyPGx version is
brought in, reviewed and approved there; this package receives the result as
a mapping bundle. A report records its own library as `thermoLibrary` in the
provenance sidecar (read from its `#%MetabolizerFile=` header), and a report
made with a different library than `thermo_alleles.json` names is still
translated, with a warning on every gene.

## Merging rules

- **One entry per gene.** PharmCAT emits a gene report per phenotype source, so
  the same 22 genes appear under both CPIC and DPWG. One source is preferred
  and the other is consulted only as a fallback — see below.
- **One vocabulary for every caller.** The report shows `StarAllele` and
  `Activity` verbatim and the PyPGx pipeline writes PyPGx's own names, so every
  caller's output is held to PyPGx's allele names and phenotypes: an Activity
  outside its gene's PyPGx phenotypes is not reported (PharmCAT's DPWG
  `Normal Metabolizer` for CYP3A4), PharmCAT's ABCG2 `rs2231142 reference (G)`
  is written `Reference`, and DPYD's `c.1627A>G (*5) (heterozygous)` label is
  written `Reference/c.1627A>G (*5)`, as PyPGx writes them.
- **One spelling for every diplotype.** The two alleles are written in PyPGx's
  allele order, as a PyPGx run writes them: `*4/*2` becomes `*2/*4`,
  `c.1627A>G (*5)/Reference` becomes `Reference/c.1627A>G (*5)`, and each
  candidate of an unresolved call is ordered on its own. An allele PyPGx does
  not define keeps the caller's order. A male's single G6PD allele is written
  with `MALE` (`MALE/A- 202A_376G`), and a sub-allele number is dropped before
  a copy number too (`*3.001xN` → `*3xN`). The phenotype is unaffected: it is
  looked up in either order.
- **A no-call is written, never dropped.** A gene the caller names but did not
  call — an empty cell, pbStarPhase `./.`, PharmCAT `Unknown/Unknown` or
  `No Result`, ONT `Indeterminate`, a Thermo row with no `Known Call`
  (`UNDH`, `CN_HybridLoss`) — is written with `StarAllele: null`,
  `InReport: false` and a `ReportNote` starting `no call:`; the caller's own
  word is kept as `vendorDiplotype` and the reason is in the provenance. A
  partial call (`*1/.`) is kept as written, with no Activity. Where the
  preferred caller made no call and another caller in the run did, the other
  call is reported, with a warning saying so. PyPGx's own `Indeterminate` is
  passed through unchanged, as every PyPGx value is.
- **Nothing is called that the caller did not call.** Several candidate
  diplotypes (pbStarPhase's list, Thermo's `,`, PharmCAT's source diplotypes)
  are all reported `;`-separated, never the first one alone.
- **Several possible diplotypes are resolved or Undetermined, never dropped**
  (GAN-9404). When every possible diplotype gives the same phenotype it is
  reported: Thermo's `*1/*7,*5/*6` (CYP2B6) is Intermediate Metabolizer either
  way, with the notation kept; two unphased DPYD variants
  (`c.85T>C (*9A);c.557A>G`) can only be one on each strand and are written as
  the pair the report lists, `c.85T>C (*9A)/c.557A>G`. Where PyPGx has no
  phenotype for the gene (CYP4F2, NAT2) the report dataset decides, each
  candidate looked up there. When the possible diplotypes give different
  phenotypes, or one of them has none - `*1/*14;*4/*37` (SLCO1B1: Normal
  Function or Indeterminate), `(*1/*10)xN` (no activity score without the
  copy number), NAT2 candidates only one of which the dataset knows -
  `Activity` is `Undetermined`: the gene is reported with the caller's
  notation, nothing is chosen, and no phenotype-dependent recommendation
  applies. A single call with an allele Geneyx cannot name is a different
  case (GAN-9415) and is not Undetermined. The report looks a result up by
  its exact `StarAllele`, so `InReport` is `false` for `;` notation and the
  `ReportNote` says so with the Activity.
- **Copy number, hybrids and losses.** Deletions (`*5`), gains (`*2x2`,
  `*1x3`) and hybrid tandems (`*36+*10`, `*68+*4`) are scored as PyPGx scores
  them; pbStarPhase's sub-alleles are dropped from each part of a tandem
  (`*36.001+*10.001` → `*36+*10`). A copy number left open — `*2xN`, `*2x≥3`,
  Thermo's unplaced `(*1/*10)xN` — is read at every count it could be, and
  gets an Activity only when they all agree (`*1/*2xN` is Ultrarapid whatever
  N is; `*10x≥3/*10` is Intermediate, Normal or Ultrarapid, so none). A
  Thermo copy-number code its `Known Call` does not spell out (`*2/*4` with
  `CN_HybridGain`) is kept and flagged.
- **Unmapped terms are dropped, not passed through.** A PharmCAT phenotype with
  no Geneyx equivalent yields `Activity: null` plus a warning in the provenance
  file, rather than an unrecognised term that looks valid to the report.
- **Genotype-guided genes** (IFNL3, VKORC1) take their `StarAllele` from the
  rewritten allele names rather than from the phenotype.
- **HLA** alleles are truncated to two fields (`*01:01:01:01` → `*01:01`).
- **Single-allele genes** (MT-RNR1) are one call, written as PyPGx writes it:
  `Reference/Reference`, `m.1555A>G/m.1555A>G` (PharmCAT's `1555A>G` is renamed).

## Choosing a phenotype source

PharmCAT phenotypes come from two guideline bodies, **CPIC** and **DPWG**, and
they do not always agree. Pick one:

```bash
geneyx-pgx-parser HG002.pharmcat.report.json --phenotype-source DPWG
```

The default is **CPIC** (the first entry of `phenotype_source_preference` in
`gene_rules.json`). The choice only reorders the sources — it never narrows the
output. When the chosen source has no usable phenotype for a gene, the next
source in the default order is used instead, and the substitution is recorded:

```json
{
  "Id": "CYP2B6",
  "phenotypeSource": "CPIC",
  "warnings": ["no usable phenotype from DPWG; taken from CPIC"]
}
```

"No usable phenotype" covers three cases: the source reported none at all
(HG002 CPIC: CYP3A4, CYP4F2, VKORC1), it reported a no-call token such as
`n/a` (HG002 DPWG: CYP2B6), or its term has no Geneyx mapping yet (HG002 DPWG:
DPYD `2.0 (Normal Metabolizer)`).

The source selection affects `Activity` only — `StarAllele` is identical either
way, since both sources describe the same call. The preference actually used is
written to the provenance file as `phenotypeSourcePreference`.

## PyPGx, and callers whose terms are already Geneyx terms

The Geneyx PyPgx pipeline has produced this JSON format since it shipped, by
reading PyPGx's per-gene `data.tsv` and writing its `Genotype` and `Phenotype`
columns straight through. Those values _are_ the Geneyx vocabulary, so mapping
them would change clinical output rather than normalise it — `phenotype_terms.json`
maps none of PyPGx's G6PD, MT-RNR1 or CFTR terms, and `genotype_terms.json`
knows only PharmCAT's allele names for the genotype-guided genes.

PyPGx is therefore listed in `gene_rules.json` under `passthrough_sources`: its
calls skip term mapping, sub-allele stripping and HLA truncation entirely, and
the sidecar records which callers were passed through:

```json
{ "passthroughSources": ["pypgx"] }
```

Removing `pypgx` from that list is a clinical behaviour change gated on the
GAN-8770 mapping sheet, not a refactor.

Each PyPGx file covers one gene, and the gene comes from the file name
(`CYP2C19.tsv`), because PyPGx's own table has no gene column. A table still
named `data.tsv` needs a `Gene` column, or parsing fails rather than reporting
a gene called "data". Point the parser at the whole results folder:

```bash
geneyx-pgx-parser --input-dir /path/to/pgx/results --output-json sample.geneyx-pgx-results.json --gzip
```

## Mapping tables

The clinical vocabulary lives in `GeneyxPgxParser/Mappings/` as versioned JSON.
The tables are **owned by pgx-metadata** (Azure DevOps), where reference
sources are snapshotted, mapping proposals are reviewed and approved, and a
signed, versioned **mapping bundle** is built. This repository pins one bundle
in `bundle.lock`; the packaged tables are exactly that bundle's, and
`Test_MappingBundle.py` fails if a table is edited here. To adopt a new bundle:

```bash
python scripts/sync_bundle.py --from <pgx-metadata bundle folder> --public-key bundle.pub
```

and open one pull request with `bundle.lock` and the tables it changed.
pgx-metadata runs the parser against its candidate tables by naming their
folder in `GENEYX_PGX_MAPPINGS_DIR`; unset, the packaged tables are used.

| File                   | Contains                                                                                        |
| ---------------------- | ----------------------------------------------------------------------------------------------- |
| `gene_rules.json`      | structural rules — source preference, passthrough callers, HLA/sub-allele handling              |
| `phenotype_terms.json` | PharmCAT phenotype term → Geneyx Activity term                                                  |
| `genotype_terms.json`  | allele/diplotype rewrites for genotype-guided genes                                             |
| `thermo_alleles.json`  | Thermo PharmacoScan allele token → Geneyx allele name, and phenotype code → CPIC term, per gene |

`phenotype_terms.json` is built from the approved crosswalk
(`PyPGx_PharmCAT_phenotype_crosswalk 15.07.2026.xlsx`, sheet
`Phenotype Crosswalk (SD)`, linked from GAN-8770): its `Rule class` column
decides where a row lands, `EXACT` in the passthrough block and `TRANSFORM` in
the per-gene block.

`thermo_alleles.json` is generated, not hand-written: its source is the
`Dictionary` sheet of `PGx_Thermo_PyPGx_Reconciliation_6.xlsx` on GAN-8867
(SNP-content matching of every Thermo allele against PyPGx's definitions),
checked against the diplotypes the Geneyx model accepted in
`PharmacoScan_diplotype_ingestion_report.xlsx`. The handful of hand overrides
(CYP2C19 `*1`, the UGT1A1 TA-repeat tokens, G6PD `Null`, MT-RNR1) each carry
their reason in the file. It is marked DRAFT until it has clinical sign-off.

> **A `null` target means the crosswalk has no approved term**, not that the
> row is outstanding work. Today that is HLA-A and HLA-B, which the sheet marks
> `UNSUPPORTED` pending a phenotype table from clinpgx.org (GAN-8796). The
> genotype-guided genes in `genotype_terms.json` are still unfilled, because
> their table is a different sheet. Run the parser and read `unmappedTerms` in
> the provenance file to see what your data actually hit.

## Development

```bash
pip install -e .
pytest GeneyxPgxParser/Test/Test_GeneyxPgxParser.py -v
```

`pip install -e .` compiles the modules in place, and Python loads a
`.pyd`/`.so` in preference to the `.py` beside it — so after an edit the tests
keep running the **copy built at install time** until you reinstall. The
symptom is a change that provably has no effect. Delete the build artefacts to
go back to running the sources:

```bash
find GeneyxPgxParser -name "*.pyd" -delete -o -name "*.so" -delete -o -name "*.c" -delete
```

They are gitignored, and CI builds the wheels from source regardless.

Test fixtures are real PacBio output for **HG002**, a public GIAB reference
sample, ONT output for **NA19207**, and Thermo's own PharmacoScan 24F demo
plate unmodified — 24 samples × 32 genes, two Thermo controls and 22
1000 Genomes / Coriell cell lines, as shipped with Axiom Analysis Suite. The
whole plate is kept rather than a cut-down copy because its awkward calls are
spread across samples: between them they cover every interpretation code the
format has. No patient data belongs in this repository.
