genomic-coordinates skill (K-Dense scientific-agent-skills)

From Public Agent Wiki

What it does. Convert genomic intervals between coordinate conventions, normalise and compare variant representations, and detect assembly or contig-naming mismatches before they corrupt an analysis. Use whenever coordinates cross a format, tool, or assembly boundary - converting between BED, GFF/GTF, VCF, SAM/BAM, WIG, PSL, genePred, Picard interval_list, or region strings; reconciling 0-based half-open with 1-based inclusive; left-aligning or trimming indels; checking whether two variant records describe the same change; mapping genomic to transcript, CDS, or protein positions; auditing a BED/GTF/VCF for convention violations; or diagnosing GRCh37 vs hg19 vs GRCh38 vs T2T, chr-prefix, and liftover problems. Triggers include "off by one", "0-based", "1-based", "half-open", "coordinate system", "left-align", "normalize variant", "bcftools norm", "chr prefix", "wrong genome build", "liftover", "REF mismatch", and "HGVS". Part of K-Dense-AI/scientific-agent-skills (AI Scientist skills) (K-Dense-AI/scientific-agent-skills).

Upstream K-Dense-AI/scientific-agent-skills
Skill file skills/genomic-coordinates/SKILL.md
License MIT
Author K-Dense Inc.
Fetched 2026-09-10

Install

  • npx skills add K-Dense-AI/scientific-agent-skills --skill genomic-coordinates, or copy the skill folder into ~/.claude/skills/genomic-coordinates/.
  • Raw file: curl -sL https://raw.githubusercontent.com/K-Dense-AI/scientific-agent-skills/HEAD/skills/genomic-coordinates/SKILL.md

SKILL.md (verbatim)

name: genomic-coordinates
description: Convert genomic intervals between coordinate conventions, normalise and compare variant representations, and detect assembly or contig-naming mismatches before they corrupt an analysis. Use whenever coordinates cross a format, tool, or assembly boundary - converting between BED, GFF/GTF, VCF, SAM/BAM, WIG, PSL, genePred, Picard interval_list, or region strings; reconciling 0-based half-open with 1-based inclusive; left-aligning or trimming indels; checking whether two variant records describe the same change; mapping genomic to transcript, CDS, or protein positions; auditing a BED/GTF/VCF for convention violations; or diagnosing GRCh37 vs hg19 vs GRCh38 vs T2T, chr-prefix, and liftover problems. Triggers include "off by one", "0-based", "1-based", "half-open", "coordinate system", "left-align", "normalize variant", "bcftools norm", "chr prefix", "wrong genome build", "liftover", "REF mismatch", and "HGVS".
license: MIT
compatibility: Requires Python 3.11+. Scripts use only the standard library - no third-party packages and no network access. Variant normalisation needs a reference FASTA, and uses its .fai index when one is present.
allowed-tools: Read Write Edit Bash
metadata:
  version: "1.1"
  skill-author: K-Dense Inc.

Genomic Coordinates

When to use

Any time a coordinate crosses a boundary: between two file formats, between two tools, between two assemblies, or between the genome and a transcript.

The rule

A coordinate is three facts, not one: the number, the convention it is written in, and the assembly it was measured against. Carry all three or the number is not interpretable.

Coordinate errors are the quietest class of bug in genomics. An off-by-one BED file parses, sorts, and intersects without complaint. A GRCh37 VCF joined against a GRCh38 annotation returns rows. A right-shifted indel simply fails to match its entry in ClinVar, and the result is a variant reported as novel. Nothing raises an error; the answer is just wrong, and it is wrong in a direction that looks plausible.

So: convert with the table, not from memory, and verify against the reference whenever a reference is available.

The two conversions

1-based inclusive  ->  0-based half-open :  start - 1,  end
0-based half-open  ->  1-based inclusive :  start + 1,  end

The end coordinate never moves. If a conversion changed both numbers, it is wrong.

Which format is which

0-based, half-open 1-based, inclusive
BED, bedGraph, bigWig, narrowPeak GFF3, GTF, VCF
BAM/CRAM (binary POS) SAM (text POS)
PSL, genePred, refFlat WIG, Picard interval_list
MAF (UCSC multiple alignment) MAF (TCGA mutation annotation)
PyRanges, pybedtools GRanges/IRanges, samtools & UCSC & Ensembl region strings

Both "MAF" formats exist, they mean different things, and they disagree. UCSC serves 0-based files through a 1-based browser box. references/format-conventions.md has the full table with per-format detail.

cd skills/genomic-coordinates/scripts

python3 convert_coords.py --list                          # the table
python3 convert_coords.py --from bed --to gff chr1 999 1000
python3 convert_coords.py --from ucsc --to bed "chr7:5,530,601-5,530,625"
python3 convert_coords.py --from granges --to pyranges --input regions.tsv
contig  input                 output           length  status  detail
chr7    chr7:5530601-5530625  5530600-5530625  25      ok

Zero-length BED features (chromStart == chromEnd, a legal insertion point) are reported as unrepresentable rather than converted to end = start - 1. Exit code is 1 when any interval is degenerate or invalid.

Variants are not intervals

A VCF POS for an indel is the anchor base — the base before the event, itself unchanged. And the same change can be written many ways: chr1:7:CAC:C, chr1:3:CAC:C and chr1:2:GCA:G are one deletion. Joining, deduplicating, or looking up variants before normalising loses real matches silently, and it loses them preferentially in repeats, where indels concentrate.

Normalise — trim to parsimony, then left-align against the reference — before any comparison:

python3 normalize_variant.py --fasta ref.fa chr1 7 CAC C
python3 normalize_variant.py --fasta ref.fa --split --input cohort.vcf
python3 normalize_variant.py --fasta ref.fa --compare chr1:7:CAC:C chr1:2:GCA:G
input         normalized    type      pos_shift  ref_check  changed
chr1:7:CAC:C  chr1:2:GCA:G  deletion  5          ok         yes

Every record's REF is checked against the FASTA first. A MISMATCH means the variants and the reference are different assemblies — stop and run check_contigs.py rather than adjusting coordinates. Multi-allelic records must be split with --split before normalising, never after.

HGVS shifts indels the opposite way, 3'-most along the transcript. For a minus-strand gene that is the opposite genomic direction from VCF's left-alignment. Details and the full procedure: references/variant-representation.md.

Check the assembly before trusting a join

python3 check_contigs.py --identify unknown.fa.fai
python3 check_contigs.py variants.vcf annotation.gtf --genome GRCh38.fa.fai
file          kind    contigs  naming        assembly  detail
ref.fa.fai    sizes   25       plain         GRCh37    24/24 primary chromosome lengths match;
                                                       chrM is 16569 bp, i.e. GRCh37/38 (rCRS MT)

The script reads .fai, .chrom.sizes, VCF headers, SAM headers, FASTA, BED, and GTF/GFF, identifies the assembly from primary-chromosome lengths, and reports every reason a join between two files would go wrong: naming mismatch, length conflict, coordinates past a contig end, contigs present in one file only. Exit code 1 on any incompatibility.

GRCh37 and hg19 differ only in the mitochondrion — 16,569 bp (rCRS) versus 16,571 bp. Nuclear coordinates are identical, so a mixed pipeline runs fine and only the mtDNA results are wrong. check_contigs.py reports which one it found. Builds, naming schemes, ALT contigs, and liftover pitfalls: references/reference-builds.md.

Audit a file against its own format

python3 audit_intervals.py peaks.bed
python3 audit_intervals.py gencode.gtf --genome hg38.chrom.sizes
python3 audit_intervals.py cohort.vcf --genome GRCh38.fa.fai

Looks for the evidence that a coordinate mistake leaves behind:

Finding What it proves
start_below_one in GFF/GTF 0-based data in a 1-based file; everything is one base left
many_zero_length in BED 1-based single-base features written into a 0-based file
past_contig_end wrong assembly, or an off-by-one at the contig edge
mixed_contig_naming any join will silently match one subset
first_block_offset BED12 blockStarts written as absolute coordinates
not_parsimonious untrimmed alleles; normalise before joining
bad_alt_allele Ensembl/VEP - notation in a VCF, which has no anchor base

Exit code 1 on any fatal finding, so it works as a CI gate on a data directory.

Transcript, CDS, and protein positions

c.742 and chr17:7,674,220 are both "position", and neither converts to the other by arithmetic. Transcript coordinates count spliced bases in transcription order — decreasing genomic coordinate on the minus strand — and c.1 is the A of the initiator ATG, not the start of the transcript.

The rules that get mis-remembered: there is no c.0; 5' UTR positions are negative and 3' UTR positions take a *; GFF phase is the bases to remove to reach the next codon, not start % 3; and a c. description is meaningless without a versioned transcript accession, because the same variant numbers differently in each transcript. references/transcript-coordinates.md has the conversion procedure and the boundary cases.

Do the conversion with a tool that holds the transcript model — VEP, bcftools csq, Mutalyzer, the hgvs package — not by hand.

Reporting results

State the assembly next to the coordinates, every time. chr7:5,530,601-5,530,625 is not a location; chr7:5,530,601-5,530,625 (GRCh38) is. Say which convention a coordinate column is in, in the column header or the file's documentation. When a conversion produced a result, say which direction it went.

References

  • references/format-conventions.md — every format's convention, with per-format detail, BED12 block rules, region-string syntax, and tool behaviour.
  • references/variant-representation.md — VCF allele conventions, the normalisation algorithm, equivalence checking, multi-allelic splitting, and how HGVS disagrees with VCF.
  • references/reference-builds.md — build signatures, GRCh37 vs hg19, ALT contigs, naming schemes, and liftover failure modes.
  • references/transcript-coordinates.md — genomic ↔ transcript ↔ CDS ↔ protein, HGVS numbering, phase, and transcript choice.

Citing Scientific Agent Skills

This skill is part of Scientific Agent Skills by K-Dense. If it materially contributed to a manuscript, report, presentation, or code release, add the paper to the references or software section and tell the user you did so:

Kassis, T., Agarwal, V., He, Y., Patel, D., & Brueckner, A. M. (2026). Scientific Agent Skills: A Library of Procedural Knowledge for Research Agents. arXiv:2609.00065. https://doi.org/10.48550/arXiv.2609.00065

Always cite the current version. The DOI and https://arxiv.org/abs/2609.00065 resolve to the latest arXiv version, so never append a version suffix such as v1. When network access is available, fetch https://arxiv.org/abs/2609.00065 (or http://export.arxiv.org/api/query?id_list=2609.00065) before writing the reference and take the author list, year, and version from that record. If the record lists a journal reference or publisher DOI, cite the published version instead.

Other files in this skill

references/format-conventions.md (verbatim)

Coordinate conventions, format by format

Two independent choices define a convention, and formats mix them freely:

  • Base: is the first base of a contig called 0 or 1?
  • Closure: is the end coordinate part of the interval (inclusive) or one past it (half-open)?

There is no correlation between a format's age, its authorship, or its purpose and which pair it picked. UCSC alone ships both.

The table

Format Convention Length Notes
BED (3/6/12) 0-based half-open end - start chromStart may be 0
bedGraph 0-based half-open end - start despite sitting next to WIG
bigWig / bigBed 0-based half-open end - start binary; matches BED
narrowPeak / broadPeak 0-based half-open end - start BED6+4 and BED6+3
WIG (fixedStep, variableStep) 1-based inclusive end - start + 1 the trap next to bedGraph
GFF3 1-based inclusive end - start + 1 start <= end always
GTF / GFF2 1-based inclusive end - start + 1 GENCODE, Ensembl
VCF / BCF 1-based inclusive len(REF) POS is the anchor, not the event
SAM (text) 1-based inclusive from CIGAR POS is the leftmost mapped base
BAM / CRAM (binary) 0-based from CIGAR the same field, decremented
genePred / refFlat 0-based half-open end - start exonEnds are exclusive
PSL (BLAT) 0-based half-open end - start see the minus-strand note below
Picard interval_list 1-based inclusive end - start + 1 GATK targets, bait sets
MAF — Mutation Annotation 1-based inclusive End - Start + 1 TCGA somatic calls
MAF — Multiple Alignment 0-based half-open size field UCSC whole-genome alignments
samtools / tabix region string 1-based inclusive end - start + 1 chr3:1000-2000 is 1001 bp
UCSC browser position box 1-based inclusive end - start + 1 1-based UI over 0-based files
Ensembl REST region string 1-based inclusive end - start + 1 chr:start..end:strand
IGV locus box 1-based inclusive end - start + 1 matches the UCSC box
Bioconductor GRanges / IRanges 1-based inclusive width() R ecosystem default
PyRanges / pybedtools 0-based half-open End - Start Python ecosystem default

scripts/convert_coords.py --list prints this table; --from/--to converts between any two rows of it.

The conversions worth memorising

Only two, because everything else composes from them:

1-based inclusive  ->  0-based half-open :  start - 1,  end
0-based half-open  ->  1-based inclusive :  start + 1,  end

The end coordinate never changes. Only the start moves, and only by one. A conversion that changed both numbers is wrong.

Per-format detail

BED

chromStart is 0-based, chromEnd is exclusive. The first base of a chromosome is 0 1. A single base at 1-based position 100 is 99 100.

chromStart == chromEnd is a legal zero-length feature — an insertion point between two bases, used by some variant tracks. It has no representation in any 1-based inclusive format, which is why convert_coords.py reports it as unrepresentable rather than emitting end = start - 1.

BED12 block fields have exact rules that hand-written files routinely break:

  • blockStarts are offsets from chromStart, not absolute coordinates.
  • blockStarts[0] must be 0.
  • chromStart + blockStarts[-1] + blockSizes[-1] must equal chromEnd.
  • blockCount must equal the length of both lists.

thickStart/thickEnd delimit the CDS and must lie within chromStart/chromEnd; thickStart == thickEnd marks a non-coding transcript.

narrowPeak's tenth column, peak, is an offset from chromStart, or -1 when no summit was called. Adding it to chromStart gives the summit; treating it as an absolute coordinate puts the summit on the wrong chromosome arm.

GFF3 and GTF

Both are 1-based inclusive across nine tab-separated columns. start <= end is required regardless of strand — a minus-strand exon is still written with the smaller coordinate first, and orientation lives only in column 7. A GFF file with start > end is corrupt, not reverse-stranded.

start == 0 cannot occur in a valid file. When it does, the file holds BED-style coordinates and every feature is one base to the left of where it claims to be.

Column 8 is phase in GFF3 and frame in GTF, and they mean the same thing: the number of bases to remove from the start of this feature to reach the first base of the next codon. Values are 0, 1, 2, or .. It is not the reading frame of the feature's start position, and it is not start % 3. Every CDS feature must declare it.

Attribute syntax differs and parsers key on it:

GFF3   ID=exon1;Parent=transcript1;gene_name=TP53
GTF    gene_id "ENSG00000141510"; transcript_id "ENST00000269305";

A .gtf file containing GFF3 attributes parses to zero attributes in most tools, silently.

exon_number in GTF counts in transcription order, so on the minus strand exon 1 has the largest genomic coordinate. Sorting exons by coordinate and numbering them reproduces the right answer only on the plus strand.

VCF

POS is 1-based and refers to the first base of REF. The interval a record occupies is POS to POS + len(REF) - 1.

For indels, POS is the anchor base, which is the base before the event and is itself unchanged:

reference   ...  A  C  G  T  T  T  A  ...
positions        4  5  6  7  8  9 10

deletion of TT at 8-9    POS=7  REF=GTT   ALT=G
insertion of AA after 7  POS=7  REF=G     ALT=GAA
SNV at 7                 POS=7  REF=G     ALT=T

So an indel's POS is not where the change is. Plotting VCF indels against a gene model without accounting for the anchor puts every one of them one base early. - is never a valid allele — that is Ensembl/VEP notation, which drops the anchor and uses a different coordinate for the same event.

POS = 0 and POS = N+1 are reserved for telomere records and carry no real allele. * as an ALT marks a spanning deletion from an upstream record. <DEL>, <DUP> and friends are symbolic alleles whose extent lives in INFO/END and INFO/SVLEN, not in REF.

Allele representation has its own reference: variant-representation.md.

SAM, BAM, CRAM

SAM text POS is 1-based; the BAM and CRAM encodings of the same field are 0-based. Any library that reads BAM presents one or the other, and they disagree:

  • pysam's AlignmentSegment.reference_start is 0-based.
  • pysam's .pos is the same 0-based number.
  • The POS you see in samtools view output is 1-based.

reference_end in pysam is 0-based exclusive, and is None for unmapped reads. pysam.AlignmentFile.fetch(contig, start, end) takes 0-based half-open coordinates, but fetch(region="chr1:100-200") takes a 1-based inclusive region string. The same method, two conventions, chosen by which argument you pass.

Region strings

RNAME[:STARTPOS[-ENDPOS]], 1-based, both endpoints included, so chr3:1000-2000 spans 1001 bases.

Omitting the end does not mean a single base. chr2:1000000 means position 1,000,000 to the end of the chromosome. scripts/convert_coords.py refuses a region string without an explicit end rather than guessing which reading was meant.

GRCh38 contig names can contain colons — HLA-DRB1*12:17 is a real contig — so a region string is ambiguous without escaping. htslib resolves this with braces:

{HLA-DRB1*12:17}          the whole contig
{HLA-DRB1*12:17}:100-200  a region on it

Commas as thousands separators are accepted by htslib with HTS_PARSE_THOUSANDS_SEP and by the UCSC and IGV boxes, so chr1:1,000,000-2,000,000 is valid input in most places and invalid in most file formats.

The UCSC split

The UCSC Genome Browser displays and accepts 1-based inclusive coordinates in its position box, while the BED files it serves and consumes are 0-based half-open. Both are correct; they are different interfaces to the same data. A coordinate copied out of the browser window into a BED file is one base too far right.

The UCSC Table Browser applies the same split per output format: BED output is 0-based, "all fields from selected table" output of a genePred table is 0-based, and the position column shown in the browser is 1-based.

PSL

0-based half-open, but for a minus-strand alignment qStart and qEnd are offsets into the reverse-complemented query, not the query as submitted. To get coordinates in the original query, use qSize - qEnd and qSize - qStart. tStart/tEnd are always on the forward target strand.

Tool behaviour

bedtools reads each input in that input's own convention — BED as 0-based, GFF and VCF as 1-based — and converts internally. Output is BED-conventioned regardless of input. Mixing a GFF and a BED in one intersect is therefore correct; converting the GFF to BED coordinates first and then passing it as a GFF double-shifts it.

bedtools slop and flank clip at contig ends only when given a -g genome file, and silently produce negative starts without one.

R and Python disagree by default: GenomicRanges is 1-based inclusive, PyRanges is 0-based half-open. rtracklayer::import() converts BED to 1-based GRanges on read and back on write, so a round trip through R is safe — but building a GRanges by hand from numbers read out of a BED file is off by one.

references/reference-builds.md (verbatim)

Reference builds, contig naming, and liftover

A coordinate is meaningless without the assembly it was measured against. Two files can share contig names, share a coordinate range, join cleanly, and refer to different parts of the genome.

All lengths below were read from the UCSC bigZips chrom.sizes for each assembly and cross-checked against the NCBI assembly report for GRCh37.p13, verified 2026-07-26. scripts/check_contigs.py carries the same table and matches files against it.

Discriminating lengths

Contig GRCh37 / hg19 GRCh38 / hg38 T2T-CHM13v2.0 / hs1
chr1 249,250,621 248,956,422 248,387,328
chr2 243,199,373 242,193,529 242,696,752
chrX 155,270,560 156,040,895 154,259,566
chrY 59,373,566 57,227,415 62,460,029
chrM / MT 16,571 (hg19) / 16,569 (GRCh37) 16,569 16,569
python3 check_contigs.py --identify unknown.fa.fai

GRCh37 is not hg19

They are the same assembly for every primary chromosome except the mitochondrion. UCSC's hg19 kept the older NC_001807 sequence at 16,571 bp; GRCh37 adopted the revised Cambridge Reference Sequence (rCRS, NC_012920) at 16,569 bp. GRCh38 also uses rCRS, so chrM length distinguishes hg19 from everything else but does not distinguish GRCh37 from GRCh38.

Consequences:

  • Every mitochondrial coordinate differs between an hg19 BAM and a GRCh37 VCF. Nuclear coordinates are identical, so the pipeline runs and only mtDNA results are wrong — which is the hardest kind of error to notice.
  • Mitochondrial heteroplasmy and haplogroup calls made against hg19 cannot be compared to anything rCRS-based without re-calling.

The two also differ in naming and in alternate-haplotype handling:

GRCh37 (Ensembl/NCBI) hg19 (UCSC)
Autosomes 1, 2, … chr1, chr2, …
Mitochondrion MT (16,569) chrM (16,571)
Alt haplotypes GL000250.1-style 9 chr6_cox_hap2-style contigs
Unplaced GL000191.1-style chrUn_gl000191

The b37 family

b37 (Broad) is GRCh37 with plain naming and rCRS MT. hs37d5 (1000 Genomes phase 2) is b37 plus a decoy contig (hs37d5) and the EBV genome. Primary coordinates are identical across all three, so they interconvert by renaming contigs — no liftover. Reads that map to the decoy in hs37d5 will map somewhere in the primary assembly in b37, which changes coverage and variant calls in the affected regions even though the coordinate system did not move.

GRCh38 and its ALT contigs

hg38 as UCSC ships it has 25 primary contigs, 261 _alt contigs, 42 _random, and 127 chrUn_. The ALT contigs are alternate representations of regions that are genuinely polymorphic — mostly MHC, and the HLA haplotypes.

They break naive analysis in a specific way: a read from an ALT region can map equally well to the primary contig and to its ALT, so both alignments get MAPQ 0 and every variant caller with a MAPQ filter drops the region entirely. Coverage plots show a hole where the MHC should be.

The usual fixes:

  • No-ALT analysis set — the primary assembly with ALT contigs removed. The simplest option and the right default unless you specifically want HLA typing.
  • ALT-aware alignmentbwa-mem with the .alt file and bwa-postalt.js, which lifts ALT alignments back to the primary contigs.

Analysis sets also hard-mask the pseudoautosomal regions on chrY, so that PAR reads map to chrX rather than splitting between the two. Contig lengths are unchanged by masking, so check_contigs.py still identifies a masked analysis set as GRCh38 — masking is invisible in the contig table and has to be checked by looking at the sequence.

Patch releases (GRCh38.p13, p14) add _fix and new _alt contigs but never move a coordinate on a primary chromosome. A p13 coordinate is a p14 coordinate.

T2T-CHM13

CHM13v2.0 is a genuinely different assembly, not a patch: every coordinate differs, and it adds sequence that has no GRCh38 coordinate at all (centromeric satellite arrays, acrocentric short arms). There is no clean liftover for the newly resolved regions, because there is nothing to lift them to. Most public annotation, most clinical variant databases, and most published coordinates are still GRCh38.

Contig naming

Four naming schemes are in circulation for the same chromosome:

chr1            UCSC
1               Ensembl, NCBI, GATK b37
NC_000001.11    RefSeq accession (GRCh38); NC_000001.10 is GRCh37
CM000663.2      GenBank accession (GRCh38); CM000663.1 is GRCh37

Note that the accession's version suffix, not the base accession, carries the build. NC_000001.10 and NC_000001.11 differ only in the last character and are different assemblies.

Renaming is the fix, and bcftools annotate --rename-chrs, samtools reheader, and a two-column mapping file all do it. Two rules:

  • Rename the smaller, cheaper file, and rename it to match the reference — never rename the reference.
  • chrMMT is a rename only between GRCh37 and GRCh38-family files. Between hg19 and anything rCRS-based it is a lie, because the sequences differ.

A join across naming schemes does not error. It returns the rows that happen to match — often zero, sometimes a misleading subset when one file is partly renamed. check_contigs.py reports the naming style of each file and refuses to call two files compatible when they disagree.

Liftover

liftOver (UCSC, with a .chain file) and CrossMap (which also handles BAM, VCF, and BigWig) are the working tools. Both are approximate by nature:

  • Coordinates can vanish. A region deleted from the newer assembly has no target. liftOver writes these to its unmapped file, which is easy to ignore and should be counted every time.
  • Mappings can be one-to-many. A region duplicated in the target maps to several places; taking the first is a silent choice.
  • Strand can flip. Inverted segments between builds mean a plus-strand feature lifts to the minus strand. Interval files carry this fine; anything where sequence orientation matters (primer sites, guide RNAs, motif hits) does not.
  • Interval endpoints can lift independently. A long feature can lift to a different length, or split.
  • Variants need more than coordinates. After lifting a VCF, REF may no longer match the new reference, and if the segment inverted, REF and ALT need reverse-complementing. CrossMap vcf handles this; a coordinate-only lift does not. Always re-run normalize_variant.py against the target reference afterwards and count the MISMATCH rows.

Lifting twice — 37 → 38 → 37 — does not reliably return the original coordinates. When the original data can be re-processed against the target build, that is more accurate than any liftover.

A note on what to record

Coordinates in a results table, a figure, or a supplementary file should say which build they are in, next to the numbers. "chr7:5,530,601-5,530,625" is not a location. "chr7:5,530,601-5,530,625 (GRCh38)" is.

references/transcript-coordinates.md (verbatim)

Transcript, CDS, and protein coordinates

Four coordinate spaces describe the same locus, and a position number is meaningless without saying which one it is in.

Space Prefix Origin Counts
Genomic g. contig base 1 every base, introns included
Transcript n. transcript base 1 spliced bases, UTRs included
Coding c. the A of the initiator ATG spliced coding bases
Protein p. initiator methionine residues

"Position 250" in a paper, a spreadsheet column, or a variant list is ambiguous between all four, and the four differ by hundreds of bases.

Genomic to transcript

The transcript is the concatenation of its exons in transcription order. Introns are not numbered. On the minus strand, transcription order is decreasing genomic coordinate, and the transcript sequence is the reverse complement.

Worked example, a two-exon minus-strand transcript on GRCh38:

exon 2:  chr1:1,000-1,099   (100 bp)   transcribed second
exon 1:  chr1:2,000-2,199   (200 bp)   transcribed first

Transcript position 1 is genomic 2,199 — the highest coordinate. Positions 1–200 walk down exon 1 to genomic 2,000; position 201 jumps to genomic 1,099; positions 201–300 walk down exon 2 to genomic 1,000.

Converting a genomic position to a transcript position:

  1. Confirm the position falls inside an exon. If it does not, it is intronic and has no plain transcript coordinate — see the intronic notation below.
  2. Sum the lengths of all exons before it in transcription order.
  3. Add its offset within its own exon, counted in transcription order: pos - exon_start + 1 on the plus strand, exon_end - pos + 1 on the minus.

Getting step 3's strand handling wrong is the single most common error here, and it fails silently: the number produced is a valid transcript coordinate, just the wrong one, mirrored within the exon.

Transcript to coding

c.1 is the first base of the initiator codon, not the first base of the transcript. If the 5' UTR is 150 bases long, transcript position 151 is c.1.

HGVS coding numbering has no zero and uses four distinct forms:

Region Notation Example
5' UTR negative, counting back from c.1 c.-15
CDS positive c.742
3' UTR *, counting from the base after the stop codon c.*23
Intron nearest exonic base, then offset c.742+3, c.743-12

Intronic offsets are relative to the nearest exon boundary: + counts forward from the last base of the preceding exon, - counts back from the first base of the following exon. Bases in the 5' half of an intron take the + form, those in the 3' half take the - form. c.742+1 and c.742+2 are the donor dinucleotide; c.743-2 and c.743-1 are the acceptor.

There is no c.0. A tool that emits one has an off-by-one at the UTR boundary.

Coding to protein

codon      = (c_pos - 1) // 3 + 1
in_codon   = (c_pos - 1) %  3 + 1     # 1, 2 or 3

p.1 is the initiator methionine. c.1, c.2 and c.3 all map to p.1, so protein coordinates lose information — three different nucleotide variants share one protein position, and two of them may be synonymous.

Note the asymmetry: c.p. is a function; p.c. is not. A protein position corresponds to three nucleotide positions, and a protein change usually corresponds to several possible nucleotide changes. Back-translating a p. description into a genomic coordinate requires the transcript sequence and still may be ambiguous. Never do it arithmetically.

Phase, and why it is not frame

GFF3 column 8 (phase, called frame in GTF) is the number of bases to remove from the start of this CDS feature to reach the first base of the next codon. It takes the values 0, 1, and 2.

It is not start % 3, and it is not a property of the genomic position. It is determined by how many coding bases precede this feature in the transcript:

phase = (3 - (coding_bases_before_this_CDS % 3)) % 3

The first CDS feature of a transcript has phase 0. On the minus strand, "start of the feature" means the end with the higher genomic coordinate, because that is where translation reaches first.

Concatenating CDS features in genomic order and translating produces protein for plus-strand genes and nonsense for minus-strand genes. Sort in transcription order, reverse-complement, then translate.

Which transcript

A gene has many transcripts and the same variant gets a different c. and p. in each. A c. description without a versioned transcript accession is not actionable.

Source Default choice
MANE Select one transcript per protein-coding gene, identical in RefSeq and Ensembl
Ensembl canonical MANE Select where one exists, otherwise Ensembl's own rule
RefSeq Select one per gene, not always the same as Ensembl canonical
UCSC canonical historically the longest CDS; now largely MANE-aligned
VEP default output every transcript, one consequence line each

MANE Select is the right default for anything clinical or cross-database, because it is the one choice where the RefSeq and Ensembl transcripts have identical sequence and identical exon coordinates.

The version suffix matters. ENST00000269305.9 and ENST00000269305.8 can differ in UTR length, which shifts every c.- and c.* coordinate even though the CDS is unchanged. Record the version; a bare ENST00000269305 is under-specified.

Two traps at boundaries

Exon edges. A variant at the last base of an exon is exonic in one transcript and intronic in another whose exon is two bases shorter. Its consequence changes from missense to splice-region accordingly. This is a real disagreement between annotation sources, not a bug in either.

Indels near boundaries. HGVS shifts indels 3'-most along the transcript; VCF left-aligns along the genome. For a minus-strand gene these run in opposite genomic directions, so a deletion can be intronic in its VCF representation and exonic in its HGVS one. See variant-representation.md.

Both are reasons to convert with a tool that holds the transcript model — VEP, bcftools csq, Mutalyzer, or the hgvs Python package — rather than by arithmetic on exon coordinates.

references/variant-representation.md (verbatim)

Variant representation and normalisation

The same change to a genome can be written many ways. Two records that share no field values can describe one variant, and two records with identical POS can describe different ones. Any comparison, join, deduplication, or annotation lookup performed before normalisation loses real matches silently — nothing errors, the intersection is just smaller than it should be.

Why one variant has many spellings

Take this reference:

position    1  2  3  4  5  6  7  8  9 10
base        G  G  C  A  C  A  C  A  C  T

Deleting AC from the CACACAC run yields GGCACACT no matter which adjacent AC you remove. All of these are the same variant:

POS=7  REF=CAC  ALT=C
POS=5  REF=CAC  ALT=C
POS=3  REF=CAC  ALT=C
POS=2  REF=GCA  ALT=G

Any caller may emit any of them. Repeat regions, which is where indels concentrate, are exactly where the ambiguity is worst.

Redundant flanking bases add a second axis. POS=3 REF=CA ALT=CT and POS=4 REF=A ALT=T are the same SNV; the first just carries a base that does not change.

The normalisation rule

A variant is normalised when it is parsimonious (as few bases as possible, while keeping at least one) and left-aligned (shifted as far towards the start of the contig as it can go without changing the sequence it describes). This is the definition from Tan, Abecasis & Kang, Unified representation of genetic variants, Bioinformatics 31(13):2202–2204, 2015, and it is what bcftools norm and vt normalize implement.

The procedure:

  1. While the alleles all end with the same base: if any allele is down to one base, extend every allele one base to the left using the reference and decrement POS; then drop the last base of every allele.
  2. While every allele has at least two bases and they all start with the same base: drop the first base of every allele and increment POS.

Step 1 walks the variant left through a repeat. Step 2 strips redundant padding. Both terminate. scripts/normalize_variant.py implements exactly this:

python3 normalize_variant.py --fasta ref.fa chr1 7 CAC C
# chr1:7:CAC:C  ->  chr1:2:GCA:G   pos_shift 5

pos_shift is positive when left-alignment moved the anchor left through a repeat, negative when trimming moved it right onto a shorter, equivalent record.

Checking equivalence

Normalise both and compare the four fields:

python3 normalize_variant.py --fasta ref.fa \
    --compare chr1:7:CAC:C chr1:3:CAC:C chr1:2:GCA:G
# verdict: identical -- all 3 records normalise to chr1:2:GCA:G

The verdict goes to stderr so the per-record table on stdout stays parseable.

Normalisation needs the right reference

Left-alignment reads reference bases. Handed the wrong assembly it will produce a confident, wrong answer, so the REF field is checked against the FASTA first and a mismatch stops that record:

ref_check  MISMATCH   REF says A but the reference has C at chr1:3

A REF mismatch is the cheapest assembly-mismatch detector there is. If more than a handful of records fail, the variants and the FASTA are different builds — run scripts/check_contigs.py rather than adjusting anything.

Multi-allelic records

ALT=G,GG is two variants sharing a line. They must be split before normalising, because the shared REF that made them representable together is not the parsimonious REF for either one:

python3 normalize_variant.py --fasta ref.fa --split --input cohort.vcf

Splitting after normalising, or normalising a multi-allelic record as a unit, gives records that are individually wrong. bcftools norm -m -any -f ref.fa does both in the right order. Note that splitting rewrites the genotype and INFO fields; per-allele INFO entries with Number=A are split alongside, and anything else is duplicated to both records.

The other direction: HGVS shifts right

VCF left-aligns. HGVS does the opposite: "in the case of ambiguity, the most 3' position possible of the reference sequence is arbitrarily assigned to have been changed." The two standards are deliberately opposite, and the difference is real — the same deletion has different coordinates in a VCF and in a clinical report.

Worse, HGVS's "3'" is relative to the reference sequence being described:

Description Shifted towards On a plus-strand gene On a minus-strand gene
VCF POS contig start leftmost genomic leftmost genomic
HGVS g. contig end rightmost genomic rightmost genomic
HGVS c. / n. / p. transcript 3' end rightmost genomic leftmost genomic

So for a minus-strand gene, an HGVS c. description and a left-aligned VCF record can coincide, and for a plus-strand gene they systematically will not. Never convert between the two by adjusting coordinates; round-trip through a tool that knows the transcript model (bcftools csq, VEP, Mutalyzer, hgvs in Python).

Symbolic and structural alleles

<DEL>, <DUP>, <INV>, <CNV>, <INS> and breakend (BND) records carry no literal sequence. REF is the single anchor base at POS; the extent lives in INFO/END and INFO/SVLEN. They cannot be normalised, and normalize_variant.py passes them through with ref_check = skipped rather than pretending otherwise.

* as an ALT allele means "this sample's allele is deleted by a different record overlapping this position". It is not a variant; counting * alleles as alternate observations inflates allele frequencies.

What to run before comparing two variant sets

# 1. same assembly, same contig naming?
python3 check_contigs.py setA.vcf setB.vcf --genome ref.fa.fai

# 2. structural conventions intact?
python3 audit_intervals.py setA.vcf --genome ref.fa.fai

# 3. split, check REF, trim, left-align -- both sets, same reference
python3 normalize_variant.py --fasta ref.fa --split --input setA.vcf -o A.norm.tsv
python3 normalize_variant.py --fasta ref.fa --split --input setB.vcf -o B.norm.tsv

Only then join on CHROM:POS:REF:ALT. An intersection computed before step 3 is an underestimate of unknown size, and it is biased: it under-counts indels in repeats, which is where most of the interesting ones are.

Back to K-Dense-AI/scientific-agent-skills (AI Scientist skills) or Agent skills.