HGVS Nomenclature05/11
Starting sandbox…
Beginner·4 graded tests

SequenceVariant

SequenceVariant

Core Principle: SequenceVariant is the core object hierarchy representing parsed HGVS expressions, encapsulating the reference accession, coordinate type, position interval, and sequence edit.

The structure

SequenceVariant
 ├─ ac        'NM_000546.6'      the accession, with version
 ├─ type      'c'                g | c | n | r | p | m
 ├─ gene      None               optional
 └─ posedit   PosEdit
     ├─ pos   Interval
     │   ├─ start   BaseOffsetPosition(base=215, offset=0, datum=CDS_START)
     │   └─ end     BaseOffsetPosition(base=215, offset=0, datum=CDS_START)
     ├─ edit  NARefAlt(ref='C', alt='G')
     └─ uncertain  False
var = hp.parse_hgvs_variant("NM_000546.6:c.215C>G")

var.ac                          # 'NM_000546.6'
var.type                        # 'c'
var.posedit.pos.start.base      # 215
var.posedit.pos.start.offset    # 0  (non-zero = intronic)
var.posedit.pos.end.base        # 215
var.posedit.edit.ref            # 'C'
var.posedit.edit.alt            # 'G'
var.posedit.edit.type           # 'sub'
str(var)                        # round-trips to the original string

Position classes

Class Used by Fields
SimplePosition g., m., n. base
BaseOffsetPosition c., r. base, offset, datum
AAPosition p. base, aa
Interval all start, end, uncertain

BaseOffsetPosition is where the complexity lives:

from hgvs.enums import Datum

pos.base      # the coding position
pos.offset    # 0 = exonic; ±n = intronic
pos.datum     # Datum.CDS_START (normal), CDS_END (the * region), SEQ_START
pos.is_intronic       # offset != 0
pos.is_uncertain

Worked examples:

HGVS base offset datum
c.215 215 0 CDS_START
c.-24 −24 0 CDS_START
c.87+3 87 +3 CDS_START
c.88-3 88 −3 CDS_START
c.*12 12 0 CDS_END
c.*12-8 12 −8 CDS_END

base alone is not a position. c.87+3 has base 87, and so does c.87; they are 3 bp apart in the genome and in completely different functional contexts (one intronic, one exonic). Any code doing if var.posedit.pos.start.base == 87 without checking the offset is wrong.

Likewise, datum distinguishes c.12 (the 12th coding base) from c.*12 (the 12th base after the stop): both have base == 12.

Edit classes

Class For Fields
NARefAlt sub, del, ins, delins, identity ref, alt
Dup duplication ref
Inv inversion ref
Repeat tandem repeats ref, min, max
AARefAlt protein sub/del/ins ref, alt
AAFs frameshift ref, alt, length
AAExt extension
AASub protein substitution
var.posedit.edit.type      # 'sub' | 'del' | 'ins' | 'delins' | 'dup' | 'inv' | 'identity'

Dispatching on edit.type is how you write code that handles all variant classes:

[ ]python
out1ms

This code completed without producing output.

ref is often None. HGVS permits c.76_78del without spelling out the deleted bases. hgvs fills it in from the sequence during normalization: one of the reasons to normalize before analysing. Guard with e.ref or "".

Constructing variants programmatically

[2]python
out1ms
'NM_000546.6:c.215C>G'

Verbose, but it is how you build variants from structured data (a VCF row, a database record) without string formatting. Building a variant by string concatenation and then parsing it is fragile: you can easily produce something syntactically valid and semantically wrong (an ins that should be a dup).

Copying and mutating

SequenceVariant objects are effectively value objects. To modify one, copy it:

[ ]python
out1ms

This code completed without producing output.

Mutating in place risks aliasing bugs if you have kept references. Most hgvs operations (normalize, c_to_g) return new objects rather than mutating, which is the right design.

Extracting to a DataFrame

The bridge into the pandas half of your analysis:

def to_row(var):
    pe = var.posedit
    return {
        "accession": var.ac,
        "type": var.type,
        "start_base": pe.pos.start.base,
        "start_offset": getattr(pe.pos.start, "offset", 0),
        "end_base": pe.pos.end.base,
        "end_offset": getattr(pe.pos.end, "offset", 0),
        "edit_type": pe.edit.type,
        "ref": getattr(pe.edit, "ref", None),
        "alt": getattr(pe.edit, "alt", None),
        "is_intronic": getattr(pe.pos.start, "offset", 0) != 0,
        "hgvs": str(var),
    }

df = pd.DataFrame([to_row(v) for v in variants])

getattr(..., default) throughout, because g. variants use SimplePosition (no offset) and Dup/Inv edits have no alt. Writing pe.pos.start.offset unconditionally crashes on genomic variants.

Comparison and equality

v1 == v2                      # structural equality
str(v1) == str(v2)            # string equality

Neither is biological equivalence. c.76_78del and c.77_79del can be the same event in a repeat, and they compare unequal. To compare variants meaningfully:

  1. Normalize both (hgvs Normalizer).
  2. Project both to the same reference type and accession (AssemblyMapper).
  3. Then compare.

Skipping either step gives you false negatives. This is the single most consequential thing to understand about working with variants. See hgvs Pitfalls.

Common mistakes

  • Using base without offset. c.87+3 ≠ c.87.
  • Ignoring datum. c.12 ≠ c.*12.
  • Assuming ref is populated. Normalize first, or guard with or "".
  • Assuming alt exists on dup and inv edits.
  • pos.start.offset on a g. variant → AttributeError.
  • Comparing unnormalized variants and concluding they differ.
  • String-building variants instead of constructing objects.
  • Stripping the accession version.

See also

hgvs Parser · HGVS Nomenclature · hgvs Normalizer · Projection c to g to p · Coordinate Systems · hgvs Pitfalls

Exercise

The hgvs package is not available in this sandbox, so you will take a variant string apart with re rather than reading posedit.pos.start off a parsed object. The fields are the same three the lesson insists you always read together, and pulling them out by hand is what makes it obvious why base on its own is not a position.

Write decompose(lines), taking the input lines and returning a list of output lines. Ignore blank lines. Each remaining line is a variant whose description begins with a c. position; read only that start position, which may carry a leading *, a negative base number, and an intronic offset. Emit <variant> base=<b> offset=<o> datum=<d>, where the base keeps its sign, the offset is 0 when exonic and otherwise carries an explicit + or -, and the datum is CDS_END for a * position and CDS_START for every other. Emit <variant> unparsable for a line that does not begin that way, and leave it out of the counts.

Finish with three lines: intronic=<n> counting the positions with a non-zero offset, distinct_positions=<n> counting distinct (base, offset, datum) triples, and ambiguous_bases=<n> counting the base numbers that appear with more than one distinct (offset, datum) pair: the count that would be zero if base alone identified a position. All three lines are printed as zeros when the input is empty.

exerciseEvery submission runs independently in a fresh namespace.

Test cases · 4

#viainputexpected stdout
1stdinNM_000546.6:c.215C>G NM_000546.6:c.87+3G>A NM_000546.6:c.87A>G NM_000546.6:c.88-3T>C NM_000546.6:c.-24A>G NM_000546.6:c.-24+5G>A NM_000546.6:c.*12A>C NM_000546.6:c.*12-8del NM_000546.6:c.12A>C NM_000546.6:c.215C>G base=215 offset=0 datum=CDS_START NM_000546.6:c.87+3G>A base=87 offset=+3 datum=CDS_START NM_000546.6:c.87A>G base=87 offset=0 datum=CDS_START NM_000546.6:c.88-3T>C base=88 offset=-3 datum=CDS_START NM_000546.6:c.-24A>G base=-24 offset=0 datum=CDS_START NM_000546.6:c.-24+5G>A base=-24 offset=+5 datum=CDS_START NM_000546.6:c.*12A>C base=12 offset=0 datum=CDS_END NM_000546.6:c.*12-8del base=12 offset=-8 datum=CDS_END NM_000546.6:c.12A>C base=12 offset=0 datum=CDS_START intronic=4 distinct_positions=9 ambiguous_bases=3
2stdinNM_000546.6:c.76_78del NM_000546.6:c.100-8_100-4del NC_000017.11:g.7676154G>C NM_000546.6:c.76_77insT NM_000546.6:c.76_78del base=76 offset=0 datum=CDS_START NM_000546.6:c.100-8_100-4del base=100 offset=-8 datum=CDS_START NC_000017.11:g.7676154G>C unparsable NM_000546.6:c.76_77insT base=76 offset=0 datum=CDS_START intronic=1 distinct_positions=2 ambiguous_bases=0
3stdinNM_000546.6:c.215C>G NM_000546.6:c.215C>G base=215 offset=0 datum=CDS_START intronic=0 distinct_positions=1 ambiguous_bases=0
4stdin intronic=0 distinct_positions=0 ambiguous_bases=0

Hints · 3

01Hint

re.match(r"[^:]+:c\.(\*?)(-?\d+)([+-]\d+)?", s) reads the start of a range without consuming the rest.

02Hint

Format the offset with f"{o:+d}" only when it is non-zero, otherwise print a plain 0.

03Hint

Collect a dict of base to a set of (offset, datum) pairs, then count the entries whose set holds more than one.