Skip to content
Merged

Dev #42

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,50 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.22] - 2026-08-18

### Fixed
- **`MMGAL_TO_GAL` / `GAL_TO_MMGAL` were swapped.** 1 MMgal = 1e6 gal, so
`MMGAL_TO_GAL` is now `1e6` and `GAL_TO_MMGAL` is `1e-6` — each value was off
by a factor of 1e12. Downstream `steer-ccus-tea` multiplies ethanol plant
capacities by `Units.MMGAL_TO_GAL`, so those capacities were twelve orders of
magnitude too small.
- `__version__` had regressed to `0.2.18` in a merge, so the 0.2.20 and 0.2.21
bumps never reached the published metadata. This release carries the 0.2.20
performance work and the 0.2.21 deserialization hardening below.

### Changed
- `msgpack` pin bumped from 1.1.1 to 1.2.1.

## [0.2.21] - 2026-08-17

### Security
- **`SerializerMixin.deserialize` no longer resolves arbitrary class paths.**
Deserialization read `_class` / `__enum__.class` strings out of the payload and
passed them to `importlib.import_module` + `getattr`; the `__enum__` branch then
*called* the result with a payload-supplied argument, so a crafted file could
reach any importable callable (e.g.
`{"__enum__": true, "class": "os.system", "value": "..."}`). Class resolution is
now gated by an allowlist of top-level packages (`steer_core`,
`steer_materials`, `steer_opencell_design`) and the resolved object must be a
class of the expected kind — a `SerializerMixin` subclass for objects, an `Enum`
subclass for enums. Rejections raise the new `UnsafeClassPathError` (a
`ValueError` subclass, so existing `except ValueError` callers are unaffected).
Anything that loads a `.ocd` file from an untrusted source (uploads in the
OpenCell apps) should take this release.
- Added `allow_class_roots(*roots)` for downstream packages that serialize their
own `SerializerMixin` subclasses and need their root permitted.

## [0.2.20] - 2026-08-10

### Changed
- `CoordinateMixin.get_radius_of_points` is ~10-25x faster on dense point
clouds: inputs above 4096 points are reduced to their outer rim (max-radius
candidates per angular bin around the centroid) before the shapely
minimum-bounding-circle call, and the redundant `pd.isna` scan on float
arrays was removed. Results are identical to sub-micrometre precision for
star-shaped clouds such as jelly-roll cross-sections.

## [0.2.18] - 2026-06-10

### Changed
Expand Down
49 changes: 49 additions & 0 deletions steer_core/Constants/Periodic_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# SPDX-FileCopyrightText: 2024-2026 Stanford University
# SPDX-License-Identifier: AGPL-3.0-or-later

atomic_numbers_to_symbols = {
1: "H", 2: "He", 3: "Li", 4: "Be", 5: "B", 6: "C", 7: "N", 8: "O", 9: "F", 10: "Ne",
11: "Na", 12: "Mg", 13: "Al", 14: "Si", 15: "P", 16: "S", 17: "Cl", 18: "Ar", 19: "K", 20: "Ca",
21: "Sc", 22: "Ti", 23: "V", 24: "Cr", 25: "Mn", 26: "Fe", 27: "Co", 28: "Ni", 29: "Cu", 30: "Zn",
31: "Ga", 32: "Ge", 33: "As", 34: "Se", 35: "Br", 36: "Kr", 37: "Rb", 38: "Sr", 39: "Y", 40: "Zr",
41: "Nb", 42: "Mo", 43: "Tc", 44: "Ru", 45: "Rh", 46: "Pd", 47: "Ag", 48: "Cd", 49: "In", 50: "Sn",
51: "Sb", 52: "Te", 53: "I", 54: "Xe", 55: "Cs", 56: "Ba",
57: "La", 58: "Ce", 59: "Pr", 60: "Nd", 61: "Pm", 62: "Sm", 63: "Eu", 64: "Gd", 65: "Tb", 66: "Dy",
67: "Ho", 68: "Er", 69: "Tm", 70: "Yb", 71: "Lu",
72: "Hf", 73: "Ta", 74: "W", 75: "Re", 76: "Os", 77: "Ir", 78: "Pt", 79: "Au", 80: "Hg",
81: "Tl", 82: "Pb", 83: "Bi", 84: "Po", 85: "At", 86: "Rn", 87: "Fr", 88: "Ra",
89: "Ac", 90: "Th", 91: "Pa", 92: "U", 93: "Np", 94: "Pu",
95: "Am", 96: "Cm", 97: "Bk", 98: "Cf", 99: "Es", 100: "Fm", 101: "Md", 102: "No", 103: "Lr",
104: "Rf", 105: "Db", 106: "Sg", 107: "Bh", 108: "Hs", 109: "Mt", 110: "Ds", 111: "Rg", 112: "Cn", 113: "Nh",
114: "Fl", 115: "Mc", 116: "Lv",
117: "Ts", 118: "Og"
}

atomic_numbers_to_names = {
1: "Hydrogen", 2: "Helium", 3: "Lithium", 4: "Beryllium", 5: "Boron", 6: "Carbon", 7: "Nitrogen", 8: "Oxygen",
9: "Fluorine", 10: "Neon", 11: "Sodium", 12: "Magnesium", 13: "Aluminum", 14: "Silicon", 15: "Phosphorus", 16: "Sulfur", 17: "Chlorine", 18: "Argon", 19: "Potassium", 20: "Calcium",
21: "Scandium", 22: "Titanium", 23: "Vanadium", 24: "Chromium", 25: "Manganese", 26: "Iron", 27: "Cobalt", 28: "Nickel", 29: "Copper", 30: "Zinc",
31: "Gallium", 32: "Germanium", 33: "Arsenic", 34: "Selenium", 35: "Bromine", 36: "Krypton", 37: "Rubidium", 38: "Strontium", 39: "Yttrium", 40: "Zirconium",
41: "Niobium", 42: "Molybdenum", 43: "Technetium", 44: "Ruthenium", 45: "Rhodium", 46: "Palladium", 47: "Silver", 48: "Cadmium", 49: "Indium", 50: "Tin",
51: "Antimony", 52: "Tellurium", 53: "Iodine", 54: "Xenon", 55: "Cesium", 56: "Barium",
57: "Lanthanum", 58: "Cerium", 59: "Praseodymium", 60: "Neodymium", 61: "Promethium", 62: "Samarium", 63: "Europium", 64: "Gadolinium", 65: "Terbium", 66: "Dysprosium", 67: "Holmium", 68: "Erbium", 69: "Thulium", 70: "Ytterbium",
71: "Lutetium", 72: "Hafnium", 73: "Tantalum", 74: "Tungsten", 75: "Rhenium", 76: "Osmium", 77: "Iridium", 78: "Platinum", 79: "Gold", 80: "Mercury",
81: "Thallium", 82: "Lead", 83: "Bismuth", 84: "Polonium", 85: "Astatine", 86: "Radon", 87: "Francium", 88: "Radium",
89: "Actinium", 90: "Thorium", 91: "Protactinium", 92: "Uranium", 93: "Neptunium", 94: "Plutonium",
95: "Americium", 96: "Curium", 97: "Berkelium", 98: "Californium", 99: "Einsteinium", 100: "Fermium", 101: "Mendelevium", 102: "Nobelium", 103: "Lawrencium",
104: "Rutherfordium", 105: "Dubnium", 106: "Seaborgium", 107: "Bohrium", 108: "Hassium", 109: "Meitnerium", 110: "Darmstadtium", 111: "Roentgenium", 112: "Copernicium", 113: "Nihonium",
114: "Flerovium", 115: "Moscovium", 116: "Livermorium",
117: "Tennessine", 118: "Oganesson"
}

atomic_numbers_to_masses = {
1: 1.008, 2: 4.003, 3: 6.940, 4: 9.012, 5: 10.810, 6: 12.011, 7: 14.007, 8: 15.999, 9: 18.998, 10: 20.180, 11: 22.990, 12: 24.305, 13: 26.982, 14: 28.085, 15: 30.974, 16: 32.060, 17: 35.450, 18: 39.948,
19: 39.098, 20: 40.078, 21: 44.956, 22: 47.867, 23: 50.942, 24: 51.996, 25: 54.938, 26: 55.845, 27: 58.933, 28: 58.693, 29: 63.546, 30: 65.380,
31: 69.723, 32: 72.630, 33: 74.922, 34: 78.971, 35: 79.904, 36: 83.798, 37: 85.468, 38: 87.620, 39: 88.906, 40: 91.224, 41: 92.906, 42: 95.950, 43: 98.000, 44: 101.070, 45: 102.906, 46: 106.420, 47: 107.868, 48: 112.414, 49: 114.818,
50: 118.710, 51: 121.760, 52: 127.600, 53: 126.904, 54: 131.293, 55: 132.905, 56: 137.327, 57: 138.905, 58: 140.116, 59: 140.908, 60: 144.242, 61: 145.000, 62: 150.360, 63: 151.964, 64: 157.250, 65: 158.925, 66: 162.500, 67: 164.930, 68: 167.259,
69: 168.934, 70: 173.040, 71: 174.967, 72: 178.490, 73: 180.948, 74: 183.840, 75: 186.207, 76: 190.230, 77: 192.217, 78: 195.084, 79: 196.967, 80: 200.592, 81: 204.380, 82: 207.200, 83: 208.980, 84: 209.000, 85: 210.000, 86: 222.000, 87: 223.000, 88: 226.000,
89: 227.000, 90: 232.038, 91: 231.036, 92: 238.029, 93: 237.000, 94: 244.000, 95: 243.000, 96: 247.000, 97: 247.000, 98: 251.000, 99: 252.000, 100: 257.000, 101: 258.000, 102: 259.000, 103: 262.000, 104: 267.000, 105: 270.000, 106: 271.000, 107: 270.000, 108: 277.000, 109: 276.000, 110: 281.000, 111: 282.000, 112: 285.000, 113: 286.000, 114: 289.000,
115: 288.000, 116: 293.000, 117: 294.000, 118: 294.000
}


6 changes: 5 additions & 1 deletion steer_core/Constants/Units.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,9 @@




# =============================================================================
# ATOMIC UNITS
# =============================================================================
AU_TO_KG = 1.66053906660e-27 # Atomic mass
KG_TO_AU = 1 / AU_TO_KG

52 changes: 47 additions & 5 deletions steer_core/Mixins/Coordinates.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,32 @@ class CoordinateMixin:
'xz': (0, 2),
'yz': (1, 2),
}
# Above this point count, prefilter to the outer rim before the (expensive)
# shapely minimum_bounding_circle call. See ``_outer_rim_candidates``.
_MBC_PREFILTER_THRESHOLD = 4096
_MBC_PREFILTER_BINS = 2048

@staticmethod
def _outer_rim_candidates(points: np.ndarray, n_bins: int) -> np.ndarray:
"""Reduce a dense point cloud to its outer rim for bounding-circle work.

Bins points by angle around the centroid and keeps, per bin, the
points at maximal distance from the centroid. For star-shaped clouds
(spiral / racetrack cross-sections) this retains all convex-hull
vertices up to a sub-micrometre discretization error, while cutting
the point count from tens of thousands to at most ``n_bins``.
"""
center = points.mean(axis=0)
d = points - center
r2 = d[:, 0] ** 2 + d[:, 1] ** 2
angles = np.arctan2(d[:, 1], d[:, 0])
bins = ((angles + np.pi) * (n_bins / (2.0 * np.pi))).astype(np.intp)
np.clip(bins, 0, n_bins - 1, out=bins)
best_r2 = np.full(n_bins, -1.0)
np.maximum.at(best_r2, bins, r2)
keep = r2 >= best_r2[bins] - 1e-30
return points[keep]

@staticmethod
def get_radius_of_points(coords: np.ndarray) -> Tuple[float, Tuple[float, float]]:
"""Calculate the radius of a spiral given its coordinates.
Expand All @@ -32,13 +58,29 @@ def get_radius_of_points(coords: np.ndarray) -> Tuple[float, Tuple[float, float]
Raises:
ValueError: If input coordinates are invalid or insufficient valid points.
"""
# Filter out rows with NaN or None values
valid_mask = ~(np.isnan(coords).any(axis=1) | pd.isna(coords).any(axis=1))
valid_coords = coords[valid_mask]

coords_arr = np.asarray(coords)
if coords_arr.dtype == object:
# Rare path: object arrays may contain ``None``; normalize to NaN.
coords_arr = np.where(pd.isna(coords_arr), np.nan, coords_arr).astype(
np.float64
)

valid_mask = ~np.isnan(coords_arr).any(axis=1)
valid_coords = coords_arr[valid_mask]

if len(valid_coords) < 3:
raise ValueError(f"Insufficient valid coordinates for polygon creation. Need at least 3, got {len(valid_coords)}")


# The minimum bounding circle depends only on the outermost points, so
# a dense cloud can be reduced to its outer rim first. This turns the
# shapely call from O(N) on tens of thousands of points into O(bins).
if len(valid_coords) > CoordinateMixin._MBC_PREFILTER_THRESHOLD:
rim = CoordinateMixin._outer_rim_candidates(
valid_coords, CoordinateMixin._MBC_PREFILTER_BINS
)
if len(rim) >= 3:
valid_coords = rim

polygon = Polygon(valid_coords)
circle = minimum_bounding_circle(polygon)
center = circle.centroid
Expand Down
111 changes: 101 additions & 10 deletions steer_core/Mixins/Serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,110 @@
# Patch msgpack for numpy support once at module import
m.patch()

# Module-level cache for imported modules (avoids repeated importlib calls)
# Module-level cache for imported modules (avoids repeated importlib calls).
# Only ever holds paths that passed _get_class's allowlist + type checks, so a
# cache hit is safe to return without re-validating.
_module_cache: dict[str, type] = {}

T = TypeVar('T', bound='SerializerMixin')


class UnsafeClassPathError(ValueError):
"""A serialized payload named a class that may not be reconstructed.

Deserialization resolves class paths taken *from the payload*, so an
untrusted file would otherwise choose which module gets imported and which
callable gets invoked. Raised as a ``ValueError`` subclass so existing
callers that catch ``ValueError`` around ``deserialize`` keep working.
"""


# Top-level packages whose classes may be reconstructed from a serialized
# payload. Deserialization is a code-execution boundary: ``_get_class`` imports
# the named module and the ``__enum__`` branch *calls* the resolved object with a
# value from the payload, so without this gate a crafted file could reach any
# importable callable (e.g. ``os.system``). Every class in every cell shipped in
# the OpenCell database resolves under ``steer_opencell_design``; the sibling
# roots are listed because steer-core is shared across the STEER packages.
_ALLOWED_CLASS_ROOTS: set[str] = {
'steer_core',
'steer_materials',
'steer_opencell_design',
}


def allow_class_roots(*roots: str) -> None:
"""Permit ``roots`` as top-level packages in serialized class paths.

For downstream packages that define their own ``SerializerMixin`` subclasses
and need them to survive a round trip. Keep the set as small as possible —
every added root widens the deserialization trust boundary.
"""
_ALLOWED_CLASS_ROOTS.update(roots)


def _get_class(class_path: str) -> type:
"""Get class from module path with caching."""
if class_path not in _module_cache:
module_name, class_name = class_path.rsplit('.', 1)
module = importlib.import_module(module_name)
_module_cache[class_path] = getattr(module, class_name)
return _module_cache[class_path]
"""Resolve an allowlisted class from a serialized module path, with caching.

Raises:
UnsafeClassPathError: If ``class_path`` is malformed, rooted outside
:data:`_ALLOWED_CLASS_ROOTS`, or does not name a class.
"""
cached = _module_cache.get(class_path)
if cached is not None:
return cached

if not isinstance(class_path, str) or '.' not in class_path:
raise UnsafeClassPathError(
f"Serialized data named a malformed class path: {class_path!r}"
)

root = class_path.split('.', 1)[0]
if root not in _ALLOWED_CLASS_ROOTS:
raise UnsafeClassPathError(
f"Serialized data named a class outside the allowed packages: "
f"{class_path!r} (allowed roots: {sorted(_ALLOWED_CLASS_ROOTS)})"
)

module_name, class_name = class_path.rsplit('.', 1)
module = importlib.import_module(module_name)
obj = getattr(module, class_name, None)
if not isinstance(obj, type):
# Guards against a path that resolves to a plain function or any other
# callable — only classes are ever serialized.
raise UnsafeClassPathError(
f"Serialized data named {class_path!r}, which is not a class."
)

_module_cache[class_path] = obj
return obj


def _get_serializable_class(class_path: str) -> type:
"""Resolve a class path that must name a :class:`SerializerMixin` subclass."""
obj = _get_class(class_path)
if not issubclass(obj, SerializerMixin):
raise UnsafeClassPathError(
f"Serialized data named {class_path!r} as an object, but it is not a "
"SerializerMixin subclass."
)
return obj


def _get_enum_class(class_path: str) -> type:
"""Resolve a class path that must name an :class:`~enum.Enum` subclass.

The ``__enum__`` branch calls the resolved class with a value from the
payload, so restricting it to real enums is what makes that call safe:
``SomeEnum(value)`` is a member lookup, not arbitrary construction.
"""
obj = _get_class(class_path)
if not issubclass(obj, Enum):
raise UnsafeClassPathError(
f"Serialized data named {class_path!r} as an enum, but it is not an "
"Enum subclass."
)
return obj


class SerializerMixin:
Expand Down Expand Up @@ -240,7 +331,7 @@ def deserialize(cls: type[T], data: bytes) -> T:

# Use stored class information if available
if '_class' in obj_dict:
actual_cls = _get_class(obj_dict['_class'])
actual_cls = _get_serializable_class(obj_dict['_class'])
# Remove class marker before reconstructing
obj_data = {k: v for k, v in obj_dict.items() if k != '_class'}
return actual_cls._from_dict(obj_data)
Expand All @@ -267,14 +358,14 @@ def _deserialize_value(cls, value: Any) -> Any:
# Check special markers in priority order (most common first)
if '__object__' in value:
# Reconstruct regular object using cached class lookup
obj_class = _get_class(value['_class'])
obj_class = _get_serializable_class(value['_class'])
obj_data = {k: v for k, v in value.items() if k not in ('__object__', '_class')}
return obj_class._from_dict(obj_data)
elif '__datetime__' in value:
return datetime.fromisoformat(value['__datetime__'])
elif '__enum__' in value:
# Reconstruct enum using cached class lookup
enum_class = _get_class(value['class'])
enum_class = _get_enum_class(value['class'])
return enum_class(value['value'])
elif '__tuple__' in value:
# Recursively reconstruct tuple items
Expand Down
2 changes: 1 addition & 1 deletion steer_core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2024-2026 Stanford University
# SPDX-License-Identifier: AGPL-3.0-or-later

__version__ = "0.2.19"
__version__ = "0.2.22"

from .Mixins.Colors import ColorMixin
from .Mixins.Coordinates import CoordinateMixin
Expand Down
Loading
Loading