From 61ebf548ee9c456501d81cfd0a55a08557c52611 Mon Sep 17 00:00:00 2001 From: Paul Kienzle Date: Wed, 7 Jan 2026 20:39:37 -0500 Subject: [PATCH 01/10] Add type hinting. Fixes #104 --- doc/sphinx/discoverer/__init__.py | 6 +- doc/sphinx/discoverer/discoverer.py | 4 +- doc/sphinx/shelltable/__init__.py | 6 +- periodictable/__init__.py | 131 +++++++++++++++++- periodictable/activation.py | 78 +++++++++-- periodictable/core.py | 201 ++++++++++++++++++++-------- periodictable/covalent_radius.py | 4 +- periodictable/cromermann.py | 19 ++- periodictable/crystal_structure.py | 10 +- periodictable/density.py | 10 +- periodictable/fasta.py | 80 +++++++---- periodictable/formulas.py | 110 +++++++++------ periodictable/magnetic_ff.py | 22 +-- periodictable/mass.py | 14 +- periodictable/mass_2001.py | 4 +- periodictable/nsf.py | 93 +++++++------ periodictable/nsf_tables.py | 2 +- periodictable/util.py | 4 +- periodictable/xsf.py | 7 +- 19 files changed, 579 insertions(+), 226 deletions(-) diff --git a/doc/sphinx/discoverer/__init__.py b/doc/sphinx/discoverer/__init__.py index a4e5daf..79565f2 100644 --- a/doc/sphinx/discoverer/__init__.py +++ b/doc/sphinx/discoverer/__init__.py @@ -1,4 +1,4 @@ -import periodictable.core +from periodictable.core import default_table, delayed_load # Delayed loading of the element discoverer information def _load_discoverer(): @@ -6,5 +6,5 @@ def _load_discoverer(): The name of the person or group who discovered the element. """ from . import discoverer - discoverer.init(periodictable.core.default_table()) -periodictable.core.delayed_load(['discoverer'], _load_discoverer) + discoverer.init(default_table()) +delayed_load(['discoverer'], _load_discoverer) diff --git a/doc/sphinx/discoverer/discoverer.py b/doc/sphinx/discoverer/discoverer.py index e28b82d..8235756 100644 --- a/doc/sphinx/discoverer/discoverer.py +++ b/doc/sphinx/discoverer/discoverer.py @@ -4,14 +4,14 @@ From http://en.wikipedia.org/wiki/Discoveries_of_the_chemical_elements. """ -import periodictable.core +from periodictable.core import Element def init(table, reload=False): if 'discoverer' in table.properties and not reload: return table.properties.append('discoverer') # Set the default, if any - periodictable.core.Element.discoverer = "Unknown" + Element.discoverer = "Unknown" # Not numeric, so no discoverer_units diff --git a/doc/sphinx/shelltable/__init__.py b/doc/sphinx/shelltable/__init__.py index eabfb21..6db1a66 100644 --- a/doc/sphinx/shelltable/__init__.py +++ b/doc/sphinx/shelltable/__init__.py @@ -1,4 +1,4 @@ -import periodictable.core +from periodictable.core import default_table, delayed_load # Delayed loading of the element discoverer information def _load(): @@ -6,6 +6,6 @@ def _load(): The name of the person or group who discovered the element. """ from . import shelltable - shelltable.init(periodictable.core.default_table()) -periodictable.core.delayed_load( + shelltable.init(default_table()) +delayed_load( ['shells'], _load, isotope=True, element=False) diff --git a/periodictable/__init__.py b/periodictable/__init__.py index 2f240a4..4cb30bc 100644 --- a/periodictable/__init__.py +++ b/periodictable/__init__.py @@ -77,6 +77,126 @@ def __dir__(): density.init(elements) del mass, density +# For type hinting with vscode we need an explicit list of elements. +hydrogen = H = elements.symbol("H") +helium = He = elements.symbol("He") +lithium = Li = elements.symbol("Li") +beryllium = Be = elements.symbol("Be") +boron = B = elements.symbol("B") +carbon = C = elements.symbol("C") +nitrogen = N = elements.symbol("N") +oxygen = O = elements.symbol("O") +fluorine = F = elements.symbol("F") +neon = Ne = elements.symbol("Ne") +sodium = Na = elements.symbol("Na") +magnesium = Mg = elements.symbol("Mg") +aluminum = Al = elements.symbol("Al") +silicon = Si = elements.symbol("Si") +phosphorus = P = elements.symbol("P") +sulfur = S = elements.symbol("S") +chlorine = Cl = elements.symbol("Cl") +argon = Ar = elements.symbol("Ar") +potassium = K = elements.symbol("K") +calcium = Ca = elements.symbol("Ca") +scandium = Sc = elements.symbol("Sc") +titanium = Ti = elements.symbol("Ti") +vanadium = V = elements.symbol("V") +chromium = Cr = elements.symbol("Cr") +manganese = Mn = elements.symbol("Mn") +iron = Fe = elements.symbol("Fe") +cobalt = Co = elements.symbol("Co") +nickel = Ni = elements.symbol("Ni") +copper = Cu = elements.symbol("Cu") +zinc = Zn = elements.symbol("Zn") +gallium = Ga = elements.symbol("Ga") +germanium = Ge = elements.symbol("Ge") +arsenic = As = elements.symbol("As") +selenium = Se = elements.symbol("Se") +bromine = Br = elements.symbol("Br") +krypton = Kr = elements.symbol("Kr") +rubidium = Rb = elements.symbol("Rb") +strontium = Sr = elements.symbol("Sr") +yttrium = Y = elements.symbol("Y") +zirconium = Zr = elements.symbol("Zr") +niobium = Nb = elements.symbol("Nb") +molybdenum = Mo = elements.symbol("Mo") +technetium = Tc = elements.symbol("Tc") +ruthenium = Ru = elements.symbol("Ru") +rhodium = Rh = elements.symbol("Rh") +palladium = Pd = elements.symbol("Pd") +silver = Ag = elements.symbol("Ag") +cadmium = Cd = elements.symbol("Cd") +indium = In = elements.symbol("In") +tin = Sn = elements.symbol("Sn") +antimony = Sb = elements.symbol("Sb") +tellurium = Te = elements.symbol("Te") +iodine = I = elements.symbol("I") +xenon = Xe = elements.symbol("Xe") +cesium = Cs = elements.symbol("Cs") +barium = Ba = elements.symbol("Ba") +lanthanum = La = elements.symbol("La") +cerium = Ce = elements.symbol("Ce") +praseodymium = Pr = elements.symbol("Pr") +neodymium = Nd = elements.symbol("Nd") +promethium = Pm = elements.symbol("Pm") +samarium = Sm = elements.symbol("Sm") +europium = Eu = elements.symbol("Eu") +gadolinium = Gd = elements.symbol("Gd") +terbium = Tb = elements.symbol("Tb") +dysprosium = Dy = elements.symbol("Dy") +holmium = Ho = elements.symbol("Ho") +erbium = Er = elements.symbol("Er") +thulium = Tm = elements.symbol("Tm") +ytterbium = Yb = elements.symbol("Yb") +lutetium = Lu = elements.symbol("Lu") +hafnium = Hf = elements.symbol("Hf") +tantalum = Ta = elements.symbol("Ta") +tungsten = W = elements.symbol("W") +rhenium = Re = elements.symbol("Re") +osmium = Os = elements.symbol("Os") +iridium = Ir = elements.symbol("Ir") +platinum = Pt = elements.symbol("Pt") +gold = Au = elements.symbol("Au") +mercury = Hg = elements.symbol("Hg") +thallium = Tl = elements.symbol("Tl") +lead = Pb = elements.symbol("Pb") +bismuth = Bi = elements.symbol("Bi") +polonium = Po = elements.symbol("Po") +astatine = At = elements.symbol("At") +radon = Rn = elements.symbol("Rn") +francium = Fr = elements.symbol("Fr") +radium = Ra = elements.symbol("Ra") +actinium = Ac = elements.symbol("Ac") +thorium = Th = elements.symbol("Th") +protactinium = Pa = elements.symbol("Pa") +uranium = U = elements.symbol("U") +neptunium = Np = elements.symbol("Np") +plutonium = Pu = elements.symbol("Pu") +americium = Am = elements.symbol("Am") +curium = Cm = elements.symbol("Cm") +berkelium = Bk = elements.symbol("Bk") +californium = Cf = elements.symbol("Cf") +einsteinium = Es = elements.symbol("Es") +fermium = Fm = elements.symbol("Fm") +mendelevium = Md = elements.symbol("Md") +nobelium = No = elements.symbol("No") +lawrencium = Lr = elements.symbol("Lr") +rutherfordium = Rf = elements.symbol("Rf") +dubnium = Db = elements.symbol("Db") +seaborgium = Sg = elements.symbol("Sg") +bohrium = Bh = elements.symbol("Bh") +hassium = Hs = elements.symbol("Hs") +meitnerium = Mt = elements.symbol("Mt") +darmstadtium = Ds = elements.symbol("Ds") +roentgenium = Rg = elements.symbol("Rg") +copernicium = Cn = elements.symbol("Cn") +nihonium = Nh = elements.symbol("Nh") +flerovium = Fl = elements.symbol("Fl") +moscovium = Mc = elements.symbol("Mc") +livermorium = Lv = elements.symbol("Lv") +tennessine = Ts = elements.symbol("Ts") +oganesson = Og = elements.symbol("Og") + # Add element name and symbol (e.g. nickel and Ni) to the public attributes. __all__ += core.define_elements(elements, globals()) @@ -91,6 +211,7 @@ def _load_covalent_radius(): 'covalent_radius_units', 'covalent_radius_uncertainty'], _load_covalent_radius) +del _load_covalent_radius def _load_crystal_structure(): """ @@ -102,6 +223,7 @@ def _load_crystal_structure(): from . import crystal_structure crystal_structure.init(elements) core.delayed_load(['crystal_structure'], _load_crystal_structure) +del _load_crystal_structure def _load_neutron(): """ @@ -114,6 +236,7 @@ def _load_neutron(): from . import nsf nsf.init(elements) core.delayed_load(['neutron'], _load_neutron, isotope=True) +del _load_neutron def _load_neutron_activation(): """ @@ -127,6 +250,7 @@ def _load_neutron_activation(): activation.init(elements) core.delayed_load(['neutron_activation'], _load_neutron_activation, element=False, isotope=True) +del _load_neutron_activation def _load_xray(): """ @@ -138,6 +262,7 @@ def _load_xray(): from . import xsf xsf.init(elements) core.delayed_load(['xray'], _load_xray, ion=True) +del _load_xray def _load_emission_lines(): """ @@ -149,6 +274,7 @@ def _load_emission_lines(): xsf.init_spectral_lines(elements) core.delayed_load(['K_alpha', 'K_beta1', 'K_alpha_units', 'K_beta1_units'], _load_emission_lines) +del _load_emission_lines def _load_magnetic_ff(): """ @@ -161,6 +287,7 @@ def _load_magnetic_ff(): from . import magnetic_ff magnetic_ff.init(elements) core.delayed_load(['magnetic_ff'], _load_magnetic_ff) +del _load_magnetic_ff # Data needed for setup.py when bundling the package into an exe @@ -173,9 +300,11 @@ def data_files(): """ import os import glob + from .core import get_data_path + def _finddata(ext, patterns): files = [] - path = core.get_data_path(ext) + path = get_data_path(ext) for p in patterns: files += glob.glob(os.path.join(path, p)) return files diff --git a/periodictable/activation.py b/periodictable/activation.py index 54f696d..d290c5a 100644 --- a/periodictable/activation.py +++ b/periodictable/activation.py @@ -224,19 +224,20 @@ from math import exp, log, expm1 import os +from collections.abc import Callable, Sequence -from .formulas import formula as build_formula +from .formulas import formula as build_formula, Formula, FormulaInput from . import core LN2 = log(2) -def table_abundance(iso): +def table_abundance(iso: core.Isotope) -> float: """ Isotopic abundance in % from the periodic table package. """ return iso.abundance -def IAEA1987_isotopic_abundance(iso): +def IAEA1987_isotopic_abundance(iso: core.Isotope) -> float: """ Isotopic abundance in % from the IAEA, as provided in the activation.dat table. @@ -269,7 +270,15 @@ class Sample: Name of the sample (defaults to formula). """ - def __init__(self, formula, mass, name=None): + formula: Formula + mass: float + name: str + activity: dict["ActivationResult", list[float]] + environment: "ActivationEnvironment" + exposure: float + rest_time: tuple[float] + + def __init__(self, formula: FormulaInput, mass: float, name: str|None=None): self.formula = build_formula(formula) self.mass = mass # cell F19 self.name = name if name else str(self.formula) # cell F20 @@ -280,9 +289,13 @@ def __init__(self, formula, mass, name=None): self.exposure = 0. self.rest_times = () - def calculate_activation(self, environment, exposure=1, - rest_times=(0, 1, 24, 360), - abundance=table_abundance): + def calculate_activation( + self, + environment: "ActivationEnvironment", + exposure: float=1, + rest_times: tuple[float]=(0, 1, 24, 360), + abundance: Callable[[core.Isotope], float]=table_abundance, + ): """ Calculate sample activation (uCi) after exposure to a neutron flux. @@ -313,7 +326,7 @@ def calculate_activation(self, environment, exposure=1, A = activity(el[iso], iso_mass, environment, exposure, rest_times) self._accumulate(A) - def decay_time(self, target, tol=1e-10): + def decay_time(self, target: float, tol: float=1e-10): """ After determining the activation, compute the number of hours required to achieve a total activation level after decay. @@ -358,12 +371,12 @@ def decay_time(self, target, tol=1e-10): # for time adjustment we used to stablize the fit. return max(t+guess, 0.0) - def _accumulate(self, activity): + def _accumulate(self, activity: list[float]): for el, activity_el in activity.items(): el_total = self.activity.get(el, [0]*len(self.rest_times)) self.activity[el] = [T+v for T, v in zip(el_total, activity_el)] - def show_table(self, cutoff=0.0001, format="%.4g"): + def show_table(self, cutoff: float=0.0001, format: str="%.4g"): """ Tabulate the daughter products. @@ -428,7 +441,13 @@ def show_table(self, cutoff=0.0001, format="%.4g"): print(cformat%tuple(footer)) print(cformat%tuple(separator)) -def find_root(x, f, df, max=20, tol=1e-10): +def find_root( + x: float, + f: Callable[[float], float], + df: Callable[[float], float], + max: int=20, + tol: float=1e-10, + ): r""" Find zero of a function. @@ -447,7 +466,9 @@ def find_root(x, f, df, max=20, tol=1e-10): return x, fx -def sorted_activity(activity_pair): +def sorted_activity( + activity_pair: Sequence[tuple["ActivationResult", list[float]]] + ) -> list[tuple["ActivationResult", list[float]]]: """Interator over activity pairs sorted by isotope then daughter product.""" return sorted(activity_pair, key=lambda x: (x[0].isotope, x[0].daughter)) @@ -522,6 +543,11 @@ class ActivationEnvironment: exploring for possible products. """ + fluence: float + CD_ratio: float + fast_ratio: float + location: str + def __init__(self, fluence=1e5, Cd_ratio=0., fast_ratio=0., location=""): self.fluence = fluence # cell F13 self.Cd_ratio = Cd_ratio # cell F15 @@ -567,7 +593,13 @@ def epithermal_reduction_factor(self): FLOAT_COLUMNS = [6, 11, 14, 15, 16, 17, 19, 20, 21] UNITS_TO_HOURS = {'y': 8760, 'd': 24, 'h': 1, 'm': 1/60, 's': 1/3600} -def activity(isotope, mass, env, exposure, rest_times): +def activity( + isotope: core.Isotope, + mass: float, + env: ActivationEnvironment, + exposure: float, + rest_times: Sequence[float], + ): """ Compute isotope specific daughter products after the given exposure time and rest period. @@ -881,6 +913,26 @@ class ActivationResult: A2 = K [1 - exp(-L1*t) * L2/(L2-L1) + exp(-L2*t) * L1/(L2-L1)] """ + isotope: core.Isotope + abundance: float + symbol: str + A: int + Z: int + reaction: str + comments: str + daughter: str + isomer: str + Thalf_hrs: float + Thalf_str: str + Thalf_parent: float|None + fast: bool + thermalXS: float + resonance: float + thermalXS_parent: float + resonance_parent: float + percentIT: float|None + gT: float|None + def __init__(self, **kw): self.__dict__ = kw def __repr__(self): diff --git a/periodictable/core.py b/periodictable/core.py index fbec64f..edacdd0 100644 --- a/periodictable/core.py +++ b/periodictable/core.py @@ -62,11 +62,24 @@ 'Ion', 'Isotope', 'Element', 'PeriodicTable', 'isatom', 'iselement', 'isisotope', 'ision'] +from pathlib import Path +from typing import TYPE_CHECKING, Any, Union, TypeVar +from collections.abc import Sequence, Callable, Iterator + +if TYPE_CHECKING: + from .nsf import Neutron + from .activation import ActivationResult + from .xsf import Xray + from .magnetic_ff import MagneticFormFactor + from . import constants +Atom = Union["Element", "Isotope", "Ion"] +AtomVar = TypeVar('AtomVar', bound=Atom) + PUBLIC_TABLE_NAME = "public" -def delayed_load(all_props, loader, element=True, isotope=False, ion=False): +def delayed_load(all_props: Sequence[str], loader: Callable[[], None], element=True, isotope=False, ion=False): """ Delayed loading of an element property table. When any of property is first accessed the loader will be called to load the associated @@ -208,7 +221,10 @@ class PeriodicTable: *nuclear* and *X-ray* scattering cross sections. See section :ref:`Adding properties ` for details. """ - def __init__(self, table): + properties: list[str] + """Properties loaded into the table""" + + def __init__(self, table: str): # type: (str) -> None if table in PRIVATE_TABLES: raise ValueError("Periodic table '%s' is already defined"%table) @@ -220,22 +236,23 @@ def __init__(self, table): ions=tuple(sorted(ions+uncommon_ions)), table=table) self._element[element.number] = element setattr(self, symbol, element) + PeriodicTable.__annotations__[symbol] = Element # There are two specially named isotopes D and T - self.D = self.H.add_isotope(2) + self.D: Isotope = self.H.add_isotope(2) self.D.name = 'deuterium' self.D.symbol = 'D' - self.T = self.H.add_isotope(3) + self.T: Isotope = self.H.add_isotope(3) self.T.name = 'tritium' self.T.symbol = 'T' - def __getitem__(self, Z): + def __getitem__(self, Z: int) -> "Element": """ Retrieve element Z. """ return self._element[Z] - def __iter__(self): + def __iter__(self) -> Iterator["Element"]: """ Process the elements in Z order """ @@ -245,7 +262,7 @@ def __iter__(self): for _, el in elements[1:]: yield el - def symbol(self, input): + def symbol(self, input: str) -> "Element": """ Lookup the an element in the periodic table using its symbol. Symbols are included for 'D' and 'T', deuterium and tritium. @@ -273,7 +290,7 @@ def symbol(self, input): return value raise ValueError("unknown element "+input) - def name(self, input): + def name(self, input: str) -> "Element": """ Lookup an element given its name. @@ -303,7 +320,7 @@ def name(self, input): return self.T raise ValueError("unknown element "+input) - def isotope(self, input): + def isotope(self, input: str) -> Union["Element", "Isotope"]: """ Lookup the element or isotope in the periodic table. Elements are assumed to be given by the standard element symbols. Isotopes @@ -363,7 +380,7 @@ def isotope(self, input): # If we can't parse the string as an element or isotope, raise an error raise ValueError("unknown element "+input) - def list(self, *props, **kw): + def list(self, *props, **kw) -> None: """ Print a list of elements with the given set of properties. @@ -389,6 +406,7 @@ def list(self, *props, **kw): ... Bk: 247.00 u 14.00 g/cm^3 """ + #TODO: accept template strings #TODO: override signature in sphinx with # .. method:: list(prop1, prop2, ..., format='') format = kw.pop('format', None) @@ -412,20 +430,65 @@ def list(self, *props, **kw): # print "format", format, "args", L # raise -class IonSet: - def __init__(self, element_or_isotope): - self.element_or_isotope = element_or_isotope - self.ionset = {} - def __getitem__(self, charge): - if charge not in self.ionset: - if charge not in self.element_or_isotope.ions: - raise ValueError("%(charge)d is not a valid charge for %(symbol)s" - % dict(charge=charge, - symbol=self.element_or_isotope.symbol)) - self.ionset[charge] = Ion(self.element_or_isotope, charge) - return self.ionset[charge] +class _AtomBase: + """ + Attributes common to element, isotope and ion. -class Ion: + This class is defined only for type hinting. Some of the attributes are accessible + as properties. Those not defined in isotope or ion are delegated to the base element + through attribute access magic. + """ + # attributes delegated to Element class + table: str + name: str + symbol: str + number: int + ions: list[int] + ion: "IonSet" # TODO: could be IonSet["Element"] or IonSet["Isotope"] + charge: int # element (=0), isotope (delegate to element), ion (!= 0) + + #element: Union["Element", "Isotope"] # ion or isotope + + # mass.py + mass: float # element, isotope, ion + mass_units: str # element, isotope, ion + _mass_unc: float # Not yet official, but the data is loaded for some tables + #abundance: float # isotope only + #abundance_units: str # isotope only + + # covalent_radius.py + covalent_radius: float # element + covalent_radius_uncertainty: float # element + covalent_radius_units: str # element + + # crystal_structure.py + crystal_structure: dict[str, Any]|None # element + + # density.py + density: float + density_units: str + interatomic_distance: float + number_density: float + number_density_units: str + density_caveat: str|None + + # nsf.py + neutron: "Neutron" # element and isotope + + # activation.py + #neutron_activation: tuple["ActivationResult"]|None # isotope only + + # xsf.py + K_alpha: float|None # element + K_beta1: float|None # element + K_alpha_units: str # element + K_beta1_units: str # element + xray: "Xray" # element + + # magnetic_ff.py + magnetic_ff: dict[int, "MagneticFormFactor"]|None # element + +class Ion(_AtomBase): """ Periodic table entry for an individual ion. @@ -434,20 +497,25 @@ class Ion: properties (*charge*). Properties not specific to the ion (i.e., *charge*) are retrieved from the associated element. """ + element: Union["Element", "Isotope"] + # charge: int # inherited from _AtomBase + + # TODO: abundance and activation need to be defined for charged isotopes. + def __init__(self, element, charge): self.element = element self.charge = charge def __getattr__(self, attr): return getattr(self.element, attr) @property - def mass(self): + def mass(self) -> float: return getattr(self.element, 'mass') - constants.electron_mass*self.charge - def __str__(self): + def __str__(self) -> str: sign = '+' if self.charge > 0 else '-' value = '%d'%abs(self.charge) if abs(self.charge) > 1 else '' charge_str = '{'+value+sign+'}' if self.charge != 0 else '' return str(self.element)+charge_str - def __repr__(self): + def __repr__(self) -> str: return repr(self.element)+'.ion[%d]'%self.charge def __reduce__(self): try: @@ -460,7 +528,24 @@ def __reduce__(self): self.element.number, self.charge) -class Isotope: +class IonSet: + element_or_isotope: Union["Element", "Isotope"] + ionset: dict[int, "Ion"] + + def __init__(self, element_or_isotope: Union["Element", "Isotope"]): + self.element_or_isotope = element_or_isotope + self.ionset = {} + + def __getitem__(self, charge: int) -> Ion: + if charge not in self.ionset: + if charge not in self.element_or_isotope.ions: + raise ValueError("%(charge)d is not a valid charge for %(symbol)s" + % dict(charge=charge, + symbol=self.element_or_isotope.symbol)) + self.ionset[charge] = Ion(self.element_or_isotope, charge) + return self.ionset[charge] + +class Isotope(_AtomBase): """ Periodic table entry for an individual isotope. @@ -470,25 +555,35 @@ class Isotope: Properties not specific to the isotope (e.g., *x-ray scattering factors*) are retrieved from the associated element. """ + element: "Element" + ion: IonSet # TODO: should be IonSet["Isotope"] + + # mass.py + abundance: float # isotope only + abundance_units: str # isotope only + + # activation.py + neutron_activation: tuple["ActivationResult"]|None # isotope only + def __init__(self, element, isotope_number): self.element = element self.isotope = isotope_number self.ion = IonSet(self) def __getattr__(self, attr): return getattr(self.element, attr) - def __str__(self): + def __str__(self) -> str: # Deuterium and Tritium are special if 'symbol' in self.__dict__: return self.symbol return "%d-%s"%(self.isotope, self.element.symbol) - def __repr__(self): + def __repr__(self) -> str: return "%s[%d]"%(self.element.symbol, self.isotope) def __reduce__(self): return _make_isotope, (self.element.table, self.element.number, self.isotope) -class Element: +class Element(_AtomBase): """ Periodic table entry for an element. @@ -496,13 +591,14 @@ class Element: Individual isotopes can be referenced as element[*isotope_number*]. Individual ionization states can be referenced by element.ion[*charge*]. """ - table = PUBLIC_TABLE_NAME - charge = 0 + table: str = PUBLIC_TABLE_NAME + charge: int = 0 + def __init__(self, name, symbol, Z, ions, table): self.name = name self.symbol = symbol self.number = Z - self._isotopes = {} # The actual isotopes + self._isotopes: dict[int, "Isotope"] = {} # The actual isotopes self.ions = ions self.ion = IonSet(self) # Remember the table name for pickle dump/load @@ -510,12 +606,12 @@ def __init__(self, name, symbol, Z, ions, table): self.table = table @property - def isotopes(self): + def isotopes(self) -> list[int]: """List of all isotopes""" # Note: may want to return the iterator rather than the list... return list(sorted(self._isotopes.keys())) - def add_isotope(self, number): + def add_isotope(self, number: int) -> "Isotope": """ Add an isotope for the element. @@ -529,13 +625,13 @@ def add_isotope(self, number): self._isotopes[number] = Isotope(self, number) return self._isotopes[number] - def __getitem__(self, number): + def __getitem__(self, number: int) -> "Isotope": try: return self._isotopes[number] except KeyError: raise KeyError("%s is not an isotope of %s"%(number, self.symbol)) - def __iter__(self): + def __iter__(self) -> Iterator["Isotope"]: """ Process the isotopes in order """ @@ -546,34 +642,33 @@ def __iter__(self): # that lists of elements print nicely. Since elements are # effectively singletons, the symbol name is the representation # of the instance. - def __repr__(self): + def __repr__(self) -> str: return self.symbol def __reduce__(self): return _make_element, (self.table, self.number) -def isatom(val): +def isatom(val: Any) -> bool: """Return true if value is an element, isotope or ion""" return isinstance(val, (Element, Isotope, Ion)) -def isisotope(val): +def isisotope(val: Any) -> bool: """Return true if value is an isotope or isotope ion.""" if ision(val): val = val.element return isinstance(val, Isotope) -def ision(val): +def ision(val: Any) -> bool: """Return true if value is a specific ion of an element or isotope""" return isinstance(val, Ion) -def iselement(val): +def iselement(val: Any) -> bool: """Return true if value is an element or ion in natural abundance""" if ision(val): val = val.element return isinstance(val, Element) -def change_table(atom, table): - # type: (Union[Element,Isotope,Ion]) +def change_table[T: AtomVar](atom: T, table: str) -> T: """Search for the same element, isotope or ion from a different table""" if ision(atom): if isisotope(atom): @@ -587,19 +682,19 @@ def change_table(atom, table): return table[atom.number] PRIVATE_TABLES = {} -def _get_table(name): +def _get_table(name: str) -> PeriodicTable: try: return PRIVATE_TABLES[name] except KeyError: raise ValueError("Periodic table '%s' is not initialized"%name) -def _make_element(table, Z): +def _make_element(table: str, Z: int) -> Element: return _get_table(table)[Z] -def _make_isotope(table, Z, n): +def _make_isotope(table: str, Z: int, n: int) -> Isotope: return _get_table(table)[Z][n] -def _make_ion(table, Z, c): +def _make_ion(table: str, Z: int, c: int) -> Ion: return _get_table(table)[Z].ion[c] -def _make_isotope_ion(table, Z, n, c): +def _make_isotope_ion(table: str, Z: int, n: int, c: int) -> Ion: return _get_table(table)[Z][n].ion[c] @@ -729,7 +824,7 @@ def _make_isotope_ion(table, Z, n, c): } # pylint: enable=bad-whitespace -def default_table(table=None): +def default_table(table: PeriodicTable|None=None) -> PeriodicTable: """ Return the default table unless a specific table has been requested. @@ -741,7 +836,7 @@ def summary(table=None): """ return table if table is not None else PUBLIC_TABLE -def define_elements(table, namespace): +def define_elements(table: PeriodicTable, namespace: dict[str, Any]) -> list[str]: """ Define external variables for each element in namespace. Elements are defined both by name and by symbol. @@ -779,7 +874,7 @@ def define_elements(table, namespace): return list(names.keys()) -def get_data_path(data): +def get_data_path(data: Path|str) -> str: """ Locate the directory for the tables for the named extension. @@ -823,4 +918,4 @@ def get_data_path(data): # Make a common copy of the table for everyone to use --- equivalent to # a singleton without incurring any complexity. -PUBLIC_TABLE = PeriodicTable(PUBLIC_TABLE_NAME) +PUBLIC_TABLE: PeriodicTable = PeriodicTable(PUBLIC_TABLE_NAME) diff --git a/periodictable/covalent_radius.py b/periodictable/covalent_radius.py index 91c43c3..d4f02e5 100644 --- a/periodictable/covalent_radius.py +++ b/periodictable/covalent_radius.py @@ -63,9 +63,9 @@ """ -from .core import Element +from .core import Element, PeriodicTable -def init(table, reload=False): +def init(table: PeriodicTable, reload: bool=False) -> None: """ Add the covalent radius property to a private table. Use *reload = True* to replace the covalent radius property on an diff --git a/periodictable/cromermann.py b/periodictable/cromermann.py index ea18a8d..c82c43d 100644 --- a/periodictable/cromermann.py +++ b/periodictable/cromermann.py @@ -45,11 +45,12 @@ import os import numpy +from numpy.typing import ArrayLike from . import core -def getCMformula(symbol): +def getCMformula(symbol: str) -> "CromerMannFormula": """ Obtain Cromer-Mann formula and coefficients for a specified element. @@ -63,7 +64,7 @@ def getCMformula(symbol): return _cmformulas[symbol] -def fxrayatq(symbol, Q, charge=None): +def fxrayatq(symbol: str, Q: ArrayLike, charge: int=None) -> ArrayLike: """ Return x-ray scattering factors of an element at a given Q. @@ -81,7 +82,7 @@ def fxrayatq(symbol, Q, charge=None): return rv -def fxrayatstol(symbol, stol, charge=None): +def fxrayatstol(symbol: str, stol: ArrayLike, charge: int=None) -> ArrayLike: """ Calculate x-ray scattering factors at specified sin(theta)/lambda @@ -135,7 +136,11 @@ class CromerMannFormula: # obtained from tables/f0_WaasKirf.dat and the associated reference # D. Waasmaier, A. Kirfel, Acta Cryst. (1995). A51, 416-413 # http://dx.doi.org/10.1107/S0108767394013292 - stollimit = 6 + stollimit: float = 6 + a: numpy.ndarray + b: numpy.ndarray + c: float + symbol: str def __init__(self, symbol, a, b, c): """ @@ -148,7 +153,7 @@ def __init__(self, symbol, a, b, c): self.b = numpy.asarray(b, dtype=float) self.c = float(c) - def atstol(self, stol): + def atstol(self, stol: ArrayLike) -> ArrayLike: """ Calculate x-ray scattering factors at specified sin(theta)/lambda @@ -173,7 +178,7 @@ def atstol(self, stol): # class CromerMannFormula -def _update_cmformulas(): +def _update_cmformulas() -> None: """ Update the static dictionary of CromerMannFormula instances. """ @@ -200,6 +205,6 @@ def _update_cmformulas(): _cmformulas[cmf.symbol] = cmf symbol = None -_cmformulas = {} +_cmformulas: dict[str, CromerMannFormula] = {} # End of file diff --git a/periodictable/crystal_structure.py b/periodictable/crystal_structure.py index 3a39124..f076ef3 100644 --- a/periodictable/crystal_structure.py +++ b/periodictable/crystal_structure.py @@ -40,8 +40,9 @@ This data is from Ashcroft and Mermin. ''' +from .core import PeriodicTable -crystal_structures = [\ +crystal_structures = [ {'symmetry': 'diatom', 'd': 0.74}, #H {'symmetry': 'atom'}, #He {'symmetry': 'BCC', 'a': 3.49}, #Li @@ -106,7 +107,7 @@ {'symmetry': 'Rhombohedral', 'a': 9.00, 'alpha': 23.13}, #Sm {'symmetry': 'BCC', 'a': 4.61}, #Eu {'symmetry': 'hcp', 'c/a': 1.588, 'a': 3.64}, #Gd - {'symmetry': 'hcp', 'c/a': 1.581, 'a': 3.60}, #Th + {'symmetry': 'hcp', 'c/a': 1.581, 'a': 3.60}, #Tb {'symmetry': 'hcp', 'c/a': 1.573, 'a': 3.59}, #Dy {'symmetry': 'hcp', 'c/a': 1.570, 'a': 3.58}, #Ho {'symmetry': 'hcp', 'c/a': 1.570, 'a': 3.56}, #Er @@ -144,9 +145,10 @@ None, #Fm None, #Md None, #No - None]#Lw + None, #Lw +] -def init(table, reload=False): +def init(table: PeriodicTable, reload: bool=False) -> None: """ Add crystal_structure field to the element properties. """ diff --git a/periodictable/density.py b/periodictable/density.py index ba4ba22..dfe71c7 100644 --- a/periodictable/density.py +++ b/periodictable/density.py @@ -41,10 +41,10 @@ .. [#ILL] The ILL Neutron Data Booklet, Second Edition. """ -from .core import Element, Isotope +from .core import Element, Isotope, Atom, PeriodicTable from .constants import avogadro_number -def density(iso_el): +def density(iso_el: Atom) -> float: """ Element density for natural abundance. For isotopes, return @@ -68,7 +68,7 @@ def density(iso_el): return iso_el.element._density * (iso_el.mass/iso_el.element.mass) return iso_el._density -def interatomic_distance(element): +def interatomic_distance(element: Atom) -> float: r""" Estimated interatomic distance from atomic weight and density. The distance between isotopes is assumed to match that between atoms in @@ -105,7 +105,7 @@ def interatomic_distance(element): return None return (element.mass/(element.density*avogadro_number*1e-24))**(1./3.) -def number_density(element): +def number_density(element: Atom) -> float: r""" Estimate the number density from atomic weight and density. The density for isotopes is assumed to match that of between atoms in natural abundance. @@ -138,7 +138,7 @@ def number_density(element): return None return (element.density/element.mass)*avogadro_number -def init(table, reload=False): +def init(table: PeriodicTable, reload: bool=False) -> None: if 'density' in table.properties and not reload: return table.properties.append('density') diff --git a/periodictable/fasta.py b/periodictable/fasta.py index 6d6c262..1c532db 100644 --- a/periodictable/fasta.py +++ b/periodictable/fasta.py @@ -70,15 +70,19 @@ of DNA of mammals. Biochem Genet 4, 367–376. https://doi.org/10.1007/BF00485753 """ import warnings +from pathlib import Path +# Warning: name clash with Sequence +from collections.abc import Iterator +from typing import IO -from .formulas import formula as parse_formula +from .formulas import formula as parse_formula, Formula, FormulaInput from .nsf import neutron_sld from .xsf import xray_sld -from .core import default_table +from .core import default_table, Atom from .constants import avogadro_number # CRUFT 1.5.2: retaining fasta.isotope_substitution for compatibility -def isotope_substitution(formula, source, target, portion=1): +def isotope_substitution(formula: Formula, source: Atom, target: Atom, portion: float=1): """ Substitute one atom/isotope in a formula with another in some proportion. @@ -147,7 +151,26 @@ class Molecule: Change 1.5.3: drop *Hmass* and *Hsld*. Move *formula* to *labile_formula*. Move *Hnatural* to *formula*. """ - def __init__(self, name, formula, cell_volume=None, density=None, charge=0): + name: str + cell_volume: float + sld: float + Dsld: float + mass: float + Dmass: float + D2Omatch: float + charge: int + natural_formula: Formula + labile_formula: Formula + formula: Formula + + def __init__( + self, + name: str, + formula: FormulaInput, + cell_volume: float|None=None, + density: float|None=None, + charge: int=0, + ): # TODO: fasta does not work with table substitution elements = default_table() @@ -181,7 +204,8 @@ def __init__(self, name, formula, cell_volume=None, density=None, charge=0): # with sld and mass, which are computed with H-substitution. self.formula = self.labile_formula - def D2Osld(self, volume_fraction=1., D2O_fraction=0.): + # TODO: are sld values float or complex? + def D2Osld(self, volume_fraction: float=1., D2O_fraction: float=0.) -> float: """ Neutron SLD of the molecule in a deuterated solvent. @@ -207,8 +231,10 @@ class Sequence(Molecule): Note: rna sequence files treat T as U and dna sequence files treat U as T. """ + sequence: str + @staticmethod - def loadall(filename, type=None): + def loadall(filename: Path|str, type: str=None) -> Iterator["Sequence"]: """ Iterate over sequences in FASTA file, loading each in turn. @@ -220,7 +246,7 @@ def loadall(filename, type=None): yield Sequence(name, seq, type=type) @staticmethod - def load(filename, type=None): + def load(filename: Path|str, type=None) -> "Sequence": """ Load the first FASTA sequence from a file. """ @@ -229,7 +255,7 @@ def load(filename, type=None): name, seq = next(read_fasta(fh)) return Sequence(name, seq, type=type) - def __init__(self, name, sequence, type='aa'): + def __init__(self, name: str, sequence: str, type: str='aa'): # TODO: duplicated in Molecule.__init__ # TODO: fasta does not work with table substitution elements = default_table() @@ -251,7 +277,7 @@ def __init__(self, name, sequence, type='aa'): self, name, formula, cell_volume=cell_volume, charge=charge) self.sequence = sequence -def _guess_type_from_filename(filename, type): +def _guess_type_from_filename(filename: str, type: str) -> str: if type is None: if filename.endswith('.fna'): type = 'dna' @@ -271,7 +297,7 @@ def _guess_type_from_filename(filename, type): #: real portion of D2O sld at 20 C #: Change 1.5.2: Use correct density in SLD calculation D2O_SLD = neutron_sld("D2O@0.9982n")[0] -def D2Omatch(Hsld, Dsld): +def D2Omatch(Hsld: float, Dsld: float) -> float: """ Find the D2O% concentration of solvent such that neutron SLD of the material matches the neutron SLD of the solvent. @@ -298,7 +324,7 @@ def D2Omatch(Hsld, Dsld): return 100 * (H2O_SLD - Hsld) / (Dsld - Hsld + H2O_SLD - D2O_SLD) -def read_fasta(fp): +def read_fasta(fp: IO[str]) -> Iterator[str]: """ Iterate over the sequences in a FASTA file. @@ -319,7 +345,7 @@ def read_fasta(fp): yield (name, ''.join(seq)) -def _code_average(bases, code_table): +def _code_average(bases, code_table) -> tuple[Formula, float, int]: """ Compute average over possible nucleotides, assuming equal weight if precise nucleotide is not known @@ -335,7 +361,10 @@ def _code_average(bases, code_table): formula, cell_volume, charge = (1/n) * formula, cell_volume/n, charge/n return formula, cell_volume, charge -def _set_amino_acid_average(target, codes, name=None): +def _set_amino_acid_average(target: str, codes: str, name: str=None) -> None: + """ + Fill in partial unknowns for amino acids, such as "B" for aspartic acid or asparagine. + """ formula, cell_volume, charge = _code_average(codes, AMINO_ACID_CODES) if name is None: name = "/".join(AMINO_ACID_CODES[c].name for c in codes) @@ -347,7 +376,7 @@ def _set_amino_acid_average(target, codes, name=None): # Further, this does not allow private tables for fasta calculations. # FASTA code table -def _(code, V, formula, name): +def _(code: str, V: float, formula: str, name: str) -> tuple[str, Molecule]: if formula[-1] == '-': charge = -1 formula = formula[:-1] @@ -361,7 +390,7 @@ def _(code, V, formula, name): return code, molecule # pylint: disable=bad-whitespace -AMINO_ACID_CODES = dict(( +AMINO_ACID_CODES: dict[str, Molecule] = dict(( #code, volume, formula, name _("A", 91.5, "C3H4H[1]NO", "alanine"), #B: D or N @@ -399,10 +428,10 @@ def _(code, V, formula, name): __doc__ += "\n\n*AMINO_ACID_CODES*::\n\n " + "\n ".join( "%s: %s"%(k, v.name) for k, v in sorted(AMINO_ACID_CODES.items())) -def _(formula, V, name): +def _(formula: str, V: float, name: str) -> tuple[str, Molecule]: molecule = Molecule(name, formula, cell_volume=V) return name, molecule -NUCLEIC_ACID_COMPONENTS = dict(( +NUCLEIC_ACID_COMPONENTS: dict[str, Molecule] = dict(( # formula, volume, name _("NaPO3", 60, "phosphate"), _("C5H6H[1]O3", 125, "ribose"), @@ -416,7 +445,7 @@ def _(formula, V, name): __doc__ += "\n\n*NUCLEIC_ACID_COMPONENTS*::\n\n " + "\n ".join( "%s: %s"%(k, v.formula) for k, v in sorted(NUCLEIC_ACID_COMPONENTS.items())) -CARBOHYDRATE_RESIDUES = dict(( +CARBOHYDRATE_RESIDUES: dict[str: Molecule] = dict(( # formula, volume, name _("C6H7H[1]3O5", 171.9, "Glc"), _("C6H7H[1]3O5", 166.8, "Gal"), @@ -434,7 +463,7 @@ def _(formula, V, name): __doc__ += "\n\n*CARBOHYDRATE_RESIDUES*::\n\n " + "\n ".join( "%s: %s"%(k, v.formula) for k, v in sorted(CARBOHYDRATE_RESIDUES.items())) -LIPIDS = dict(( +LIPIDS: dict[str, Molecule] = dict(( # formula, volume, name _("CH2", 27, "methylene"), _("CD2", 27, "methylene-D"), @@ -451,7 +480,7 @@ def _(formula, V, name): __doc__ += "\n\n*LIPIDS*::\n\n " + "\n ".join( "%s: %s"%(k, v.formula) for k, v in sorted(LIPIDS.items())) -def _(code, formula, V, name): +def _(code: str, formula: str, V: float, name: str) -> tuple[str, Molecule]: """ Convert RNA/DNA table values into Molecule. @@ -467,7 +496,7 @@ def _(code, formula, V, name): molecule = Molecule(name, formula, cell_volume=cell_volume) molecule.code = code return code, molecule -RNA_BASES = dict(( +RNA_BASES: dict[str, Molecule] = dict(( # code, formula, volume (mL/mol), name _("A", "C10H8H[1]3N5O6P", 170.8, "adenosine"), _("T", "C9H8H[1]2N2O8P", 151.7, "uridine"), # Use H[1] for U in RNA @@ -477,7 +506,7 @@ def _(code, formula, V, name): __doc__ += "\n\n*RNA_BASES*::\n\n " + "\n ".join( "%s:%s"%(k, v.name) for k, v in sorted(RNA_BASES.items())) -DNA_BASES = dict(( +DNA_BASES: dict[str, Molecule] = dict(( # code, formula, volume (mL/mol), name _("A", "C10H9H[1]2N5O5P", 169.8, "adenosine"), _("T", "C10H11H[1]1N2O7P", 167.6, "thymidine"), @@ -487,7 +516,7 @@ def _(code, formula, V, name): __doc__ += "\n\n*DNA_BASES*::\n\n " + "\n ".join( "%s:%s"%(k, v.name) for k, v in sorted(DNA_BASES.items())) -def _(code, bases, name): +def _(code: str, bases: str, name: str) -> tuple[tuple[str,Molecule], tuple[str,Molecule]]: D, V, _ = _code_average(bases, RNA_BASES) rna = Molecule(name, D.hill, cell_volume=V) rna.code = code @@ -495,6 +524,7 @@ def _(code, bases, name): dna = Molecule(name, D.hill, cell_volume=V) rna.code = code return (code,rna), (code,dna) +# TODO: define types for the RNA and DNA code dictionaries. RNA_CODES,DNA_CODES = [dict(v) for v in zip( #code, nucleotides, name _("A", "A", "adenosine"), @@ -519,13 +549,13 @@ def _(code, bases, name): # pylint: enable=bad-whitespace -CODE_TABLES = { +CODE_TABLES: dict[str, dict[str, Molecule]] = { 'aa': AMINO_ACID_CODES, 'dna': DNA_CODES, 'rna': RNA_CODES, } -def fasta_table(): +def fasta_table() -> None: elements = default_table() rows = [] diff --git a/periodictable/formulas.py b/periodictable/formulas.py index 5940f2b..f50b681 100644 --- a/periodictable/formulas.py +++ b/periodictable/formulas.py @@ -6,21 +6,27 @@ from copy import copy from math import pi, sqrt +from typing import Union, Any +from collections.abc import Sequence, Callable # Requires that the pyparsing module is installed. -from pyparsing import (Literal, Optional, White, Regex, +from pyparsing import (ParserElement, Literal, Optional, White, Regex, ZeroOrMore, OneOrMore, Forward, StringEnd, Group) from .core import default_table, isatom, isisotope, ision, change_table +from .core import Atom, PeriodicTable # for typing from .constants import avogadro_number from .util import cell_volume +FormulaInput = Union[str, "Formula", Atom, dict[Atom, float], Sequence[tuple[float, Any]], None] +Fragment = tuple[float, Union[Atom, tuple["Fragment"]]] + PACKING_FACTORS = dict(cubic=pi/6, bcc=pi*sqrt(3)/8, hcp=pi/sqrt(18), fcc=pi/sqrt(18), diamond=pi*sqrt(3)/16) -def mix_by_weight(*args, **kw): +def mix_by_weight(*args, **kw) -> "Formula": """ Generate a mixture which apportions each formula by weight. @@ -82,7 +88,7 @@ def mix_by_weight(*args, **kw): result.name = name return result -def _mix_by_weight_pairs(pairs): +def _mix_by_weight_pairs(pairs: Sequence[Fragment]) -> "Formula": # Drop pairs with zero quantity # Note: must be first statement in order to accept iterators @@ -104,7 +110,7 @@ def _mix_by_weight_pairs(pairs): result.density = result.mass/volume return result -def mix_by_volume(*args, **kw): +def mix_by_volume(*args, **kw) -> "Formula": """ Generate a mixture which apportions each formula by volume. @@ -166,7 +172,7 @@ def mix_by_volume(*args, **kw): result.name = name return result -def _mix_by_volume_pairs(pairs): +def _mix_by_volume_pairs(pairs: Sequence[Fragment]) -> "Formula": # Drop pairs with zero quantity # Note: must be first statement in order to accept iterators @@ -194,8 +200,13 @@ def _mix_by_volume_pairs(pairs): return result -def formula(compound=None, density=None, natural_density=None, - name=None, table=None): +def formula( + compound: FormulaInput=None, + density: float|None=None, + natural_density: float|None=None, + name: str|None=None, + table: PeriodicTable|None=None, + ) -> "Formula": r""" Construct a chemical formula representation from a string, a dictionary of atoms or another formula. @@ -300,7 +311,7 @@ def formula(compound=None, density=None, natural_density=None, else: try: structure = _immutable(compound) - except: + except Exception: raise ValueError("not a valid chemical formula: "+str(compound)) return Formula(structure=structure, name=name, density=density, natural_density=natural_density) @@ -310,8 +321,12 @@ class Formula: Simple chemical formula representation. """ - def __init__(self, structure=tuple(), density=None, natural_density=None, - name=None): + def __init__(self, + structure: Sequence[tuple[float, Any]]=tuple(), + density: float|None=None, + natural_density: float|None=None, + name: str|None=None, + ): self.structure = structure self.name = name @@ -331,7 +346,7 @@ def __init__(self, structure=tuple(), density=None, natural_density=None, self.density = None @property - def atoms(self): + def atoms(self) -> dict[Atom, float]: """ { *atom*: *count*, ... } @@ -342,7 +357,7 @@ def atoms(self): return _count_atoms(self.structure) @property - def hill(self): + def hill(self) -> "Formula": """ Formula @@ -352,7 +367,7 @@ def hill(self): """ return formula(self.atoms) - def natural_mass_ratio(self): + def natural_mass_ratio(self) -> float: """ Natural mass to isotope mass ratio. @@ -376,7 +391,7 @@ def natural_mass_ratio(self): return total_natural_mass/total_isotope_mass @property - def natural_density(self): + def natural_density(self) -> float: """ |g/cm^3| @@ -391,7 +406,7 @@ def natural_density(self, natural_density): self.density = natural_density / self.natural_mass_ratio() @property - def mass(self): + def mass(self) -> float: """ atomic mass units u (C[12] = 12 u) @@ -404,7 +419,7 @@ def mass(self): return mass @property - def molecular_mass(self): + def molecular_mass(self) -> float: """ g @@ -413,21 +428,21 @@ def molecular_mass(self): return self.mass/avogadro_number @property - def charge(self): + def charge(self) -> float: """ Net charge of the molecule. """ return sum([m*a.charge for a, m in self.atoms.items()]) @property - def mass_fraction(self): + def mass_fraction(self) -> dict[Atom, float]: """ Fractional mass representation of each element/isotope/ion. """ total_mass = self.mass return dict((a, m*a.mass/total_mass) for a, m in self.atoms.items()) - def _pf(self): + def _pf(self) -> float: """ packing factor | unitless @@ -435,7 +450,7 @@ def _pf(self): """ return self.density - def volume(self, *args, **kw): + def volume(self, *args, **kw) -> float: r""" Estimate unit cell volume. @@ -518,7 +533,8 @@ def volume(self, *args, **kw): packing_factor = PACKING_FACTORS[packing_factor.lower()] return V/packing_factor*1e-24 - def neutron_sld(self, *, wavelength=None, energy=None): + # TODO; remove neutron_sld/xray_sld otherwise we have circular imports + def neutron_sld(self, *, wavelength: float=None, energy: float=None) -> tuple[float, float, float]: """ Neutron scattering information for the molecule. @@ -542,7 +558,7 @@ def neutron_sld(self, *, wavelength=None, energy=None): return neutron_sld(self.atoms, density=self.density, wavelength=wavelength, energy=energy) - def xray_sld(self, *, energy=None, wavelength=None): + def xray_sld(self, *, energy: float=None, wavelength: float=None) -> tuple[float, float, float]: """ X-ray scattering length density for the molecule. @@ -571,7 +587,7 @@ def xray_sld(self, *, energy=None, wavelength=None): return xray_sld(self.atoms, density=self.density, wavelength=wavelength, energy=energy) - def change_table(self, table): + def change_table(self, table: PeriodicTable) -> "Formula": """ Replace the table used for the components of the formula. """ @@ -645,7 +661,7 @@ def __repr__(self): return "formula('%s')"%(str(self)) -def _isotope_substitution(compound, source, target, portion=1): +def _isotope_substitution(compound: "Formula", source: Atom, target: Atom, portion: float=1) -> "Formula": """ Substitute one atom/isotope in a formula with another in some proportion. @@ -679,7 +695,7 @@ def _isotope_substitution(compound, source, target, portion=1): VOLUME_UNITS = {'nL': 1e-9, 'uL': 1e-6, 'mL': 1e-3, 'L': 1e+0} LENGTH_RE = '('+'|'.join(LENGTH_UNITS.keys())+')' MASS_VOLUME_RE = '('+'|'.join(list(MASS_UNITS.keys())+list(VOLUME_UNITS.keys()))+')' -def formula_grammar(table): +def formula_grammar(table: PeriodicTable) -> ParserElement: """ Construct a parser for molecular formulas. @@ -948,7 +964,7 @@ def convert_mixture(string, location, tokens): grammar.setName('Chemical Formula') return grammar -_PARSER_CACHE = {} +_PARSER_CACHE: dict[PeriodicTable, ParserElement] = {} def parse_formula(formula_str, table=None): """ Parse a chemical formula, returning a structure with elements from the @@ -959,9 +975,9 @@ def parse_formula(formula_str, table=None): _PARSER_CACHE[table] = formula_grammar(table) parser = _PARSER_CACHE[table] #print(parser) - return parser.parseString(formula_str)[0] + return parser.parse_string(formula_str)[0] -def _count_atoms(seq): +def _count_atoms(seq: tuple[tuple[float, Any]]): """ Traverse formula structure, counting the total number of atoms. """ @@ -977,7 +993,9 @@ def _count_atoms(seq): total[atom] += atom_count*count return total -def count_elements(compound, by_isotope=False): +# TODO: return type is dict[Element, float] if by_isotope is False +# TODO: return type is dict[Element|Isotope, float], but being lazy and using Atom +def count_elements(compound: FormulaInput, by_isotope: bool=False) -> dict[Atom, float]: """ Element composition of the molecule. @@ -988,7 +1006,7 @@ def count_elements(compound, by_isotope=False): If *by_isotope* is True, then sum across ionization levels, keeping the individual isotopes separate. """ - total = {} + total: dict[Atom, float] = {} # Note: could accumulate charge at the same time as counting elements. for part, count in formula(compound).atoms.items(): # Resolve isotopes and ions to the underlying element. Four cases: @@ -1003,7 +1021,7 @@ def count_elements(compound, by_isotope=False): total[part] = count + total.get(part, 0) return total -def _immutable(seq): +def _immutable(seq: Sequence[Fragment]) -> tuple[Fragment]: """ Traverse formula structure, checking that the counts are numeric and units are atoms. Returns an immutable copy of the structure, with all @@ -1013,14 +1031,14 @@ def _immutable(seq): return seq return tuple((count+0, _immutable(fragment)) for count, fragment in seq) -def _change_table(seq, table): +def _change_table(seq: tuple[Fragment], table: PeriodicTable) -> tuple[Fragment]: """Converts lists to tuples so that structure is immutable.""" if isatom(seq): return change_table(seq, table) return tuple((count, _change_table(fragment, table)) for count, fragment in seq) -def _hill_compare(a, b): +def _hill_compare(a: Atom, b: Atom) -> int: """ Compare elements in standard order. """ @@ -1039,19 +1057,19 @@ def _hill_compare(a, b): else: return cmp(a.symbol, b.symbol) -def _hill_key(a): +def _hill_key(a: Atom) -> str: return "".join((("0" if a.symbol in ("C", "H") else "1"), a.symbol, "%4d"%(a.isotope if isisotope(a) else 0))) -def _convert_to_hill_notation(atoms): +def _convert_to_hill_notation(atoms: dict[Atom, float]) -> list[tuple[float, Atom]]: """ Return elements listed in standard order. """ #return [(atoms[el], el) for el in sorted(atoms.keys(), cmp=_hill_compare)] return [(atoms[el], el) for el in sorted(atoms.keys(), key=_hill_key)] -def _str_one_atom(fragment): +def _str_one_atom(fragment: Atom) -> str: # Normal isotope string form is #-Yy, but we want Yy[#] if isisotope(fragment) and 'symbol' not in fragment.__dict__: ret = "%s[%d]"%(fragment.symbol, fragment.isotope) @@ -1063,7 +1081,7 @@ def _str_one_atom(fragment): ret += '{'+value+sign+'}' return ret -def _str_atoms(seq): +def _str_atoms(seq: tuple[Fragment]) -> str: """ Convert formula structure to string. """ @@ -1084,7 +1102,7 @@ def _str_atoms(seq): return ret -def _is_string_like(val): +def _is_string_like(val: Any) -> bool: """Returns True if val acts like a string""" try: val+'' @@ -1092,7 +1110,11 @@ def _is_string_like(val): return False return True -def from_subscript(value): +def from_subscript(value: str) -> str: + """ + Convert unicode subscript characters to normal characters. This allows us to parse, + for example, H₂O as H2O. + """ subscript_codepoints = { '\u2080': '0', '\u2081': '1', '\u2082': '2', '\u2083': '3', '\u2084': '4', '\u2085': '5', '\u2086': '6', '\u2087': '7', @@ -1106,7 +1128,7 @@ def from_subscript(value): } return ''.join(subscript_codepoints.get(char, char) for char in str(value)) -def unicode_subscript(value): +def unicode_subscript(value: str) -> str: # Unicode subscript codepoints. Note that decimal point looks okay as subscript subscript_codepoints = { '0': '\u2080', '1': '\u2081', '2': '\u2082', '3': '\u2083', @@ -1124,7 +1146,7 @@ def unicode_subscript(value): } return ''.join(subscript_codepoints.get(char, char) for char in str(value)) -def unicode_superscript(value): +def unicode_superscript(value: str) -> str: # Unicode subscript codepoints. Note that decimal point looks okay as subscript superscript_codepoints = { #'.': '\u00B0', # degree symbol looks too much like zero @@ -1144,14 +1166,14 @@ def unicode_superscript(value): } return ''.join(superscript_codepoints.get(char, char) for char in str(value)) -SUBSCRIPT = { +SUBSCRIPT: dict[str, Callable[[str], str]] = { # The latex renderer should work for github style markdown 'latex': lambda text: f'$_{{{text}}}$', 'html': lambda text: f'{text}', 'unicode': unicode_subscript, 'plain': lambda text: text } -def pretty(compound, mode='unicode'): +def pretty(compound: Formula, mode: str='unicode') -> str: """ Convert the formula to a string. The *mode* can be 'unicode', 'html' or 'latex' depending on how subscripts should be rendered. If *mode* is 'plain' @@ -1161,7 +1183,7 @@ def pretty(compound, mode='unicode'): """ return _pretty(compound.structure, SUBSCRIPT[mode]) -def _pretty(structure, subscript): +def _pretty(structure: tuple[Fragment], subscript: Callable[[str], str]) -> str: # TODO: if superscript is not None then render O[16] as {}^{16}O parts = [] for count, part in structure: diff --git a/periodictable/magnetic_ff.py b/periodictable/magnetic_ff.py index eb4acff..f2fb71e 100644 --- a/periodictable/magnetic_ff.py +++ b/periodictable/magnetic_ff.py @@ -14,8 +14,11 @@ import numpy from numpy import pi, exp +from numpy.typing import ArrayLike -def formfactor_0(j0, q): +from .core import PeriodicTable + +def formfactor_0(j0: tuple[float], q: ArrayLike) -> numpy.ndarray: """ Returns the scattering potential for form factor *j0* at the given *q*. """ @@ -24,7 +27,7 @@ def formfactor_0(j0, q): A, a, B, b, C, c, D = j0 return A * exp(-a*s_sq) + B * exp(-b*s_sq) + C * exp(-c*s_sq) + D -def formfactor_n(jn, q): +def formfactor_n(jn: tuple[float], q: ArrayLike): """ Returns the scattering potential for form factor *jn* at the given *q*. """ @@ -69,36 +72,37 @@ class MagneticFormFactor: """ + M: tuple[float] def _getM(self): return self.j0 M = property(_getM, doc="j0") - def j0_Q(self, Q): + def j0_Q(self, Q: ArrayLike) -> ArrayLike: """Returns *j0* scattering potential at *Q* |1/Ang|""" return formfactor_0(self.j0, Q) - def j2_Q(self, Q): + def j2_Q(self, Q: ArrayLike) -> ArrayLike: """Returns *j2* scattering potential at *Q* |1/Ang|""" return formfactor_n(self.j2, Q) - def j4_Q(self, Q): + def j4_Q(self, Q: ArrayLike) -> ArrayLike: """Returns *j4* scattering potential at *Q* |1/Ang|""" return formfactor_n(self.j4, Q) - def j6_Q(self, Q): + def j6_Q(self, Q: ArrayLike) -> ArrayLike: """Returns j6 scattering potential at *Q* |1/Ang|""" return formfactor_n(self.j6, Q) - def J_Q(self, Q): + def J_Q(self, Q: ArrayLike) -> ArrayLike: """Returns J scattering potential at *Q* |1/Ang|""" return formfactor_0(self.J, Q) M_Q = j0_Q -def init(table, reload=False): +def init(table: PeriodicTable, reload: bool=False) -> None: """Add magnetic form factor properties to the periodic table""" if 'magnetic_ff' not in table.properties: # First call to init @@ -108,7 +112,7 @@ def init(table, reload=False): return # Function for interpreting ionization state and form factor tuple - def add_form_factor(jn, symbol, charge, values): + def add_form_factor(jn: str, symbol: str, charge: int, values: tuple[float]) -> None: # Add the magnetic form factor info to the element el = table.symbol(symbol.capitalize()) if not hasattr(el, 'magnetic_ff'): diff --git a/periodictable/mass.py b/periodictable/mass.py index d7bad12..80b1ee0 100644 --- a/periodictable/mass.py +++ b/periodictable/mass.py @@ -61,10 +61,10 @@ materials (IUPAC Technical Report). Pure and Applied Chemistry, 93(1), 155-166. https://doi.org/10.1515/pac-2018-0916 """ -from .core import Element, Isotope, default_table +from .core import Element, Isotope, PeriodicTable, default_table from .util import parse_uncertainty -def mass(isotope): +def mass(isotope: Element|Isotope) -> float: """ Atomic weight. @@ -77,7 +77,7 @@ def mass(isotope): """ return isotope._mass -def abundance(isotope): +def abundance(isotope: Element|Isotope) -> float: """ Natural abundance. @@ -89,7 +89,7 @@ def abundance(isotope): """ return isotope._abundance -def init(table, reload=False): +def init(table: PeriodicTable, reload: bool=False) -> None: """Add mass attribute to period table elements and isotopes""" if 'mass' in table.properties and not reload: return @@ -182,7 +182,7 @@ def init(table, reload=False): #print(f"Li6:Li7 ratio changed from {Li_ratio:.1f} to {new_Li_ratio:.1f}") -def print_natural_mass(table=None): +def print_natural_mass(table: PeriodicTable|None=None) -> None: from uncertainties import ufloat as U table = default_table(table) for el in table: @@ -201,7 +201,7 @@ def print_natural_mass(table=None): #print(f"{el.number}-{el}: {delta:fS}") #print("%d-%s: %s (from sum: %s)"%(el.number, el, str(el_mass), str(iso_sum))) -def print_abundance(table=None): +def print_abundance(table: PeriodicTable|None=None) -> None: table = default_table(table) for el in table: abundance = ["%8s %g"%(iso, iso.abundance/100) for iso in el if iso.abundance>0] @@ -209,7 +209,7 @@ def print_abundance(table=None): print("\n".join(abundance)) print() -def check_abundance(table=None): +def check_abundance(table: PeriodicTable|None=None) -> None: table = default_table(table) for el in table: abundance = [iso.abundance for iso in el if iso.abundance>0] diff --git a/periodictable/mass_2001.py b/periodictable/mass_2001.py index 565e7a2..26383da 100644 --- a/periodictable/mass_2001.py +++ b/periodictable/mass_2001.py @@ -55,11 +55,11 @@ and High-Energy Physics, Amsterdam, The Netherlands. """ -from .core import Element, Isotope +from .core import Element, Isotope, PeriodicTable from .mass import mass, abundance from .util import parse_uncertainty -def init(table, reload=False): +def init(table: PeriodicTable, reload: bool=False) -> None: """Add mass attribute to period table elements and isotopes""" if 'mass' in table.properties and not reload: return diff --git a/periodictable/nsf.py b/periodictable/nsf.py index 2986c4b..a5fdfe8 100644 --- a/periodictable/nsf.py +++ b/periodictable/nsf.py @@ -188,10 +188,12 @@ import numpy as np from numpy import sqrt, pi, asarray, inf -from .core import Element, Isotope, default_table +from numpy.typing import NDArray, ArrayLike +from .core import Element, Isotope, PeriodicTable, default_table from .constants import (avogadro_number, planck_constant, electron_volt, neutron_mass, atomic_mass_constant) -from .util import parse_uncertainty + +DoubleArray = NDArray[np.float64] __all__ = ['init', 'Neutron', 'neutron_energy', 'neutron_wavelength', @@ -223,7 +225,7 @@ VELOCITY_FACTOR = ( 1e10 * planck_constant / (neutron_mass * atomic_mass_constant)) -def neutron_wavelength(energy): +def neutron_wavelength(energy: ArrayLike) -> NDArray: r""" Convert neutron energy to wavelength. @@ -249,7 +251,8 @@ def neutron_wavelength(energy): """ return sqrt(ENERGY_FACTOR / asarray(energy)) -def neutron_wavelength_from_velocity(velocity): +# TODO: why is neutron_wavelength_from_velocity not using asarray() ? +def neutron_wavelength_from_velocity(velocity: float) -> float: r""" Convert neutron velocity to wavelength. @@ -273,7 +276,7 @@ def neutron_wavelength_from_velocity(velocity): """ return VELOCITY_FACTOR / velocity -def neutron_energy(wavelength): +def neutron_energy(wavelength: ArrayLike) -> NDArray: r""" Convert neutron wavelength to energy. @@ -419,44 +422,45 @@ class Neutron: .. Note:: 1 barn = 100 |fm^2| """ - b_c = None - b_c_units = "fm" - b_c_i = None - b_c_i_units = "fm" - b_c_complex = None - b_c_complex_units = "fm" - bp = None - bp_i = None - bp_units = "fm" - bm = None - bm_i = None - bm_units = "fm" - coherent = None - coherent_units = "barn" - incoherent = None - incoherent_units = "barn" - total = None - total_units = "barn" - absorption = None - absorption_units = "barn" - abundance = 0. - abundance_units = "%" - is_energy_dependent = False - nsf_table = None + b_c: float|None = None + b_c_units: str = "fm" + b_c_i: float|None = None + b_c_i_units: str = "fm" + b_c_complex: complex|None = None + b_c_complex_units: str = "fm" + bp: float|None = None + bp_i: float|None = None + bp_units: str = "fm" + bm: float|None = None + bm_i: float|None = None + bm_units: str = "fm" + coherent: float|None = None + coherent_units: str = "barn" + incoherent: float|None = None + incoherent_units: str = "barn" + total: float|None = None + total_units: str = "barn" + absorption: float|None = None + absorption_units: str = "barn" + abundance: float = 0. + abundance_units: str = "%" + is_energy_dependent: bool = False + nsf_table: tuple[DoubleArray, DoubleArray]|None = None + def __init__(self): self._number_density = None - def __str__(self): + def __str__(self) -> str: return ("b_c=%.3g coh=%.3g inc=%.3g abs=%.3g" % (self.b_c, self.coherent, self.incoherent, self.absorption)) - def has_sld(self): + def has_sld(self) -> bool: """Returns *True* if sld is defined for this element/isotope.""" # TODO: use NaN for missing information #return np.isnan(self.b_c * self._number_density) return self.b_c is not None and self._number_density is not None # PAK 2021-04-05: allow energy dependent b_c - def scattering_by_wavelength(self, wavelength): + def scattering_by_wavelength(self, wavelength: ArrayLike) -> tuple[NDArray, NDArray]: r""" Return scattering length and total cross section for each wavelength. @@ -486,7 +490,7 @@ def scattering_by_wavelength(self, wavelength): sigma_s = _4PI_100*abs(b_c)**2 # 1 barn = 1 fm^2 1e-2 barn/fm^2 return b_c, sigma_s - def sld(self, *, wavelength=ABSORPTION_WAVELENGTH): + def sld(self, *, wavelength: ArrayLike=ABSORPTION_WAVELENGTH) -> NDArray: r""" Returns scattering length density for the element at natural abundance and density. @@ -507,7 +511,7 @@ def sld(self, *, wavelength=ABSORPTION_WAVELENGTH): return None, None, None return self.scattering(wavelength=wavelength)[0] - def scattering(self, *, wavelength=ABSORPTION_WAVELENGTH): + def scattering(self, *, wavelength: ArrayLike=ABSORPTION_WAVELENGTH) -> tuple[NDArray, NDArray, NDArray]: r""" Returns neutron scattering information for the element at natural abundance and density. @@ -540,7 +544,7 @@ def scattering(self, *, wavelength=ABSORPTION_WAVELENGTH): b_c, sigma_s = self.scattering_by_wavelength(wavelength) return _calculate_scattering(number_density, wavelength, b_c, sigma_s) -def energy_dependent_init(table): +def energy_dependent_init(table: PeriodicTable) -> None: from .nsf_tables import ENERGY_DEPENDENT_TABLES for (el_name, iso_num), values in ENERGY_DEPENDENT_TABLES.items(): @@ -562,7 +566,7 @@ def energy_dependent_init(table): table.Lu.neutron.nsf_table = wavelength, bc_nat #table.Lu.neutron.total = 0. # zap total cross section -def init(table, reload=False): +def init(table: PeriodicTable, reload: bool=False) -> None: """ Loads the Rauch table from the neutron data book. """ @@ -663,9 +667,18 @@ def init(table, reload=False): # TODO: split incoherent into spin and isotope incoherence (eq 17-19 of Sears) # TODO: require parsed compound rather than including formula() keywords in api # Note: docs and function prototype are reproduced in __init__ -def neutron_scattering(compound, *, density=None, - wavelength=None, energy=None, - natural_density=None, table=None): +# CRUFT: deprecated circular import with periodictable.formulas +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from .formulas import FormulaInput +def neutron_scattering( + compound: "FormulaInput", *, + density: float|None=None, + wavelength: ArrayLike|None=None, + energy: ArrayLike|None=None, + natural_density: float|None=None, + table: PeriodicTable|None=None, + ) -> tuple[NDArray, NDArray, NDArray]: r""" Computes neutron scattering cross sections for molecules. @@ -907,8 +920,8 @@ def neutron_scattering(compound, *, density=None, t_u\,({\rm cm}) &= 1/(\Sigma_{\rm s}\, 1/{\rm cm} \,+\, \Sigma_{\rm abs}\, 1/{\rm cm}) """ - from . import formulas + compound = formulas.formula( compound, density=density, natural_density=natural_density, table=table) assert compound.density is not None, "scattering calculation needs density" diff --git a/periodictable/nsf_tables.py b/periodictable/nsf_tables.py index 1199dc7..40aafa5 100644 --- a/periodictable/nsf_tables.py +++ b/periodictable/nsf_tables.py @@ -6,7 +6,7 @@ From Lynn and Seeger (1990) """ -ENERGY_DEPENDENT_TABLES = { +ENERGY_DEPENDENT_TABLES: dict[tuple[str, int|None], list[list[float]]] = { ("Sm", None): [ [0.01, 1.24, -1.16, 1.70], [0.02, 0.96, -1.42, 1.72], diff --git a/periodictable/util.py b/periodictable/util.py index e729547..33d9557 100644 --- a/periodictable/util.py +++ b/periodictable/util.py @@ -5,7 +5,7 @@ """ from math import sqrt -def parse_uncertainty(s): +def parse_uncertainty(s: str) -> tuple[float, float]: """ Given a floating point value plus uncertainty return the pair (val, unc). @@ -53,7 +53,7 @@ def parse_uncertainty(s): # Plain value with no uncertainty return float(s), 0 -def cell_volume(a=None, b=None, c=None, alpha=None, beta=None, gamma=None): +def cell_volume(a=None, b=None, c=None, alpha=None, beta=None, gamma=None) -> float: r""" Compute cell volume from lattice parameters. diff --git a/periodictable/xsf.py b/periodictable/xsf.py index a1a5950..6172ede 100644 --- a/periodictable/xsf.py +++ b/periodictable/xsf.py @@ -247,10 +247,11 @@ class Xray: X-ray scattering properties for the elements. Refer help(periodictable.xsf) from command prompt for details. """ + sftable_units: tuple[str, str, str] = ("eV", "", "") + scattering_factors_units: tuple[str, str] = ("", "") + sld_units: tuple[str, str] = ("1e-6/Ang^2", "1e-6/Ang^2") + element: Element _nff_path = get_data_path('xsf') - sftable_units = ["eV", "", ""] - scattering_factors_units = ["", ""] - sld_units = ["1e-6/Ang^2", "1e-6/Ang^2"] _table = None def __init__(self, element): self.element = element From cf84f461577ffadde68d26db04e4f12921873383 Mon Sep 17 00:00:00 2001 From: Paul Kienzle Date: Thu, 8 Jan 2026 11:13:45 -0500 Subject: [PATCH 02/10] python 3.11 support for type hints --- periodictable/core.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/periodictable/core.py b/periodictable/core.py index edacdd0..843d358 100644 --- a/periodictable/core.py +++ b/periodictable/core.py @@ -668,7 +668,10 @@ def iselement(val: Any) -> bool: val = val.element return isinstance(val, Element) -def change_table[T: AtomVar](atom: T, table: str) -> T: +# CRUFT: the following is not supported in python 3.11 +#def change_table[T: AtomVar](atom: T, table: str) -> T: +T = TypeVar('T') +def change_table(atom: T, table: str) -> T: """Search for the same element, isotope or ion from a different table""" if ision(atom): if isisotope(atom): From 084835593796f4404c7974ba5c4f9a6a57629f58 Mon Sep 17 00:00:00 2001 From: Paul Kienzle Date: Thu, 8 Jan 2026 11:22:21 -0500 Subject: [PATCH 03/10] Camel case deprecated in pyparsing --- periodictable/formulas.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/periodictable/formulas.py b/periodictable/formulas.py index f50b681..9b16b91 100644 --- a/periodictable/formulas.py +++ b/periodictable/formulas.py @@ -707,7 +707,7 @@ def formula_grammar(table: PeriodicTable) -> ParserElement: :Returns: *parser* : pyparsing.ParserElement. - The ``parser.parseString()`` method returns a list of + The ``parser.parse_string()`` method returns a list of pairs (*count, fragment*), where fragment is an *isotope*, an *element* or a list of pairs (*count, fragment*). @@ -727,34 +727,34 @@ def formula_grammar(table: PeriodicTable) -> ParserElement: # Lookup the element in the element table symbol = Regex("[A-Z][a-z]?") - symbol = symbol.setParseAction(lambda s, l, t: table.symbol(t[0])) + symbol = symbol.set_parse_action(lambda s, l, t: table.symbol(t[0])) # Translate isotope openiso = Literal('[').suppress() closeiso = Literal(']').suppress() isotope = Optional(~White()+openiso+Regex("[1-9][0-9]*")+closeiso, default='0') - isotope = isotope.setParseAction(lambda s, l, t: int(t[0]) if t[0] else 0) + isotope = isotope.set_parse_action(lambda s, l, t: int(t[0]) if t[0] else 0) # Translate ion openion = Literal('{').suppress() closeion = Literal('}').suppress() ion = Optional(~White() +openion +Regex("([1-9][0-9]*)?[+-]") +closeion, default='0+') - ion = ion.setParseAction(lambda s, l, t: int(t[0][-1]+(t[0][:-1] if len(t[0]) > 1 else '1'))) + ion = ion.set_parse_action(lambda s, l, t: int(t[0][-1]+(t[0][:-1] if len(t[0]) > 1 else '1'))) # Translate counts # TODO: regex should reject a bare '.' if we want to allow dots between formula parts fract = Regex("(0|[1-9][0-9]*|)([.][0-9]*)") - fract = fract.setParseAction(lambda s, l, t: float(t[0]) if t[0] else 1) + fract = fract.set_parse_action(lambda s, l, t: float(t[0]) if t[0] else 1) whole = Regex("(0|[1-9][0-9]*)") - whole = whole.setParseAction(lambda s, l, t: int(t[0]) if t[0] else 1) + whole = whole.set_parse_action(lambda s, l, t: int(t[0]) if t[0] else 1) number = Optional(~White()+(fract|whole), default=1) # TODO use unicode ₀₁₉ in the code below? sub_fract = Regex("(\u2080|[\u2081-\u2089][\u2080-\u2089]*|)([.][\u2080-\u2089]*)") - sub_fract = sub_fract.setParseAction(lambda s, l, t: float(from_subscript(t[0])) if t[0] else 1) + sub_fract = sub_fract.set_parse_action(lambda s, l, t: float(from_subscript(t[0])) if t[0] else 1) sub_whole = Regex("(\u2080|[\u2081-\u2089][\u2080-\u2089]*)") - sub_whole = sub_whole.setParseAction(lambda s, l, t: int(from_subscript(t[0])) if t[0] else 1) + sub_whole = sub_whole.set_parse_action(lambda s, l, t: int(from_subscript(t[0])) if t[0] else 1) sub_count = Optional(~White()+(fract|whole|sub_fract|sub_whole), default=1) # Fasta code @@ -770,7 +770,7 @@ def convert_fasta(string, location, tokens): raise ValueError(f"Invalid fasta sequence type '{seq_type}:'") seq = fasta.Sequence(name=None, sequence=seq, type=seq_type) return seq.labile_formula - fasta.setParseAction(convert_fasta) + fasta.set_parse_action(convert_fasta) # Convert symbol, isotope, ion, count to (count, isotope) element = symbol+isotope+ion+sub_count @@ -783,7 +783,7 @@ def convert_element(string, location, tokens): if ion != 0: symbol = symbol.ion[ion] return (count, symbol) - element = element.setParseAction(convert_element) + element = element.set_parse_action(convert_element) # Convert "count elements" to a pair implicit_group = number+OneOrMore(element) @@ -793,7 +793,7 @@ def convert_implicit(string, location, tokens): count = tokens[0] fragment = tokens[1:] return fragment if count == 1 else (count, fragment) - implicit_group = implicit_group.setParseAction(convert_implicit) + implicit_group = implicit_group.set_parse_action(convert_implicit) # Convert "(composite) count" to a pair opengrp = space + Literal('(').suppress() + space @@ -805,7 +805,7 @@ def convert_explicit(string, location, tokens): count = tokens[-1] fragment = tokens[:-1] return fragment if count == 1 else (count, fragment) - explicit_group = explicit_group.setParseAction(convert_explicit) + explicit_group = explicit_group.set_parse_action(convert_explicit) # Build composite from a set of groups group = implicit_group | explicit_group @@ -840,7 +840,7 @@ def convert_compound(string, location, tokens): formula.density = density #print("compound", formula, f"{formula.density=:.3f}") return formula - compound = compound.setParseAction(convert_compound) + compound = compound.set_parse_action(convert_compound) partsep = space + Literal('//').suppress() + space percent = Literal('%').suppress() @@ -872,7 +872,7 @@ def convert_by_weight(string, location, tokens): """convert mixture by wt% or mass%""" piece, fract = _parts_by_weight_vol(tokens) return _mix_by_weight_pairs(zip(piece, fract)) - mixture_by_weight = by_weight.setParseAction(convert_by_weight) + mixture_by_weight = by_weight.set_parse_action(convert_by_weight) by_volume = (number + volume_percent + mixture + ZeroOrMore(partsep+number+(volume_percent|percent)+mixture) @@ -881,7 +881,7 @@ def convert_by_volume(string, location, tokens): """convert mixture by vol%""" piece, fract = _parts_by_weight_vol(tokens) return _mix_by_volume_pairs(zip(piece, fract)) - mixture_by_volume = by_volume.setParseAction(convert_by_volume) + mixture_by_volume = by_volume.set_parse_action(convert_by_volume) mixture_by_layer = Forward() layer_thick = Group(number + Regex(LENGTH_RE) + space) @@ -907,7 +907,7 @@ def convert_by_layer(string, location, tokens): result = _mix_by_volume_pairs(zip(piece, vfract)) result.thickness = total return result - mixture_by_layer = mixture_by_layer.setParseAction(convert_by_layer) + mixture_by_layer = mixture_by_layer.set_parse_action(convert_by_layer) mixture_by_absmass = Forward() absmass_mass = Group(number + Regex(MASS_VOLUME_RE) + space) @@ -941,7 +941,7 @@ def convert_by_absmass(string, location, tokens): result = _mix_by_weight_pairs(zip(piece, mfract)) result.total_mass = total return result - mixture_by_absmass = mixture_by_absmass.setParseAction(convert_by_absmass) + mixture_by_absmass = mixture_by_absmass.set_parse_action(convert_by_absmass) ungrouped_mixture = (mixture_by_weight | mixture_by_volume | mixture_by_layer | mixture_by_absmass) @@ -955,13 +955,13 @@ def convert_mixture(string, location, tokens): formula.density = tokens[-2] # elif tokens[-1] is None return formula - grouped_mixture = grouped_mixture.setParseAction(convert_mixture) + grouped_mixture = grouped_mixture.set_parse_action(convert_mixture) mixture << (compound | grouped_mixture) formula = (compound | ungrouped_mixture | grouped_mixture) grammar = Optional(formula, default=Formula()) + StringEnd() - grammar.setName('Chemical Formula') + grammar.set_name('Chemical Formula') return grammar _PARSER_CACHE: dict[PeriodicTable, ParserElement] = {} From 443d8a6c0177a7f6f4141c19d34f6908c590d484 Mon Sep 17 00:00:00 2001 From: Paul Kienzle Date: Thu, 8 Jan 2026 22:42:34 -0500 Subject: [PATCH 04/10] Partial support for mypy checking...still 56 errors remaining --- doc/sphinx/conf.py | 20 ++ periodictable/__init__.py | 2 +- periodictable/activation.py | 44 ++-- periodictable/core.py | 461 ++++++++++++++++++++++------------- periodictable/cromermann.py | 4 +- periodictable/density.py | 26 +- periodictable/fasta.py | 219 +++++++++-------- periodictable/formulas.py | 160 ++++++------ periodictable/magnetic_ff.py | 23 +- periodictable/mass.py | 42 ++-- periodictable/mass_2001.py | 10 +- periodictable/nsf.py | 32 ++- periodictable/util.py | 2 +- 13 files changed, 617 insertions(+), 428 deletions(-) diff --git a/doc/sphinx/conf.py b/doc/sphinx/conf.py index df13b15..dbaac0d 100644 --- a/doc/sphinx/conf.py +++ b/doc/sphinx/conf.py @@ -49,6 +49,26 @@ nitpick_ignore = [ ('py:class', 'type'), ('py:class', 'object'), + + ('py:class', 'collections.abc.Callable'), + ('py:class', 'collections.abc.Iterator'), + ('py:class', 'collections.abc.Sequence'), + ('py:class', 'pathlib.Path'), + + ('py:class', 'numpy.dtype'), + ('py:class', 'numpy.ndarray'), + ('py:class', 'numpy.float64'), + ('py:class', 'numpy._typing._array_like._Buffer'), + ('py:class', 'numpy._typing._array_like._SupportsArray'), + ('py:class', 'numpy._typing._array_like._ScalarT'), + ('py:class', 'numpy._typing._nested_sequence._NestedSequence'), + ('py:class', 'pyparsing.core.ParserElement'), + + ('py:class', 'periodictable.core._AtomBase'), + ('py:class', 'periodictable.core.IonSet'), + ('py:class', 'periodictable.core.AtomVar'), + ('py:class', 'Fragment'), + ('py:class', 'FormulaInput'), ] # Add any paths that contain templates here, relative to this directory. diff --git a/periodictable/__init__.py b/periodictable/__init__.py index 4cb30bc..1219ef8 100644 --- a/periodictable/__init__.py +++ b/periodictable/__init__.py @@ -36,7 +36,7 @@ from . import mass from . import density -_LAZY_MODULES = [] +_LAZY_MODULES: list[str] = [] _LAZY_LOAD = { 'formula': 'formulas', 'mix_by_weight': 'formulas', diff --git a/periodictable/activation.py b/periodictable/activation.py index d290c5a..12a3823 100644 --- a/periodictable/activation.py +++ b/periodictable/activation.py @@ -225,6 +225,7 @@ from math import exp, log, expm1 import os from collections.abc import Callable, Sequence +from typing import cast from .formulas import formula as build_formula, Formula, FormulaInput from . import core @@ -235,7 +236,7 @@ def table_abundance(iso: core.Isotope) -> float: """ Isotopic abundance in % from the periodic table package. """ - return iso.abundance + return iso.abundance if iso.abundance else 0. def IAEA1987_isotopic_abundance(iso: core.Isotope) -> float: """ @@ -247,10 +248,10 @@ def IAEA1987_isotopic_abundance(iso: core.Isotope) -> float: IAEA 273: Handbook on Nuclear Activation Data, 1987. """ - try: - return iso.neutron_activation[0].abundance - except AttributeError: - return 0 + activation = getattr(iso, "neutron_activation", None) + if activation is not None: + return activation[0].abundance + return 0. class Sample: """ @@ -276,7 +277,7 @@ class Sample: activity: dict["ActivationResult", list[float]] environment: "ActivationEnvironment" exposure: float - rest_time: tuple[float] + rest_times: tuple[float, ...] def __init__(self, formula: FormulaInput, mass: float, name: str|None=None): self.formula = build_formula(formula) @@ -285,7 +286,7 @@ def __init__(self, formula: FormulaInput, mass: float, name: str|None=None): self.activity = {} # The following are set in calculation_activation - self.environment = None # type: "ActivationEnvironment" + #self.environment = None self.exposure = 0. self.rest_times = () @@ -293,7 +294,7 @@ def calculate_activation( self, environment: "ActivationEnvironment", exposure: float=1, - rest_times: tuple[float]=(0, 1, 24, 360), + rest_times: tuple[float, ...]=(0, 1, 24, 360), abundance: Callable[[core.Isotope], float]=table_abundance, ): """ @@ -317,13 +318,14 @@ def calculate_activation( self.rest_times = rest_times for el, frac in self.formula.mass_fraction.items(): if core.isisotope(el): - A = activity(el, self.mass*frac, environment, exposure, rest_times) + A = activity(cast(core.Isotope, el), self.mass*frac, environment, exposure, rest_times) self._accumulate(A) else: - for iso in el.isotopes: - iso_mass = self.mass*frac*abundance(el[iso])*0.01 + for iso_num in el.isotopes: + iso: core.Isotope = cast(core.Element, el)[iso_num] + iso_mass = self.mass*frac*abundance(iso)*0.01 if iso_mass: - A = activity(el[iso], iso_mass, environment, exposure, rest_times) + A = activity(iso, iso_mass, environment, exposure, rest_times) self._accumulate(A) def decay_time(self, target: float, tol: float=1e-10): @@ -371,7 +373,7 @@ def decay_time(self, target: float, tol: float=1e-10): # for time adjustment we used to stablize the fit. return max(t+guess, 0.0) - def _accumulate(self, activity: list[float]): + def _accumulate(self, activity: dict["ActivationResult", list[float]]): for el, activity_el in activity.items(): el_total = self.activity.get(el, [0]*len(self.rest_times)) self.activity[el] = [T+v for T, v in zip(el_total, activity_el)] @@ -394,8 +396,8 @@ def show_table(self, cutoff: float=0.0001, format: str="%.4g"): # Track individual rows with more than 1 uCi of activation, and total activation # Replace any activation below the cutoff with '---' rows = [] - total = [0]*len(self.rest_times) - for el, activity_el in sorted_activity(self.activity.items()): + total = [0.]*len(self.rest_times) + for el, activity_el in sorted_activity(self.activity): total = [t+a for t, a in zip(total, activity_el)] if all(a < cutoff for a in activity_el): continue @@ -467,10 +469,10 @@ def find_root( def sorted_activity( - activity_pair: Sequence[tuple["ActivationResult", list[float]]] + activity: dict["ActivationResult", list[float]], ) -> list[tuple["ActivationResult", list[float]]]: """Interator over activity pairs sorted by isotope then daughter product.""" - return sorted(activity_pair, key=lambda x: (x[0].isotope, x[0].daughter)) + return sorted(activity.items(), key=lambda x: (x[0].isotope, x[0].daughter)) class ActivationEnvironment: @@ -599,7 +601,7 @@ def activity( env: ActivationEnvironment, exposure: float, rest_times: Sequence[float], - ): + ) -> dict["ActivationResult", list[float]]: """ Compute isotope specific daughter products after the given exposure time and rest period. @@ -650,7 +652,7 @@ def activity( # included. Because 1-H => 2-H (act) is not in the table, is this why # there is no entry for 1-H => 3-H (2n). - result = {} + result: dict["ActivationResult", list[float]] = {} if not hasattr(isotope, 'neutron_activation'): return result @@ -924,7 +926,7 @@ class ActivationResult: isomer: str Thalf_hrs: float Thalf_str: str - Thalf_parent: float|None + Thalf_parent: float fast: bool thermalXS: float resonance: float @@ -1011,7 +1013,7 @@ def init(table, reload=False): kw['Thalf_parent'] = parent.Thalf_hrs else: #assert kw['Thalf_parent'] == 0 - kw['Thalf_parent'] = None + kw['Thalf_parent'] = 0. # Value not used if reaction is not 'b' or '2n' # Half-lives use My, Gy, Ty, Py value, units = float(kw['_Thalf']), kw['_Thalf_unit'] if units == 'y': diff --git a/periodictable/core.py b/periodictable/core.py index 843d358..07e65d1 100644 --- a/periodictable/core.py +++ b/periodictable/core.py @@ -63,7 +63,7 @@ 'isatom', 'iselement', 'isisotope', 'ision'] from pathlib import Path -from typing import TYPE_CHECKING, Any, Union, TypeVar +from typing import TYPE_CHECKING, cast, Any, Union, TypeVar from collections.abc import Sequence, Callable, Iterator if TYPE_CHECKING: @@ -224,8 +224,130 @@ class PeriodicTable: properties: list[str] """Properties loaded into the table""" - def __init__(self, table: str): - # type: (str) -> None + # Tedious listing of available elements for typed table.El access + n: "Element" + H: "Element" + D: "Isotope" + T: "Isotope" + He: "Element" + Li: "Element" + Be: "Element" + B: "Element" + C: "Element" + N: "Element" + O: "Element" + F: "Element" + Ne: "Element" + Na: "Element" + Mg: "Element" + Al: "Element" + Si: "Element" + P: "Element" + S: "Element" + Cl: "Element" + Ar: "Element" + K: "Element" + Ca: "Element" + Sc: "Element" + Ti: "Element" + V: "Element" + Cr: "Element" + Mn: "Element" + Fe: "Element" + Co: "Element" + Ni: "Element" + Cu: "Element" + Zn: "Element" + Ga: "Element" + Ge: "Element" + As: "Element" + Se: "Element" + Br: "Element" + Kr: "Element" + Rb: "Element" + Sr: "Element" + Y: "Element" + Zr: "Element" + Nb: "Element" + Mo: "Element" + Tc: "Element" + Ru: "Element" + Rh: "Element" + Pd: "Element" + Ag: "Element" + Cd: "Element" + In: "Element" + Sn: "Element" + Sb: "Element" + Te: "Element" + I: "Element" + Xe: "Element" + Cs: "Element" + Ba: "Element" + La: "Element" + Ce: "Element" + Pr: "Element" + Nd: "Element" + Pm: "Element" + Sm: "Element" + Eu: "Element" + Gd: "Element" + Tb: "Element" + Dy: "Element" + Ho: "Element" + Er: "Element" + Tm: "Element" + Yb: "Element" + Lu: "Element" + Hf: "Element" + Ta: "Element" + W: "Element" + Re: "Element" + Os: "Element" + Ir: "Element" + Pt: "Element" + Au: "Element" + Hg: "Element" + Tl: "Element" + Pb: "Element" + Bi: "Element" + Po: "Element" + At: "Element" + Rn: "Element" + Fr: "Element" + Ra: "Element" + Ac: "Element" + Th: "Element" + Pa: "Element" + U: "Element" + Np: "Element" + Pu: "Element" + Am: "Element" + Cm: "Element" + Bk: "Element" + Cf: "Element" + Es: "Element" + Fm: "Element" + Md: "Element" + No: "Element" + Lr: "Element" + Rf: "Element" + Db: "Element" + Sg: "Element" + Bh: "Element" + Hs: "Element" + Mt: "Element" + Ds: "Element" + Rg: "Element" + Cn: "Element" + Nh: "Element" + Fl: "Element" + Mc: "Element" + Lv: "Element" + Ts: "Element" + Og: "Element" + + def __init__(self, table: str) -> None: if table in PRIVATE_TABLES: raise ValueError("Periodic table '%s' is already defined"%table) PRIVATE_TABLES[table] = self @@ -239,10 +361,10 @@ def __init__(self, table: str): PeriodicTable.__annotations__[symbol] = Element # There are two specially named isotopes D and T - self.D: Isotope = self.H.add_isotope(2) + self.D = self.H.add_isotope(2) self.D.name = 'deuterium' self.D.symbol = 'D' - self.T: Isotope = self.H.add_isotope(3) + self.T = self.H.add_isotope(3) self.T.name = 'tritium' self.T.symbol = 'T' @@ -262,7 +384,7 @@ def __iter__(self) -> Iterator["Element"]: for _, el in elements[1:]: yield el - def symbol(self, input: str) -> "Element": + def symbol(self, input: str) -> Union["Element", "Isotope"]: """ Lookup the an element in the periodic table using its symbol. Symbols are included for 'D' and 'T', deuterium and tritium. @@ -290,7 +412,7 @@ def symbol(self, input: str) -> "Element": return value raise ValueError("unknown element "+input) - def name(self, input: str) -> "Element": + def name(self, input: str) -> Union["Element", "Isotope"]: """ Lookup an element given its name. @@ -298,7 +420,7 @@ def name(self, input: str) -> "Element": *input* : string Element name to be looked up in periodictable. - :Returns: Element + :Returns: Element (or Isotope for "D" and "T") :Raises: *ValueError* if element does not exist. @@ -430,6 +552,13 @@ def list(self, *props, **kw) -> None: # print "format", format, "args", L # raise +# TODO: types for properties are not being handled correctly +# I've left them as simple "name: float" for now so that the editor will see them. +# Because I'm using delegation via __getattr__ the properties need to be listed in +# _AtomBase, but I can't define them as properties there because it confuses the +# delegation mechanism. Possible work-arounds are to build the delegation into the +# property, or figure out how to define a property return type that is seen by the +# static type analyzers without defining the property itself. class _AtomBase: """ Attributes common to element, isotope and ion. @@ -443,37 +572,41 @@ class _AtomBase: name: str symbol: str number: int - ions: list[int] + ions: tuple[int, ...] ion: "IonSet" # TODO: could be IonSet["Element"] or IonSet["Isotope"] charge: int # element (=0), isotope (delegate to element), ion (!= 0) #element: Union["Element", "Isotope"] # ion or isotope # mass.py - mass: float # element, isotope, ion - mass_units: str # element, isotope, ion + mass: float # property + _mass: float # internal _mass_unc: float # Not yet official, but the data is loaded for some tables + mass_units: str # element, isotope, ion #abundance: float # isotope only #abundance_units: str # isotope only # covalent_radius.py - covalent_radius: float # element - covalent_radius_uncertainty: float # element + covalent_radius: float|None # element + covalent_radius_uncertainty: float|None # element covalent_radius_units: str # element # crystal_structure.py crystal_structure: dict[str, Any]|None # element # density.py - density: float + density: float # property + _density: float density_units: str - interatomic_distance: float - number_density: float + interatomic_distance: float # property + interatomic_distance_units: str + number_density: float # property number_density_units: str density_caveat: str|None # nsf.py neutron: "Neutron" # element and isotope + #nuclear_spin: str # isotope only # activation.py #neutron_activation: tuple["ActivationResult"]|None # isotope only @@ -486,7 +619,7 @@ class _AtomBase: xray: "Xray" # element # magnetic_ff.py - magnetic_ff: dict[int, "MagneticFormFactor"]|None # element + magnetic_ff: dict[int, "MagneticFormFactor"] # element class Ion(_AtomBase): """ @@ -502,10 +635,10 @@ class Ion(_AtomBase): # TODO: abundance and activation need to be defined for charged isotopes. - def __init__(self, element, charge): + def __init__(self, element: Union["Element", "Isotope"], charge: int): self.element = element self.charge = charge - def __getattr__(self, attr): + def __getattr__(self, attr: str) -> Any: return getattr(self.element, attr) @property def mass(self) -> float: @@ -557,15 +690,23 @@ class Isotope(_AtomBase): """ element: "Element" ion: IonSet # TODO: should be IonSet["Isotope"] + isotope: int # mass.py - abundance: float # isotope only + # TODO: Do we still need to modify isotope.abundance during mass.init()? + @property + def abundance(self) -> float: return self._abundance + _abundance: float + _abundance_unc: float abundance_units: str # isotope only # activation.py - neutron_activation: tuple["ActivationResult"]|None # isotope only + neutron_activation: tuple["ActivationResult"] # isotope only - def __init__(self, element, isotope_number): + # nsf.py + nuclear_spin: str + + def __init__(self, element: "Element", isotope_number: int): self.element = element self.isotope = isotope_number self.ion = IonSet(self) @@ -594,7 +735,7 @@ class Element(_AtomBase): table: str = PUBLIC_TABLE_NAME charge: int = 0 - def __init__(self, name, symbol, Z, ions, table): + def __init__(self, name: str, symbol: str, Z: int, ions: tuple[int, ...], table: "str"): self.name = name self.symbol = symbol self.number = Z @@ -668,23 +809,22 @@ def iselement(val: Any) -> bool: val = val.element return isinstance(val, Element) -# CRUFT: the following is not supported in python 3.11 -#def change_table[T: AtomVar](atom: T, table: str) -> T: -T = TypeVar('T') -def change_table(atom: T, table: str) -> T: +def change_table(atom: AtomVar, table: PeriodicTable) -> Atom: """Search for the same element, isotope or ion from a different table""" if ision(atom): if isisotope(atom): - return table[atom.number][atom.isotope].ion[atom.charge] + iso = cast(Isotope, atom) + return table[iso.number][iso.isotope].ion[iso.charge] else: return table[atom.number].ion[atom.charge] else: if isisotope(atom): - return table[atom.number][atom.isotope] + iso = cast(Isotope, atom) + return table[iso.number][iso.isotope] else: return table[atom.number] -PRIVATE_TABLES = {} +PRIVATE_TABLES: dict[str, PeriodicTable] = {} def _get_table(name: str) -> PeriodicTable: try: return PRIVATE_TABLES[name] @@ -702,128 +842,128 @@ def _make_isotope_ion(table: str, Z: int, n: int, c: int) -> Ion: # pylint: disable=bad-whitespace -element_base = { +element_base: dict[int, tuple[str, str, list[int], list[int]]] = { # number: name symbol common_ions uncommon_ions # ion info comes from Wikipedia: list of oxidation states of the elements. - 0: ['neutron', 'n', [], []], - 1: ['Hydrogen', 'H', [-1, 1], []], - 2: ['Helium', 'He', [], [1, 2]], # +1,+2 http://periodic.lanl.gov/2.shtml - 3: ['Lithium', 'Li', [1], []], - 4: ['Beryllium', 'Be', [2], [1]], - 5: ['Boron', 'B', [3], [-5, -1, 1, 2]], - 6: ['Carbon', 'C', [-4, -3, -2, -1, 1, 2, 3, 4], []], - 7: ['Nitrogen', 'N', [-3, 3, 5], [-2, -1, 1, 2, 4]], - 8: ['Oxygen', 'O', [-2], [-1, 1, 2]], - 9: ['Fluorine', 'F', [-1], []], - 10: ['Neon', 'Ne', [], []], - 11: ['Sodium', 'Na', [1], [-1]], - 12: ['Magnesium', 'Mg', [2], [1]], - 13: ['Aluminum', 'Al', [3], [-2, -1, 1, 2]], - 14: ['Silicon', 'Si', [-4, 4], [-3, -2, -1, 1, 2, 3]], - 15: ['Phosphorus', 'P', [-3, 3, 5], [-2, -1, 1, 2, 4]], - 16: ['Sulfur', 'S', [-2, 2, 4, 6], [-1, 1, 3, 5]], - 17: ['Chlorine', 'Cl', [-1, 1, 3, 5, 7], [2, 4, 6]], - 18: ['Argon', 'Ar', [], []], - 19: ['Potassium', 'K', [1], [-1]], - 20: ['Calcium', 'Ca', [2], [1]], - 21: ['Scandium', 'Sc', [3], [1, 2]], - 22: ['Titanium', 'Ti', [4], [-2, -1, 1, 2, 3]], - 23: ['Vanadium', 'V', [5], [-3, -1, 1, 2, 3, 4]], - 24: ['Chromium', 'Cr', [3, 6], [-4, -2, -1, 1, 2, 4, 5]], - 25: ['Manganese', 'Mn', [2, 4, 7], [-3, -2, -1, 1, 3, 5, 6]], - 26: ['Iron', 'Fe', [2, 3, 6], [-4, -2, -1, 1, 4, 5, 7]], - 27: ['Cobalt', 'Co', [2, 3], [-3, -1, 1, 4, 5]], - 28: ['Nickel', 'Ni', [2], [-2, -1, 1, 3, 4]], - 29: ['Copper', 'Cu', [2], [-2, 1, 3, 4]], - 30: ['Zinc', 'Zn', [2], [-2, 1]], - 31: ['Gallium', 'Ga', [3], [-5, -4, -2, -1, 1, 2]], - 32: ['Germanium', 'Ge', [-4, 2, 4], [-3, -2, -1, 1, 3]], - 33: ['Arsenic', 'As', [-3, 3, 5], [-2, -1, 1, 2, 4]], - 34: ['Selenium', 'Se', [-2, 2, 4, 6], [-1, 1, 3, 5]], - 35: ['Bromine', 'Br', [-1, 1, 3, 5], [4, 7]], - 36: ['Krypton', 'Kr', [2], []], - 37: ['Rubidium', 'Rb', [1], [-1]], - 38: ['Strontium', 'Sr', [2], [1]], - 39: ['Yttrium', 'Y', [3], [1, 2]], - 40: ['Zirconium', 'Zr', [4], [-2, 1, 2, 3]], - 41: ['Niobium', 'Nb', [5], [-3, -1, 1, 2, 3, 4]], - 42: ['Molybdenum', 'Mo', [4, 6], [-4, -2, -1, 1, 2, 3, 5]], - 43: ['Technetium', 'Tc', [4, 7], [-3, -1, 1, 2, 3, 5, 6]], - 44: ['Ruthenium', 'Ru', [3, 4], [-4, -2, 1, 2, 5, 6, 7, 8]], - 45: ['Rhodium', 'Rh', [3], [-3, -1, 1, 2, 4, 5, 6]], - 46: ['Palladium', 'Pd', [2, 4], [1, 3, 5, 6]], - 47: ['Silver', 'Ag', [1], [-2, -1, 2, 3, 4]], - 48: ['Cadmium', 'Cd', [2], [-2, 1]], - 49: ['Indium', 'In', [3], [-5, -2, -1, 1, 2]], - 50: ['Tin', 'Sn', [-4, 2, 4], [-3, -2, -1, 1, 3]], - 51: ['Antimony', 'Sb', [-3, 3, 5], [-2, -1, 1, 2, 4]], - 52: ['Tellurium', 'Te', [-2, 2, 4, 6], [-1, 1, 3, 5]], - 53: ['Iodine', 'I', [-1, 1, 3, 5, 7], [4, 6]], - 54: ['Xenon', 'Xe', [2, 4, 6], [8]], - 55: ['Cesium', 'Cs', [1], [-1]], - 56: ['Barium', 'Ba', [2], [1]], - 57: ['Lanthanum', 'La', [3], [1, 2]], - 58: ['Cerium', 'Ce', [3, 4], [2]], - 59: ['Praseodymium', 'Pr', [3], [2, 4, 5]], - 60: ['Neodymium', 'Nd', [3], [2, 4]], - 61: ['Promethium', 'Pm', [3], [2]], - 62: ['Samarium', 'Sm', [3], [2]], - 63: ['Europium', 'Eu', [2, 3], []], - 64: ['Gadolinium', 'Gd', [3], [1, 2]], - 65: ['Terbium', 'Tb', [3], [1, 2, 4]], - 66: ['Dysprosium', 'Dy', [3], [2, 4]], - 67: ['Holmium', 'Ho', [3], [2]], - 68: ['Erbium', 'Er', [3], [2]], - 69: ['Thulium', 'Tm', [3], [2]], - 70: ['Ytterbium', 'Yb', [3], [2]], - 71: ['Lutetium', 'Lu', [3], [2]], - 72: ['Hafnium', 'Hf', [4], [-2, 1, 2, 3]], - 73: ['Tantalum', 'Ta', [5], [-3, -1, 1, 2, 3, 4]], - 74: ['Tungsten', 'W', [4, 6], [-4, -2, -1, 1, 2, 3, 5]], - 75: ['Rhenium', 'Re', [4], [-3, -1, 1, 2, 3, 5, 6, 7]], - 76: ['Osmium', 'Os', [4], [-4, -2, -1, 1, 2, 3, 5, 6, 7, 8]], - 77: ['Iridium', 'Ir', [3, 4], [-3, -1, 1, 2, 5, 6, 7, 8, 9]], - 78: ['Platinum', 'Pt', [2, 4], [-3, -2, -1, 1, 3, 5, 6]], - 79: ['Gold', 'Au', [3], [-3, -2, -1, 1, 2, 5]], - 80: ['Mercury', 'Hg', [1, 2], [-2, 4]], # +4 doi:10.1002/anie.200703710 - 81: ['Thallium', 'Tl', [1, 3], [-5, -2, -1, 2]], - 82: ['Lead', 'Pb', [2, 4], [-4, -2, -1, 1, 3]], - 83: ['Bismuth', 'Bi', [3], [-3, -2, -1, 1, 2, 4, 5]], - 84: ['Polonium', 'Po', [-2, 2, 4], [5, 6]], - 85: ['Astatine', 'At', [-1, 1], [3, 5, 7]], - 86: ['Radon', 'Rn', [2], [6]], - 87: ['Francium', 'Fr', [1], []], - 88: ['Radium', 'Ra', [2], []], - 89: ['Actinium', 'Ac', [3], []], - 90: ['Thorium', 'Th', [4], [1, 2, 3]], - 91: ['Protactinium', 'Pa', [5], [3, 4]], - 92: ['Uranium', 'U', [6], [1, 2, 3, 4, 5]], - 93: ['Neptunium', 'Np', [5], [2, 3, 4, 6, 7]], - 94: ['Plutonium', 'Pu', [4], [2, 3, 5, 6, 7]], - 95: ['Americium', 'Am', [3], [2, 4, 5, 6, 7]], - 96: ['Curium', 'Cm', [3], [4, 6]], - 97: ['Berkelium', 'Bk', [3], [4]], - 98: ['Californium', 'Cf', [3], [2, 4]], - 99: ['Einsteinium', 'Es', [3], [2, 4]], - 100: ['Fermium', 'Fm', [3], [2]], - 101: ['Mendelevium', 'Md', [3], [2]], - 102: ['Nobelium', 'No', [2], [3]], - 103: ['Lawrencium', 'Lr', [3], []], - 104: ['Rutherfordium', 'Rf', [4], []], - 105: ['Dubnium', 'Db', [5], []], - 106: ['Seaborgium', 'Sg', [6], []], - 107: ['Bohrium', 'Bh', [7], []], - 108: ['Hassium', 'Hs', [8], []], - 109: ['Meitnerium', 'Mt', [], []], - 110: ['Darmstadtium', 'Ds', [], []], - 111: ['Roentgenium', 'Rg', [], []], - 112: ['Copernicium', 'Cn', [2], []], - 113: ['Nihonium', 'Nh', [], []], - 114: ['Flerovium', 'Fl', [], []], - 115: ['Moscovium', 'Mc', [], []], - 116: ['Livermorium', 'Lv', [], []], - 117: ['Tennessine', 'Ts', [], []], - 118: ['Oganesson', 'Og', [], []], + 0: ('neutron', 'n', [], []), + 1: ('Hydrogen', 'H', [-1, 1], []), + 2: ('Helium', 'He', [], [1, 2]), # +1,+2 http://periodic.lanl.gov/2.shtml + 3: ('Lithium', 'Li', [1], []), + 4: ('Beryllium', 'Be', [2], [1]), + 5: ('Boron', 'B', [3], [-5, -1, 1, 2]), + 6: ('Carbon', 'C', [-4, -3, -2, -1, 1, 2, 3, 4], []), + 7: ('Nitrogen', 'N', [-3, 3, 5], [-2, -1, 1, 2, 4]), + 8: ('Oxygen', 'O', [-2], [-1, 1, 2]), + 9: ('Fluorine', 'F', [-1], []), + 10: ('Neon', 'Ne', [], []), + 11: ('Sodium', 'Na', [1], [-1]), + 12: ('Magnesium', 'Mg', [2], [1]), + 13: ('Aluminum', 'Al', [3], [-2, -1, 1, 2]), + 14: ('Silicon', 'Si', [-4, 4], [-3, -2, -1, 1, 2, 3]), + 15: ('Phosphorus', 'P', [-3, 3, 5], [-2, -1, 1, 2, 4]), + 16: ('Sulfur', 'S', [-2, 2, 4, 6], [-1, 1, 3, 5]), + 17: ('Chlorine', 'Cl', [-1, 1, 3, 5, 7], [2, 4, 6]), + 18: ('Argon', 'Ar', [], []), + 19: ('Potassium', 'K', [1], [-1]), + 20: ('Calcium', 'Ca', [2], [1]), + 21: ('Scandium', 'Sc', [3], [1, 2]), + 22: ('Titanium', 'Ti', [4], [-2, -1, 1, 2, 3]), + 23: ('Vanadium', 'V', [5], [-3, -1, 1, 2, 3, 4]), + 24: ('Chromium', 'Cr', [3, 6], [-4, -2, -1, 1, 2, 4, 5]), + 25: ('Manganese', 'Mn', [2, 4, 7], [-3, -2, -1, 1, 3, 5, 6]), + 26: ('Iron', 'Fe', [2, 3, 6], [-4, -2, -1, 1, 4, 5, 7]), + 27: ('Cobalt', 'Co', [2, 3], [-3, -1, 1, 4, 5]), + 28: ('Nickel', 'Ni', [2], [-2, -1, 1, 3, 4]), + 29: ('Copper', 'Cu', [2], [-2, 1, 3, 4]), + 30: ('Zinc', 'Zn', [2], [-2, 1]), + 31: ('Gallium', 'Ga', [3], [-5, -4, -2, -1, 1, 2]), + 32: ('Germanium', 'Ge', [-4, 2, 4], [-3, -2, -1, 1, 3]), + 33: ('Arsenic', 'As', [-3, 3, 5], [-2, -1, 1, 2, 4]), + 34: ('Selenium', 'Se', [-2, 2, 4, 6], [-1, 1, 3, 5]), + 35: ('Bromine', 'Br', [-1, 1, 3, 5], [4, 7]), + 36: ('Krypton', 'Kr', [2], []), + 37: ('Rubidium', 'Rb', [1], [-1]), + 38: ('Strontium', 'Sr', [2], [1]), + 39: ('Yttrium', 'Y', [3], [1, 2]), + 40: ('Zirconium', 'Zr', [4], [-2, 1, 2, 3]), + 41: ('Niobium', 'Nb', [5], [-3, -1, 1, 2, 3, 4]), + 42: ('Molybdenum', 'Mo', [4, 6], [-4, -2, -1, 1, 2, 3, 5]), + 43: ('Technetium', 'Tc', [4, 7], [-3, -1, 1, 2, 3, 5, 6]), + 44: ('Ruthenium', 'Ru', [3, 4], [-4, -2, 1, 2, 5, 6, 7, 8]), + 45: ('Rhodium', 'Rh', [3], [-3, -1, 1, 2, 4, 5, 6]), + 46: ('Palladium', 'Pd', [2, 4], [1, 3, 5, 6]), + 47: ('Silver', 'Ag', [1], [-2, -1, 2, 3, 4]), + 48: ('Cadmium', 'Cd', [2], [-2, 1]), + 49: ('Indium', 'In', [3], [-5, -2, -1, 1, 2]), + 50: ('Tin', 'Sn', [-4, 2, 4], [-3, -2, -1, 1, 3]), + 51: ('Antimony', 'Sb', [-3, 3, 5], [-2, -1, 1, 2, 4]), + 52: ('Tellurium', 'Te', [-2, 2, 4, 6], [-1, 1, 3, 5]), + 53: ('Iodine', 'I', [-1, 1, 3, 5, 7], [4, 6]), + 54: ('Xenon', 'Xe', [2, 4, 6], [8]), + 55: ('Cesium', 'Cs', [1], [-1]), + 56: ('Barium', 'Ba', [2], [1]), + 57: ('Lanthanum', 'La', [3], [1, 2]), + 58: ('Cerium', 'Ce', [3, 4], [2]), + 59: ('Praseodymium', 'Pr', [3], [2, 4, 5]), + 60: ('Neodymium', 'Nd', [3], [2, 4]), + 61: ('Promethium', 'Pm', [3], [2]), + 62: ('Samarium', 'Sm', [3], [2]), + 63: ('Europium', 'Eu', [2, 3], []), + 64: ('Gadolinium', 'Gd', [3], [1, 2]), + 65: ('Terbium', 'Tb', [3], [1, 2, 4]), + 66: ('Dysprosium', 'Dy', [3], [2, 4]), + 67: ('Holmium', 'Ho', [3], [2]), + 68: ('Erbium', 'Er', [3], [2]), + 69: ('Thulium', 'Tm', [3], [2]), + 70: ('Ytterbium', 'Yb', [3], [2]), + 71: ('Lutetium', 'Lu', [3], [2]), + 72: ('Hafnium', 'Hf', [4], [-2, 1, 2, 3]), + 73: ('Tantalum', 'Ta', [5], [-3, -1, 1, 2, 3, 4]), + 74: ('Tungsten', 'W', [4, 6], [-4, -2, -1, 1, 2, 3, 5]), + 75: ('Rhenium', 'Re', [4], [-3, -1, 1, 2, 3, 5, 6, 7]), + 76: ('Osmium', 'Os', [4], [-4, -2, -1, 1, 2, 3, 5, 6, 7, 8]), + 77: ('Iridium', 'Ir', [3, 4], [-3, -1, 1, 2, 5, 6, 7, 8, 9]), + 78: ('Platinum', 'Pt', [2, 4], [-3, -2, -1, 1, 3, 5, 6]), + 79: ('Gold', 'Au', [3], [-3, -2, -1, 1, 2, 5]), + 80: ('Mercury', 'Hg', [1, 2], [-2, 4]), # +4 doi:10.1002/anie.200703710 + 81: ('Thallium', 'Tl', [1, 3], [-5, -2, -1, 2]), + 82: ('Lead', 'Pb', [2, 4], [-4, -2, -1, 1, 3]), + 83: ('Bismuth', 'Bi', [3], [-3, -2, -1, 1, 2, 4, 5]), + 84: ('Polonium', 'Po', [-2, 2, 4], [5, 6]), + 85: ('Astatine', 'At', [-1, 1], [3, 5, 7]), + 86: ('Radon', 'Rn', [2], [6]), + 87: ('Francium', 'Fr', [1], []), + 88: ('Radium', 'Ra', [2], []), + 89: ('Actinium', 'Ac', [3], []), + 90: ('Thorium', 'Th', [4], [1, 2, 3]), + 91: ('Protactinium', 'Pa', [5], [3, 4]), + 92: ('Uranium', 'U', [6], [1, 2, 3, 4, 5]), + 93: ('Neptunium', 'Np', [5], [2, 3, 4, 6, 7]), + 94: ('Plutonium', 'Pu', [4], [2, 3, 5, 6, 7]), + 95: ('Americium', 'Am', [3], [2, 4, 5, 6, 7]), + 96: ('Curium', 'Cm', [3], [4, 6]), + 97: ('Berkelium', 'Bk', [3], [4]), + 98: ('Californium', 'Cf', [3], [2, 4]), + 99: ('Einsteinium', 'Es', [3], [2, 4]), + 100: ('Fermium', 'Fm', [3], [2]), + 101: ('Mendelevium', 'Md', [3], [2]), + 102: ('Nobelium', 'No', [2], [3]), + 103: ('Lawrencium', 'Lr', [3], []), + 104: ('Rutherfordium', 'Rf', [4], []), + 105: ('Dubnium', 'Db', [5], []), + 106: ('Seaborgium', 'Sg', [6], []), + 107: ('Bohrium', 'Bh', [7], []), + 108: ('Hassium', 'Hs', [8], []), + 109: ('Meitnerium', 'Mt', [], []), + 110: ('Darmstadtium', 'Ds', [], []), + 111: ('Roentgenium', 'Rg', [], []), + 112: ('Copernicium', 'Cn', [2], []), + 113: ('Nihonium', 'Nh', [], []), + 114: ('Flerovium', 'Fl', [], []), + 115: ('Moscovium', 'Mc', [], []), + 116: ('Livermorium', 'Lv', [], []), + 117: ('Tennessine', 'Ts', [], []), + 118: ('Oganesson', 'Og', [], []), } # pylint: enable=bad-whitespace @@ -861,21 +1001,12 @@ def define_elements(table: PeriodicTable, namespace: dict[str, Any]) -> list[str """ # Build the dictionary of element symbols - names = {} - for el in table: - names[el.symbol] = el - names[el.name] = el - for el in [table.D, table.T, table.n]: - names[el.symbol] = el - names[el.name] = el - - # Copy it to the namespace - for k, v in names.items(): - namespace[k] = v - - # return the keys - return list(names.keys()) - + names: list[str] = [] + for atom in [*table, table.D, table.T, table.n]: + namespace[atom.symbol] = atom + namespace[atom.name] = atom + names.extend((atom.symbol, atom.name)) + return names def get_data_path(data: Path|str) -> str: """ diff --git a/periodictable/cromermann.py b/periodictable/cromermann.py index c82c43d..38112f9 100644 --- a/periodictable/cromermann.py +++ b/periodictable/cromermann.py @@ -64,7 +64,7 @@ def getCMformula(symbol: str) -> "CromerMannFormula": return _cmformulas[symbol] -def fxrayatq(symbol: str, Q: ArrayLike, charge: int=None) -> ArrayLike: +def fxrayatq(symbol: str, Q: ArrayLike, charge: int|None=None) -> ArrayLike: """ Return x-ray scattering factors of an element at a given Q. @@ -82,7 +82,7 @@ def fxrayatq(symbol: str, Q: ArrayLike, charge: int=None) -> ArrayLike: return rv -def fxrayatstol(symbol: str, stol: ArrayLike, charge: int=None) -> ArrayLike: +def fxrayatstol(symbol: str, stol: ArrayLike, charge: int|None=None) -> ArrayLike: """ Calculate x-ray scattering factors at specified sin(theta)/lambda diff --git a/periodictable/density.py b/periodictable/density.py index dfe71c7..9ad69f7 100644 --- a/periodictable/density.py +++ b/periodictable/density.py @@ -41,10 +41,12 @@ .. [#ILL] The ILL Neutron Data Booklet, Second Edition. """ -from .core import Element, Isotope, Atom, PeriodicTable +from typing import cast + +from .core import Element, Isotope, Atom, PeriodicTable, isisotope, ision from .constants import avogadro_number -def density(iso_el: Atom) -> float: +def density(atom: Atom) -> float: """ Element density for natural abundance. For isotopes, return @@ -63,12 +65,13 @@ def density(iso_el: Atom) -> float: 80th ed. (1999).* """ + # Note: materials with pure isotopes are adjusted by the natural mass ratio. + if isisotope(atom): + iso = cast(Isotope, atom) + return iso.element._density * (iso.mass/iso.element.mass) + return cast(Element, atom)._density - if hasattr(iso_el, 'element'): - return iso_el.element._density * (iso_el.mass/iso_el.element.mass) - return iso_el._density - -def interatomic_distance(element: Atom) -> float: +def interatomic_distance(element: Element) -> float|None: r""" Estimated interatomic distance from atomic weight and density. The distance between isotopes is assumed to match that between atoms in @@ -98,14 +101,12 @@ def interatomic_distance(element: Atom) -> float: (10^{-8} cm\cdot \AA^{-1})^3))^{1/3} = \AA """ - - if hasattr(element, 'isotope'): - element = element.element + # Note: don't need to check for Ion or Isotope because these delegate to Element if element.density is None or element.mass is None: return None return (element.mass/(element.density*avogadro_number*1e-24))**(1./3.) -def number_density(element: Atom) -> float: +def number_density(element: Element) -> float: r""" Estimate the number density from atomic weight and density. The density for isotopes is assumed to match that of between atoms in natural abundance. @@ -132,8 +133,7 @@ def number_density(element: Atom) -> float: = atoms\cdot cm^{-3} """ - if hasattr(element, 'isotope'): - element = element.element + # Note: don't need to check for Ion or Isotope because these delegate to Element if element.density is None or element.mass is None: return None return (element.density/element.mass)*avogadro_number diff --git a/periodictable/fasta.py b/periodictable/fasta.py index 1c532db..43365d4 100644 --- a/periodictable/fasta.py +++ b/periodictable/fasta.py @@ -121,7 +121,8 @@ class Molecule: *density* is the natural density of the molecule. If None, density will be inferred from cell volume. - *charge* is the overall charge on the molecule. + *charge* is the overall charge on the molecule. Note that charge can be + fractional if the molecule is the average of a statistical ensemble. **Attributes** @@ -158,10 +159,11 @@ class Molecule: mass: float Dmass: float D2Omatch: float - charge: int + charge: float # fractional to allow ensemble average molecules natural_formula: Formula labile_formula: Formula formula: Formula + code: str # fasta code for dna/rna def __init__( self, @@ -169,7 +171,7 @@ def __init__( formula: FormulaInput, cell_volume: float|None=None, density: float|None=None, - charge: int=0, + charge: float=0, ): # TODO: fasta does not work with table substitution elements = default_table() @@ -234,13 +236,13 @@ class Sequence(Molecule): sequence: str @staticmethod - def loadall(filename: Path|str, type: str=None) -> Iterator["Sequence"]: + def loadall(filename: Path|str, type: str|None=None) -> Iterator["Sequence"]: """ Iterate over sequences in FASTA file, loading each in turn. Yields one FASTA sequence each cycle. """ - type = _guess_type_from_filename(filename, type) + type = _guess_type_from_filename(str(filename), type) with open(filename, 'rt') as fh: for name, seq in read_fasta(fh): yield Sequence(name, seq, type=type) @@ -250,7 +252,7 @@ def load(filename: Path|str, type=None) -> "Sequence": """ Load the first FASTA sequence from a file. """ - type = _guess_type_from_filename(filename, type) + type = _guess_type_from_filename(str(filename), type) with open(filename, 'rt') as fh: name, seq = next(read_fasta(fh)) return Sequence(name, seq, type=type) @@ -277,7 +279,7 @@ def __init__(self, name: str, sequence: str, type: str='aa'): self, name, formula, cell_volume=cell_volume, charge=charge) self.sequence = sequence -def _guess_type_from_filename(filename: str, type: str) -> str: +def _guess_type_from_filename(filename: str, type: str|None) -> str: if type is None: if filename.endswith('.fna'): type = 'dna' @@ -324,7 +326,7 @@ def D2Omatch(Hsld: float, Dsld: float) -> float: return 100 * (H2O_SLD - Hsld) / (Dsld - Hsld + H2O_SLD - D2O_SLD) -def read_fasta(fp: IO[str]) -> Iterator[str]: +def read_fasta(fp: IO[str]) -> Iterator[tuple[str, str]]: """ Iterate over the sequences in a FASTA file. @@ -332,26 +334,28 @@ def read_fasta(fp: IO[str]) -> Iterator[str]: Change 1.5.3: Now uses H[1] rather than T for labile hydrogen. """ - name, seq = None, [] + name = "" + seq: list[str] = [] for line in fp: line = line.rstrip() if line.startswith(">"): if name: - yield (name, ''.join(seq)) + yield name, ''.join(seq) name, seq = line, [] else: seq.append(line) if name: - yield (name, ''.join(seq)) + yield name, ''.join(seq) - -def _code_average(bases, code_table) -> tuple[Formula, float, int]: +def _code_average(bases, code_table) -> tuple[Formula, float, float]: """ Compute average over possible nucleotides, assuming equal weight if - precise nucleotide is not known + precise nucleotide is not known. + + Note: averaging can lead to a fractional charge on the returned molecule. """ n = len(bases) - formula, cell_volume, charge = parse_formula(), 0, 0 + formula, cell_volume, charge = parse_formula(), 0., 0. for c in bases: base = code_table[c] formula += base.labile_formula @@ -361,7 +365,7 @@ def _code_average(bases, code_table) -> tuple[Formula, float, int]: formula, cell_volume, charge = (1/n) * formula, cell_volume/n, charge/n return formula, cell_volume, charge -def _set_amino_acid_average(target: str, codes: str, name: str=None) -> None: +def _set_amino_acid_average(target: str, codes: str, name: str|None=None) -> None: """ Fill in partial unknowns for amino acids, such as "B" for aspartic acid or asparagine. """ @@ -428,59 +432,66 @@ def _(code: str, V: float, formula: str, name: str) -> tuple[str, Molecule]: __doc__ += "\n\n*AMINO_ACID_CODES*::\n\n " + "\n ".join( "%s: %s"%(k, v.name) for k, v in sorted(AMINO_ACID_CODES.items())) -def _(formula: str, V: float, name: str) -> tuple[str, Molecule]: +# mypy doesn't like redefinitions +def _1(formula: str, V: float, name: str) -> tuple[str, Molecule]: molecule = Molecule(name, formula, cell_volume=V) return name, molecule NUCLEIC_ACID_COMPONENTS: dict[str, Molecule] = dict(( # formula, volume, name - _("NaPO3", 60, "phosphate"), - _("C5H6H[1]O3", 125, "ribose"), - _("C5H7O2", 115, "deoxyribose"), - _("C5H2H[1]2N5", 114, "adenine"), - _("C4H2H[1]N2O2", 99, "uracil"), - _("C5H4H[1]N2O2", 126, "thymine"), - _("C5HH[1]3N5O", 119, "guanine"), - _("C4H2H[1]2N3O", 103, "cytosine"), + _1("NaPO3", 60, "phosphate"), + _1("C5H6H[1]O3", 125, "ribose"), + _1("C5H7O2", 115, "deoxyribose"), + _1("C5H2H[1]2N5", 114, "adenine"), + _1("C4H2H[1]N2O2", 99, "uracil"), + _1("C5H4H[1]N2O2", 126, "thymine"), + _1("C5HH[1]3N5O", 119, "guanine"), + _1("C4H2H[1]2N3O", 103, "cytosine"), )) __doc__ += "\n\n*NUCLEIC_ACID_COMPONENTS*::\n\n " + "\n ".join( "%s: %s"%(k, v.formula) for k, v in sorted(NUCLEIC_ACID_COMPONENTS.items())) -CARBOHYDRATE_RESIDUES: dict[str: Molecule] = dict(( +CARBOHYDRATE_RESIDUES: dict[str, Molecule] = dict(( # formula, volume, name - _("C6H7H[1]3O5", 171.9, "Glc"), - _("C6H7H[1]3O5", 166.8, "Gal"), - _("C6H7H[1]3O5", 170.8, "Man"), - _("C6H7H[1]4O5", 170.8, "Man (terminal)"), - _("C8H10H[1]3NO5", 222.0, "GlcNAc"), - _("C8H10H[1]3NO5", 232.9, "GalNAc"), - _("C6H7H[1]3O4", 160.8, "Fuc (terminal)"), - _("C11H11H[1]5NO8", 326.3, "NeuNac (terminal)"), + _1("C6H7H[1]3O5", 171.9, "Glc"), + _1("C6H7H[1]3O5", 166.8, "Gal"), + _1("C6H7H[1]3O5", 170.8, "Man"), + _1("C6H7H[1]4O5", 170.8, "Man (terminal)"), + _1("C8H10H[1]3NO5", 222.0, "GlcNAc"), + _1("C8H10H[1]3NO5", 232.9, "GalNAc"), + _1("C6H7H[1]3O4", 160.8, "Fuc (terminal)"), + _1("C11H11H[1]5NO8", 326.3, "NeuNac (terminal)"), # Glycosaminoglycans - _("C14H15H[1]5NO11Na", 390.7, "hyaluronate"), # GlcA.GlcNAc - _("C14H17H[1]5NO13SNa", 473.5, "keratan sulphate"), # Gal.GlcNAc.SO4 - _("C14H15H[1]4NO14SNa", 443.5, "chondroitin sulphate"), # GlcA.GalNAc.SO4 + _1("C14H15H[1]5NO11Na", 390.7, "hyaluronate"), # GlcA.GlcNAc + _1("C14H17H[1]5NO13SNa", 473.5, "keratan sulphate"), # Gal.GlcNAc.SO4 + _1("C14H15H[1]4NO14SNa", 443.5, "chondroitin sulphate"), # GlcA.GalNAc.SO4 )) __doc__ += "\n\n*CARBOHYDRATE_RESIDUES*::\n\n " + "\n ".join( "%s: %s"%(k, v.formula) for k, v in sorted(CARBOHYDRATE_RESIDUES.items())) LIPIDS: dict[str, Molecule] = dict(( # formula, volume, name - _("CH2", 27, "methylene"), - _("CD2", 27, "methylene-D"), - _("C10H18NO8P", 350, "phospholipid headgroup"), - _("C6H5O6", 240, "triglyceride headgroup"), - _("C36H72NO8P", 1089, "DMPC"), - _("C36H20D52NO8P", 1089, "DMPC-D52"), - _("C29H55H[1]3NO8P", 932, "DLPE"), - _("C27H45H[1]O", 636, "cholesteral"), - _("C45H78O2", 1168, "oleate"), - _("C57H104O6", 1617, "trioleate form"), - _("C39H77H[1]2N2O2P", 1166, "palmitate ester"), + _1("CH2", 27, "methylene"), + _1("CD2", 27, "methylene-D"), + _1("C10H18NO8P", 350, "phospholipid headgroup"), + _1("C6H5O6", 240, "triglyceride headgroup"), + _1("C36H72NO8P", 1089, "DMPC"), + _1("C36H20D52NO8P", 1089, "DMPC-D52"), + _1("C29H55H[1]3NO8P", 932, "DLPE"), + _1("C27H45H[1]O", 636, "cholesteral"), + _1("C45H78O2", 1168, "oleate"), + _1("C57H104O6", 1617, "trioleate form"), + _1("C39H77H[1]2N2O2P", 1166, "palmitate ester"), )) __doc__ += "\n\n*LIPIDS*::\n\n " + "\n ".join( "%s: %s"%(k, v.formula) for k, v in sorted(LIPIDS.items())) -def _(code: str, formula: str, V: float, name: str) -> tuple[str, Molecule]: + +RNA_BASES: dict[str, Molecule] = {} +RNA_CODES: dict[str, Molecule] = {} +DNA_BASES: dict[str, Molecule] = {} +DNA_CODES: dict[str, Molecule] = {} + +def _set_rna_dna_codes() -> None: """ Convert RNA/DNA table values into Molecule. @@ -492,62 +503,72 @@ def _(code: str, formula: str, V: float, name: str) -> tuple[str, Molecule]: give volumes for AGC in the DNA nucleosides despite them being different in the Buckin source (especially guanosine). """ - cell_volume = V * 1e24/avogadro_number + 30.39 - molecule = Molecule(name, formula, cell_volume=cell_volume) - molecule.code = code - return code, molecule -RNA_BASES: dict[str, Molecule] = dict(( # code, formula, volume (mL/mol), name - _("A", "C10H8H[1]3N5O6P", 170.8, "adenosine"), - _("T", "C9H8H[1]2N2O8P", 151.7, "uridine"), # Use H[1] for U in RNA - _("G", "C10H7H[1]4N5O7P", 178.2, "guanosine"), - _("C", "C9H8H[1]3N3O7P", 153.7, "cytidine"), - )) + rna_bases = ( + ("A", "C10H8H[1]3N5O6P", 170.8, "adenosine"), + ("T", "C9H8H[1]2N2O8P", 151.7, "uridine"), # Use H[1] for U in RNA + ("G", "C10H7H[1]4N5O7P", 178.2, "guanosine"), + ("C", "C9H8H[1]3N3O7P", 153.7, "cytidine"), + ) + dna_bases = ( + ("A", "C10H9H[1]2N5O5P", 169.8, "adenosine"), + ("T", "C10H11H[1]1N2O7P", 167.6, "thymidine"), + ("G", "C10H8H[1]3N5O6P", 173.7, "guanosine"), + ("C", "C9H9H[1]2N3O6P", 153.4, "cytidine"), + ) + + codes = ( + #code, nucleotides, name + ("A", "A", "adenosine"), + ("C", "C", "cytidine"), + ("G", "G", "guanosine"), + ("T", "T", "thymidine"), + ("U", "T", "uridine"), # RNA_BASES["T"] is uridine + ("R", "AG", "purine"), + ("Y", "CT", "pyrimidine"), + ("K", "GT", "ketone"), + ("M", "AC", "amino"), + ("S", "CG", "strong"), + ("W", "AT", "weak"), + ("B", "CGT", "not A"), + ("D", "AGT", "not C"), + ("H", "ACT", "not G"), + ("V", "ACG", "not T"), + ("N", "ACGT", "any base"), + ("X", "", "masked"), + ("-", "", "gap"), + ) + + for code, formula, volume, name in rna_bases: + cell_volume = volume * 1e24/avogadro_number + 30.39 + molecule = Molecule(name, formula, cell_volume=cell_volume) + molecule.code = code + RNA_BASES[code] = molecule + + for code, formula, volume, name in dna_bases: + cell_volume = volume * 1e24/avogadro_number + 30.39 + molecule = Molecule(name, formula, cell_volume=cell_volume) + molecule.code = code + DNA_BASES[code] = molecule + + for code, bases, name in codes: + D, V, _ = _code_average(bases, RNA_BASES) + rna = Molecule(name, D.hill, cell_volume=V) + rna.code = code + D, V, _ = _code_average(bases, DNA_BASES) + dna = Molecule(name, D.hill, cell_volume=V) + dna.code = code + RNA_CODES[code] = rna + DNA_CODES[code] = dna + +_set_rna_dna_codes() +# pylint: enable=bad-whitespace + __doc__ += "\n\n*RNA_BASES*::\n\n " + "\n ".join( "%s:%s"%(k, v.name) for k, v in sorted(RNA_BASES.items())) - -DNA_BASES: dict[str, Molecule] = dict(( - # code, formula, volume (mL/mol), name - _("A", "C10H9H[1]2N5O5P", 169.8, "adenosine"), - _("T", "C10H11H[1]1N2O7P", 167.6, "thymidine"), - _("G", "C10H8H[1]3N5O6P", 173.7, "guanosine"), - _("C", "C9H9H[1]2N3O6P", 153.4, "cytidine"), - )) __doc__ += "\n\n*DNA_BASES*::\n\n " + "\n ".join( "%s:%s"%(k, v.name) for k, v in sorted(DNA_BASES.items())) -def _(code: str, bases: str, name: str) -> tuple[tuple[str,Molecule], tuple[str,Molecule]]: - D, V, _ = _code_average(bases, RNA_BASES) - rna = Molecule(name, D.hill, cell_volume=V) - rna.code = code - D, V, _ = _code_average(bases, DNA_BASES) - dna = Molecule(name, D.hill, cell_volume=V) - rna.code = code - return (code,rna), (code,dna) -# TODO: define types for the RNA and DNA code dictionaries. -RNA_CODES,DNA_CODES = [dict(v) for v in zip( - #code, nucleotides, name - _("A", "A", "adenosine"), - _("C", "C", "cytidine"), - _("G", "G", "guanosine"), - _("T", "T", "thymidine"), - _("U", "T", "uridine"), # RNA_BASES["T"] is uridine - _("R", "AG", "purine"), - _("Y", "CT", "pyrimidine"), - _("K", "GT", "ketone"), - _("M", "AC", "amino"), - _("S", "CG", "strong"), - _("W", "AT", "weak"), - _("B", "CGT", "not A"), - _("D", "AGT", "not C"), - _("H", "ACT", "not G"), - _("V", "ACG", "not T"), - _("N", "ACGT", "any base"), - _("X", "", "masked"), - _("-", "", "gap"), - )] -# pylint: enable=bad-whitespace - CODE_TABLES: dict[str, dict[str, Molecule]] = { 'aa': AMINO_ACID_CODES, diff --git a/periodictable/formulas.py b/periodictable/formulas.py index 9b16b91..561db4c 100644 --- a/periodictable/formulas.py +++ b/periodictable/formulas.py @@ -6,7 +6,7 @@ from copy import copy from math import pi, sqrt -from typing import Union, Any +from typing import cast, Union, Any from collections.abc import Sequence, Callable # Requires that the pyparsing module is installed. @@ -15,12 +15,13 @@ ZeroOrMore, OneOrMore, Forward, StringEnd, Group) from .core import default_table, isatom, isisotope, ision, change_table -from .core import Atom, PeriodicTable # for typing -from .constants import avogadro_number +from .core import Atom, Element, Isotope, Ion, PeriodicTable # for typing +from .constants import avogadro_number, electron_mass from .util import cell_volume FormulaInput = Union[str, "Formula", Atom, dict[Atom, float], Sequence[tuple[float, Any]], None] -Fragment = tuple[float, Union[Atom, tuple["Fragment"]]] +Fragment = tuple[float, Union[Atom, "Structure"]] +Structure = tuple["Fragment", ...] PACKING_FACTORS = dict(cubic=pi/6, bcc=pi*sqrt(3)/8, hcp=pi/sqrt(18), fcc=pi/sqrt(18), diamond=pi*sqrt(3)/16) @@ -88,7 +89,7 @@ def mix_by_weight(*args, **kw) -> "Formula": result.name = name return result -def _mix_by_weight_pairs(pairs: Sequence[Fragment]) -> "Formula": +def _mix_by_weight_pairs(pairs: list[tuple["Formula", float]]) -> "Formula": # Drop pairs with zero quantity # Note: must be first statement in order to accept iterators @@ -106,6 +107,7 @@ def _mix_by_weight_pairs(pairs: Sequence[Fragment]) -> "Formula": for f, q in pairs: result += ((q/f.mass)/scale) * f if all(f.density for f, _ in pairs): + # Tested that densities are not None, so the following will work volume = sum(q/f.density for f, q in pairs)/scale result.density = result.mass/volume return result @@ -172,7 +174,7 @@ def mix_by_volume(*args, **kw) -> "Formula": result.name = name return result -def _mix_by_volume_pairs(pairs: Sequence[Fragment]) -> "Formula": +def _mix_by_volume_pairs(pairs: list[tuple["Formula", float]]) -> "Formula": # Drop pairs with zero quantity # Note: must be first statement in order to accept iterators @@ -283,6 +285,7 @@ def formula( The representations are simple, but preserve some of the structure for display purposes. """ + structure: Structure if compound is None or compound == '': structure = tuple() elif isinstance(compound, Formula): @@ -292,9 +295,9 @@ def formula( if not name: name = compound.name elif isatom(compound): - structure = ((1, compound), ) + structure = ((1, cast(Atom, compound)), ) elif isinstance(compound, dict): - structure = _convert_to_hill_notation(compound) + structure = _convert_to_hill_notation(cast(dict[Atom, float], compound)) elif _is_string_like(compound): try: chem = parse_formula(compound, table=table) @@ -310,7 +313,7 @@ def formula( #print "parsed", compound, "as", self else: try: - structure = _immutable(compound) + structure = _immutable(cast(Structure, compound)) except Exception: raise ValueError("not a valid chemical formula: "+str(compound)) return Formula(structure=structure, name=name, density=density, @@ -319,10 +322,13 @@ def formula( class Formula: """ Simple chemical formula representation. - """ + structure: Structure + density: float|None + name: str|None + def __init__(self, - structure: Sequence[tuple[float, Any]]=tuple(), + structure: Structure=tuple(), density: float|None=None, natural_density: float|None=None, name: str|None=None, @@ -379,16 +385,22 @@ def natural_mass_ratio(self) -> float: of the isotopes used in the formula. If the cell volume is preserved with isotope substitution, then the ratio of the masses will be the ratio of the densities. + + 2026-01-08 Fixed ratio calculation for molecules with charged isotopes. """ - total_natural_mass = total_isotope_mass = 0 - for el, count in self.atoms.items(): - try: - natural_mass = el.element.mass - except AttributeError: - natural_mass = el.mass + total_natural_mass = total_isotope_mass = total_charge = 0. + for atom, count in self.atoms.items(): + # Use the mass of the neutral atom for the calculation. If the atom is a charged + # isotope this requires atom.element.element.mass rather than just atom.mass. + total_charge += count * atom.charge + if ision(atom): + atom = cast(Ion, atom).element + natural_mass = cast(Isotope, atom).element.mass if isisotope(atom) else atom.mass total_natural_mass += count * natural_mass - total_isotope_mass += count * el.mass - return total_natural_mass/total_isotope_mass + total_isotope_mass += count * atom.mass + # Note: negative charge means extra electrons + charge_correction = -total_charge * electron_mass + return (total_natural_mass + charge_correction)/(total_isotope_mass + charge_correction) @property def natural_density(self) -> float: @@ -413,7 +425,7 @@ def mass(self) -> float: Molar mass of the molecule. Use molecular_mass to get the mass in grams. """ - mass = 0 + mass = 0. for el, count in self.atoms.items(): mass += el.mass*count return mass @@ -442,6 +454,7 @@ def mass_fraction(self) -> dict[Atom, float]: total_mass = self.mass return dict((a, m*a.mass/total_mass) for a, m in self.atoms.items()) + # TODO: Remove compound._pf. It is unused and wrong. def _pf(self) -> float: """ packing factor | unitless @@ -507,7 +520,7 @@ def volume(self, *args, **kw) -> float: # Get packing factor if len(args) == 1 and not kw: packing_factor = args[0] - args = [] + args = tuple() else: packing_factor = kw.pop('packing_factor', 'hcp') @@ -516,9 +529,9 @@ def volume(self, *args, **kw) -> float: return cell_volume(*args, **kw)*1e-24 # Compute atomic volume - V = 0 - for el, count in self.atoms.items(): - radius = el.covalent_radius + V = 0. + for atom, count in self.atoms.items(): + radius = atom.covalent_radius #if el.number == 1 and H_radius is not None: # radius = H_radius V += radius**3*count @@ -534,7 +547,7 @@ def volume(self, *args, **kw) -> float: return V/packing_factor*1e-24 # TODO; remove neutron_sld/xray_sld otherwise we have circular imports - def neutron_sld(self, *, wavelength: float=None, energy: float=None) -> tuple[float, float, float]: + def neutron_sld(self, *, wavelength: float|None=None, energy: float|None=None) -> tuple[float, float, float]|tuple[None, None, None]: """ Neutron scattering information for the molecule. @@ -558,7 +571,7 @@ def neutron_sld(self, *, wavelength: float=None, energy: float=None) -> tuple[fl return neutron_sld(self.atoms, density=self.density, wavelength=wavelength, energy=energy) - def xray_sld(self, *, energy: float=None, wavelength: float=None) -> tuple[float, float, float]: + def xray_sld(self, *, energy: float|None=None, wavelength: float|None=None) -> tuple[float, float]|tuple[None, None]: """ X-ray scattering length density for the molecule. @@ -673,6 +686,7 @@ def _isotope_substitution(compound: "Formula", source: Atom, target: Atom, porti *portion* is the proportion of source which is substituted for target. """ + # TODO: fails if density is not defined atoms = compound.atoms if source in atoms: mass = compound.mass @@ -727,34 +741,34 @@ def formula_grammar(table: PeriodicTable) -> ParserElement: # Lookup the element in the element table symbol = Regex("[A-Z][a-z]?") - symbol = symbol.set_parse_action(lambda s, l, t: table.symbol(t[0])) + symbol.set_parse_action(lambda s, l, t: table.symbol(t[0])) # Translate isotope openiso = Literal('[').suppress() closeiso = Literal(']').suppress() isotope = Optional(~White()+openiso+Regex("[1-9][0-9]*")+closeiso, default='0') - isotope = isotope.set_parse_action(lambda s, l, t: int(t[0]) if t[0] else 0) + isotope.set_parse_action(lambda s, l, t: int(t[0]) if t[0] else 0) # Translate ion openion = Literal('{').suppress() closeion = Literal('}').suppress() ion = Optional(~White() +openion +Regex("([1-9][0-9]*)?[+-]") +closeion, default='0+') - ion = ion.set_parse_action(lambda s, l, t: int(t[0][-1]+(t[0][:-1] if len(t[0]) > 1 else '1'))) + ion.set_parse_action(lambda s, l, t: int(t[0][-1]+(t[0][:-1] if len(t[0]) > 1 else '1'))) # Translate counts # TODO: regex should reject a bare '.' if we want to allow dots between formula parts fract = Regex("(0|[1-9][0-9]*|)([.][0-9]*)") - fract = fract.set_parse_action(lambda s, l, t: float(t[0]) if t[0] else 1) + fract.set_parse_action(lambda s, l, t: float(t[0]) if t[0] else 1) whole = Regex("(0|[1-9][0-9]*)") - whole = whole.set_parse_action(lambda s, l, t: int(t[0]) if t[0] else 1) + whole.set_parse_action(lambda s, l, t: int(t[0]) if t[0] else 1) number = Optional(~White()+(fract|whole), default=1) # TODO use unicode ₀₁₉ in the code below? sub_fract = Regex("(\u2080|[\u2081-\u2089][\u2080-\u2089]*|)([.][\u2080-\u2089]*)") - sub_fract = sub_fract.set_parse_action(lambda s, l, t: float(from_subscript(t[0])) if t[0] else 1) + sub_fract.set_parse_action(lambda s, l, t: float(from_subscript(t[0])) if t[0] else 1) sub_whole = Regex("(\u2080|[\u2081-\u2089][\u2080-\u2089]*)") - sub_whole = sub_whole.set_parse_action(lambda s, l, t: int(from_subscript(t[0])) if t[0] else 1) + sub_whole.set_parse_action(lambda s, l, t: int(from_subscript(t[0])) if t[0] else 1) sub_count = Optional(~White()+(fract|whole|sub_fract|sub_whole), default=1) # Fasta code @@ -783,7 +797,7 @@ def convert_element(string, location, tokens): if ion != 0: symbol = symbol.ion[ion] return (count, symbol) - element = element.set_parse_action(convert_element) + element.set_parse_action(convert_element) # Convert "count elements" to a pair implicit_group = number+OneOrMore(element) @@ -793,7 +807,7 @@ def convert_implicit(string, location, tokens): count = tokens[0] fragment = tokens[1:] return fragment if count == 1 else (count, fragment) - implicit_group = implicit_group.set_parse_action(convert_implicit) + implicit_group.set_parse_action(convert_implicit) # Convert "(composite) count" to a pair opengrp = space + Literal('(').suppress() + space @@ -805,7 +819,7 @@ def convert_explicit(string, location, tokens): count = tokens[-1] fragment = tokens[:-1] return fragment if count == 1 else (count, fragment) - explicit_group = explicit_group.set_parse_action(convert_explicit) + explicit_group.set_parse_action(convert_explicit) # Build composite from a set of groups group = implicit_group | explicit_group @@ -840,7 +854,7 @@ def convert_compound(string, location, tokens): formula.density = density #print("compound", formula, f"{formula.density=:.3f}") return formula - compound = compound.set_parse_action(convert_compound) + compound.set_parse_action(convert_compound) partsep = space + Literal('//').suppress() + space percent = Literal('%').suppress() @@ -848,7 +862,7 @@ def convert_compound(string, location, tokens): volume = Regex("v(ol(ume)?)?").suppress() weight_percent = (percent + weight) | (weight + percent) + space volume_percent = (percent + volume) | (volume + percent) + space - by_weight = (number + weight_percent + mixture + mixture_by_weight = (number + weight_percent + mixture + ZeroOrMore(partsep+number+(weight_percent|percent)+mixture) + Optional(partsep + mixture, default=None)) def _parts_by_weight_vol(tokens): @@ -872,16 +886,16 @@ def convert_by_weight(string, location, tokens): """convert mixture by wt% or mass%""" piece, fract = _parts_by_weight_vol(tokens) return _mix_by_weight_pairs(zip(piece, fract)) - mixture_by_weight = by_weight.set_parse_action(convert_by_weight) + mixture_by_weight.set_parse_action(convert_by_weight) - by_volume = (number + volume_percent + mixture + mixture_by_volume = (number + volume_percent + mixture + ZeroOrMore(partsep+number+(volume_percent|percent)+mixture) + Optional(partsep + mixture, default=None)) def convert_by_volume(string, location, tokens): """convert mixture by vol%""" piece, fract = _parts_by_weight_vol(tokens) return _mix_by_volume_pairs(zip(piece, fract)) - mixture_by_volume = by_volume.set_parse_action(convert_by_volume) + mixture_by_volume.set_parse_action(convert_by_volume) mixture_by_layer = Forward() layer_thick = Group(number + Regex(LENGTH_RE) + space) @@ -907,7 +921,7 @@ def convert_by_layer(string, location, tokens): result = _mix_by_volume_pairs(zip(piece, vfract)) result.thickness = total return result - mixture_by_layer = mixture_by_layer.set_parse_action(convert_by_layer) + mixture_by_layer.set_parse_action(convert_by_layer) mixture_by_absmass = Forward() absmass_mass = Group(number + Regex(MASS_VOLUME_RE) + space) @@ -941,7 +955,7 @@ def convert_by_absmass(string, location, tokens): result = _mix_by_weight_pairs(zip(piece, mfract)) result.total_mass = total return result - mixture_by_absmass = mixture_by_absmass.set_parse_action(convert_by_absmass) + mixture_by_absmass.set_parse_action(convert_by_absmass) ungrouped_mixture = (mixture_by_weight | mixture_by_volume | mixture_by_layer | mixture_by_absmass) @@ -955,7 +969,7 @@ def convert_mixture(string, location, tokens): formula.density = tokens[-2] # elif tokens[-1] is None return formula - grouped_mixture = grouped_mixture.set_parse_action(convert_mixture) + grouped_mixture.set_parse_action(convert_mixture) mixture << (compound | grouped_mixture) formula = (compound | ungrouped_mixture | grouped_mixture) @@ -965,7 +979,7 @@ def convert_mixture(string, location, tokens): return grammar _PARSER_CACHE: dict[PeriodicTable, ParserElement] = {} -def parse_formula(formula_str, table=None): +def parse_formula(formula_str: str, table: PeriodicTable|None=None) -> Formula: """ Parse a chemical formula, returning a structure with elements from the given periodic table. @@ -977,11 +991,11 @@ def parse_formula(formula_str, table=None): #print(parser) return parser.parse_string(formula_str)[0] -def _count_atoms(seq: tuple[tuple[float, Any]]): +def _count_atoms(seq: Structure) -> dict[Atom, float]: """ Traverse formula structure, counting the total number of atoms. """ - total = {} + total: dict[Atom, float] = {} for count, fragment in seq: if isinstance(fragment, (list, tuple)): partial = _count_atoms(fragment) @@ -1008,20 +1022,21 @@ def count_elements(compound: FormulaInput, by_isotope: bool=False) -> dict[Atom, """ total: dict[Atom, float] = {} # Note: could accumulate charge at the same time as counting elements. - for part, count in formula(compound).atoms.items(): + for atom, count in formula(compound).atoms.items(): # Resolve isotopes and ions to the underlying element. Four cases: # isotope with charge needs fragment.element.element # isotope without charge needs fragment.element # element with charge needs fragment.element # element without charge needs fragment - if ision(part): - part = part.element - if not by_isotope: - part = getattr(part, "element", part) - total[part] = count + total.get(part, 0) + if ision(atom): + atom = cast(Ion, atom).element + if not by_isotope and isisotope(atom): + atom = cast(Isotope, atom).element + total[atom] = count + total.get(atom, 0.) return total -def _immutable(seq: Sequence[Fragment]) -> tuple[Fragment]: +# TODO: _immutable() should return Structure +def _immutable(seq): """ Traverse formula structure, checking that the counts are numeric and units are atoms. Returns an immutable copy of the structure, with all @@ -1031,48 +1046,29 @@ def _immutable(seq: Sequence[Fragment]) -> tuple[Fragment]: return seq return tuple((count+0, _immutable(fragment)) for count, fragment in seq) -def _change_table(seq: tuple[Fragment], table: PeriodicTable) -> tuple[Fragment]: +# TODO: _change_table() should return Structure +def _change_table(seq, table: PeriodicTable): """Converts lists to tuples so that structure is immutable.""" if isatom(seq): return change_table(seq, table) return tuple((count, _change_table(fragment, table)) for count, fragment in seq) -def _hill_compare(a: Atom, b: Atom) -> int: - """ - Compare elements in standard order. - """ - if a.symbol == b.symbol: - a = a.isotope if isisotope(a) else 0 - b = b.isotope if isisotope(b) else 0 - return cmp(a, b) - elif a.symbol in ("C", "H"): - if b.symbol in ("C", "H"): - return cmp(a.symbol, b.symbol) - else: - return -1 - else: - if b.symbol in ("C", "H"): - return 1 - else: - return cmp(a.symbol, b.symbol) - def _hill_key(a: Atom) -> str: return "".join((("0" if a.symbol in ("C", "H") else "1"), a.symbol, - "%4d"%(a.isotope if isisotope(a) else 0))) + "%4d"%(cast(Isotope, a).isotope if isisotope(a) else 0))) -def _convert_to_hill_notation(atoms: dict[Atom, float]) -> list[tuple[float, Atom]]: +def _convert_to_hill_notation(atoms: dict[Atom, float]) -> Structure: """ Return elements listed in standard order. """ - #return [(atoms[el], el) for el in sorted(atoms.keys(), cmp=_hill_compare)] - return [(atoms[el], el) for el in sorted(atoms.keys(), key=_hill_key)] + return tuple((atoms[el], el) for el in sorted(atoms.keys(), key=_hill_key)) def _str_one_atom(fragment: Atom) -> str: # Normal isotope string form is #-Yy, but we want Yy[#] if isisotope(fragment) and 'symbol' not in fragment.__dict__: - ret = "%s[%d]"%(fragment.symbol, fragment.isotope) + ret = "%s[%d]"%(fragment.symbol, cast(Isotope, fragment).isotope) else: ret = fragment.symbol if fragment.charge != 0: @@ -1081,7 +1077,8 @@ def _str_one_atom(fragment: Atom) -> str: ret += '{'+value+sign+'}' return ret -def _str_atoms(seq: tuple[Fragment]) -> str: +# TODO: add typing to _str_atoms +def _str_atoms(seq) -> str: """ Convert formula structure to string. """ @@ -1183,7 +1180,8 @@ def pretty(compound: Formula, mode: str='unicode') -> str: """ return _pretty(compound.structure, SUBSCRIPT[mode]) -def _pretty(structure: tuple[Fragment], subscript: Callable[[str], str]) -> str: +# TODO: type hinting for _pretty +def _pretty(structure, subscript: Callable[[str], str]) -> str: # TODO: if superscript is not None then render O[16] as {}^{16}O parts = [] for count, part in structure: diff --git a/periodictable/magnetic_ff.py b/periodictable/magnetic_ff.py index f2fb71e..fffd326 100644 --- a/periodictable/magnetic_ff.py +++ b/periodictable/magnetic_ff.py @@ -18,7 +18,9 @@ from .core import PeriodicTable -def formfactor_0(j0: tuple[float], q: ArrayLike) -> numpy.ndarray: +JnCoeff = tuple[float, float, float, float, float, float, float] + +def formfactor_0(j0: JnCoeff, q: ArrayLike) -> numpy.ndarray: """ Returns the scattering potential for form factor *j0* at the given *q*. """ @@ -27,7 +29,7 @@ def formfactor_0(j0: tuple[float], q: ArrayLike) -> numpy.ndarray: A, a, B, b, C, c, D = j0 return A * exp(-a*s_sq) + B * exp(-b*s_sq) + C * exp(-c*s_sq) + D -def formfactor_n(jn: tuple[float], q: ArrayLike): +def formfactor_n(jn: JnCoeff, q: ArrayLike): """ Returns the scattering potential for form factor *jn* at the given *q*. """ @@ -72,13 +74,17 @@ class MagneticFormFactor: """ - M: tuple[float] + j0: JnCoeff + j2: JnCoeff + j4: JnCoeff + j6: JnCoeff + J: JnCoeff - def _getM(self): + @property + def M(self) -> JnCoeff: + """j0""" return self.j0 - M = property(_getM, doc="j0") - def j0_Q(self, Q: ArrayLike) -> ArrayLike: """Returns *j0* scattering potential at *Q* |1/Ang|""" return formfactor_0(self.j0, Q) @@ -112,13 +118,16 @@ def init(table: PeriodicTable, reload: bool=False) -> None: return # Function for interpreting ionization state and form factor tuple - def add_form_factor(jn: str, symbol: str, charge: int, values: tuple[float]) -> None: + def add_form_factor(jn: str, symbol: str, charge: int, values: tuple[float, ...]) -> None: # Add the magnetic form factor info to the element el = table.symbol(symbol.capitalize()) + # Make sure element has a magnetic_ff dict if not hasattr(el, 'magnetic_ff'): el.magnetic_ff = {} + # Make sure dict has an entry for charge if charge not in el.magnetic_ff: el.magnetic_ff[charge] = MagneticFormFactor() + # Set coefficients for magnetic_ff[charge].jn setattr(el.magnetic_ff[charge], jn, values) # Transformed from fortran with: diff --git a/periodictable/mass.py b/periodictable/mass.py index 80b1ee0..1ad7b73 100644 --- a/periodictable/mass.py +++ b/periodictable/mass.py @@ -64,7 +64,7 @@ from .core import Element, Isotope, PeriodicTable, default_table from .util import parse_uncertainty -def mass(isotope: Element|Isotope) -> float: +def mass(atom: Element|Isotope) -> float: """ Atomic weight. @@ -75,9 +75,9 @@ def mass(isotope: Element|Isotope) -> float: *mass* : float | u Atomic weight of the element. """ - return isotope._mass + return atom._mass -def abundance(isotope: Element|Isotope) -> float: +def abundance(atom: Isotope) -> float: """ Natural abundance. @@ -87,7 +87,7 @@ def abundance(isotope: Element|Isotope) -> float: :Returns: *abundance* : float | % """ - return isotope._abundance + return atom._abundance def init(table: PeriodicTable, reload: bool=False) -> None: """Add mass attribute to period table elements and isotopes""" @@ -95,27 +95,27 @@ def init(table: PeriodicTable, reload: bool=False) -> None: return table.properties.append('mass') Element.mass = property(mass, doc=mass.__doc__) + Element.mass_units = "u" Isotope.mass = property(mass, doc=mass.__doc__) Isotope.abundance = property(abundance, doc=abundance.__doc__) - Element.mass_units = "u" - Element.abundance_units = "%" + Isotope.abundance_units = "%" # Parse isotope mass table where each line looks like: # z-el-iso,isotope mass(unc)#?,abundance(unc),element mass(unc) # The abundance and element masses will be set from other tables, so # ignore them here. for line in isotope_mass.split('\n'): - isotope, m, p, avg = line.split(',') - z, sym, iso = isotope.split('-') - el = table[int(z)] + isotope, iso_mass, iso_abundance, el_mass = line.split(',') + zstr, sym, astr = isotope.split('-') + el = table[int(zstr)] assert el.symbol == sym, \ "Symbol %s does not match %s"%(sym, el.symbol) - iso = el.add_isotope(int(iso)) + iso = el.add_isotope(int(astr)) # Note: new mass table doesn't include nominal values for transuranics # so use old masses here and override later with new masses. - el._mass, el._mass_unc = parse_uncertainty(avg) + el._mass, el._mass_unc = parse_uncertainty(el_mass) #el._mass, el._mass_unc = None, None - iso._mass, iso._mass_unc = parse_uncertainty(m) + iso._mass, iso._mass_unc = parse_uncertainty(iso_mass) #iso._abundance, iso._abundance_unc = parse_uncertainty(p) iso._abundance, iso._abundance_unc = 0, 0 @@ -130,16 +130,16 @@ def init(table: PeriodicTable, reload: bool=False) -> None: # Parse element mass table where each line looks like: # z El element mass(unc)|[low,high]|- note note ... for line in element_mass.split('\n'): - z, symbol, name, value = line.split()[:4] - #print(z, symbol, name, value) - el = table[int(z)] - if value != '-': - #v, dv = parse_uncertainty(value) + zstr, symbol, name, valstr = line.split()[:4] + #print(z, symbol, name, valstr) + el = table[int(zstr)] + if valstr != '-': + #v, dv = parse_uncertainty(valstr) #delta = abs(v-el._mass)/el._mass*100 #from uncertainties import ufloat as U #if delta > 0.01: # print(f"{el.number}-{el.symbol} mass changed by {delta:.2f}% to {U(v,dv):fS} from {U(el._mass,el._mass_unc):fS}") - el._mass, el._mass_unc = parse_uncertainty(value) + el._mass, el._mass_unc = parse_uncertainty(valstr) #Li_ratio = table.Li[7]._abundance/table.Li[6]._abundance @@ -147,7 +147,7 @@ def init(table: PeriodicTable, reload: bool=False) -> None: # z El element\n iso mass(unc)|[low,high] note ... # Note: tables modified for Pb, Ar, and N to use 2013 values z = 0 - value = {} + value: dict[int, tuple[float, float]] = {} for line in isotope_abundance.split('\n'): #print(line) # New element @@ -725,7 +725,9 @@ def check_abundance(table: PeriodicTable|None=None) -> None: # Coursey. J. S., Schwab. D. J., and Dragoset. R. A., NIST, # Physics Laboratory, Office of Electronic Commerce in Scientific # and Engineering Data. - +# +# Column layout: +# z-el-iso,isotope mass(unc)#?,abundance(unc),element mass(unc) isotope_mass = """\ 1-H-1,1.0078250319000(100),99.9885(70),1.00794(7) 1-H-2,2.0141017778400(200),0.0115(70),1.00794(7) diff --git a/periodictable/mass_2001.py b/periodictable/mass_2001.py index 26383da..48d8227 100644 --- a/periodictable/mass_2001.py +++ b/periodictable/mass_2001.py @@ -65,18 +65,18 @@ def init(table: PeriodicTable, reload: bool=False) -> None: return table.properties.append('mass') Element.mass = property(mass, doc=mass.__doc__) + Element.mass_units = "u" Isotope.mass = property(mass, doc=mass.__doc__) Isotope.abundance = property(abundance, doc=abundance.__doc__) - Element.mass_units = "u" - Element.abundance_units = "%" + Isotope.abundance_units = "%" for line in massdata.split('\n'): isotope, m, p, avg = line.split(',') - el, sym, iso = isotope.split('-') - el = table[int(el)] + z, sym, a = isotope.split('-') + el = table[int(z)] assert el.symbol == sym, \ "Symbol %s does not match %s"%(sym, el.symbol) - iso = el.add_isotope(int(iso)) + iso = el.add_isotope(int(a)) el._mass, el._mass_unc = parse_uncertainty(avg) iso._mass, iso._mass_unc = parse_uncertainty(m) iso._abundance,iso._abundance_unc = parse_uncertainty(p) if p else (0,0) diff --git a/periodictable/nsf.py b/periodictable/nsf.py index a5fdfe8..68b0f89 100644 --- a/periodictable/nsf.py +++ b/periodictable/nsf.py @@ -186,6 +186,8 @@ # Wiley InterScience. pp 126-146. doi:10.1107/97809553602060000584 # +from typing import TYPE_CHECKING, cast + import numpy as np from numpy import sqrt, pi, asarray, inf from numpy.typing import NDArray, ArrayLike @@ -450,8 +452,10 @@ class Neutron: def __init__(self): self._number_density = None def __str__(self) -> str: - return ("b_c=%.3g coh=%.3g inc=%.3g abs=%.3g" - % (self.b_c, self.coherent, self.incoherent, self.absorption)) + if not self.has_sld(): + return "unknown" + b_c, coh, inc, abs = self.b_c, self.coherent, self.incoherent, self.absorption + return f"b_c={cast(float, b_c):.3g} coh={cast(float, coh):.3g} inc={cast(float, inc):.3g} abs={cast(float, abs):.3g}" def has_sld(self) -> bool: """Returns *True* if sld is defined for this element/isotope.""" @@ -460,7 +464,7 @@ def has_sld(self) -> bool: return self.b_c is not None and self._number_density is not None # PAK 2021-04-05: allow energy dependent b_c - def scattering_by_wavelength(self, wavelength: ArrayLike) -> tuple[NDArray, NDArray]: + def scattering_by_wavelength(self, wavelength: ArrayLike) -> tuple[ArrayLike, ArrayLike]|tuple[None, None]: r""" Return scattering length and total cross section for each wavelength. @@ -479,10 +483,13 @@ def scattering_by_wavelength(self, wavelength: ArrayLike) -> tuple[NDArray, NDAr *sigma_s* \: float(s) | barn """ + if not self.has_sld(): + return None, None + # TODO: do vector conversion at the end rather than the beginning. if self.nsf_table is None: ones = 1 if np.isscalar(wavelength) else np.ones_like(wavelength) - return ones*self.b_c_complex, ones*self.total + return ones*cast(float, self.b_c_complex), ones*cast(float, self.total) #energy = neutron_energy(wavelength) #b_c = np.interp(energy, self.nsf_table[0], self.nsf_table[1]) b_c = np.interp(wavelength, self.nsf_table[0], self.nsf_table[1]) @@ -490,7 +497,7 @@ def scattering_by_wavelength(self, wavelength: ArrayLike) -> tuple[NDArray, NDAr sigma_s = _4PI_100*abs(b_c)**2 # 1 barn = 1 fm^2 1e-2 barn/fm^2 return b_c, sigma_s - def sld(self, *, wavelength: ArrayLike=ABSORPTION_WAVELENGTH) -> NDArray: + def sld(self, *, wavelength: ArrayLike=ABSORPTION_WAVELENGTH) -> NDArray|None: r""" Returns scattering length density for the element at natural abundance and density. @@ -508,10 +515,10 @@ def sld(self, *, wavelength: ArrayLike=ABSORPTION_WAVELENGTH) -> NDArray: """ # TODO: deprecate in favour of neutron_scattering(el) if not self.has_sld(): - return None, None, None + return None return self.scattering(wavelength=wavelength)[0] - def scattering(self, *, wavelength: ArrayLike=ABSORPTION_WAVELENGTH) -> tuple[NDArray, NDArray, NDArray]: + def scattering(self, *, wavelength: ArrayLike=ABSORPTION_WAVELENGTH) -> tuple[NDArray, NDArray, NDArray]|tuple[None, None, None]: r""" Returns neutron scattering information for the element at natural abundance and density. @@ -563,7 +570,7 @@ def energy_dependent_init(table: PeriodicTable) -> None: bc_175 = Lu175.neutron.b_c_complex wavelength, bc_176 = Lu176.neutron.nsf_table bc_nat = (bc_175*Lu175.abundance + bc_176*Lu176.abundance)/100.0 # 1 fm = 1fm * %/100 - table.Lu.neutron.nsf_table = wavelength, bc_nat + table.Lu.neutron.nsf_table = wavelength, cast(DoubleArray, bc_nat) #table.Lu.neutron.total = 0. # zap total cross section def init(table: PeriodicTable, reload: bool=False) -> None: @@ -668,7 +675,6 @@ def init(table: PeriodicTable, reload: bool=False) -> None: # TODO: require parsed compound rather than including formula() keywords in api # Note: docs and function prototype are reproduced in __init__ # CRUFT: deprecated circular import with periodictable.formulas -from typing import TYPE_CHECKING if TYPE_CHECKING: from .formulas import FormulaInput def neutron_scattering( @@ -678,7 +684,7 @@ def neutron_scattering( energy: ArrayLike|None=None, natural_density: float|None=None, table: PeriodicTable|None=None, - ) -> tuple[NDArray, NDArray, NDArray]: + ) -> tuple[NDArray, NDArray, NDArray]|tuple[None, None, None]: r""" Computes neutron scattering cross sections for molecules. @@ -932,9 +938,9 @@ def neutron_scattering( elif wavelength is None: wavelength = ABSORPTION_WAVELENGTH - # Sum over the quantities - molar_mass = num_atoms = 0 - b_c = sigma_s = 0 + # Sum over the quantities (note: formulas can have fractional atoms) + molar_mass, num_atoms = 0., 0. + b_c, sigma_s = 0., 0. is_energy_dependent = False for element, quantity in compound.atoms.items(): # TODO: use NaN rather than None diff --git a/periodictable/util.py b/periodictable/util.py index 33d9557..f947046 100644 --- a/periodictable/util.py +++ b/periodictable/util.py @@ -5,7 +5,7 @@ """ from math import sqrt -def parse_uncertainty(s: str) -> tuple[float, float]: +def parse_uncertainty(s: str) -> tuple[float, float]|tuple[None, None]: """ Given a floating point value plus uncertainty return the pair (val, unc). From 03e52573dec7a28e06830ed32f3d3ea9efd3ed34 Mon Sep 17 00:00:00 2001 From: Paul Kienzle Date: Mon, 12 Jan 2026 12:31:53 -0500 Subject: [PATCH 05/10] update min python test version --- .github/workflows/test.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8c6749a..8f40ffb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.14' - name: Build the wheel run: | @@ -50,12 +50,11 @@ jobs: strategy: matrix: cfg: - #- { os: ubuntu-20.04, py: 2.7 } #- { os: ubuntu-20.04, py: 3.6 } - - { os: ubuntu-latest, py: 3.8 } - - { os: ubuntu-latest, py: 3.11, doc: 1 } - - { os: windows-latest, py: 3.11 } - - { os: macos-latest, py: 3.11 } + - { os: ubuntu-latest, py: 3.10 } + - { os: ubuntu-latest, py: 3.14, doc: 1 } + - { os: windows-latest, py: 3.14 } + - { os: macos-latest, py: 3.14 } fail-fast: false steps: From b70671a64326811ebfd1b6c3b006c928df131d20 Mon Sep 17 00:00:00 2001 From: Paul Kienzle Date: Mon, 12 Jan 2026 12:40:36 -0500 Subject: [PATCH 06/10] Fix problem with list method shadowing builtin list --- periodictable/core.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/periodictable/core.py b/periodictable/core.py index 07e65d1..fa65a24 100644 --- a/periodictable/core.py +++ b/periodictable/core.py @@ -63,7 +63,7 @@ 'isatom', 'iselement', 'isisotope', 'ision'] from pathlib import Path -from typing import TYPE_CHECKING, cast, Any, Union, TypeVar +from typing import TYPE_CHECKING, cast, Any, Union, TypeVar, List from collections.abc import Sequence, Callable, Iterator if TYPE_CHECKING: @@ -221,7 +221,7 @@ class PeriodicTable: *nuclear* and *X-ray* scattering cross sections. See section :ref:`Adding properties ` for details. """ - properties: list[str] + properties: List[str] # list method shadows builtin list, so using List instead """Properties loaded into the table""" # Tedious listing of available elements for typed table.El access @@ -502,6 +502,7 @@ def isotope(self, input: str) -> Union["Element", "Isotope"]: # If we can't parse the string as an element or isotope, raise an error raise ValueError("unknown element "+input) + # TODO: list method shadows builtin list in "properties: list[str]" above def list(self, *props, **kw) -> None: """ Print a list of elements with the given set of properties. From cf496c01dc61660a875f79da86fded118ef9a3b6 Mon Sep 17 00:00:00 2001 From: Paul Kienzle Date: Mon, 12 Jan 2026 12:48:27 -0500 Subject: [PATCH 07/10] update min python test version --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f40ffb..47b928e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,8 +51,8 @@ jobs: matrix: cfg: #- { os: ubuntu-20.04, py: 3.6 } - - { os: ubuntu-latest, py: 3.10 } - { os: ubuntu-latest, py: 3.14, doc: 1 } + - { os: ubuntu-latest, py: 3.10 } - { os: windows-latest, py: 3.14 } - { os: macos-latest, py: 3.14 } fail-fast: false From 1a5fd518669de7f750fc5b72fc86fa82904212f1 Mon Sep 17 00:00:00 2001 From: Paul Kienzle Date: Mon, 12 Jan 2026 12:50:22 -0500 Subject: [PATCH 08/10] update min python test version --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 47b928e..54719ee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,8 +51,8 @@ jobs: matrix: cfg: #- { os: ubuntu-20.04, py: 3.6 } + - { os: ubuntu-latest, py: 3.9 } - { os: ubuntu-latest, py: 3.14, doc: 1 } - - { os: ubuntu-latest, py: 3.10 } - { os: windows-latest, py: 3.14 } - { os: macos-latest, py: 3.14 } fail-fast: false From 990ebc366cf406a9078562f926a32bf610edc1a4 Mon Sep 17 00:00:00 2001 From: Paul Kienzle Date: Mon, 12 Jan 2026 12:52:39 -0500 Subject: [PATCH 09/10] update min python test version --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 54719ee..e8e4dfd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,7 +51,7 @@ jobs: matrix: cfg: #- { os: ubuntu-20.04, py: 3.6 } - - { os: ubuntu-latest, py: 3.9 } + - { os: ubuntu-latest, py: 3.10.19 } - { os: ubuntu-latest, py: 3.14, doc: 1 } - { os: windows-latest, py: 3.14 } - { os: macos-latest, py: 3.14 } From b5e755911a89b0735286687691bb90abc8b83461 Mon Sep 17 00:00:00 2001 From: Paul Kienzle Date: Mon, 12 Jan 2026 13:18:52 -0500 Subject: [PATCH 10/10] update min python test version --- .github/workflows/test.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e8e4dfd..165acdc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,11 +50,11 @@ jobs: strategy: matrix: cfg: - #- { os: ubuntu-20.04, py: 3.6 } - - { os: ubuntu-latest, py: 3.10.19 } - - { os: ubuntu-latest, py: 3.14, doc: 1 } - - { os: windows-latest, py: 3.14 } - - { os: macos-latest, py: 3.14 } + #- { os: ubuntu-20.04, py: '3.6' } + - { os: ubuntu-latest, py: '3.10' } + - { os: ubuntu-latest, py: '3.14', doc: 1 } + - { os: windows-latest, py: '3.14' } + - { os: macos-latest, py: '3.14' } fail-fast: false steps: