From ceb75b733c18357be1769b1e6d3bb96c9b9885af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20Laur=C3=A9n?= Date: Thu, 9 Jul 2026 12:32:33 +0300 Subject: [PATCH] Address holistic review findings; bump to 1.6.4 Documentation - isinstance_types: teach `Q[Dim, Any]` (a bare `Q[Dim]` means `Quantity[Dim, Numpy1DArray]` statically, so a TypeIs narrow of a scalar quantity collapses to Never). Warning box in usage.md, fixed README snippet. - `Q` is the alias each module creates itself; the library exports no `Q` (the accidental re-export from encomp.constants is gone). - Document: the pint application-registry takeover and the pinned registry options, eager fluid-name resolution, tolerant `<=` / `>=`, that `plus_minus()` leaves the dimensionality-typed system, and the closed set of accepted magnitude / unit types. - Replace the ~80 hardcoded CoolProp fluid-name listings with the `get_global_param_string` one-liners plus examples. - Docstrings for the public members that rendered empty in the API reference. Typing - New unit literals: `ppm`, `torr`, `J/kg`, `J/g`, `g/cm3` family, and the new `MassPerNormalVolumeUnits`, `MolarDensityUnits`, `MolarSpecificEnthalpyUnits`, `PowerPerAreaUnits`, each with matching `__new__` overloads. - New `__mul__` / `__truediv__` pairs: Force/Area=Pressure, Force*Length=Energy, Force*Velocity=Power, Mass/MolarMass=Substance, Substance/Volume=MolarDensity, HeatTransferCoefficient*TemperatureDifference=PowerPerArea, ThermalConductivity/Length=HeatTransferCoefficient, and their inverses. - `__getitem__` accepts boolean masks and integer index arrays, so the `q[q > threshold]` idiom the comparison operators produce type-checks. - `ideal_gas_density` accepts scalar `T` / `M` alongside a vector `P`. - New dimensionalities `SurfaceTension` and `MolarSpecificHeatCapacity`; typed `Fluid.RHOCRIT/CVMASS/CPMOLAR/CVMOLAR/GAS_CONSTANT/I/TAU` and `HumidAir.CV/CVha` (the latter two now match `C`/`Cha` instead of resolving to the generic `SpecificHeatCapacity`). Behavior - `Quantity(True)` raises: bool is an int subclass and silently became 1.0. - Passing a `Quantity` as the *unit* raises, statically and at runtime -- its magnitude was silently dropped. `.to(qty)` still accepts one. - `Fluid("wat3r", ...)` raises at construction; the name is resolved against CoolProp once per name (only successful resolutions are cached). - Writing a pinned registry option logs a warning instead of being discarded in silence. - `CoolPropFluid.__getattr__` points at `.search()` for an unknown property. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 15 +- docs/usage.md | 48 +++- encomp/constants.py | 18 +- encomp/context.py | 14 +- encomp/coolprop/__init__.py | 4 + encomp/fluids.py | 221 ++++++++++------- encomp/gases.py | 20 +- encomp/sympy.py | 5 + encomp/tests/test_fluids.py | 97 +++++++- encomp/tests/test_gases.py | 20 +- encomp/tests/test_inference.py | 81 +++++- encomp/tests/test_static_typing.py | 52 +++- encomp/tests/test_units.py | 92 +++++++ encomp/tests/test_utypes.py | 1 + encomp/units.py | 386 ++++++++++++++++++++++++++++- encomp/utypes.py | 99 ++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 18 files changed, 1034 insertions(+), 143 deletions(-) diff --git a/README.md b/README.md index 7268741..d3b91d4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Documentation](https://readthedocs.org/projects/encomp/badge/?version=latest)](https://encomp.readthedocs.io) [![License](https://img.shields.io/pypi/l/encomp.svg)](https://github.com/wlaur/encomp/blob/main/LICENSE) -encomp logo +encomp logo > General-purpose library for *en*gineering *comp*utations. @@ -46,8 +46,9 @@ pip install encomp ## The `Quantity` class -`encomp.units.Quantity` (alias `Q`) extends `pint.Quantity`. +`encomp.units.Quantity` extends `pint.Quantity`. A quantity has a *magnitude* and a *unit*; each unit has a *dimensionality* (a combination of the base dimensions), and each dimensionality has multiple associated units. +The examples below abbreviate it as `Q` via `from encomp.units import Quantity as Q`; the library does not export a name `Q`. ```python from encomp.units import Quantity as Q @@ -78,6 +79,8 @@ A newly created dimensionality gets a class name of the form `Dimensionality[... The second type parameter is the magnitude container. It defaults to `Numpy1DArray`, so annotate scalar quantities explicitly as `Quantity[Pressure, float]` (or `Q[Pressure, float]`). ```python +from typing import Any + from encomp.misc import isinstance_types from encomp.units import ExpectedDimensionalityError from encomp.units import Quantity as Q @@ -99,8 +102,10 @@ rho = m / V # Quantity[Density, float] m_ = Q(25, "kg/week") # Quantity[UnknownDimensionality, float] # at runtime, the dimensionality of m_ is evaluated to MassFlow; -# use isinstance_types for parameterized Quantity checks in type-checked code -assert isinstance_types(m_, Q[MassFlow]) +# use isinstance_types for parameterized Quantity checks in type-checked code. +# always spell the magnitude parameter here: a bare Q[MassFlow] means +# Quantity[MassFlow, Numpy1DArray] to a type checker, which narrows m_ to Never +assert isinstance_types(m_, Q[MassFlow, Any]) # these operations (Mass**2 divided by Volume) are not explicitly defined as overloads # at runtime, the type will be evaluated to @@ -377,6 +382,8 @@ Settings are loaded when `encomp.settings` is imported; for runtime changes to q Because the `.env` file is resolved relative to the current working directory, a stray `.env` that sets an invalid `ENCOMP_*` value (for example `ENCOMP_UNITS` pointing at a missing file) makes `import encomp` fail with a `pydantic.ValidationError`, even in an unrelated project. Remove or correct the offending value; unrelated keys in the `.env` are ignored. +`import encomp` also installs `encomp.units.UNIT_REGISTRY` as pint's process-wide *application registry*. This is deliberate: every quantity in the process must come from that registry, or the dimensionality subclasses, the custom `[currency]` / `[normal]` dimensions and `on_redefinition="raise"` would silently not apply. The consequence is that another pint-based library in the same process gets encomp's registry (and its unit definitions, including the `Nm³` reinterpretation) after `import encomp`. Registry options that encomp pins — `force_ndarray`, `force_ndarray_like`, `autoconvert_offset_to_baseunit` — cannot be reassigned; a write to one is discarded and logs a warning. + ## Documentation The usage guide, example notebooks, and API reference are at [encomp.readthedocs.io](https://encomp.readthedocs.io). diff --git a/docs/usage.md b/docs/usage.md index 2ff2320..d6379f8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -14,7 +14,8 @@ A {py:class}`encomp.units.Quantity` stores the *magnitude*, *unit* and *dimensio Each dimensionality is a separate subclass, so static type checkers catch dimensionality errors before the code runs. :::{note} -`Q` is an alias for `Quantity`: `from encomp.units import Quantity as Q` +Throughout this guide, `Q` is the alias created by `from encomp.units import Quantity as Q`. +The library does not export a name `Q` -- create the alias yourself in each module that wants it. ::: Import the class, then create an instance representing an absolute pressure of 1 bar: @@ -41,11 +42,17 @@ pressure_kpa = pressure.to("kPa") ``` For measured values with uncertainty, `Quantity.plus_minus()` keeps the unit and stores an `uncertainties` magnitude. +Uncertainty propagation is delegated to `pint` and `uncertainties`: the result is a `pint.Measurement`, not an `encomp` dimensionality subclass, so it carries no `Quantity[DT, MT]` typing and leaves the dimensionality-typed system. The unit definition file (`encomp/defs/units.txt`) lists the accepted unit names. It is based on the `default_en.txt` file from `pint`, with minor modifications. The `Nm3`/`Nm³`/`nm3` spellings mean *normal cubic meter*, not nanometer cubed; use `nanometer**3` for nanoscale volumes. +The magnitude and the unit are always separate arguments, and each has a closed set of accepted types. +A magnitude is a real scalar, a 1-dimensional sequence, a NumPy array, a Polars `Series`/`Expr`, or a SymPy atom -- a string (`Q("24 kg")`) and a `bool` (`Q(True)`) are rejected, statically where possible and at runtime always. +A unit is a string, a `Unit`, a `pint.UnitsContainer`, or `None`; passing a `Quantity` as the unit is an error, because its magnitude would be silently dropped. +Use `qty.u` to reuse another quantity's unit, or {py:meth}`encomp.units.Quantity.to`, which does accept a `Quantity` and converts to its unit. + Quantities can also be constructed from unit registry attributes: ```python @@ -64,6 +71,17 @@ v = d / ureg.s mf = Q(25, ureg.kg / ureg.h) ``` +:::{warning} +`import encomp` installs {py:data}`encomp.units.UNIT_REGISTRY` as `pint`'s process-wide +*application registry*. Every quantity in the process must come from it, or the +dimensionality subclasses, the custom `[currency]` / `[normal]` dimensions and +`on_redefinition="raise"` would silently not apply. Another `pint`-based library in the +same process therefore gets `encomp`'s registry (and its unit definitions) after the +import. The registry options `force_ndarray`, `force_ndarray_like` and +`autoconvert_offset_to_baseunit` are pinned: assigning to them is discarded and logs a +warning. +::: + ### Quantity types Each dimensionality is a unique subclass of {py:class}`encomp.units.Quantity`. @@ -146,6 +164,8 @@ Check the physical dimensionality of a quantity with {py:meth}`encomp.units.Quan For semantic dimensionality checks and parameterized types like `list[Quantity[Pressure]]`, use {py:func}`encomp.misc.isinstance_types`. ```python +from typing import Any + from encomp.misc import isinstance_types from encomp.units import Quantity as Q from encomp.utypes import Length, Pressure, Temperature, TemperatureDifference @@ -165,15 +185,29 @@ Q(1, "delta_degC").check(Temperature) # True # isinstance_types works with simple Quantity types and nested containers -isinstance_types(pressure, Q[Pressure]) # True -isinstance_types(pressure, Q[Length]) # False -isinstance_types([pressure, pressure], list[Q[Pressure]]) # True -isinstance_types({1: Q(2, "m"), 2: Q(25, "cm")}, dict[int, Q[Length]]) # True +isinstance_types(pressure, Q[Pressure, Any]) # True +isinstance_types(pressure, Q[Length, Any]) # False +isinstance_types([pressure, pressure], list[Q[Pressure, Any]]) # True +isinstance_types({1: Q(2, "m"), 2: Q(25, "cm")}, dict[int, Q[Length, Any]]) # True # all Quantity[...] objects are subclasses of Quantity isinstance_types(pressure, Q) # True ``` +:::{warning} +Spell the magnitude parameter when `isinstance_types` narrows a variable you go on to use: +write `Q[Pressure, Any]` (or the exact magnitude type, `Q[Pressure, float]`), not a bare +`Q[Pressure]`. + +At *runtime* `Q[Dim]` is magnitude-agnostic, so a bare `Q[Pressure]` matches a scalar +quantity. *Statically*, `Q[Dim]` means `Quantity[Dim, Numpy1DArray]` (the magnitude +parameter defaults to `Numpy1DArray`), and `isinstance_types` is a +{py:obj}`typing.TypeIs` predicate: a type checker intersects the declared type with the +narrowed one, so `Quantity[Pressure, float]` narrows to `Never` and every later use of +the variable is an error. `Q[Pressure, Any]` behaves identically at runtime and narrows +correctly. +::: + For functions and methods, use the `typeguard.typechecked` decorator instead of explicit checks in the function body: ```python @@ -190,7 +224,7 @@ def func(_p1: Q[Pressure, float]) -> tuple[Q[Length, float], Q[Power, float]]: `typeguard.TypeCheckError` is raised if the arguments or the return value have incorrect dimensionalities. -Scalar `Quantity` equality is tolerant (`rtol=1e-9`, `atol=1e-12`) and compares after unit conversion, so values that differ only by tiny floating-point noise may compare equal. Hashing is supported for float magnitudes and uses root units; vector magnitudes are unhashable. +Scalar `Quantity` equality is tolerant (`rtol=1e-9`, `atol=1e-12`) and compares after unit conversion, so values that differ only by tiny floating-point noise may compare equal. The same tolerance folds into the non-strict ordering comparisons, so `Q(1 + 1e-12, "m") <= Q(1, "m")` is `True`; `<` and `>` are strict. Hashing is supported for float magnitudes and uses root units; vector magnitudes are unhashable. Pickling preserves the dimensionality class for module-global dimensionalities. Dynamically generated dimensionalities round-trip by deriving the dimensionality from the stored unit. @@ -443,6 +477,8 @@ Pass the CoolProp fluid name and the fixed points (for example *P, T*) to the co Not every combination of input values can fix the state: with an invalid state, derived properties evaluate to `nan` and `encomp.fluids` logs a warning, but no exception is raised. Set `ENCOMP_IGNORE_COOLPROP_WARNINGS=true` (the default) to suppress these calculation warnings, or `false` to emit them through Python logging. An invalid property *name* or output-only state input, on the other hand, raises `ValueError`. +So does an unknown *fluid* name: it is resolved against CoolProp in the constructor (and the answer is cached per name, so repeated construction of the same fluid costs nothing). +The *dimensionality* of each state-input quantity is still validated lazily, at the first property evaluation. ```python from typing import Any diff --git a/encomp/constants.py b/encomp/constants.py index 3be01ce..07cb739 100644 --- a/encomp/constants.py +++ b/encomp/constants.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from typing import Any -from .units import Quantity as Q +from .units import Quantity from .utypes import Pressure, Temperature __all__ = ["CONSTANTS", "Constants"] @@ -13,22 +13,26 @@ class Constants: """Namespace of constants exposed through the :data:`CONSTANTS` singleton.""" - R: Q[Any, float] = field(default_factory=lambda: Q(8.31446261815324, "kg*m²/K/mol/s²")) + R: Quantity[Any, float] = field(default_factory=lambda: Quantity(8.31446261815324, "kg*m²/K/mol/s²")) """Molar gas constant, exact by the 2019 SI definition.""" - SIGMA: Q[Any, float] = field(default_factory=lambda: Q(5.670374419e-8, "W/m**2/K**4")) + SIGMA: Quantity[Any, float] = field(default_factory=lambda: Quantity(5.670374419e-8, "W/m**2/K**4")) """Stefan-Boltzmann constant.""" - normal_conditions_pressure: Q[Pressure, float] = field(default_factory=lambda: Q(1, "atm")) + normal_conditions_pressure: Quantity[Pressure, float] = field(default_factory=lambda: Quantity(1, "atm")) """Normal-condition pressure, 1 atm.""" - normal_conditions_temperature: Q[Temperature, float] = field(default_factory=lambda: Q(0, "degC").to("K")) + normal_conditions_temperature: Quantity[Temperature, float] = field( + default_factory=lambda: Quantity(0, "degC").to("K") + ) """Normal-condition temperature, 0 degC.""" - standard_conditions_pressure: Q[Pressure, float] = field(default_factory=lambda: Q(1, "atm")) + standard_conditions_pressure: Quantity[Pressure, float] = field(default_factory=lambda: Quantity(1, "atm")) """Standard-condition pressure, 1 atm.""" - standard_conditions_temperature: Q[Temperature, float] = field(default_factory=lambda: Q(15, "degC").to("K")) + standard_conditions_temperature: Quantity[Temperature, float] = field( + default_factory=lambda: Quantity(15, "degC").to("K") + ) """Standard-condition temperature, 15 degC.""" diff --git a/encomp/context.py b/encomp/context.py index 96a12f2..12554b8 100644 --- a/encomp/context.py +++ b/encomp/context.py @@ -1,4 +1,13 @@ -"""Context managers for process-local filesystem and formatting changes.""" +"""Context managers for process-local filesystem and formatting changes. + +Two unrelated groups share this module: + +* *process utilities* -- :func:`working_dir`, :func:`temp_dir` and :func:`silence_stdout` + temporarily change the working directory or redirect ``stdout``; they have nothing to do + with units. +* *quantity formatting* -- :func:`quantity_format` scopes the process-wide default format + used when rendering quantities and units. +""" import os import sys @@ -84,7 +93,8 @@ def quantity_format(fmt: str = "compact") -> Generator[None]: ---------- fmt : str Unit format string: one of ``'~P', '~L', '~H', '~Lx'``. - Also accepts aliases: ``'compact': '~P'`` and ``'siunitx': '~Lx'``. + Also accepts the aliases ``'compact': '~P'``, ``'normal': '~P'`` + and ``'siunitx': '~Lx'``. """ default = UNIT_REGISTRY.formatter.default_format or "~P" diff --git a/encomp/coolprop/__init__.py b/encomp/coolprop/__init__.py index 38d773c..386d9a8 100644 --- a/encomp/coolprop/__init__.py +++ b/encomp/coolprop/__init__.py @@ -719,6 +719,10 @@ def self_check() -> bool: """True if the plugin loads and evaluates one known value correctly (cached).""" ok = _self_check_cached() if not ok: + # DELIBERATELY warnings.warn, not the module logger the rest of encomp uses: this + # is an install-time diagnostic (a broken/mismatched native plugin), aimed at the + # developer running the check rather than at an application's log stream, and it + # is what the cibuildwheel test-command surfaces. Settled -- do not "unify". warnings.warn( "encomp.coolprop plugin self_check failed; the cached failed result will be reused in this process", RuntimeWarning, diff --git a/encomp/fluids.py b/encomp/fluids.py index 59cfb63..56f28ba 100644 --- a/encomp/fluids.py +++ b/encomp/fluids.py @@ -8,6 +8,7 @@ from abc import ABC, abstractmethod from collections import OrderedDict from collections.abc import Callable, Mapping +from functools import cache from threading import Lock from typing import Annotated, Any, ClassVar, Generic, Literal, Self, TypedDict, Unpack, cast @@ -53,6 +54,7 @@ MolarMass, MolarSpecificEnthalpy, MolarSpecificEntropy, + MolarSpecificHeatCapacity, MolarSpecificInternalEnergy, Numpy1DArray, Pressure, @@ -62,6 +64,7 @@ SpecificHeatPerDryAir, SpecificHeatPerHumidAir, SpecificInternalEnergy, + SurfaceTension, Temperature, ThermalConductivity, Velocity, @@ -222,10 +225,38 @@ class HumidAirState(TypedDict, Generic[MT], total=False): # noqa: UP046 def clear_expr_evaluation_cache() -> None: + """Drop the cache of constructed CoolProp ``pl.Expr`` nodes.""" + with _EXPR_EVALUATION_CACHE_LOCK: _EXPR_EVALUATION_CACHE.clear() +@cache +def _resolve_fluid_name(backend: str, fluids: str) -> None: + # Constructing the AbstractState is the only reliable way to ask CoolProp whether a + # name resolves (it covers pure fluids, INCOMP, and mixtures alike). It costs tens of + # microseconds for the usual backends -- and seconds the very first time a tabular + # backend builds its tables, a cost that would otherwise be paid at the first property + # access anyway. Cached per (backend, fluids), so the 1000th Fluid("Water", ...) pays + # a dict lookup rather than another CoolProp initialization. + # + # functools.cache stores return values, never exceptions, so only a resolved name is + # remembered: a failure (an invalid name, or a transient CoolProp error) is re-checked + # on the next call instead of being cached as "this fluid does not exist". + _cp.AbstractState(backend, fluids) + + +def _validate_fluid_name(name: CName, composition: Composition | None = None) -> None: + backend, fluids, _ = resolve_fluid_spec(name, composition) + + try: + _resolve_fluid_name(backend, fluids) + except Exception as e: + raise ValueError( + f"Fluid '{name}' could not be initialized, ensure that the name is a valid CoolProp fluid name" + ) from e + + def _expr_cache_digest(expr: pl.Expr) -> str: serialized = expr.meta.undo_aliases().meta.serialize(format="json") return hashlib.blake2s(serialized.encode(), digest_size=16).hexdigest() @@ -528,89 +559,24 @@ def __init__(self, name: CName, **kwargs: Quantity[Any, MT] | Quantity[Any, floa (this method must set instance attributes ``name`` and ``points``). Fluid names for pure fluids are not case-sensitive, but the mixture names are. - The following fluid names are recognized by CoolProp: - - **Pure** - - .. code:: none - - 1-Butene,Acetone,Air,Ammonia,Argon,Benzene,CarbonDioxide,CarbonMonoxide, - CarbonylSulfide,CycloHexane,CycloPropane,Cyclopentane,D4,D5,D6,Deuterium, - Dichloroethane,DiethylEther,DimethylCarbonate,DimethylEther,Ethane, - Ethanol,EthylBenzene,Ethylene,EthyleneOxide,Fluorine,HFE143m,HeavyWater, - Helium,Hydrogen,HydrogenChloride,HydrogenSulfide,IsoButane,IsoButene, - Isohexane,Isopentane,Krypton,MD2M,MD3M,MD4M,MDM,MM,Methane,Methanol, - MethylLinoleate,MethylLinolenate,MethylOleate,MethylPalmitate,MethylStearate, - Neon,Neopentane,Nitrogen,NitrousOxide,Novec649,OrthoDeuterium,OrthoHydrogen, - Oxygen,ParaDeuterium,ParaHydrogen,Propylene,Propyne,R11,R113,R114,R115, - R116,R12,R123,R1233zd(E),R1234yf,R1234ze(E),R1234ze(Z),R124,R1243zf, - R125,R13,R134a,R13I1,R14,R141b,R142b,R143a,R152A,R161,R21,R218,R22,R227EA, - R23,R236EA,R236FA,R245ca,R245fa,R32,R365MFC,R40,R404A,R407C,R41,R410A, - R507A,RC318,SES36,SulfurDioxide,SulfurHexafluoride,Toluene,Water,Xenon, - cis-2-Butene,m-Xylene,n-Butane,n-Decane,n-Dodecane,n-Heptane,n-Hexane, - n-Nonane,n-Octane,n-Pentane,n-Propane,n-Undecane,o-Xylene,p-Xylene,trans-2-Butene - - **Incompressible pure** - - .. code:: none - - INCOMP::AS10,INCOMP::AS20,INCOMP::AS30,INCOMP::AS40,INCOMP::AS55,INCOMP::DEB, - INCOMP::DSF,INCOMP::DowJ,INCOMP::DowJ2,INCOMP::DowQ,INCOMP::DowQ2,INCOMP::HC10, - INCOMP::HC20,INCOMP::HC30,INCOMP::HC40,INCOMP::HC50,INCOMP::HCB,INCOMP::HCM, - INCOMP::HFE,INCOMP::HFE2,INCOMP::HY20,INCOMP::HY30,INCOMP::HY40,INCOMP::HY45, - INCOMP::HY50,INCOMP::NBS,INCOMP::NaK,INCOMP::PBB,INCOMP::PCL,INCOMP::PCR, - INCOMP::PGLT,INCOMP::PHE,INCOMP::PHR,INCOMP::PLR,INCOMP::PMR,INCOMP::PMS1, - INCOMP::PMS2,INCOMP::PNF,INCOMP::PNF2,INCOMP::S800,INCOMP::SAB,INCOMP::T66, - INCOMP::T72,INCOMP::TCO,INCOMP::TD12,INCOMP::TVP1,INCOMP::TVP1869,INCOMP::TX22, - INCOMP::TY10,INCOMP::TY15,INCOMP::TY20,INCOMP::TY24,INCOMP::Water,INCOMP::XLT, - INCOMP::XLT2,INCOMP::ZS10,INCOMP::ZS25,INCOMP::ZS40,INCOMP::ZS45,INCOMP::ZS55 - - **Incompressible mixtures** - - .. code:: none - - INCOMP::FRE,INCOMP::IceEA,INCOMP::IceNA,INCOMP::IcePG,INCOMP::LiBr,INCOMP::MAM, - INCOMP::MAM2,INCOMP::MCA,INCOMP::MCA2,INCOMP::MEA,INCOMP::MEA2,INCOMP::MEG, - INCOMP::MEG2,INCOMP::MGL,INCOMP::MGL2,INCOMP::MITSW,INCOMP::MKA,INCOMP::MKA2, - INCOMP::MKC,INCOMP::MKC2,INCOMP::MKF,INCOMP::MLI,INCOMP::MMA,INCOMP::MMA2, - INCOMP::MMG,INCOMP::MMG2,INCOMP::MNA,INCOMP::MNA2,INCOMP::MPG,INCOMP::MPG2, - INCOMP::VCA,INCOMP::VKC,INCOMP::VMA,INCOMP::VMG,INCOMP::VNA,INCOMP::AEG, - INCOMP::AKF,INCOMP::AL,INCOMP::AN,INCOMP::APG,INCOMP::GKN,INCOMP::PK2, - INCOMP::PKL,INCOMP::ZAC,INCOMP::ZFC,INCOMP::ZLC,INCOMP::ZM,INCOMP::ZMC - - **Mixtures** - - .. code:: none - - AIR.MIX,AMARILLO.MIX,Air.mix,Amarillo.mix,EKOFISK.MIX,Ekofisk.mix,GULFCOAST.MIX, - GULFCOASTGAS(NIST1).MIX,GulfCoast.mix,GulfCoastGas(NIST1).mix,HIGHCO2.MIX, - HIGHN2.MIX,HighCO2.mix,HighN2.mix,NATURALGASSAMPLE.MIX,NaturalGasSample.mix, - R401A.MIX,R401A.mix,R401B.MIX,R401B.mix,R401C.MIX,R401C.mix,R402A.MIX,R402A.mix, - R402B.MIX,R402B.mix,R403A.MIX,R403A.mix,R403B.MIX,R403B.mix,R404A.MIX,R404A.mix, - R405A.MIX,R405A.mix,R406A.MIX,R406A.mix,R407A.MIX,R407A.mix,R407B.MIX,R407B.mix, - R407C.MIX,R407C.mix,R407D.MIX,R407D.mix,R407E.MIX,R407E.mix,R407F.MIX,R407F.mix, - R408A.MIX,R408A.mix,R409A.MIX,R409A.mix,R409B.MIX,R409B.mix,R410A.MIX,R410A.mix, - R410B.MIX,R410B.mix,R411A.MIX,R411A.mix,R411B.MIX,R411B.mix,R412A.MIX,R412A.mix, - R413A.MIX,R413A.mix,R414A.MIX,R414A.mix,R414B.MIX,R414B.mix,R415A.MIX,R415A.mix, - R415B.MIX,R415B.mix,R416A.MIX,R416A.mix,R417A.MIX,R417A.mix,R417B.MIX,R417B.mix, - R417C.MIX,R417C.mix,R418A.MIX,R418A.mix,R419A.MIX,R419A.mix,R419B.MIX,R419B.mix, - R420A.MIX,R420A.mix,R421A.MIX,R421A.mix,R421B.MIX,R421B.mix,R422A.MIX,R422A.mix, - R422B.MIX,R422B.mix,R422C.MIX,R422C.mix,R422D.MIX,R422D.mix,R422E.MIX,R422E.mix, - R423A.MIX,R423A.mix,R424A.MIX,R424A.mix,R425A.MIX,R425A.mix,R426A.MIX,R426A.mix, - R427A.MIX,R427A.mix,R428A.MIX,R428A.mix,R429A.MIX,R429A.mix,R430A.MIX,R430A.mix, - R431A.MIX,R431A.mix,R432A.MIX,R432A.mix,R433A.MIX,R433A.mix,R433B.MIX,R433B.mix, - R433C.MIX,R433C.mix,R434A.MIX,R434A.mix,R435A.MIX,R435A.mix,R436A.MIX,R436A.mix, - R436B.MIX,R436B.mix,R437A.MIX,R437A.mix,R438A.MIX,R438A.mix,R439A.MIX,R439A.mix, - R440A.MIX,R440A.mix,R441A.MIX,R441A.mix,R442A.MIX,R442A.mix,R443A.MIX,R443A.mix, - R444A.MIX,R444A.mix,R444B.MIX,R444B.mix,R445A.MIX,R445A.mix,R446A.MIX,R446A.mix, - R447A.MIX,R447A.mix,R448A.MIX,R448A.mix,R449A.MIX,R449A.mix,R449B.MIX,R449B.mix, - R450A.MIX,R450A.mix,R451A.MIX,R451A.mix,R451B.MIX,R451B.mix,R452A.MIX,R452A.mix, - R453A.MIX,R453A.mix,R454A.MIX,R454A.mix,R454B.MIX,R454B.mix,R500.MIX,R500.mix, - R501.MIX,R501.mix,R502.MIX,R502.mix,R503.MIX,R503.mix,R504.MIX,R504.mix,R507A.MIX, - R507A.mix,R508A.MIX,R508A.mix,R508B.MIX,R508B.mix,R509A.MIX,R509A.mix,R510A.MIX, - R510A.mix,R511A.MIX,R511A.mix,R512A.MIX,R512A.mix,R513A.MIX,R513A.mix, - TYPICALNATURALGAS.MIX,TypicalNaturalGas.mix + A name may fold in the backend (``"IF97::Water"``, ``"HEOS::CO2&O2"``); a bare + name defaults to the HEOS backend. Incompressible fluids and their mixtures use + the ``INCOMP::`` prefix, optionally with a concentration + (``"INCOMP::MEG[0.5]"``). An unknown name raises ``ValueError`` at construction. + + The names recognized by the installed CoolProp build are listed by + .. code:: python + + import CoolProp.CoolProp as CP + + CP.get_global_param_string("FluidsList") # pure and pseudo-pure + CP.get_global_param_string("incompressible_list_pure") + CP.get_global_param_string("incompressible_list_solution") + CP.get_global_param_string("predefined_mixtures") + + Examples: ``Water``, ``Air``, ``Nitrogen``, ``CarbonDioxide``, ``Ammonia``, + ``R134a``, ``Toluene``, ``INCOMP::T66``, ``INCOMP::MPG[0.5]``, ``R410A.mix``. Refer to the CoolProp documentation for more information: @@ -649,6 +615,8 @@ def __init__(self, name: CName, **kwargs: Quantity[Any, MT] | Quantity[Any, floa @classmethod def get_prop_key(cls, prop: str) -> tuple[CProperty, ...]: + """Return the tuple of synonyms in :attr:`PROPERTY_MAP` that ``prop`` belongs to.""" + if prop not in cls.ALL_PROPERTIES: raise ValueError(f'Property "{prop}" is not a valid CoolProp property name') @@ -660,6 +628,8 @@ def get_prop_key(cls, prop: str) -> tuple[CProperty, ...]: @classmethod def get_coolprop_unit(cls, prop: CProperty) -> Unit: + """Return the unit CoolProp reports ``prop`` in, before conversion to :attr:`RETURN_UNITS`.""" + key = cls.get_prop_key(prop) if key in cls.PROPERTY_MAP: @@ -670,6 +640,8 @@ def get_coolprop_unit(cls, prop: CProperty) -> Unit: @classmethod def is_valid_prop(cls, prop: str) -> bool: + """Whether ``prop`` is a CoolProp property name known to this class.""" + try: cls.get_prop_key(prop) return True @@ -679,6 +651,8 @@ def is_valid_prop(cls, prop: str) -> bool: @classmethod def check_inputs(cls, kwargs: Mapping[str, object]) -> None: + """Raise ``ValueError`` unless every key names a distinct CoolProp state input.""" + invalid = [key for key in kwargs if not cls.is_valid_prop(key)] if len(invalid): @@ -726,6 +700,8 @@ def _build_points( @classmethod def describe(cls, prop: CProperty) -> str: + """Return a one-line description of ``prop``: its synonyms, meaning, and unit.""" + key = cls.get_prop_key(prop) if key in cls.PROPERTY_MAP: @@ -742,6 +718,8 @@ def describe(cls, prop: CProperty) -> str: @classmethod def search(cls, inp: str) -> list[str]: + """Return the :meth:`describe` lines of every property whose description contains ``inp``.""" + matches: list[str] = [] for key in cls.PROPERTY_MAP: @@ -756,6 +734,12 @@ def _warn_coolprop_nan(self, prop: CProperty, msg: str) -> None: _LOGGER.warning(f'CoolProp could not calculate "{prop}" for fluid "{self.name}", output is NaN: {msg}') def check_exception(self, prop: CProperty, e: ValueError) -> None: + """Re-raise ``e``, or return so the caller can yield ``NaN`` for ``prop``. + + CoolProp signals both "out of range" and "not implemented" with ``ValueError``; + those cases warn (or stay silent) and produce ``NaN``. Anything else re-raises. + """ + msg = str(e) # this error occurs in case the input values are outside @@ -1010,6 +994,8 @@ def _reduce_single_element(x: float | Numpy1DArray | pl.Expr) -> float | Numpy1D return x def evaluate_single(self, output: CProperty, *points: tuple[CProperty, float]) -> float: + """Evaluate ``output`` for scalar inputs through the CoolProp backend, returning ``NaN`` on an invalid state.""" + inputs = list(flatten(points)) if self._append_name_to_cp_inputs: @@ -1031,6 +1017,8 @@ def evaluate_single(self, output: CProperty, *points: tuple[CProperty, float]) - return np.nan def evaluate_expression(self, output: CProperty, *points: tuple[CProperty, pl.Expr]) -> pl.Expr: + """Build (or reuse) the cached ``pl.Expr`` plugin node that evaluates ``output``.""" + # pl.Expr (lazy) inputs are evaluated only through the GIL-free rust plugin; # _rust_expr raises if it is unavailable (no map_batches fallback). key = _get_expr_evaluation_cache_key(self, output, points) @@ -1050,6 +1038,8 @@ def evaluate_multiple_separately( arrs_flat_masked: list[Numpy1DArray], N: int, ) -> Numpy1DArray: + """Evaluate ``output`` row by row, so a single invalid row yields ``NaN`` rather than failing the batch.""" + vals: list[float] = [] for i in range(N): @@ -1075,6 +1065,8 @@ def evaluate_multiple_separately( return np.array(vals) def evaluate_multiple(self, output: CProperty, *points: tuple[CProperty, Numpy1DArray]) -> Numpy1DArray: + """Evaluate ``output`` for array inputs in one vectorized backend call; non-finite rows are ``NaN``.""" + props: list[CProperty] = [pt[0] for pt in points] arrs = [pt[1] for pt in points] shape = arrs[0].shape @@ -1129,6 +1121,8 @@ def validate_output(x: Numpy1DArray) -> Numpy1DArray: def evaluate( self, output: CProperty, *points: tuple[CProperty, float | Numpy1DArray | pl.Expr] ) -> float | Numpy1DArray | pl.Expr: + """Evaluate ``output`` in CoolProp's own unit, dispatching on the magnitude type of ``points``.""" + if self._lowlevel: return self._evaluate_lowlevel(output, points) @@ -1199,6 +1193,8 @@ def _eager_series_output_dtype(self) -> pl.DataType: def construct_quantity( self, val: float | Numpy1DArray | pl.Expr, output: CProperty, convert_magnitude: bool = True ) -> Quantity[Any, MT]: + """Wrap a raw CoolProp result as a ``Quantity``, converted to the preferred :attr:`RETURN_UNITS`.""" + unit_output = self.get_coolprop_unit(output) # the dimensionality is not known until runtime @@ -1223,6 +1219,12 @@ def construct_quantity( def to_numeric_correct_unit( self, prop: CProperty, qty: Quantity[Any, MT] | Quantity[Any, float] ) -> float | Numpy1DArray | pl.Expr: + """Convert a state-input quantity to CoolProp's unit for ``prop`` and return its bare magnitude. + + This is where the dimensionality of a state input is validated: a mismatch raises + :class:`encomp.units.ExpectedDimensionalityError`. + """ + unit = self.get_coolprop_unit(prop) try: @@ -1288,7 +1290,16 @@ def __getattr__(self, attr: str) -> Quantity[Any, MT]: # narrow to a known property before delegating to the strictly-typed get(). if (is_fluid_param(attr) or is_humid_air_param(attr)) and attr in self.ALL_PROPERTIES: return self.get(attr) - raise AttributeError(attr) + + # dunder lookups reach here during copy/pickle/inspect protocol probing; those + # must fail plainly, without suggesting a property lookup + if attr.startswith("__") and attr.endswith("__"): + raise AttributeError(attr) + + raise AttributeError( + f'"{attr}" is not a CoolProp property name for {type(self).__name__} ' + f'(property names are case-sensitive); use {type(self).__name__}.search("...") to look one up' + ) def _get_repr(self, prop: CProperty, fmt: str) -> str: if all(isinstance(n[1].m, (float, int)) for n in self.points): @@ -1345,6 +1356,10 @@ def __init__( Represents a fluid at a fixed state, for example at a specific temperature and pressure. + The fluid name is resolved eagerly: an unknown name raises ``ValueError`` here, + not at the first property access. The dimensionality of each state input is still + validated lazily, when a property is evaluated. + Parameters ---------- name : CName @@ -1374,6 +1389,8 @@ def __init__( else: self.name = name + _validate_fluid_name(self.name) + points = self._build_points(kwargs) self.point_1: tuple[CProperty, Quantity[Any, MT] | Quantity[Any, float]] = points[0] @@ -1563,10 +1580,18 @@ def Z(self) -> Quantity[Dimensionless, MT]: def DELTA(self) -> Quantity[Dimensionless, MT]: return self.get("DELTA").asdim(Dimensionless) + @property + def TAU(self) -> Quantity[Dimensionless, MT]: + return self.get("TAU").asdim(Dimensionless) + @property def D(self) -> Quantity[Density, MT]: return self.get("D").asdim(Density) + @property + def RHOCRIT(self) -> Quantity[Density, MT]: + return self.get("RHOCRIT").asdim(Density) + @property def RHOMASS_REDUCING(self) -> Quantity[Density, MT]: return self.get("RHOMASS_REDUCING").asdim(Density) @@ -1595,6 +1620,28 @@ def L(self) -> Quantity[ThermalConductivity, MT]: def C(self) -> Quantity[SpecificHeatCapacity, MT]: return self.get("C").asdim(SpecificHeatCapacity) + @property + def CVMASS(self) -> Quantity[SpecificHeatCapacity, MT]: + return self.get("CVMASS").asdim(SpecificHeatCapacity) + + @property + def CPMOLAR(self) -> Quantity[MolarSpecificHeatCapacity, MT]: + return self.get("CPMOLAR").asdim(MolarSpecificHeatCapacity) + + @property + def CVMOLAR(self) -> Quantity[MolarSpecificHeatCapacity, MT]: + return self.get("CVMOLAR").asdim(MolarSpecificHeatCapacity) + + @property + def GAS_CONSTANT(self) -> Quantity[MolarSpecificHeatCapacity, MT]: + """Molar gas constant, which shares its dimensions with a molar heat capacity.""" + + return self.get("GAS_CONSTANT").asdim(MolarSpecificHeatCapacity) + + @property + def I(self) -> Quantity[SurfaceTension, MT]: # noqa: E743 # CoolProp's name for surface tension + return self.get("I").asdim(SurfaceTension) + @property def M(self) -> Quantity[MolarMass, MT]: return self.get("M").asdim(MolarMass) @@ -1804,6 +1851,14 @@ def C(self) -> Quantity[SpecificHeatPerDryAir, MT]: def Cha(self) -> Quantity[SpecificHeatPerHumidAir, MT]: return self.get("Cha").asdim(SpecificHeatPerHumidAir) + @property + def CV(self) -> Quantity[SpecificHeatPerDryAir, MT]: + return self.get("CV").asdim(SpecificHeatPerDryAir) + + @property + def CVha(self) -> Quantity[SpecificHeatPerHumidAir, MT]: + return self.get("CVha").asdim(SpecificHeatPerHumidAir) + @property def H(self) -> Quantity[MixtureEnthalpyPerDryAir, MT]: return self.get("H").asdim(MixtureEnthalpyPerDryAir) diff --git a/encomp/gases.py b/encomp/gases.py index 65e296d..0918e7d 100644 --- a/encomp/gases.py +++ b/encomp/gases.py @@ -12,6 +12,11 @@ ``"N"`` means normal conditions (0 °C, 1 atm) and ``"S"`` means standard conditions (15 °C, 1 atm). +The functions here name their fluid parameter ``fluid_name``, whereas +:class:`encomp.fluids.Fluid` names it ``name``. That is intentional, not an +inconsistency: ``name`` is unambiguous on a fluid object, while a free function that +also takes volumes, masses and conditions has to say *whose* name it is. + .. note:: Humid-air-specific conversions are not implemented here; use :class:`encomp.fluids.HumidAir` for humid-air properties. @@ -98,8 +103,8 @@ def _resolve_gas_condition(condition: object, name: str) -> GasCondition: def ideal_gas_density( P: Quantity[Pressure, MT], - T: Quantity[Temperature, MT], - M: Quantity[MolarMass, MT], + T: Quantity[Temperature, MT] | Quantity[Temperature, float], + M: Quantity[MolarMass, MT] | Quantity[MolarMass, float], ) -> Quantity[Density, MT]: """ Returns the density :math:`\\rho` of an ideal gas. @@ -114,13 +119,16 @@ def ideal_gas_density( \\text{m}^2}{\\text{s}^2 \\, \\text{K} \\, \\text{mol}}` (exact by the 2019 SI definition). + The magnitude type of the result follows ``P``; ``T`` and ``M`` may be scalars + alongside a vector ``P`` (a scalar molar mass is the common case). + Parameters ---------- P : Quantity[Pressure, MT] Absolute pressure of the gas - T : Quantity[Temperature, MT] + T : Quantity[Temperature, MT] | Quantity[Temperature, float] Temperature of the gas - M : Quantity[MolarMass, MT] + M : Quantity[MolarMass, MT] | Quantity[MolarMass, float] Molar mass of the gas Returns @@ -173,6 +181,10 @@ def convert_gas_volume( (``CONSTANTS.normal_conditions_*``); ``"S"`` is 15 °C and 1 atm (``CONSTANTS.standard_conditions_*``). + Both conditions default to ``"N"``, so calling this with only ``V1`` converts + nothing (it still evaluates :math:`Z` twice, at the same state). Pass at least one + of ``condition_1`` / ``condition_2`` for a conversion to take place. + Parameters ---------- V1 : Quantity[Volume, MT] | Quantity[VolumeFlow, MT] diff --git a/encomp/sympy.py b/encomp/sympy.py index e42d511..a09ad23 100644 --- a/encomp/sympy.py +++ b/encomp/sympy.py @@ -827,6 +827,11 @@ def _patch_symbol_class(dest: type[sp.Symbol] | sp.Symbol) -> None: def symbols(inp: str, **kwargs: Any) -> list[Symbol]: # noqa: ANN401 + """Like ``sympy.symbols``, but typed as a list and always returning more than one symbol. + + ``kwargs`` are SymPy assumptions (``positive=True``, ``integer=True``, ...). + """ + ret = sp.symbols(inp, **kwargs) if not isinstance(ret, Iterable): diff --git a/encomp/tests/test_fluids.py b/encomp/tests/test_fluids.py index a877040..2499eb5 100644 --- a/encomp/tests/test_fluids.py +++ b/encomp/tests/test_fluids.py @@ -20,6 +20,7 @@ HumidAir, HumidAirState, Water, + _resolve_fluid_name, clear_expr_evaluation_cache, ) from ..settings import SETTINGS @@ -250,12 +251,9 @@ def test_humid_air_out_of_range_logs_warning(caplog: pytest.LogCaptureFixture, m def test_incorrect_inputs() -> None: - # NOTE: the name cannot be checked until CoolProp is actually - # called, so the name is not validated in __init__ - invalid = Fluid("this fluid name does not exist", P=Q(2, "bar"), T=Q(25, "°C")) - - with pytest.raises(ValueError): - invalid.P + # the name is resolved eagerly (cached per name), so construction is where it fails + with pytest.raises(ValueError, match="could not be initialized"): + Fluid("this fluid name does not exist", P=Q(2, "bar"), T=Q(25, "°C")) p = np.zeros((5, 5)) t = np.zeros(5) @@ -310,13 +308,43 @@ def test_describe_rejects_unknown_property() -> None: Fluid.describe(cast(Any, "not_a_real_property")) -def test_invalid_fluid_repr_does_not_raise() -> None: - invalid = Fluid(cast(Any, "Watr"), P=Q(2, "bar"), T=Q(25, "degC")) +def test_invalid_fluid_name_rejected_at_construction() -> None: + with pytest.raises(ValueError, match="could not be initialized"): + Fluid(cast(Any, "Watr"), P=Q(2, "bar"), T=Q(25, "degC")) + + +def test_fluid_name_validation_is_cached() -> None: + # a valid name is resolved by CoolProp once; every later construction is a cache hit + _resolve_fluid_name.cache_clear() + + for _ in range(50): + Fluid("Water", P=Q(2, "bar"), T=Q(25, "degC")) + + info = _resolve_fluid_name.cache_info() + + assert info.misses == 1 + assert info.hits == 49 + + # an invalid name is NOT cached: functools.cache stores return values, not exceptions, + # so a transient CoolProp failure can never be remembered as "this fluid is invalid" + _resolve_fluid_name.cache_clear() + + for _ in range(3): + with pytest.raises(ValueError, match="could not be initialized"): + Fluid("Watr", P=Q(2, "bar"), T=Q(25, "degC")) + + assert _resolve_fluid_name.cache_info().currsize == 0 + + +def test_invalid_fluid_state_repr_does_not_raise() -> None: + # a valid name whose state cannot be fixed still reprs: the name resolved, only the + # property evaluation fails, so the derived properties come back as NaN + invalid = Fluid("water", P=Q(-2.0, "bar"), T=Q(25.0, "degC")) text = repr(invalid) - assert text.startswith(' None: @@ -1098,3 +1126,52 @@ def check_polars(series: pl.Series, n_missing: int) -> None: Fluid[pl.Expr]("Water", P=Q(pl.col("P"), "Pa"), T=Q(pl.col("T"), "K")).get("I").m.alias("i") )["i"] check_polars(st_col, 2) + + +def test_new_typed_fluid_properties() -> None: + water = Fluid("HEOS::Water", P=Q(1.0, "bar"), T=Q(25.0, "degC")) + + assert_type(water.RHOCRIT, Q[ut.Density, float]) # pyrefly: ignore[assert-type] + assert_type(water.CVMASS, Q[ut.SpecificHeatCapacity, float]) # pyrefly: ignore[assert-type] + assert_type(water.CPMOLAR, Q[ut.MolarSpecificHeatCapacity, float]) # pyrefly: ignore[assert-type] + assert_type(water.CVMOLAR, Q[ut.MolarSpecificHeatCapacity, float]) # pyrefly: ignore[assert-type] + assert_type(water.GAS_CONSTANT, Q[ut.MolarSpecificHeatCapacity, float]) # pyrefly: ignore[assert-type] + assert_type(water.TAU, Q[ut.Dimensionless, float]) # pyrefly: ignore[assert-type] + + assert water.RHOCRIT.to("kg/m³").m == approx(322.0, rel=1e-3) + # CoolProp carries its own value of R per equation of state, so compare loosely + assert water.GAS_CONSTANT.to("J/mol/K").m == approx(8.3144598, rel=1e-4) + + # the typed accessors agree with the untyped __getattr__ path + assert water.CVMASS.m == approx(water.__getattr__("Cvmass").m) + assert water.CPMOLAR.m == approx(water.__getattr__("Cpmolar").m) + + # surface tension needs a new dimensionality, [force] / [length] + saturated = Fluid("HEOS::Water", T=Q(25.0, "degC"), Q=Q(0.0)) + + assert_type(saturated.I, Q[ut.SurfaceTension, float]) # pyrefly: ignore[assert-type] + assert saturated.I.to("mN/m").m == approx(72.0, rel=1e-2) + + +def test_humid_air_constant_volume_heat_capacity_is_typed() -> None: + humid_air = HumidAir(T=Q(25.0, "degC"), P=Q(1.0, "bar"), R=Q(0.5)) + + # CV / CVha now match C / Cha instead of falling back to SpecificHeatCapacity + assert_type(humid_air.CV, Q[ut.SpecificHeatPerDryAir, float]) # pyrefly: ignore[assert-type] + assert_type(humid_air.CVha, Q[ut.SpecificHeatPerHumidAir, float]) # pyrefly: ignore[assert-type] + + assert type(humid_air.CV).__name__ == type(humid_air.C).__name__ + assert type(humid_air.CVha).__name__ == type(humid_air.Cha).__name__ + + assert humid_air.CV.m < humid_air.C.m + + +def test_unknown_property_error_message_points_at_search() -> None: + fluid = Fluid("water", P=Q(2.0, "bar"), T=Q(25.0, "degC")) + + with pytest.raises(AttributeError, match=r'use Fluid\.search\("\.\.\."\) to look one up'): + fluid.NOT_A_PROPERTY + + # dunder probing (copy, pickle, inspect) still fails plainly + with pytest.raises(AttributeError): + fluid.__deepcopy__ diff --git a/encomp/tests/test_gases.py b/encomp/tests/test_gases.py index 05f9c3a..3a965c7 100644 --- a/encomp/tests/test_gases.py +++ b/encomp/tests/test_gases.py @@ -67,17 +67,25 @@ def test_convert_gas_volume() -> None: def test_ideal_gas_density() -> None: - # ideal_gas_density ties P/T/M to one magnitude TypeVar (its body does arithmetic - # across all three), so a mixed array/scalar call cannot be typed: the scalar P/M - # args are cast (needed for both checkers), and pyrefly additionally cannot solve - # the shared constrained TypeVar across multiple arguments at all + # P drives the magnitude type of the result; T and M widen to accept a scalar + # alongside a vector P (a scalar molar mass is the usual case). pyrefly cannot + # solve the constrained magnitude TypeVar across several arguments at all assert_type( # pyrefly: ignore[assert-type] ideal_gas_density(Q(12, "bar"), Q(25, "degC"), Q(12, "g/mol")), # pyrefly: ignore[bad-specialization] Q[Density, float], ) - ret = ideal_gas_density(cast(Any, Q(12, "bar")), Q([25, 26], "degC"), cast(Any, Q(12, "g/mol"))) # pyrefly: ignore[bad-argument-type, bad-specialization] - assert_type(ret, Q[Density, Numpy1DArray]) # pyrefly: ignore[assert-type] + P = Q([12, 13], "bar") + T = Q([25, 26], "degC") + + # mixed vector P with scalar T and M: a static error before the parameters widened + mixed = ideal_gas_density(P, Q(25, "degC"), Q(12, "g/mol")) + assert_type(mixed, Q[Density, Numpy1DArray]) + + vector = ideal_gas_density(P, T, Q(12, "g/mol")) + assert_type(vector, Q[Density, Numpy1DArray]) + + assert mixed.m[0] == approx(vector.m[0]) def test_gas_conversion() -> None: diff --git a/encomp/tests/test_inference.py b/encomp/tests/test_inference.py index 6fbe9ce..22f4323 100644 --- a/encomp/tests/test_inference.py +++ b/encomp/tests/test_inference.py @@ -157,7 +157,7 @@ def test_inference_complex_units() -> None: assert_type(Q(1000, "kg") / Q(1, "m^3"), Q[ut.Density, float]) # pressure = force / area - assert_type(Q(100, "N") / Q(2, "m^2"), Q[ut.UnknownDimensionality, float]) + assert_type(Q(100, "N") / Q(2, "m^2"), Q[ut.Pressure, float]) def test_inference_dimensional_multiplication() -> None: @@ -365,9 +365,9 @@ def test_inference_dimensional_derived_units() -> None: # MolarMass = Mass / Substance assert_type(Q(0.018, "kg/mol"), Q[ut.MolarMass, float]) - assert_type(Q(18.0, "g") / Q(1.0, "mol"), Q[ut.UnknownDimensionality, float]) - assert_type(Q([18.0, 44.0], "g") / Q(1.0, "mol"), Q[ut.UnknownDimensionality, ut.Numpy1DArray]) - assert_type(Q(pl.lit(18.0), "g") / Q(1.0, "mol"), Q[ut.UnknownDimensionality, pl.Expr]) + assert_type(Q(18.0, "g") / Q(1.0, "mol"), Q[ut.MolarMass, float]) + assert_type(Q([18.0, 44.0], "g") / Q(1.0, "mol"), Q[ut.MolarMass, ut.Numpy1DArray]) + assert_type(Q(pl.lit(18.0), "g") / Q(1.0, "mol"), Q[ut.MolarMass, pl.Expr]) # SubstancePerMass = Substance / Mass (reciprocal of MolarMass) assert_type(Q(1.0, "mol") / Q(18.0, "g"), Q[ut.UnknownDimensionality, float]) @@ -1137,3 +1137,76 @@ def test_unknown() -> None: assert_type(Q(25, "kg").unknown(), Q[ut.UnknownDimensionality, float]) assert_type(Q([1, 23], "kg").unknown(), Q[ut.UnknownDimensionality, ut.Numpy1DArray]) assert_type(Q([1, 23], "kg").unknown(), Q[ut.UnknownDimensionality, ut.Numpy1DArray]) + + +def test_inference_new_unit_literals() -> None: + # spellings that used to fall back to UnknownDimensionality + assert_type(Q(250.0, "ppm"), Q[ut.Dimensionless, float]) + assert_type(Q(760.0, "torr"), Q[ut.Pressure, float]) + assert_type(Q(2.5e6, "J/kg"), Q[ut.EnergyPerMass, float]) + assert_type(Q(0.998, "g/cm3"), Q[ut.Density, float]) + + # dimensionalities that had no unit literal at all + assert_type(Q(1.2, "kg/Nm3"), Q[ut.MassPerNormalVolume, float]) + assert_type(Q(40.9, "mol/m3"), Q[ut.MolarDensity, float]) + assert_type(Q(-285.8, "kJ/mol"), Q[ut.MolarSpecificEnthalpy, float]) + assert_type(Q(1000.0, "W/m2"), Q[ut.PowerPerArea, float]) + + # ... in every magnitude container + assert_type(Q([40.9, 41.0], "mol/m3"), Q[ut.MolarDensity, ut.Numpy1DArray]) + assert_type(Q(pl.Series([1000.0]), "W/m²"), Q[ut.PowerPerArea, pl.Series]) + assert_type(Q(pl.lit(1.2), "kg/Nm³"), Q[ut.MassPerNormalVolume, pl.Expr]) + + +def test_inference_mechanical_overloads() -> None: + # Pressure * Area = Force, and its inverses + assert_type(Q(2.0, "bar") * Q(0.5, "m^2"), Q[ut.Force, float]) + assert_type(Q(0.5, "m^2") * Q(2.0, "bar"), Q[ut.Force, float]) + assert_type(Q(100.0, "N") / Q(0.5, "m^2"), Q[ut.Pressure, float]) + assert_type(Q(100.0, "N") / Q(2.0, "bar"), Q[ut.Area, float]) + + # Force * Length = Energy, and its inverses + assert_type(Q(100.0, "N") * Q(2.0, "m"), Q[ut.Energy, float]) + assert_type(Q(2.0, "m") * Q(100.0, "N"), Q[ut.Energy, float]) + assert_type(Q(200.0, "J") / Q(2.0, "m"), Q[ut.Force, float]) + assert_type(Q(200.0, "J") / Q(100.0, "N"), Q[ut.Length, float]) + + # Force * Velocity = Power, and its inverses + assert_type(Q(100.0, "N") * Q(3.0, "m/s"), Q[ut.Power, float]) + assert_type(Q(3.0, "m/s") * Q(100.0, "N"), Q[ut.Power, float]) + assert_type(Q(300.0, "W") / Q(100.0, "N"), Q[ut.Velocity, float]) + assert_type(Q(300.0, "W") / Q(3.0, "m/s"), Q[ut.Force, float]) + + # every pair carries the three magnitude variants + assert_type(Q([2.0, 3.0], "bar") * Q(0.5, "m^2"), Q[ut.Force, ut.Numpy1DArray]) + assert_type(Q(2.0, "bar") * Q(pl.Series([0.5]), "m^2"), Q[ut.Force, pl.Series]) + assert_type(Q(pl.lit(100.0), "N") / Q(0.5, "m^2"), Q[ut.Pressure, pl.Expr]) + + +def test_inference_molar_overloads() -> None: + # Substance * MolarMass = Mass, and its inverses + assert_type(Q(2.0, "mol") * Q(18.0, "g/mol"), Q[ut.Mass, float]) + assert_type(Q(18.0, "g/mol") * Q(2.0, "mol"), Q[ut.Mass, float]) + assert_type(Q(36.0, "g") / Q(18.0, "g/mol"), Q[ut.Substance, float]) + assert_type(Q(36.0, "g") / Q(2.0, "mol"), Q[ut.MolarMass, float]) + + # Substance / Volume = MolarDensity, and its inverses + assert_type(Q(2.0, "mol") / Q(0.5, "m^3"), Q[ut.MolarDensity, float]) + assert_type(Q(4.0, "mol/m3") * Q(0.5, "m^3"), Q[ut.Substance, float]) + assert_type(Q(0.5, "m^3") * Q(4.0, "mol/m3"), Q[ut.Substance, float]) + assert_type(Q(2.0, "mol") / Q(4.0, "mol/m3"), Q[ut.Volume, float]) + + +def test_inference_heat_transfer_overloads() -> None: + htc = Q(10.0, "W/m^2/K") + + # HeatTransferCoefficient * TemperatureDifference = PowerPerArea + assert_type(htc * Q(5.0, "delta_degC"), Q[ut.PowerPerArea, float]) + assert_type(Q(5.0, "delta_degC") * htc, Q[ut.PowerPerArea, float]) + assert_type(Q(50.0, "W/m2") / Q(5.0, "delta_degC"), Q[ut.HeatTransferCoefficient, float]) + + # ThermalConductivity / Length = HeatTransferCoefficient + assert_type(Q(0.6, "W/m/K") / Q(0.06, "m"), Q[ut.HeatTransferCoefficient, float]) + assert_type(Q(0.6, "W/m/K") / htc, Q[ut.Length, float]) + assert_type(htc * Q(0.06, "m"), Q[ut.ThermalConductivity, float]) + assert_type(Q(0.06, "m") * htc, Q[ut.ThermalConductivity, float]) diff --git a/encomp/tests/test_static_typing.py b/encomp/tests/test_static_typing.py index f296698..7fad316 100644 --- a/encomp/tests/test_static_typing.py +++ b/encomp/tests/test_static_typing.py @@ -1,11 +1,15 @@ -"""String inputs to ``Quantity`` are deliberately unsupported: the magnitude and unit -are always passed separately (``Q(24, "kg")``, never ``Q("24 kg")``). - -The runtime already rejects strings (``_validate_magnitude``); these tests additionally -pin the *static* rejection: the fallback ``Quantity.__new__`` overload enumerates the -supported magnitude types and excludes ``str``, so ``Q("24 kg")`` must fail both pyright -and pyrefly. A valid twin snippet is checked as a control, so a failure of the invalid -snippet cannot be explained away by import-resolution problems. +"""Some ``Quantity`` constructor inputs are deliberately unsupported and must fail the +type checkers, not just the runtime. + +* the magnitude and unit are always passed separately (``Q(24, "kg")``, never + ``Q("24 kg")``), so ``str`` is excluded from the magnitude types; +* a ``Quantity`` is not a unit: ``Q(1, Q(2, "m"))`` silently dropped the ``2``, so the + fallback overload excludes it from the unit types. + +The runtime rejects both (``_validate_magnitude`` / ``__new__``); these tests additionally +pin the *static* rejection through the fallback ``Quantity.__new__`` overload, under both +pyright and pyrefly. A valid twin snippet is checked as a control, so a failure of the +invalid snippet cannot be explained away by import-resolution problems. """ from __future__ import annotations @@ -44,6 +48,18 @@ def _tool(name: str) -> str | None: q2 = Q("24", "kg") """ +_VALID_UNIT = """ +from encomp.units import Quantity as Q + +q = Q(1, Q(2, "m").u) +""" + +_INVALID_UNIT = """ +from encomp.units import Quantity as Q + +q = Q(1, Q(2, "m")) +""" + _ROOT = Path(__file__).resolve().parents[2] @@ -82,3 +98,23 @@ def test_string_input_rejected_at_runtime() -> None: with pytest.raises(ValueError): Q(cast(Any, "24"), "kg") + + +@pytest.mark.skipif(_PYREFLY is None, reason="pyrefly not installed") +def test_quantity_unit_rejected_by_pyrefly(tmp_path: Path) -> None: + assert _PYREFLY is not None # narrowed by the skipif above + cmd = [_PYREFLY, "check", "--config", str(_ROOT / "pyproject.toml")] + assert _check(cmd, _VALID_UNIT, tmp_path) == 0, "control snippet must type-check" + assert _check(cmd, _INVALID_UNIT, tmp_path) != 0, "a Quantity unit must not type-check" + + +@pytest.mark.skipif(_PYRIGHT is None, reason="pyright not installed") +def test_quantity_unit_rejected_by_pyright(tmp_path: Path) -> None: + assert _PYRIGHT is not None # narrowed by the skipif above + assert _check([_PYRIGHT], _VALID_UNIT, tmp_path) == 0, "control snippet must type-check" + assert _check([_PYRIGHT], _INVALID_UNIT, tmp_path) != 0, "a Quantity unit must not type-check" + + +def test_quantity_unit_rejected_at_runtime() -> None: + with pytest.raises(TypeError, match="got Quantity"): + Q(1, cast(Any, Q(2, "m"))) diff --git a/encomp/tests/test_units.py b/encomp/tests/test_units.py index 72c6ac7..a46a7d3 100644 --- a/encomp/tests/test_units.py +++ b/encomp/tests/test_units.py @@ -2,6 +2,7 @@ import copy import inspect +import logging import pickle import subprocess import sys @@ -49,6 +50,7 @@ NormalVolume, NormalVolumeFlow, Numpy1DArray, + Numpy1DBoolArray, Power, Pressure, SpecificEnthalpy, @@ -2130,3 +2132,93 @@ def test_power_div_massflow_energypermass() -> None: p_expr = Q(pl.col.test, "kW") assert_type(p_expr / mf, Q[EnergyPerMass, pl.Expr]) assert_type(p_expr / epm, Q[MassFlow, pl.Expr]) + + +def test_bool_magnitude_is_rejected() -> None: + # bool is an int subclass, so Q(True) would silently become 1.0 dimensionless + with pytest.raises(TypeError, match="not a bool"): + Q(cast(Any, True)) + + with pytest.raises(TypeError, match="not a bool"): + Q(cast(Any, False), "kg") + + # comparing against a bool still works (the bool never becomes a magnitude) + assert Q(1.0) == True # noqa: E712 + assert Q(0.5) != True # noqa: E712 + + +def test_quantity_as_unit_is_rejected() -> None: + # Q(1, Q(2, "m")) silently dropped the 2; it is an error now + with pytest.raises(TypeError, match="got Quantity"): + Q(1, cast(Any, Q(2, "m"))) + + with pytest.raises(TypeError, match="got Quantity"): + Q(Q(1, "m"), cast(Any, Q(2, "cm"))) + + # the unit of another quantity is still reachable, explicitly + assert Q(1, Q(2, "m").u) == Q(1, "m") + + # ... and .to() keeps accepting a quantity, where the intent is unambiguous + assert Q(100, "cm").to(Q(2, "m")) == Q(1, "m") + + +def test_boolean_mask_and_fancy_indexing() -> None: + q = Q([1.0, 2.0, 3.0], "m") + + # the comparison operators produce exactly the mask type __getitem__ accepts + mask = q > Q(1.5, "m") + assert_type(mask, Numpy1DBoolArray) + + masked = q[mask] + assert_type(masked, Q[Length, Numpy1DArray]) + assert (masked == Q([2.0, 3.0], "m")).all() + + from_list = q[[0, 2]] + assert_type(from_list, Q[Length, Numpy1DArray]) + assert (from_list == Q([1.0, 3.0], "m")).all() + + from_index_array = q[np.array([0, 2])] + assert_type(from_index_array, Q[Length, Numpy1DArray]) + assert (from_index_array == Q([1.0, 3.0], "m")).all() + + +def test_static_registry_option_write_warns(caplog: pytest.LogCaptureFixture) -> None: + registry = cast(Any, UNIT_REGISTRY) + + with caplog.at_level(logging.WARNING, logger="encomp.units"): + registry.force_ndarray = True + + assert "encomp pins this registry option" in caplog.text + + # the write is still discarded + assert registry.force_ndarray is False + + caplog.clear() + + # writing the pinned value is not a warning + with caplog.at_level(logging.WARNING, logger="encomp.units"): + registry.force_ndarray = False + + assert not caplog.text + + +def test_set_quantity_format_error_lists_every_alias() -> None: + # the alias is rejected before the default format is touched, so this test has no + # process-wide side effect + with pytest.raises(ValueError, match="normal") as excinfo: + set_quantity_format("not-a-format") + + message = str(excinfo.value) + + for alias in ("compact", "normal", "siunitx"): + assert alias in message + + +def test_no_q_alias_is_exported() -> None: + # the docs create the alias with `from encomp.units import Quantity as Q`; the + # library itself never exports the name (it used to leak from encomp.constants) + import encomp.constants + import encomp.units + + assert not hasattr(encomp.units, "Q") + assert not hasattr(encomp.constants, "Q") diff --git a/encomp/tests/test_utypes.py b/encomp/tests/test_utypes.py index b69815b..2109488 100644 --- a/encomp/tests/test_utypes.py +++ b/encomp/tests/test_utypes.py @@ -13,6 +13,7 @@ "MT_", "Numpy1DArray", "Numpy1DBoolArray", + "Numpy1DIntArray", "UnitsContainer", "get_registered_units", } diff --git a/encomp/units.py b/encomp/units.py index 70b8ebf..728f39b 100644 --- a/encomp/units.py +++ b/encomp/units.py @@ -97,9 +97,15 @@ Mass, MassFlow, MassFlowUnits, + MassPerNormalVolume, + MassPerNormalVolumeUnits, MassUnits, + MolarDensity, + MolarDensityUnits, MolarMass, MolarMassUnits, + MolarSpecificEnthalpy, + MolarSpecificEnthalpyUnits, NormalVolume, NormalVolumeFlow, NormalVolumeFlowUnits, @@ -108,7 +114,10 @@ NormalVolumeUnits, Numpy1DArray, Numpy1DBoolArray, + Numpy1DIntArray, Power, + PowerPerArea, + PowerPerAreaUnits, PowerUnits, Pressure, PressureUnits, @@ -232,9 +241,22 @@ class DimensionalityRedefinitionError(ValueError): class _UnitRegistry(UnitRegistry[Any]): + # flipped once the pinned values in _REGISTRY_STATIC_OPTIONS have been applied; + # writes before that are pint's own initialization, not user overrides + _static_options_pinned: bool = False + def __setattr__(self, key: str, value: Any) -> None: # noqa: ANN401 # ensure that static options cannot be overridden if key in _REGISTRY_STATIC_OPTIONS and value != _REGISTRY_STATIC_OPTIONS[key]: + if self._static_options_pinned: + # pint documents these as ordinary mutable options; encomp pins them, + # so tell the caller instead of discarding the write in silence + _LOGGER.warning( + "Ignoring assignment UNIT_REGISTRY.%s = %r: encomp pins this registry option to %r", + key, + value, + _REGISTRY_STATIC_OPTIONS[key], + ) return return super().__setattr__(key, value) @@ -259,7 +281,15 @@ def __init(self) -> None: # pyright: ignore[reportUnusedFunction] # invoked by for _option_name, _option_value in _REGISTRY_STATIC_OPTIONS.items(): setattr(UNIT_REGISTRY, _option_name, _option_value) -# make sure that UNIT_REGISTRY is the only registry that can be used +setattr(UNIT_REGISTRY, "_static_options_pinned", True) # noqa: B010 + +# Make sure that UNIT_REGISTRY is the only registry that can be used. This DELIBERATELY +# reaches into pint internals and takes over its process-wide application registry: every +# Quantity in the process must come from encomp's registry, or the dimensionality +# subclasses, the custom dimensions (currency, normal) and on_redefinition="raise" would +# silently not apply. The trade-off is documented (README "Settings"): another pint-based +# library in the same process sees encomp's registry after `import encomp`. This is an +# intentional, settled design decision -- not an oversight to be re-flagged. setattr(pint, "_DEFAULT_REGISTRY", UNIT_REGISTRY) # noqa: B010 cast(Any, pint.application_registry).set(UNIT_REGISTRY) @@ -283,7 +313,7 @@ def set_quantity_format(fmt: str = "compact") -> None: raise ValueError( f'Cannot set default format to "{fmt}", ' f"fmt must be one of {Quantity.FORMATTING_SPECS} " - "or alias compact: ~P, siunitx: ~Lx" + f"or an alias: {', '.join(f'{k}: {v}' for k, v in fmt_aliases.items())}" ) UNIT_REGISTRY.formatter.default_format = fmt @@ -428,6 +458,10 @@ class Quantity( _max_recursion_depth: int = 10 + # NOTE: __repr__ is inherited from pint and shows its generic class name + # (), while type() and __str__ show the + # dimensionality subclass. Overriding __repr__ would ripple through doctests, + # notebook outputs and user logs for no functional gain -- settled, not an oversight. def __str__(self) -> str: return self.__format__(self._REGISTRY.formatter.default_format) @@ -512,6 +546,8 @@ def __array__(self, t: Any | None = None, copy: bool = False, dtype: str | None @staticmethod def validate_magnitude_type(mt: type) -> None: + """Raise ``TypeError`` unless ``mt`` is a supported magnitude container type.""" + if mt == np.float64: raise TypeError(f"Invalid magnitude type: {mt}, expected one of float, np.ndarray, pl.Series, pl.Expr") @@ -560,6 +596,8 @@ def _get_magnitude_type_from_name(mt_name: MagnitudeTypeName) -> type: @staticmethod def get_unknown_dimensionality_subclass() -> type[Quantity[UnknownDimensionality, Any]]: + """Return the magnitude-agnostic quantity subclass with unknown dimensionality.""" + return Quantity[UnknownDimensionality, Any] @classmethod @@ -703,6 +741,8 @@ def _validate_unit( elif isinstance(unit, Unit): return Unit(unit) elif isinstance(unit, Quantity): + # only reachable through to()/ito(), where "convert to another quantity's + # unit" is intended -- the constructor rejects a Quantity unit outright qty = cast("Quantity[DT, Any]", unit) return qty.u elif isinstance(unit, dict): @@ -718,7 +758,8 @@ def _validate_unit( # pint registry -- fail with the accepted types instead of a bare # AttributeError from str-only parsing raise TypeError( - f"unit must be a str, Unit, UnitsContainer, dict, Quantity or None, got {type(unit).__name__}: {unit!r}" + f"unit must be a str, Unit, UnitsContainer, dict or None, got {type(unit).__name__}: {unit!r} " + "(a Quantity is accepted by .to()/.ito(), not by the Quantity constructor)" ) @staticmethod @@ -729,6 +770,12 @@ def _validate_magnitude(val: MT | Sequence[float] | str | bytes | None) -> MT: "or 1-dimensional sequence of real numbers; strings and None are not supported" ) + # bool is an int subclass, so Q(True) would otherwise become 1.0 dimensionless. + # A bool magnitude is always a mistake (e.g. a swapped argument or a comparison + # result); the coolprop composition path rejects it for the same reason + if isinstance(val, bool): + raise TypeError("magnitude must be a real number, not a bool") + if isinstance(val, int): return cast("MT", float(val)) elif isinstance(val, float): @@ -771,6 +818,8 @@ def get_unit(cls, unit_name: AllUnits | str) -> Unit: return Unit(cast(Any, cls._REGISTRY.parse_units(unit_name))) def get_subclass(self, dt: type[DT_], mt: type[MT_]) -> type[Quantity[DT_, MT_]]: + """Return the ``Quantity`` subclass for dimensionality ``dt`` and magnitude type ``mt``.""" + subcls = self._get_dimensional_subclass(dt, mt) return cast("type[Quantity[DT_, MT_]]", subcls) @@ -899,14 +948,22 @@ def __new__( cls, val: Sequence[float], unit: NormalVolumePerMassUnits ) -> Quantity[NormalVolumePerMass, Numpy1DArray]: ... @overload + def __new__( + cls, val: Sequence[float], unit: MassPerNormalVolumeUnits + ) -> Quantity[MassPerNormalVolume, Numpy1DArray]: ... + @overload def __new__(cls, val: Sequence[float], unit: DensityUnits) -> Quantity[Density, Numpy1DArray]: ... @overload + def __new__(cls, val: Sequence[float], unit: MolarDensityUnits) -> Quantity[MolarDensity, Numpy1DArray]: ... + @overload def __new__(cls, val: Sequence[float], unit: SpecificVolumeUnits) -> Quantity[SpecificVolume, Numpy1DArray]: ... @overload def __new__(cls, val: Sequence[float], unit: EnergyUnits) -> Quantity[Energy, Numpy1DArray]: ... @overload def __new__(cls, val: Sequence[float], unit: PowerUnits) -> Quantity[Power, Numpy1DArray]: ... @overload + def __new__(cls, val: Sequence[float], unit: PowerPerAreaUnits) -> Quantity[PowerPerArea, Numpy1DArray]: ... + @overload def __new__(cls, val: Sequence[float], unit: VelocityUnits) -> Quantity[Velocity, Numpy1DArray]: ... @overload def __new__(cls, val: Sequence[float], unit: ForceUnits) -> Quantity[Force, Numpy1DArray]: ... @@ -919,6 +976,10 @@ def __new__( @overload def __new__(cls, val: Sequence[float], unit: EnergyPerMassUnits) -> Quantity[EnergyPerMass, Numpy1DArray]: ... @overload + def __new__( + cls, val: Sequence[float], unit: MolarSpecificEnthalpyUnits + ) -> Quantity[MolarSpecificEnthalpy, Numpy1DArray]: ... + @overload def __new__( cls, val: Sequence[float], unit: SpecificHeatCapacityUnits ) -> Quantity[SpecificHeatCapacity, Numpy1DArray]: ... @@ -995,14 +1056,20 @@ def __new__(cls, val: MT, unit: NormalVolumeFlowUnits) -> Quantity[NormalVolumeF @overload def __new__(cls, val: MT, unit: NormalVolumePerMassUnits) -> Quantity[NormalVolumePerMass, MT]: ... @overload + def __new__(cls, val: MT, unit: MassPerNormalVolumeUnits) -> Quantity[MassPerNormalVolume, MT]: ... + @overload def __new__(cls, val: MT, unit: DensityUnits) -> Quantity[Density, MT]: ... @overload + def __new__(cls, val: MT, unit: MolarDensityUnits) -> Quantity[MolarDensity, MT]: ... + @overload def __new__(cls, val: MT, unit: SpecificVolumeUnits) -> Quantity[SpecificVolume, MT]: ... @overload def __new__(cls, val: MT, unit: EnergyUnits) -> Quantity[Energy, MT]: ... @overload def __new__(cls, val: MT, unit: PowerUnits) -> Quantity[Power, MT]: ... @overload + def __new__(cls, val: MT, unit: PowerPerAreaUnits) -> Quantity[PowerPerArea, MT]: ... + @overload def __new__(cls, val: MT, unit: VelocityUnits) -> Quantity[Velocity, MT]: ... @overload def __new__(cls, val: MT, unit: ForceUnits) -> Quantity[Force, MT]: ... @@ -1013,6 +1080,8 @@ def __new__(cls, val: MT, unit: KinematicViscosityUnits) -> Quantity[KinematicVi @overload def __new__(cls, val: MT, unit: EnergyPerMassUnits) -> Quantity[EnergyPerMass, MT]: ... @overload + def __new__(cls, val: MT, unit: MolarSpecificEnthalpyUnits) -> Quantity[MolarSpecificEnthalpy, MT]: ... + @overload def __new__(cls, val: MT, unit: SpecificHeatCapacityUnits) -> Quantity[SpecificHeatCapacity, MT]: ... @overload def __new__(cls, val: MT, unit: ThermalConductivityUnits) -> Quantity[ThermalConductivity, MT]: ... @@ -1039,7 +1108,10 @@ def __new__( # fallback overload: any supported magnitude type not covered abov # statically (it also raises ValueError at runtime) -- Quantity magnitudes and # units are always passed separately val: float | np.ndarray[Any, Any] | pl.Series | pl.Expr | Sequence[float] | Quantity[Any, Any] | sp.Basic, - unit: Any = None, # noqa: ANN401 + # Quantity is deliberately absent: passing one as the unit drops its magnitude, + # which is a bug rather than a shorthand. It is rejected here and at runtime; + # .to() does accept a Quantity, where reusing another quantity's unit is the point + unit: Unit[Any] | UnitsContainer | dict[str, Any] | str | None = None, _depth: int = 0, ) -> Quantity[Any, Any]: ... def __new__( @@ -1048,6 +1120,13 @@ def __new__( unit: Any = None, _depth: int = 0, ) -> Quantity[Any, Any]: + if isinstance(unit, Quantity): + raise TypeError( + f"unit must be a str, Unit, UnitsContainer, dict or None, got Quantity: {unit!r}. " + 'Pass the unit itself ("qty.u"), or use "value.to(qty)" to convert to another ' + "quantity's unit" + ) + unit = cast("Unit[DT] | UnitsContainer | str | dict[str, numbers.Number] | None", unit) if isinstance(val, Quantity): @@ -1755,6 +1834,12 @@ def validate( qty: Any, # noqa: ANN401 info: Any, # noqa: ANN401, ARG003 ) -> Quantity[DT, MT]: + """Pydantic validator: coerce ``qty`` to this subclass, checking dimensionality and magnitude type. + + Every failure is re-raised as a ``pydantic_core.PydanticCustomError`` with type + ``quantity_dimensionality``, ``quantity_magnitude_type`` or ``quantity_validation``. + """ + try: ret = cls._pydantic_build_quantity(qty) cls._pydantic_enforce_dimensionality(ret) @@ -1771,6 +1856,13 @@ def validate( return cast("Quantity[DT, MT]", ret) def check_compatibility(self, other: Quantity[Any, Any] | float) -> None: + """Raise ``DimensionalityTypeError`` unless ``other`` can be combined with this quantity. + + Compatibility is semantic, not just dimensional: ``Temperature`` and + ``TemperatureDifference`` share ``[temperature]`` but are not compatible. A plain + number is compatible only with a dimensionless quantity. + """ + if not isinstance(other, Quantity): if not self.dimensionless: raise DimensionalityTypeError( @@ -1816,6 +1908,8 @@ def is_compatible_with( *contexts: Any, # noqa: ANN401 **ctx_kwargs: Any, # noqa: ANN401 ) -> bool: + """Whether ``other`` is convertible to this quantity's unit *and* :meth:`check_compatibility` passes.""" + # add an additional check of the dimensionality types is_compatible = super().is_compatible_with(other, *contexts, **ctx_kwargs) @@ -1868,10 +1962,14 @@ def __round__(self, ndigits: int | None = None) -> Quantity[DT, MT]: @property def is_scalar(self) -> bool: + """Whether the magnitude is a scalar (``float``) rather than a container.""" + return isinstance(self.m, float) @property def ndim(self) -> int: + """Number of magnitude dimensions: 0 for a scalar, 1 for a vector magnitude.""" + if isinstance(self.m, (float, int)): return 0 @@ -1918,6 +2016,8 @@ def asdim(self, other: type[DT_] | Quantity[DT_, MT]) -> Quantity[DT_, MT]: return cast("Quantity[DT_, MT]", subcls(self.m, unit)) def unknown(self) -> Quantity[UnknownDimensionality, MT]: + """Return this quantity with its dimensionality erased, i.e. ``asdim(UnknownDimensionality)``.""" + return self.asdim(UnknownDimensionality) @overload @@ -2183,7 +2283,8 @@ def __eq__(self, other: object) -> bool | Numpy1DBoolArray | pl.Series | pl.Expr return False if isinstance(other, (float, int)): - other = Quantity(other, "dimensionless") + # float() so a bool operand still compares (Quantity rejects bool magnitudes) + other = Quantity(float(other), "dimensionless") m = self.m other_m = cast(float | Numpy1DArray | pl.Series | pl.Expr, cast(Any, other).to(self.u).m) @@ -2267,7 +2368,8 @@ def __ne__(self, other: object) -> bool | Numpy1DBoolArray | pl.Series | pl.Expr return True if isinstance(other, (float, int)): - other = Quantity(other, "dimensionless") + # float() so a bool operand still compares (Quantity rejects bool magnitudes) + other = Quantity(float(other), "dimensionless") m = self.m other_m = cast(float | Numpy1DArray | pl.Series | pl.Expr, cast(Any, other).to(self.u).m) @@ -2707,6 +2809,142 @@ def __mul__( self: Quantity[TemperatureDifference, float], other: Quantity[SpecificHeatCapacity, MT_] ) -> Quantity[EnergyPerMass, MT_]: ... + # Pressure * Area = Force + @overload + def __mul__(self: Quantity[Pressure, MT], other: Quantity[Area, MT]) -> Quantity[Force, MT]: ... + @overload + def __mul__(self: Quantity[Pressure, MT], other: Quantity[Area, float]) -> Quantity[Force, MT]: ... + @overload + def __mul__(self: Quantity[Pressure, float], other: Quantity[Area, MT_]) -> Quantity[Force, MT_]: ... + + # Area * Pressure = Force + @overload + def __mul__(self: Quantity[Area, MT], other: Quantity[Pressure, MT]) -> Quantity[Force, MT]: ... + @overload + def __mul__(self: Quantity[Area, MT], other: Quantity[Pressure, float]) -> Quantity[Force, MT]: ... + @overload + def __mul__(self: Quantity[Area, float], other: Quantity[Pressure, MT_]) -> Quantity[Force, MT_]: ... + + # Force * Length = Energy + @overload + def __mul__(self: Quantity[Force, MT], other: Quantity[Length, MT]) -> Quantity[Energy, MT]: ... + @overload + def __mul__(self: Quantity[Force, MT], other: Quantity[Length, float]) -> Quantity[Energy, MT]: ... + @overload + def __mul__(self: Quantity[Force, float], other: Quantity[Length, MT_]) -> Quantity[Energy, MT_]: ... + + # Length * Force = Energy + @overload + def __mul__(self: Quantity[Length, MT], other: Quantity[Force, MT]) -> Quantity[Energy, MT]: ... + @overload + def __mul__(self: Quantity[Length, MT], other: Quantity[Force, float]) -> Quantity[Energy, MT]: ... + @overload + def __mul__(self: Quantity[Length, float], other: Quantity[Force, MT_]) -> Quantity[Energy, MT_]: ... + + # Force * Velocity = Power + @overload + def __mul__(self: Quantity[Force, MT], other: Quantity[Velocity, MT]) -> Quantity[Power, MT]: ... + @overload + def __mul__(self: Quantity[Force, MT], other: Quantity[Velocity, float]) -> Quantity[Power, MT]: ... + @overload + def __mul__(self: Quantity[Force, float], other: Quantity[Velocity, MT_]) -> Quantity[Power, MT_]: ... + + # Velocity * Force = Power + @overload + def __mul__(self: Quantity[Velocity, MT], other: Quantity[Force, MT]) -> Quantity[Power, MT]: ... + @overload + def __mul__(self: Quantity[Velocity, MT], other: Quantity[Force, float]) -> Quantity[Power, MT]: ... + @overload + def __mul__(self: Quantity[Velocity, float], other: Quantity[Force, MT_]) -> Quantity[Power, MT_]: ... + + # Substance * MolarMass = Mass + @overload + def __mul__(self: Quantity[Substance, MT], other: Quantity[MolarMass, MT]) -> Quantity[Mass, MT]: ... + @overload + def __mul__(self: Quantity[Substance, MT], other: Quantity[MolarMass, float]) -> Quantity[Mass, MT]: ... + @overload + def __mul__(self: Quantity[Substance, float], other: Quantity[MolarMass, MT_]) -> Quantity[Mass, MT_]: ... + + # MolarMass * Substance = Mass + @overload + def __mul__(self: Quantity[MolarMass, MT], other: Quantity[Substance, MT]) -> Quantity[Mass, MT]: ... + @overload + def __mul__(self: Quantity[MolarMass, MT], other: Quantity[Substance, float]) -> Quantity[Mass, MT]: ... + @overload + def __mul__(self: Quantity[MolarMass, float], other: Quantity[Substance, MT_]) -> Quantity[Mass, MT_]: ... + + # MolarDensity * Volume = Substance + @overload + def __mul__(self: Quantity[MolarDensity, MT], other: Quantity[Volume, MT]) -> Quantity[Substance, MT]: ... + @overload + def __mul__(self: Quantity[MolarDensity, MT], other: Quantity[Volume, float]) -> Quantity[Substance, MT]: ... + @overload + def __mul__(self: Quantity[MolarDensity, float], other: Quantity[Volume, MT_]) -> Quantity[Substance, MT_]: ... + + # Volume * MolarDensity = Substance + @overload + def __mul__(self: Quantity[Volume, MT], other: Quantity[MolarDensity, MT]) -> Quantity[Substance, MT]: ... + @overload + def __mul__(self: Quantity[Volume, MT], other: Quantity[MolarDensity, float]) -> Quantity[Substance, MT]: ... + @overload + def __mul__(self: Quantity[Volume, float], other: Quantity[MolarDensity, MT_]) -> Quantity[Substance, MT_]: ... + + # HeatTransferCoefficient * TemperatureDifference = PowerPerArea + @overload + def __mul__( + self: Quantity[HeatTransferCoefficient, MT], other: Quantity[TemperatureDifference, MT] + ) -> Quantity[PowerPerArea, MT]: ... + @overload + def __mul__( + self: Quantity[HeatTransferCoefficient, MT], other: Quantity[TemperatureDifference, float] + ) -> Quantity[PowerPerArea, MT]: ... + @overload + def __mul__( + self: Quantity[HeatTransferCoefficient, float], other: Quantity[TemperatureDifference, MT_] + ) -> Quantity[PowerPerArea, MT_]: ... + + # TemperatureDifference * HeatTransferCoefficient = PowerPerArea + @overload + def __mul__( + self: Quantity[TemperatureDifference, MT], other: Quantity[HeatTransferCoefficient, MT] + ) -> Quantity[PowerPerArea, MT]: ... + @overload + def __mul__( + self: Quantity[TemperatureDifference, MT], other: Quantity[HeatTransferCoefficient, float] + ) -> Quantity[PowerPerArea, MT]: ... + @overload + def __mul__( + self: Quantity[TemperatureDifference, float], other: Quantity[HeatTransferCoefficient, MT_] + ) -> Quantity[PowerPerArea, MT_]: ... + + # HeatTransferCoefficient * Length = ThermalConductivity + @overload + def __mul__( + self: Quantity[HeatTransferCoefficient, MT], other: Quantity[Length, MT] + ) -> Quantity[ThermalConductivity, MT]: ... + @overload + def __mul__( + self: Quantity[HeatTransferCoefficient, MT], other: Quantity[Length, float] + ) -> Quantity[ThermalConductivity, MT]: ... + @overload + def __mul__( + self: Quantity[HeatTransferCoefficient, float], other: Quantity[Length, MT_] + ) -> Quantity[ThermalConductivity, MT_]: ... + + # Length * HeatTransferCoefficient = ThermalConductivity + @overload + def __mul__( + self: Quantity[Length, MT], other: Quantity[HeatTransferCoefficient, MT] + ) -> Quantity[ThermalConductivity, MT]: ... + @overload + def __mul__( + self: Quantity[Length, MT], other: Quantity[HeatTransferCoefficient, float] + ) -> Quantity[ThermalConductivity, MT]: ... + @overload + def __mul__( + self: Quantity[Length, float], other: Quantity[HeatTransferCoefficient, MT_] + ) -> Quantity[ThermalConductivity, MT_]: ... + # Unknown * Unknown = Unknown @overload def __mul__( @@ -3054,6 +3292,131 @@ def __truediv__( self: Quantity[EnergyPerMass, float], other: Quantity[TemperatureDifference, MT_] ) -> Quantity[SpecificHeatCapacity, MT_]: ... + # Force / Area = Pressure + @overload + def __truediv__(self: Quantity[Force, MT], other: Quantity[Area, MT]) -> Quantity[Pressure, MT]: ... + @overload + def __truediv__(self: Quantity[Force, MT], other: Quantity[Area, float]) -> Quantity[Pressure, MT]: ... + @overload + def __truediv__(self: Quantity[Force, float], other: Quantity[Area, MT_]) -> Quantity[Pressure, MT_]: ... + + # Force / Pressure = Area + @overload + def __truediv__(self: Quantity[Force, MT], other: Quantity[Pressure, MT]) -> Quantity[Area, MT]: ... + @overload + def __truediv__(self: Quantity[Force, MT], other: Quantity[Pressure, float]) -> Quantity[Area, MT]: ... + @overload + def __truediv__(self: Quantity[Force, float], other: Quantity[Pressure, MT_]) -> Quantity[Area, MT_]: ... + + # Energy / Length = Force + @overload + def __truediv__(self: Quantity[Energy, MT], other: Quantity[Length, MT]) -> Quantity[Force, MT]: ... + @overload + def __truediv__(self: Quantity[Energy, MT], other: Quantity[Length, float]) -> Quantity[Force, MT]: ... + @overload + def __truediv__(self: Quantity[Energy, float], other: Quantity[Length, MT_]) -> Quantity[Force, MT_]: ... + + # Energy / Force = Length + @overload + def __truediv__(self: Quantity[Energy, MT], other: Quantity[Force, MT]) -> Quantity[Length, MT]: ... + @overload + def __truediv__(self: Quantity[Energy, MT], other: Quantity[Force, float]) -> Quantity[Length, MT]: ... + @overload + def __truediv__(self: Quantity[Energy, float], other: Quantity[Force, MT_]) -> Quantity[Length, MT_]: ... + + # Power / Force = Velocity + @overload + def __truediv__(self: Quantity[Power, MT], other: Quantity[Force, MT]) -> Quantity[Velocity, MT]: ... + @overload + def __truediv__(self: Quantity[Power, MT], other: Quantity[Force, float]) -> Quantity[Velocity, MT]: ... + @overload + def __truediv__(self: Quantity[Power, float], other: Quantity[Force, MT_]) -> Quantity[Velocity, MT_]: ... + + # Power / Velocity = Force + @overload + def __truediv__(self: Quantity[Power, MT], other: Quantity[Velocity, MT]) -> Quantity[Force, MT]: ... + @overload + def __truediv__(self: Quantity[Power, MT], other: Quantity[Velocity, float]) -> Quantity[Force, MT]: ... + @overload + def __truediv__(self: Quantity[Power, float], other: Quantity[Velocity, MT_]) -> Quantity[Force, MT_]: ... + + # Mass / MolarMass = Substance + @overload + def __truediv__(self: Quantity[Mass, MT], other: Quantity[MolarMass, MT]) -> Quantity[Substance, MT]: ... + @overload + def __truediv__(self: Quantity[Mass, MT], other: Quantity[MolarMass, float]) -> Quantity[Substance, MT]: ... + @overload + def __truediv__(self: Quantity[Mass, float], other: Quantity[MolarMass, MT_]) -> Quantity[Substance, MT_]: ... + + # Mass / Substance = MolarMass + @overload + def __truediv__(self: Quantity[Mass, MT], other: Quantity[Substance, MT]) -> Quantity[MolarMass, MT]: ... + @overload + def __truediv__(self: Quantity[Mass, MT], other: Quantity[Substance, float]) -> Quantity[MolarMass, MT]: ... + @overload + def __truediv__(self: Quantity[Mass, float], other: Quantity[Substance, MT_]) -> Quantity[MolarMass, MT_]: ... + + # Substance / Volume = MolarDensity + @overload + def __truediv__(self: Quantity[Substance, MT], other: Quantity[Volume, MT]) -> Quantity[MolarDensity, MT]: ... + @overload + def __truediv__(self: Quantity[Substance, MT], other: Quantity[Volume, float]) -> Quantity[MolarDensity, MT]: ... + @overload + def __truediv__(self: Quantity[Substance, float], other: Quantity[Volume, MT_]) -> Quantity[MolarDensity, MT_]: ... + + # Substance / MolarDensity = Volume + @overload + def __truediv__(self: Quantity[Substance, MT], other: Quantity[MolarDensity, MT]) -> Quantity[Volume, MT]: ... + @overload + def __truediv__(self: Quantity[Substance, MT], other: Quantity[MolarDensity, float]) -> Quantity[Volume, MT]: ... + @overload + def __truediv__(self: Quantity[Substance, float], other: Quantity[MolarDensity, MT_]) -> Quantity[Volume, MT_]: ... + + # PowerPerArea / TemperatureDifference = HeatTransferCoefficient + # (the reverse, PowerPerArea / HeatTransferCoefficient, is intentionally omitted for + # the same reason as EnergyPerMass / SpecificHeatCapacity above: it resolves to + # Temperature rather than TemperatureDifference) + @overload + def __truediv__( + self: Quantity[PowerPerArea, MT], other: Quantity[TemperatureDifference, MT] + ) -> Quantity[HeatTransferCoefficient, MT]: ... + @overload + def __truediv__( + self: Quantity[PowerPerArea, MT], other: Quantity[TemperatureDifference, float] + ) -> Quantity[HeatTransferCoefficient, MT]: ... + @overload + def __truediv__( + self: Quantity[PowerPerArea, float], other: Quantity[TemperatureDifference, MT_] + ) -> Quantity[HeatTransferCoefficient, MT_]: ... + + # ThermalConductivity / Length = HeatTransferCoefficient + @overload + def __truediv__( + self: Quantity[ThermalConductivity, MT], other: Quantity[Length, MT] + ) -> Quantity[HeatTransferCoefficient, MT]: ... + @overload + def __truediv__( + self: Quantity[ThermalConductivity, MT], other: Quantity[Length, float] + ) -> Quantity[HeatTransferCoefficient, MT]: ... + @overload + def __truediv__( + self: Quantity[ThermalConductivity, float], other: Quantity[Length, MT_] + ) -> Quantity[HeatTransferCoefficient, MT_]: ... + + # ThermalConductivity / HeatTransferCoefficient = Length + @overload + def __truediv__( + self: Quantity[ThermalConductivity, MT], other: Quantity[HeatTransferCoefficient, MT] + ) -> Quantity[Length, MT]: ... + @overload + def __truediv__( + self: Quantity[ThermalConductivity, MT], other: Quantity[HeatTransferCoefficient, float] + ) -> Quantity[Length, MT]: ... + @overload + def __truediv__( + self: Quantity[ThermalConductivity, float], other: Quantity[HeatTransferCoefficient, MT_] + ) -> Quantity[Length, MT_]: ... + @overload def __truediv__( self: Quantity[Dimensionless, MT], other: Quantity[DT_, float] @@ -3193,7 +3556,16 @@ def __getitem__(self: Quantity[DT, pl.Series], index: slice) -> Quantity[DT, pl. def __getitem__(self: Quantity[DT, Numpy1DArray], index: int) -> Quantity[DT, float]: ... @overload def __getitem__(self: Quantity[DT, Numpy1DArray], index: slice) -> Quantity[DT, Numpy1DArray]: ... - def __getitem__(self, index: int | slice) -> Quantity[Any, Any]: + # numpy fancy indexing: a boolean mask (the type the comparison operators return, so + # q[q > threshold] type-checks) or an integer index array/sequence. Not offered for a + # pl.Series magnitude: polars refuses a boolean-mask __getitem__ (use .m.filter(...)) + @overload + def __getitem__( + self: Quantity[DT, Numpy1DArray], index: Numpy1DBoolArray | Numpy1DIntArray | Sequence[int] + ) -> Quantity[DT, Numpy1DArray]: ... + def __getitem__( + self, index: int | slice | Numpy1DBoolArray | Numpy1DIntArray | Sequence[int] + ) -> Quantity[Any, Any]: ret = cast("Quantity[DT, Any]", self._pint_super.__getitem__(index)) magnitude_type = cast("type[Any]", type(ret.m)) diff --git a/encomp/utypes.py b/encomp/utypes.py index 859be6b..3a5de95 100644 --- a/encomp/utypes.py +++ b/encomp/utypes.py @@ -3,6 +3,14 @@ The dimensionalities defined in this module can be combined with ``*`` and ``/``. Some commonly used derived dimensionalities (like density) are defined for convenience. + +Most dimensionalities are backed by unit literals (see :py:func:`get_registered_units`) +and by ``*`` / ``/`` overloads on :py:class:`encomp.units.Quantity`, so they are inferred +statically. A few -- ``NormalTemperature``, ``MassPerEnergy``, ``PowerPerLength``, +``PowerPerVolume``, ``PowerPerTemperature`` and the ``HeatingValue`` family -- are +deliberately *vocabulary only*: they have no unit literals and no overloads, and exist so +user code can annotate quantities with them and reinterpret via +:py:meth:`encomp.units.Quantity.asdim`. """ from __future__ import annotations @@ -51,6 +59,7 @@ "wt-%", "weight%", "weight-%", + "ppm", "-", "dimensionless", ] @@ -279,6 +288,7 @@ "MPa", "mbar", "mmHg", + "torr", "psi", "atm", "N/m2", @@ -367,6 +377,23 @@ "g/l", "g/L", "gram/liter", + "g/cm3", + "g/cm**3", + "g/cm^3", + "g/cm³", +] + +MolarDensityUnits = Literal[ + "mol/m3", + "mol/m**3", + "mol/m^3", + "mol/m³", + "kmol/m3", + "kmol/m**3", + "kmol/m^3", + "kmol/m³", + "mol/l", + "mol/L", ] SpecificVolumeUnits = Literal[ @@ -386,6 +413,18 @@ "nm³/kg", ] +MassPerNormalVolumeUnits = Literal[ + "kg/Nm3", + "kg/Nm^3", + "kg/Nm³", + "kg/nm3", + "kg/nm^3", + "kg/nm³", + "g/Nm3", + "g/Nm^3", + "g/Nm³", +] + EnergyUnits = Literal[ "J", "kJ", @@ -461,6 +500,8 @@ "MWh/kg", "kJ/kg", "kWh/kg", + "J/kg", + "J/g", "MJ/t", "MWh/t", "kJ/t", @@ -471,6 +512,15 @@ "kWh/ton", ] +# CoolProp reports molar enthalpy/internal energy in J/mol +MolarSpecificEnthalpyUnits = Literal[ + "J/mol", + "kJ/mol", + "J/kmol", + "kJ/kmol", + "MJ/kmol", +] + SpecificHeatCapacityUnits = Literal[ "kJ/kg/K", "kJ/kg/delta_degC", @@ -504,6 +554,17 @@ "mW/m/K", ] +PowerPerAreaUnits = Literal[ + "W/m2", + "W/m^2", + "W/m**2", + "W/m²", + "kW/m2", + "kW/m^2", + "kW/m**2", + "kW/m²", +] + HeatTransferCoefficientUnits = Literal[ "W/m2/K", "W/m^2/K", @@ -549,15 +610,19 @@ | VolumeFlowUnits | NormalVolumeFlowUnits | DensityUnits + | MolarDensityUnits | SpecificVolumeUnits | NormalVolumePerMassUnits + | MassPerNormalVolumeUnits | EnergyUnits | PowerUnits + | PowerPerAreaUnits | VelocityUnits | ForceUnits | DynamicViscosityUnits | KinematicViscosityUnits | EnergyPerMassUnits + | MolarSpecificEnthalpyUnits | SpecificHeatCapacityUnits | ThermalConductivityUnits | HeatTransferCoefficientUnits @@ -706,6 +771,12 @@ def __init_subclass__(cls) -> None: @classmethod def get_dimensionality(cls, dimensions: UnitsContainer) -> type[Dimensionality]: + """Return the registered dimensionality for ``dimensions``. + + Unregistered dimensions get a new subclass named after them, for example + ``Dimensionality[[mass] ** 2 / [length] ** 3]``. + """ + if dimensions in cls._registry_reversed: return cls._registry_reversed[dimensions] @@ -726,6 +797,13 @@ def get_dimensionality(cls, dimensions: UnitsContainer) -> type[Dimensionality]: @classmethod def is_distinct(cls) -> bool: + """Whether this class is the one :meth:`get_dimensionality` returns for its dimensions. + + Unless ``_distinct`` is set explicitly, only the first registered subclass with a + given ``dimensions`` is distinct. The answer is registry-relative and can change + when a later subclass registers the same dimensions. + """ + if cls._distinct is None: # special case if dimensions was overridden to None if getattr(cls, "dimensions", None) is None: @@ -755,6 +833,9 @@ def is_distinct(cls) -> bool: Numpy1DArray = np.ndarray[tuple[int], np.dtype[np.float64]] Numpy1DBoolArray = np.ndarray[tuple[int], np.dtype[np.bool]] +# integer index array, as accepted by numpy "fancy" indexing +Numpy1DIntArray = np.ndarray[tuple[int], np.dtype[np.intp]] + MT = TypeVar( "MT", @@ -858,6 +939,7 @@ class Luminosity(Dimensionality): _MassPerNormalVolumeUC = _MassUC / _NormalVolumeUC _MassPerEnergyUC = _MassUC / _EnergyUC _MolarSpecificEntropyUC = _EnergyUC / _TemperatureUC / _SubstanceUC +_SurfaceTensionUC = _ForceUC / _LengthUC class Area(Dimensionality): @@ -1000,6 +1082,10 @@ class MassPerEnergy(Dimensionality): dimensions = _MassPerEnergyUC +class SurfaceTension(Dimensionality): + dimensions = _SurfaceTensionUC + + class MolarSpecificEntropy(Dimensionality): dimensions = _MolarSpecificEntropyUC @@ -1053,6 +1139,12 @@ class MolarSpecificInternalEnergy(Dimensionality): dimensions = _EnergyUC / _SubstanceUC +# shares its dimensions with the (earlier, and therefore distinct) MolarSpecificEntropy, +# so a unit like J/mol/K still resolves to MolarSpecificEntropy at runtime +class MolarSpecificHeatCapacity(Dimensionality): + dimensions = _MolarSpecificEntropyUC + + class SpecificHeatCapacity(Dimensionality): _distinct = True dimensions = _EnergyUC / _MassUC / _TemperatureUC @@ -1157,6 +1249,7 @@ class MixtureVolumePerHumidAir(IndistinctDimensionality): "MassFlowUnits", "MassPerEnergy", "MassPerNormalVolume", + "MassPerNormalVolumeUnits", "MassUnits", "MixtureEnthalpyPerDryAir", "MixtureEnthalpyPerHumidAir", @@ -1165,10 +1258,13 @@ class MixtureVolumePerHumidAir(IndistinctDimensionality): "MixtureVolumePerDryAir", "MixtureVolumePerHumidAir", "MolarDensity", + "MolarDensityUnits", "MolarMass", "MolarMassUnits", "MolarSpecificEnthalpy", + "MolarSpecificEnthalpyUnits", "MolarSpecificEntropy", + "MolarSpecificHeatCapacity", "MolarSpecificInternalEnergy", "Normal", "NormalTemperature", @@ -1180,8 +1276,10 @@ class MixtureVolumePerHumidAir(IndistinctDimensionality): "NormalVolumeUnits", "Numpy1DArray", "Numpy1DBoolArray", + "Numpy1DIntArray", "Power", "PowerPerArea", + "PowerPerAreaUnits", "PowerPerLength", "PowerPerTemperature", "PowerPerVolume", @@ -1201,6 +1299,7 @@ class MixtureVolumePerHumidAir(IndistinctDimensionality): "SubstancePerMass", "SubstancePerMassUnits", "SubstanceUnits", + "SurfaceTension", "Temperature", "TemperatureDifference", "TemperatureDifferenceUnits", diff --git a/pyproject.toml b/pyproject.toml index 0d96e90..0363b84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "encomp" -version = "1.6.3" +version = "1.6.4" description = "General-purpose library for engineering computations" authors = [{ name = "William Laurén", email = "lauren.william.a@gmail.com" }] requires-python = ">=3.13" diff --git a/uv.lock b/uv.lock index 860cde3..d6f5529 100644 --- a/uv.lock +++ b/uv.lock @@ -514,7 +514,7 @@ wheels = [ [[package]] name = "encomp" -version = "1.6.3" +version = "1.6.4" source = { editable = "." } dependencies = [ { name = "coolprop" },