Files
obitools4/pkg/obithermo/model.go
T

222 lines
6.3 KiB
Go
Raw Normal View History

// Package obithermo computes DNA duplex melting temperatures with the
// nearest-neighbor model used by ecoPrimers (SantaLucia 1998 parameters, with
// the SantaLucia or Owczarzy 2008 salt corrections).
//
// Only the situations ecoPrimers actually evaluates are supported: a primer
// against its perfect complement (SelfTm), and a primer against a genomic site
// of the same length that may carry mismatches (Tm). Gaps, bulges and dangling
// ends are not modeled.
package obithermo
import (
"fmt"
"math"
)
// SaltMethod selects the monovalent salt correction.
type SaltMethod int
const (
// SantaLucia applies a per-step entropy correction 0.368*ln[Na+].
SantaLucia SaltMethod = 1
// Owczarzy applies the GC-dependent correction of Owczarzy et al. 2008.
Owczarzy SaltMethod = 2
)
// Default experimental conditions, those of ecoPrimers.
const (
DefaultPrimerConc = 8e-7 // total primer strand concentration (M)
DefaultTargetConc = 0.0 // target strand concentration (M)
DefaultSalt = 0.05 // monovalent cation concentration (M)
)
const (
gasConstant = 1.987 // cal/mol/K
initEntropy = -5.9 // duplex initiation, cal/mol/K
kelvinOffset = 273.15 // ecoPrimers subtracts 273.0 (see package notes)
owczarzyGCSlope = 4.29e-5 // (4.29*fGC - 3.95)e-5 * ln[Na]
owczarzyOffset = 3.95e-5
owczarzyQuadratic = 9.40e-6 // 9.4e-6 * ln[Na]^2
)
// Model holds the thermodynamic tables and the experimental conditions.
// A Model is immutable after NewModel and safe for concurrent use.
type Model struct {
method SaltMethod
rlogc float64 // R*ln(Ct factor), included in the initial entropy
logNa float64 // ln[Na+]
// Indexed by (top0<<6 | top1<<4 | bot0<<2 | bot1) with A,C,G,T = 0..3.
// dS already includes the SantaLucia salt term when that method is selected.
dH [256]float64
dS [256]float64
}
// NewModel builds a Model for the given strand concentrations (M), monovalent
// salt concentration (M) and salt correction method.
func NewModel(primerConc, targetConc, salt float64, method SaltMethod) (*Model, error) {
if method != SantaLucia && method != Owczarzy {
return nil, fmt.Errorf("obithermo: unknown salt method %d", method)
}
if salt <= 0 {
return nil, fmt.Errorf("obithermo: salt concentration must be positive, got %g", salt)
}
// Effective strand concentration, as in nparam_InitParams.
var ctFactor float64
switch {
case primerConc == targetConc:
ctFactor = primerConc / 2
case primerConc > targetConc:
ctFactor = primerConc - targetConc/2
default:
ctFactor = targetConc - primerConc/2
}
if ctFactor <= 0 {
return nil, fmt.Errorf("obithermo: strand concentrations must be positive (primer %g, target %g)",
primerConc, targetConc)
}
m := &Model{
method: method,
rlogc: gasConstant * math.Log(ctFactor),
logNa: math.Log(salt),
}
m.fillTables()
return m, nil
}
// DefaultModel returns a Model with the ecoPrimers default conditions and the
// SantaLucia salt correction.
func DefaultModel() *Model {
m, err := NewModel(DefaultPrimerConc, DefaultTargetConc, DefaultSalt, SantaLucia)
if err != nil {
panic(err) // the defaults are valid
}
return m
}
var baseCode = func() (t [256]int8) {
for i := range t {
t[i] = -1
}
for i, c := range "ACGT" {
t[c] = int8(i)
t[c+'a'-'A'] = int8(i)
}
return
}()
func stepIndex(t0, t1, b0, b1 int) int { return t0<<6 | t1<<4 | b0<<2 | b1 }
func (m *Model) fillTables() {
// Double mismatches (no Watson-Crick pair at either end of the step) are
// treated as interior-loop expansions. Complement of code c is 3-c.
const loopH, loopS = 0.0, -0.97
for x := range 4 {
for y := range 4 {
for a := range 4 {
for b := range 4 {
if x+a != 3 && y+b != 3 {
i := stepIndex(x, y, a, b)
m.dH[i], m.dS[i] = loopH, loopS
}
}
}
}
}
// Explicit parameters override the defaults (Watson-Crick and single mismatches).
for _, e := range nnEntries {
i := stepIndex(int(baseCode[e.top[0]]), int(baseCode[e.top[1]]),
int(baseCode[e.bottom[0]]), int(baseCode[e.bottom[1]]))
m.dH[i], m.dS[i] = e.dH, e.dS
}
// SantaLucia salt correction: 0.5*kfac per side of each step, i.e. kfac.
if m.method == SantaLucia {
kfac := 0.368 * m.logNa
for i := range m.dS {
m.dS[i] += kfac
}
}
}
// GCCount returns the number of G and C (upper or lower case) in seq.
func GCCount(seq []byte) int {
n := 0
for _, c := range seq {
switch c {
case 'G', 'C', 'g', 'c':
n++
}
}
return n
}
// SelfTm returns the melting temperature (degrees Celsius) of primer against
// its perfect complement. It returns NaN when the model predicts no stable duplex.
func (m *Model) SelfTm(primer []byte) (float64, error) {
return m.duplex(primer, primer, true)
}
// Tm returns the melting temperature (degrees Celsius) of primer against the
// site, a stretch of the target read in the same orientation as the primer
// (primer and site have the same length; mismatches are primer[i] != site[i]).
// It returns NaN when the model predicts no stable duplex.
func (m *Model) Tm(primer, site []byte) (float64, error) {
if len(site) != len(primer) {
return 0, fmt.Errorf("obithermo: primer (%d) and site (%d) lengths differ", len(primer), len(site))
}
return m.duplex(primer, site, false)
}
func (m *Model) duplex(primer, site []byte, perfect bool) (float64, error) {
n := len(primer)
if n < 2 {
return 0, fmt.Errorf("obithermo: primer too short (%d), need at least 2 bases", n)
}
dH := 0.0
dS := initEntropy + m.rlogc
p0 := int(baseCode[primer[0]])
s0 := p0
if !perfect {
s0 = int(baseCode[site[0]])
}
if p0 < 0 || s0 < 0 {
return 0, fmt.Errorf("obithermo: non-ACGT base at position 0")
}
for i := 1; i < n; i++ {
p1 := int(baseCode[primer[i]])
s1 := p1
if !perfect {
s1 = int(baseCode[site[i]])
}
if p1 < 0 || s1 < 0 {
return 0, fmt.Errorf("obithermo: non-ACGT base at position %d", i)
}
// The partner strand carries the complement of the site.
idx := stepIndex(p0, p1, 3-s0, 3-s1)
dH += m.dH[idx]
dS += m.dS[idx]
p0, s0 = p1, s1
}
if m.method == Owczarzy {
fgc := float64(GCCount(primer)) / float64(n)
dS += dH * ((owczarzyGCSlope*fgc-owczarzyOffset)*m.logNa + owczarzyQuadratic*m.logNa*m.logNa)
}
// One-state transition, as nparam_CalcTM: valid only for dS < 0 and Tm >= 0.
if dS >= 0 {
return math.NaN(), nil
}
tm := dH / dS
if tm < 0 {
return math.NaN(), nil
}
return tm - kelvinOffset, nil
}