Skip to content

Commit 5b242c8

Browse files
author
finlayclark
committed
Initial commit
1 parent 8ab0905 commit 5b242c8

13 files changed

+355
-0
lines changed

.github/workflows/CI.yaml

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
name: CI
2+
3+
on:
4+
# GitHub has started calling new repo's first branch "main" https://github.com/github/renaming
5+
# The cookiecutter uses the "--initial-branch" flag when it runs git-init
6+
push:
7+
branches:
8+
- "main"
9+
pull_request:
10+
branches:
11+
- "main"
12+
schedule:
13+
# Weekly tests run on main by default:
14+
# Scheduled workflows run on the latest commit on the default or base branch.
15+
# (from https://help.github.com/en/actions/reference/events-that-trigger-workflows#scheduled-events-schedule)
16+
- cron: "0 0 * * 0"
17+
18+
jobs:
19+
test:
20+
name: Test on ${{ matrix.os }}, Python ${{ matrix.python-version }}
21+
runs-on: ${{ matrix.os }}
22+
strategy:
23+
matrix:
24+
os: [ubuntu-latest, macos-latest, windows-latest]
25+
python-version: ['3.9', '3.10', '3.11']
26+
27+
steps:
28+
- uses: actions/checkout@v3
29+
30+
- name: Additional info about the build
31+
shell: bash
32+
run: |
33+
uname -a
34+
df -h
35+
ulimit -a
36+
37+
- uses: mamba-org/setup-micromamba@v1
38+
with:
39+
environment-file: env.yaml
40+
environment-name: test
41+
create-args: >- # beware the >- instead of |, we don't split on newlines but on spaces
42+
python=${{ matrix.python-version }}
43+
44+
- name: Install package
45+
# conda setup requires this special shell
46+
shell: bash -l {0}
47+
run: |
48+
python -m pip install .
49+
micromamba list
50+
51+
- name: Run tests
52+
# conda setup requires this special shell
53+
shell: bash -l {0}
54+
run: |
55+
pytest -v --cov=K2DG --cov-report=xml --color=yes tests/
56+
57+
- name: Upload coverage reports to Codecov
58+
uses: codecov/codecov-action@v3
59+
with:
60+
file: ./coverage.xml
61+
flags: unittests
62+
name: codecov-${{ matrix.os }}-py${{ matrix.python-version }}

README.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# k2dg
2+
3+
A command line tool to convert between dissociation constants and free energies.

env.yml

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
name: k2dg
2+
channels:
3+
- conda-forge
4+
- defaults
5+
dependencies:
6+
- python

k2dg/__init__.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from pint import UnitRegistry
2+
3+
ureg = UnitRegistry()
4+
Q_ = ureg.Quantity
5+
6+
from .conversion import *

k2dg/__main__.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from k2dg._cli import run_cli
2+
3+
4+
def main():
5+
run_cli()
6+
7+
8+
if __name__ == "__main__":
9+
main()

k2dg/_cli.py

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Command line interface for k2dg."""
2+
3+
import argparse
4+
from . import ureg, Q_
5+
from .conversion import dg0_to_kd0 as _dg0_to_kd0, kd0_to_dg0 as _kd0_to_dg0
6+
from ._parse import (
7+
_parse_dg_units,
8+
_parse_k_units,
9+
_parse_temperature,
10+
K_UNITS,
11+
DG_UNITS,
12+
)
13+
from ._print import _print_dg0, _print_kd0
14+
15+
16+
def run_cli() -> None:
17+
"""Run the command line interface."""
18+
19+
global_parser = argparse.ArgumentParser(prog="k2dg")
20+
global_parser.add_argument(
21+
"-v", "--version", action="version", version="%(prog)s 0.1.0"
22+
)
23+
subparsers = global_parser.add_subparsers(
24+
title="subcommands",
25+
help="Converters from dissociation constants to free energies of binding and vice versa.",
26+
)
27+
28+
# Add subparsers to handle conversion in each direction
29+
to_dg_parser = subparsers.add_parser(
30+
"2dg", help="Convert a dissociation constant to a free energy of binding."
31+
)
32+
to_kd_parser = subparsers.add_parser(
33+
"2kd", help="Convert a free energy of binding to a dissociation constant."
34+
)
35+
36+
subparser_dict = {
37+
to_dg_parser: {
38+
"units": K_UNITS,
39+
"value": "kd",
40+
"func": _kd0_to_dg0,
41+
"parse_func": _parse_k_units,
42+
"print_func": _print_dg0,
43+
},
44+
to_kd_parser: {
45+
"units": DG_UNITS,
46+
"value": "dg",
47+
"func": _dg0_to_kd0,
48+
"parse_func": _parse_dg_units,
49+
"print_func": _print_kd0,
50+
},
51+
}
52+
53+
for subparser in subparser_dict:
54+
subparser.add_argument(
55+
"value",
56+
type=float,
57+
help=f"The value of {subparser_dict[subparser]['value']}.",
58+
)
59+
subparser.add_argument(
60+
"units",
61+
type=str,
62+
help=f"The units of the value. Must be one of {list(subparser_dict[subparser]['units'].keys())}.",
63+
)
64+
subparser.add_argument(
65+
"-t",
66+
"--temperature",
67+
type=float,
68+
help="The temperature (in Kelvin) at which the dissociation constant was measured.",
69+
default=298.15,
70+
)
71+
subparser.set_defaults(func=subparser_dict[subparser]["func"])
72+
subparser.set_defaults(parse_func=subparser_dict[subparser]["parse_func"])
73+
subparser.set_defaults(print_func=subparser_dict[subparser]["print_func"])
74+
75+
args = global_parser.parse_args()
76+
# Check that a subparser was selected
77+
if not hasattr(args, "func"):
78+
global_parser.print_help()
79+
return
80+
units = args.parse_func(args.units)
81+
temperature = _parse_temperature(args.temperature)
82+
args.print_func(args.func(args.value * units, temperature))

k2dg/_parse.py

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Functions for parsing units from the command line input."""
2+
3+
from . import Q_
4+
5+
from pint import Quantity
6+
7+
K_UNITS = {"pM": 1e-12, "nM": 1e-9, "uM": 1e-6, "mM": 1e-3, "M": 1}
8+
DG_UNITS = {"kcal/mol": Q_(1, "kcal/mol"), "kJ/mol": Q_(1, "kJ/mol")}
9+
10+
11+
def _parse_k_units(units: str) -> float:
12+
"""
13+
Parse a string of units (for the dissociation constant) into a float. Note
14+
that there is an implcit conversion from the dissociation constant to the
15+
standard dissociation constant.
16+
"""
17+
lower_case_units = {k.lower(): v for k, v in K_UNITS.items()}
18+
try:
19+
return lower_case_units[units.lower()]
20+
except KeyError:
21+
raise ValueError(
22+
f"Units {units} not recognized. Please use one of {K_UNITS.keys()}."
23+
)
24+
25+
26+
def _parse_dg_units(units: str) -> Quantity:
27+
"""Parse a string of units (for the free energy of binding) into a float."""
28+
lower_case_units = {k.lower(): v for k, v in DG_UNITS.items()}
29+
try:
30+
return lower_case_units[units.lower()]
31+
except KeyError:
32+
raise ValueError(
33+
f"Units {units} not recognized. Please use one of {DG_UNITS.keys()}."
34+
)
35+
36+
37+
def _parse_temperature(temperature: float) -> Quantity:
38+
"""Parse a temperature in Kelvin."""
39+
if temperature < 100:
40+
raise ValueError(
41+
"Temperature must be in Kelvin. The supplied value is likely in Celsius."
42+
)
43+
return Q_(temperature, "K")

k2dg/_print.py

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Functions for printing results to the command line."""
2+
3+
from pint import Quantity
4+
from ._parse import K_UNITS
5+
6+
7+
def _print_dg0(dg0: Quantity) -> None:
8+
"""Print the free energy of binding in kcal/mol."""
9+
print(f"{dg0.to('kcal/mol').magnitude:#.3g} kcal/mol")
10+
11+
12+
def _print_kd0(kd0: float) -> None:
13+
"""
14+
Print the dissociation constant with an appropriate prefix. Note that there
15+
is an implicit conversion from the standard dissociation constant to the
16+
dissociation constant.
17+
"""
18+
# The appropriate prefix is the one that gives a value between 0.1 and 100
19+
# (i.e. between 1e-1 and 1e2)
20+
kd = kd0.magnitude
21+
for prefix, value in K_UNITS.items():
22+
if 1e-1 < kd / value < 1e2:
23+
print(f"{kd / value:#.3g} {prefix}")
24+
break
25+
# The value is less than 0.1 pM
26+
print(f"{kd / 1e-12:#.3g} pM")

k2dg/conversion.py

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Functions to convert between standard dissociation constants and free energies of binding."""
2+
3+
import numpy as np
4+
from pint import Quantity
5+
from scipy import constants
6+
7+
from . import ureg
8+
9+
__all__ = ["get_kbT", "kd0_to_dg0", "dg0_to_kd0"]
10+
11+
12+
@ureg.check("[temperature]")
13+
def get_kbT(temperature: Quantity) -> Quantity:
14+
"""Return the value of k_b * T at a given temperature."""
15+
k = constants.k * ureg.J / ureg.K
16+
na = constants.N_A / ureg.mol
17+
return k * temperature * na
18+
19+
20+
# @ureg.check("[None]", "[temperature]") # None is not accepted TODO: Find a work around
21+
def kd0_to_dg0(kd0: float, temperature: Quantity) -> Quantity:
22+
"""Convert an standard dissociation constant a standard free energy of binding."""
23+
return -get_kbT(temperature) * np.log(1 / kd0)
24+
25+
26+
@ureg.check("[energy] / [substance]", "[temperature]")
27+
def dg0_to_kd0(dg0: Quantity, temperature: Quantity) -> float:
28+
"""Convert a standard free energy of binding to a standard dissociation constant."""
29+
return 1 / np.exp(-dg0 / get_kbT(temperature))

pyproject.toml

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[project]
2+
name = "k2dg"
3+
version = "0.1.0"
4+
description = "A simple command line app to interconvert dissociation constants and free energies of binding."
5+
authors = [{name = "Finlay Clark"}]
6+
readme = "README.md"
7+
requires-python = ">=3.9.0"
8+
dependencies = ["numpy", "scipy", "pint", "pytest"]
9+
10+
[build-system]
11+
requires = ["setuptools>=64.0.0", "wheel"]
12+
build-backend = "setuptools.build_meta"
13+
14+
[project.scripts]
15+
k2dg = "k2dg.__main__:main"
16+

tests/__init__.py

Whitespace-only changes.

tests/test_conversion.py

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Test the conversion functions."""
2+
3+
import numpy as np
4+
from k2dg import conversion
5+
from k2dg import Q_
6+
7+
8+
def test_get_kbT():
9+
temperature = Q_(298.15, "K")
10+
expected_kbT = Q_(0.592483, "kcal/mol")
11+
assert np.isclose(
12+
conversion.get_kbT(temperature).to("kcal/mol").magnitude,
13+
expected_kbT.magnitude,
14+
rtol=1e-5,
15+
)
16+
17+
18+
def test_kd0_to_dg0():
19+
temperature = Q_(298.15, "K")
20+
kd0 = 0.347e-6
21+
expected_dg0 = Q_(-8.812586, "kcal/mol")
22+
print(conversion.kd0_to_dg0(kd0, temperature).to("kcal/mol").magnitude)
23+
assert np.isclose(
24+
conversion.kd0_to_dg0(kd0, temperature).to("kcal/mol").magnitude,
25+
expected_dg0.magnitude,
26+
rtol=1e-3,
27+
)
28+
29+
30+
def test_dg0_to_kd0():
31+
temperature = Q_(298.15, "K")
32+
dg0 = Q_(-8.812586, "kcal/mol")
33+
expected_kd0 = 0.347e-6
34+
print(conversion.dg0_to_kd0(dg0, temperature))
35+
assert np.isclose(
36+
conversion.dg0_to_kd0(dg0, temperature),
37+
expected_kd0,
38+
rtol=1e-3,
39+
)

tests/test_parse.py

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Tests for the parse module."""
2+
from k2dg._parse import _parse_k_units, _parse_dg_units, _parse_temperature
3+
4+
import pytest
5+
6+
from k2dg import Q_
7+
8+
9+
def test_parse_k_units():
10+
assert _parse_k_units("nM") == 1e-9
11+
assert _parse_k_units("M") == 1
12+
assert _parse_k_units("mM") == 1e-3
13+
assert _parse_k_units("uM") == 1e-6
14+
assert _parse_k_units("pM") == 1e-12
15+
with pytest.raises(ValueError) as e:
16+
_parse_k_units("foo")
17+
assert (
18+
str(e.value)
19+
== "Units foo not recognized. Please use one of dict_keys(['pM', 'nM', 'uM', 'mM', 'M'])."
20+
)
21+
22+
23+
def test_parse_dg_units():
24+
assert _parse_dg_units("kcal/mol") == Q_(1, "kcal/mol")
25+
assert _parse_dg_units("kJ/mol") == Q_(1, "kJ/mol")
26+
with pytest.raises(ValueError):
27+
_parse_dg_units("foo")
28+
29+
30+
def test_parse_temperature():
31+
assert _parse_temperature(273.15) == Q_(273.15, "K")
32+
assert _parse_temperature(300) == Q_(300, "K")
33+
with pytest.raises(ValueError):
34+
_parse_temperature(37)

0 commit comments

Comments
 (0)