From a4eaa9e6a67d31c97beb29d822cf81c41db775e7 Mon Sep 17 00:00:00 2001 From: Gabriel Gerlero Date: Thu, 22 Sep 2022 15:55:36 -0300 Subject: [PATCH] Add support for arbitrary charge numbers --- README.md | 32 +++--- electrolytes/__init__.py | 187 +++++++++++++++++------------------ electrolytes/__main__.py | 89 +++++++++++++---- electrolytes/_constituent.py | 37 ------- tests/test_api.py | 24 ++--- tests/test_cli.py | 58 +++++++++-- 6 files changed, 238 insertions(+), 189 deletions(-) delete mode 100644 electrolytes/_constituent.py diff --git a/README.md b/README.md index c23e649..19d3173 100644 --- a/README.md +++ b/README.md @@ -44,33 +44,37 @@ The Python API is provided for `electroMicroTransport` case setup scripts. from electrolytes import database, Properties ``` -You can directly use `database` as if it were a `dict` (technically, it is a [`MutableMapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping)), mapping component names to their properties (`Properties` class). Extra methods are also defined: +You can look up components in the `database` as you would with `dict` (with component names as keys), and also add user-defined components with the `add` method (as if `database` were a set). Components are instances of the `Constituent` class. Extra methods are also defined for `database`: ```python + def user_defined(self) -> Iterable[str]: ... def is_user_defined(self, name: str) -> bool: ... ``` -Component names are case insensitive and will be automatically converted to all uppercase. Any components added (or removed) will be saved for the current operating system user. Default components cannot be changed or removed (expect a `ValueError` if you try). +`Constituent` names are case insensitive and will be automatically converted to all uppercase. Any instances added to (or removed from) the `database` will be saved for the current operating system user. Default components cannot be changed or removed (expect a `ValueError` if you try). -The public stubs of the `Properties` class are: +The public stubs of the `Constituent` class are: ```python -class Properties: +class Constituent: def __init__(self, - mobilities: Sequence[Optional[float]], - pkas: Sequence[Optional[float]]): ... - - def mobilities(self) -> Sequence[float]: ... - - def pkas(self) -> Sequence[float]: ... - - def diffusivity(self) -> float: ... + *, + name: str, + u_neg: Sequence[float], # [-neg_count, -neg_count+1, -neg_count+2, ..., -1] + u_pos: Sequence[float], # [+1, +2, +3, ..., +pos_count] + pkas_neg: Sequence[float], # [-neg_count, -neg_count+1, -neg_count+2, ..., -1] + pkas_pos: Sequence[float], # [+1, +2, +3, ..., +pos_count] + neg_count: int = None, + pos_count: int = None): ... + + # Interface for electroMicroTransport + def mobilities(self) -> Sequence[float]: ... # [+n, ..., +3, +2, +1, -1, -2, -3, ..., -n] (with n >= 3), SI units + def pkas(self) -> Sequence[float]: ... # [+n, ..., +3, +2, +1, -1, -2, -3, ..., -n] (with n >= 3) + def diffusivity(self) -> float: ... # SI units ``` -with all sequences of length 6 (for +3, +2, +1, -1, -2, -3 respectively). Mobilities and diffusivities are in SI units. - # Data credits Electrolyte data taken from the Simul 6 [1] application ([homepage](https://simul6.app), [GitHub](https://github.com/hobrasoft/simul6)). The dataset of different electrolytes was originally compiled by Prof. Hirokawa [2]. diff --git a/electrolytes/__init__.py b/electrolytes/__init__.py index 3569754..2358026 100644 --- a/electrolytes/__init__.py +++ b/electrolytes/__init__.py @@ -1,19 +1,94 @@ import pkgutil import json from pathlib import Path -from collections import abc from typing import Iterable, Iterator, List, Sequence, Dict, Optional -from pydantic import parse_file_as, parse_raw_as +from pydantic import BaseModel, Field, validator, root_validator, parse_file_as, parse_raw_as from typer import get_app_dir -from ._constituent import Constituent - _APP_NAME = "electrolytes" __version__ = "0.1.2" +class Constituent(BaseModel): + id: int = -1 + name: str + u_neg: List[float] = Field([], alias="uNeg") # [-neg_count, -neg_count+1, -neg_count+2, ..., -1] + u_pos: List[float] = Field([], alias="uPos") # [+1, +2, +3, ..., +pos_count] + pkas_neg: List[float] = Field([], alias="pKaNeg") # [-neg_count, -neg_count+1, -neg_count+2, ..., -1] + pkas_pos: List[float] = Field([], alias="pKaPos") # [+1, +2, +3, ..., +pos_count] + neg_count: int = Field(None, alias="negCount") + pos_count: int = Field(None, alias="posCount") + + @property + def charges_neg(self) -> range: + return range(-self.neg_count, 0) + + @property + def charges_pos(self) -> range: + return range(1, self.pos_count + 1) + + def mobilities(self) -> Sequence[float]: + n = max(self.neg_count, self.pos_count, 3) + ret = [0.0]*(n - self.pos_count) + [u*1e-9 for u in self.u_pos[::-1]] + [u*1e-9 for u in self.u_neg[::-1]] + [0.0]*(n - self.neg_count) + assert len(ret) == 2*n + return ret + + def pkas(self) -> Sequence[float]: + n = max(self.neg_count, self.pos_count, 3) + ret = [self._default_pka(c) for c in range(n, self.pos_count, -1)] + self.pkas_pos[::-1] + self.pkas_neg[::-1] + [self._default_pka(-c) for c in range(self.neg_count+1, n+1)] + assert len(ret) == 2*n + return ret + + def diffusivity(self) -> float: + mobs = [] + try: + mobs.append(self.u_neg[-1]*1e-9) + except IndexError: + pass + try: + mobs.append(self.u_pos[0]*1e-9) + except IndexError: + pass + + return max(mobs, default=0)*8.314*300/96485 + + @staticmethod + def _default_pka(charge: int) -> float: + assert charge != 0 + if charge < 0: + return 14 - charge + else: + return -charge + + class Config: + allow_population_by_field_name = True + + @validator("pkas_neg", "pkas_pos") + def pka_lengths(cls, v, values, field): + if len(v) != len(values[f"u_{field.name[5:]}"]): + raise ValueError(f"len({field.name}) != len(u_{field.name[5:]})") + return v + + @validator("neg_count", "pos_count", always=True) + def counts(cls, v, values, field): + if v is None: + v = len(values[f"u_{field.name[:3]}"]) + elif v != len(values[f"u_{field.name[:3]}"]): + raise ValueError(f"{field.name} != len(u_{field.name[:3]})") + return v + + @root_validator + def pkas_not_increasing(cls, values): + pkas = [*values["pkas_neg"], *values["pkas_pos"]] + + if not all(x>=y for x, y in zip(pkas, pkas[1:])): + raise ValueError("pKa values must not increase with charge") + + return values + + _USER_CONSTITUENTS_FILE = Path(get_app_dir(_APP_NAME), "user_constituents.json") def _load_user_constituents() -> Dict[str, Constituent]: @@ -35,7 +110,7 @@ def _load_default_constituents() -> Dict[str, Constituent]: raise RuntimeError("failed to load default constituents") constituents = parse_raw_as(Dict[str, List[Constituent]], data)["constituents"] - + for c in constituents: if " " in c.name: c.name = c.name.replace(" ", "_") @@ -45,90 +120,7 @@ def _load_default_constituents() -> Dict[str, Constituent]: return {c.name: c for c in constituents} -class Properties: - - def __init__(self, mobilities: Sequence[Optional[float]], pkas: Sequence[Optional[float]]): - self._check_omitted_charges(mobilities, pkas) - self._mobilities = mobilities - self._pkas = pkas - - - def mobilities(self) -> Sequence[float]: - return [m if m is not None else 0 for m in self._mobilities] - - - DEFAULT_PKAS = (-3, -2, -1, 15, 16, 17) - - def pkas(self) -> Sequence[float]: - return [p if p is not None else d for p,d in zip(self._pkas, self.DEFAULT_PKAS)] - - - def diffusivity(self) -> float: - return max(self.mobilities())*8.314*300/96485 - - - @staticmethod - def _check_omitted_charges(mobilities: Sequence[Optional[float]], pkas: Sequence[Optional[float]]) -> None: - assert len(mobilities) == 6 and len(pkas) == 6 - - negative = (mobilities[3:], pkas[3:]) - positive = (mobilities[:3][::-1], pkas[:3][::-1]) - - for mobilities,pkas in negative, positive: - assert len(mobilities) == 3 and len(pkas) == 3 - - any_omitted = False - for m,p in zip(mobilities, pkas): - if m is None or p is None: - if m is not None or p is not None: - raise ValueError("to omit a charge, both mobility and pKa must be None") - any_omitted = True - elif any_omitted: - raise ValueError("can only omit charges at the extremes") - - - def _as_constituent(self, name: str) -> Constituent: - - uNeg = [m*1e9 for m in self._mobilities[3:][::-1] if m is not None] - uPos = [m*1e9 for m in self._mobilities[:3][::-1] if m is not None] - - pKaNeg = [p for p in self._pkas[3:][::-1] if p is not None] - pKaPos = [p for p in self._pkas[:3][::-1] if p is not None] - - return Constituent(id=-1, - name=name, - negCount=len(uNeg), - posCount=len(uPos), - uNeg=uNeg, - uPos=uPos, - pKaNeg=pKaNeg, - pKaPos=pKaPos) - - @staticmethod - def _of_constituent(constituent: Constituent) -> "Properties": - if constituent.negCount > 3 or constituent.posCount > 3: - raise ValueError - - mobilities: List[Optional[float]] = [None]*6 - pkas: List[Optional[float]] = [None]*6 - - for j in range(constituent.negCount): - mobilities[3+j] = 1e-9*constituent.uNeg[-j-1] - pkas[3+j] = constituent.pKaNeg[-j-1] - - for j in range(constituent.posCount): - mobilities[2-j] = 1e-9*constituent.uPos[j] - pkas[2-j] = constituent.pKaPos[j] - - return Properties(mobilities=mobilities, - pkas=pkas) - - - def __repr__(self) -> str: - return f"{self.__class__.__name__}(mobilities={self._mobilities!r}, pkas={self._pkas!r})" - - -class _Database(abc.MutableMapping): +class _Database: def __init__(self): super().__init__() @@ -154,21 +146,18 @@ def __iter__(self) -> Iterator[str]: yield from sorted([*self._default_constituents, *self._user_constituents]) - def __getitem__(self, name: str) -> Properties: + def __getitem__(self, name: str) -> Constituent: name = name.upper() try: - return Properties._of_constituent(self._user_constituents[name]) + return self._user_constituents[name] except KeyError: - return Properties._of_constituent(self._default_constituents[name]) + return self._default_constituents[name] - def __setitem__(self, name: str, properties: Properties) -> None: - name = name.upper() - if name in self._default_constituents: - raise ValueError(f"{name}: cannot replace default component") - - self._user_constituents[name] = properties._as_constituent(name) - _save_user_constituents(self._user_constituents) + def add(self, constituent: Constituent) -> None: + if constituent.name not in self: + self._user_constituents[constituent.name] = constituent + _save_user_constituents(self._user_constituents) def __delitem__(self, name: str) -> None: diff --git a/electrolytes/__main__.py b/electrolytes/__main__.py index 0541ed6..d917a55 100644 --- a/electrolytes/__main__.py +++ b/electrolytes/__main__.py @@ -3,7 +3,7 @@ import typer from click import Context, Parameter -from . import _APP_NAME, database, Properties +from . import _APP_NAME, database, Constituent app = typer.Typer() @@ -18,28 +18,68 @@ def complete_name_user_defined(ctx: Context, patam: Parameter, incomplete: str) @app.command() def add(name: str = typer.Argument(..., shell_complete=complete_name_user_defined), - p1: Tuple[float, float] = typer.Option((None, Properties.DEFAULT_PKAS[2]), "+1", help="Mobility (*1e-9) and pKa for +1"), - p2: Tuple[float, float] = typer.Option((None, Properties.DEFAULT_PKAS[1]), "+2", help="Mobility (*1e-9) and pKa for +2"), - p3: Tuple[float, float] = typer.Option((None, Properties.DEFAULT_PKAS[0]), "+3", help="Mobility (*1e-9) and pKa for +3"), - m1: Tuple[float, float] = typer.Option((None, Properties.DEFAULT_PKAS[3]), "-1", help="Mobility (*1e-9) and pKa for -1"), - m2: Tuple[float, float] = typer.Option((None, Properties.DEFAULT_PKAS[4]), "-2", help="Mobility (*1e-9) and pKa for -2"), - m3: Tuple[float, float] = typer.Option((None, Properties.DEFAULT_PKAS[5]), "-3", help="Mobility (*1e-9) and pKa for -3"), + p1: Tuple[float, float] = typer.Option((None, None), "+1", help="Mobility (*1e-9) and pKa for +1"), + p2: Tuple[float, float] = typer.Option((None, None), "+2", help="Mobility (*1e-9) and pKa for +2"), + p3: Tuple[float, float] = typer.Option((None, None), "+3", help="Mobility (*1e-9) and pKa for +3"), + p4: Tuple[float, float] = typer.Option((None, None), "+4", help="Mobility (*1e-9) and pKa for +4"), + p5: Tuple[float, float] = typer.Option((None, None), "+5", help="Mobility (*1e-9) and pKa for +5"), + p6: Tuple[float, float] = typer.Option((None, None), "+6", help="Mobility (*1e-9) and pKa for +6"), + m1: Tuple[float, float] = typer.Option((None, None), "-1", help="Mobility (*1e-9) and pKa for -1"), + m2: Tuple[float, float] = typer.Option((None, None), "-2", help="Mobility (*1e-9) and pKa for -2"), + m3: Tuple[float, float] = typer.Option((None, None), "-3", help="Mobility (*1e-9) and pKa for -3"), + m4: Tuple[float, float] = typer.Option((None, None), "-4", help="Mobility (*1e-9) and pKa for -4"), + m5: Tuple[float, float] = typer.Option((None, None), "-5", help="Mobility (*1e-9) and pKa for -5"), + m6: Tuple[float, float] = typer.Option((None, None), "-6", help="Mobility (*1e-9) and pKa for -6"), force: bool = typer.Option(False, "-f", help="Replace any existing user-defined component with the same name")) -> None: """Save a user-defined component""" + name = name.upper() - mobilities, pkas = zip(*((None, None) if m is None else (m,p) for m,p in (p3, p2, p1, m1, m2, m3))) - - if all(m is None for m in mobilities): + if not p1 and not m1: typer.echo("Error: at least one of the +1 or -1 options is required", err=True) raise typer.Exit(code=1) + neg: List[Tuple[float, float]] = [] + any_omitted = False + for i,m in enumerate([m1, m2, m3, m4, m5, m6]): + if m[0] is None: + assert m[1] is None + any_omitted = True + elif any_omitted: + typer.echo(f"Error: missing charge +{i}", err=True) + raise typer.Exit(code=1) + else: + neg.insert(0, m) + + pos: List[Tuple[float, float]] = [] + any_omitted = False + for i,p in enumerate([p1, p2, p3, p4, p5, p6]): + if p[0] is None: + assert p[1] is None + any_omitted = True + elif any_omitted: + typer.echo(f"Error: missing charge -{i}", err=True) + raise typer.Exit(code=1) + else: + pos.append(p) + if not force and database.is_user_defined(name): - typer.echo(f"Error: user-defined component {name.upper()} already exists (use -f to replace)", err=True) + typer.echo(f"Error: user-defined component {name} already exists (use -f to replace)", err=True) raise typer.Exit(code=1) try: - database[name.upper()] = Properties(mobilities=[m*1e-9 if m is not None else None for m in mobilities], - pkas=pkas) + constituent = Constituent(name=name, + u_neg=[x[0] for x in neg], + u_pos=[x[0] for x in pos], + pkas_neg=[x[1] for x in neg], + pkas_pos=[x[1] for x in pos]) + + try: + del database[name] + except KeyError: + pass + + database.add(constituent) + except Exception as e: typer.echo(f"Error: {e}", err=True) raise typer.Exit(code=1) @@ -49,18 +89,25 @@ def add(name: str = typer.Argument(..., shell_complete=complete_name_user_define def info(name: str = typer.Argument(..., shell_complete=complete_name)) -> None: """Show the properties of a component""" try: - props = database[name] - typer.echo(f"Component: {name.upper()}") - if database.is_user_defined(name): - typer.echo("[user-defined]") - typer.echo(f" {'+3':^8} {'+2':^8} {'+1':^8} {'-1':^8} {'-2':^8} {'-3':^8}") - typer.echo( "Mobilities *1e-9: {:^8.2f} {:^8.2f} {:^8.2f} {:^8.2f} {:^8.2f} {:^8.2f}".format(*(m*1e9 for m in props.mobilities()))) - typer.echo( "pKas: {:^8.2f} {:^8.2f} {:^8.2f} {:^8.2f} {:^8.2f} {:^8.2f}".format(*props.pkas())) - typer.echo(f"Diffusivity: {props.diffusivity():.4e}") + constituent = database[name] except KeyError: typer.echo(f"Error: {name}: no such component", err=True) raise typer.Exit(code=1) + charges = list(constituent.charges_pos[::-1]) + list(constituent.charges_neg[::-1]) + uu = constituent.u_pos[::-1] + constituent.u_neg[::-1] + pkas = constituent.pkas_pos[::-1] + constituent.pkas_neg[::-1] + + assert len(charges) == len(uu) == len(pkas) + + typer.echo(f"Component: {name}") + if database.is_user_defined(name): + typer.echo("[user-defined]") + typer.echo( " " + " ".join(f"{c:^+8d}" for c in charges)) + typer.echo( "Mobilities *1e-9:" + " ".join(f"{u:^8.2f}" for u in uu)) + typer.echo( "pKas: " + " ".join(f"{p:^8.2f}" for p in pkas)) + typer.echo(f"Diffusivity: {constituent.diffusivity():.4e}") + @app.command() def ls(user_only: bool=typer.Option(False, "--user", help="Show only user-defined components")) -> None: diff --git a/electrolytes/_constituent.py b/electrolytes/_constituent.py deleted file mode 100644 index 895e91b..0000000 --- a/electrolytes/_constituent.py +++ /dev/null @@ -1,37 +0,0 @@ -from collections import abc -from typing import List, Iterator, Iterable - -from pydantic import BaseModel, validator, root_validator - - -class Constituent(BaseModel): - """constituents.json representation""" - id: int - name: str - negCount: int - posCount: int - uNeg: List[float] # [-n, -n+1, -n+2, ...] - uPos: List[float] # [+1, +2, +3, ...] - pKaNeg: List[float] # [-n, -n+1, -n+2, ...] - pKaPos: List[float] # [+1, +2, +3, ...] - - @validator("uNeg", "pKaNeg") - def len_neg(cls, v, values, field): - if len(v) != values["negCount"]: - raise ValueError(f"len({field}) != negCount") - return v - - @validator("uPos", "pKaPos") - def len_pos(cls, v, values, field): - if len(v) != values["posCount"]: - raise ValueError(f"len({field}) != posCount") - return v - - @root_validator - def pkas_not_increasing(cls, values): - pkas = [*values["pKaNeg"], *values["pKaPos"]] - - if not all(x>=y for x, y in zip(pkas, pkas[1:])): - raise ValueError("pKa values must not increase with charge") - - return values diff --git a/tests/test_api.py b/tests/test_api.py index 440e2ec..dec4269 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -19,18 +19,20 @@ def test_list_components() -> None: def test_get_component() -> None: - props = database["LYSINE"] - assert isinstance(props, Properties) - assert len(props.mobilities()) == 6 - assert len(props.pkas()) == 6 - assert props.diffusivity() == pytest.approx(max(props.mobilities())*8.314*300/96485) + c = database["LYSINE"] + assert isinstance(c, Constituent) + assert len(c.mobilities()) == 6 + assert len(c.pkas()) == 6 + print(c.mobilities()) + print(c.diffusivity()) + assert c.diffusivity() == pytest.approx(28.60*1e-9*8.314*300/96485) def test_known_component_properties() -> None: - props = database["CYSTINE"] - props.mobilities() == pytest.approx([0.0, 5.39e-08, 2.7e-08, 2.7e-08, 5.39e-08, 0.0]) - props.diffusivity() == pytest.approx(1.393350054412603e-09) - props.pkas() == pytest.approx([-3.0, 1.65, 2.26, 8.405, 9.845, 17]) + c = database["CYSTINE"] + c.mobilities() == pytest.approx([0.0, 5.39e-08, 2.7e-08, 2.7e-08, 5.39e-08, 0.0]) + c.diffusivity() == pytest.approx(1.393350054412603e-09) + c.pkas() == pytest.approx([-3.0, 1.65, 2.26, 8.405, 9.845, 17]) def test_try_get_nonexistent() -> None: @@ -48,5 +50,5 @@ def test_try_add_default() -> None: assert not database.is_user_defined("SILVER") assert "SILVER" not in database.user_defined() with pytest.raises(ValueError): - database["SILVER"] = Properties(mobilities=[0, 0, 64.50, 0, 0, 0], - pkas=[-3.00, -2.00, 11.70, 15.00, 16.00, 17.00]) \ No newline at end of file + database.add(Constituent(u_pos=[64.50], + pkas_pos=[11.70])) \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index bc02e5e..be0c901 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,3 @@ -from typing import NamedTuple import pytest from typer.testing import CliRunner @@ -33,12 +32,14 @@ def test_add_and_rm() -> None: assert result.exit_code == 0 assert name in database - props = database[name] - assert props.mobilities() == pytest.approx([0, 0, 6e-9, 2e-9, 4e-9, 0]) - assert props.pkas()[0] == Properties.DEFAULT_PKAS[0] - assert props.pkas()[1] == Properties.DEFAULT_PKAS[1] - assert props.pkas()[2:-1] == pytest.approx([-1.5, 3, 5]) - assert props.pkas()[-1] == Properties.DEFAULT_PKAS[-1] + c = database[name] + assert len(c.mobilities()) == 6 + assert len(c.pkas()) == 6 + assert c.mobilities() == pytest.approx([0, 0, 6e-9, 2e-9, 4e-9, 0]) + assert c.pkas()[0] == Constituent._default_pka(+3) + assert c.pkas()[1] == Constituent._default_pka(+2) + assert c.pkas()[2:-1] == pytest.approx([-1.5, 3, 5]) + assert c.pkas()[-1] == Constituent._default_pka(-3) result = runner.invoke(app, ["info", name]) assert result.exit_code == 0 @@ -53,3 +54,46 @@ def test_add_and_rm() -> None: result = runner.invoke(app, ["rm", name]) assert result.exit_code != 0 + + +def test_extra_charges() -> None: + name = "TEST1328849821" + try: + del database[name] + except KeyError: + pass + + assert name not in database + with pytest.raises(KeyError): + database[name] + + result = runner.invoke(app, ["add", name, + "+1", "5", "8", + "+2", "7", "6", + "+3", "9", "4", + "+4", "11", "2", + "-1", "1", "10", + "-2", "3", "12"]) + + + assert result.exit_code == 0 + assert name in database + c = database[name] + assert len(c.mobilities()) == 8 + assert len(c.pkas()) == 8 + assert c.mobilities() == pytest.approx([11e-9, 9e-9, 7e-9, 5e-9, 1e-9, 3e-9, 0, 0]) + assert c.pkas() == pytest.approx([2, 4, 6, 8, 10, 12, Constituent._default_pka(-3), Constituent._default_pka(-4)]) + + result = runner.invoke(app, ["info", name]) + assert result.exit_code == 0 + + result = runner.invoke(app, ["rm", name]) + assert result.exit_code == 0 + + with pytest.raises(KeyError): + del database[name] + + assert name not in database + + result = runner.invoke(app, ["rm", name]) + assert result.exit_code != 0 \ No newline at end of file