Skip to content

Commit

Permalink
Add support for arbitrary charge numbers
Browse files Browse the repository at this point in the history
  • Loading branch information
gerlero committed Dec 28, 2022
1 parent 6a8d774 commit a4eaa9e
Show file tree
Hide file tree
Showing 6 changed files with 238 additions and 189 deletions.
32 changes: 18 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
187 changes: 88 additions & 99 deletions electrolytes/__init__.py
Original file line number Diff line number Diff line change
@@ -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]:
Expand All @@ -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(" ", "_")
Expand All @@ -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__()
Expand All @@ -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:
Expand Down
Loading

0 comments on commit a4eaa9e

Please sign in to comment.