From 50d6c339e5b18555cea60228e5fee390261b845f Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Mon, 9 Feb 2026 09:08:27 +0100 Subject: [PATCH 1/4] Introduce simcoon.ashby package and dataset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the old monolithic ashby.py script with a new simcoon.ashby package. Adds datamodel (Material, MaterialCollection, SymmetryType), data loaders (load_builtin, load_csv), a Materials Project database fetcher (mp-api optional), and a bridge to simcoon for stiffness/compliance/solver props. Plotting helpers are re-exported for backward compatibility and a bundled materials.json (~60 curated materials) is included. Includes sensible unit conversions (GPa→MPa, density units) and explicit NotImplemented/validation for more complex symmetries (transverse-isotropic, orthotropic) where full tensors or extra inputs are required. --- python-setup/simcoon/ashby.py | 141 ---- python-setup/simcoon/ashby/__init__.py | 64 ++ python-setup/simcoon/ashby/bridge.py | 169 +++++ python-setup/simcoon/ashby/data.py | 69 ++ python-setup/simcoon/ashby/database.py | 174 +++++ python-setup/simcoon/ashby/material.py | 228 ++++++ python-setup/simcoon/ashby/materials.json | 848 ++++++++++++++++++++++ python-setup/simcoon/ashby/plotting.py | 435 +++++++++++ 8 files changed, 1987 insertions(+), 141 deletions(-) delete mode 100755 python-setup/simcoon/ashby.py create mode 100644 python-setup/simcoon/ashby/__init__.py create mode 100644 python-setup/simcoon/ashby/bridge.py create mode 100644 python-setup/simcoon/ashby/data.py create mode 100644 python-setup/simcoon/ashby/database.py create mode 100644 python-setup/simcoon/ashby/material.py create mode 100644 python-setup/simcoon/ashby/materials.json create mode 100644 python-setup/simcoon/ashby/plotting.py diff --git a/python-setup/simcoon/ashby.py b/python-setup/simcoon/ashby.py deleted file mode 100755 index afecaac65..000000000 --- a/python-setup/simcoon/ashby.py +++ /dev/null @@ -1,141 +0,0 @@ -# -*- coding: utf-8 -*- - -import numpy as np -from scipy.spatial import ConvexHull -import matplotlib.pyplot as plt -from matplotlib.path import Path -import matplotlib.patches as patches -from matplotlib import rcParams -import math as m - -def unit_vector(pt_a, pt_b): - b_a = [pt_b[0]-pt_a[0],pt_b[1]-pt_a[1]] - distance = m.sqrt(b_a[0]**2+b_a[1]**2) - return np.array([b_a[0]*(1.0/distance),b_a[1]*(1.0/distance)]) - -def length(pt_a, pt_b): - b_a = [pt_b[0]-pt_a[0],pt_b[1]-pt_a[1]] - distance = m.sqrt(b_a[0]**2+b_a[1]**2) - return distance - -def uv_2(uv1, uv2): - b_a = [uv1[0]-uv2[0],uv1[1]-uv2[1]] - distance = m.sqrt(b_a[0]**2+b_a[1]**2) - return np.array([b_a[0]*(1.0/distance),b_a[1]*(1.0/distance)]) - -def poly_convexHull(points, color, coef_multi=0.1, rad=0.3, lw=2): - """ - Plot the convex hull around a set of points as a - shaded polygon. - """ - pt_env = points - for i in range(0,len(points)): - pt_env = np.append(pt_env, points[i] + (2.0*coef_multi*np.random.rand(10,2)-coef_multi)*points[i], axis=0) - - hull_env = ConvexHull(pt_env) - hull_indices_env = hull_env.vertices - - u_v = np.zeros((len(hull_indices_env),2)) - - verts = np.zeros((len(hull_indices_env),2)) - dist = np.zeros((len(hull_indices_env),2)) - - for i in range(0,len(hull_indices_env)): - verts[i] = pt_env[hull_indices_env[i]] - u_v[i] = unit_vector(pt_env[hull_indices_env[i-1]], pt_env[hull_indices_env[i]]) - dist[i] = length(pt_env[hull_indices_env[i-1]], pt_env[hull_indices_env[i]]) - - verts2 = np.zeros((3*len(verts)+1,2)) - for i in range(0,len(hull_indices_env)-1): - verts2[i*3] = verts[i] - u_v[i]*dist[i]*rad - verts2[i*3+1] = verts[i] - verts2[i*3+2] = verts[i] + u_v[i+1]*dist[i+1]*rad - verts2[-4] = verts[-1] - u_v[-1]*dist[-1]*rad - verts2[-3] = verts[-1] - verts2[-2] = verts[-1] + u_v[0]*dist[0]*rad - verts2[-1] = verts2[0] - -# for pt in pt_env: -# plt.plot(pt[0], pt[1], 'ko') - -# for pt in points: -# plt.plot(pt[0], pt[1], 'ro') - - codes = [Path.MOVETO,] - for j in range(len(verts)): - codes.extend([Path.CURVE3, Path.CURVE3, Path.LINETO,]) - #codes.append(Path.CURVE3) - - path = Path(verts2, codes) - patch = patches.PathPatch(path, facecolor=color, lw=0, alpha=0.2) - edge = patches.PathPatch(path, edgecolor=color, facecolor='none', lw=lw) - plt.gca().add_patch(patch) - plt.gca().add_patch(edge) - - -def poly_enclose(points, color, inc=1.2, rad=0.3, lw=2): - """ - Plot the convex hull around a set of points as a - shaded polygon. - """ - hull = ConvexHull(points) - - - cent = np.mean(points, 0) - pts = [] - for pt in points[hull.vertices]: - pts.append(pt.tolist()) - # pts.append(pt.tolist()) - - # pts.sort(key=lambda p: np.arctan2(p[1] - cent[1], - # p[0] - cent[0])) - # pts = pts[0::2] # Deleting duplicates - pts.insert(len(pts), pts[0]) - - - verts = inc*(np.array(pts)- cent) + cent - verts2 = np.zeros((3*verts.shape[0]-2,2)) - verts2[0::3] = verts - verts2[1::3,:] = (1-rad)*verts[0:-1,:] + rad*verts[1:,:] - verts2[2::3,:] = rad*verts[0:-1,:] + (1-rad)*verts[1:,:] - verts2[0:-1] = verts2[1:] - verts2[-1] = verts2[0] - - codes = [Path.MOVETO, Path.LINETO, Path.CURVE3,] - for j in range(len(pts)-2): - codes.extend([Path.CURVE3, Path.LINETO, Path.CURVE3,]) - codes.append(Path.CURVE3) - - - path = Path(verts2, codes) - patch = patches.PathPatch(path, facecolor=color, lw=0, alpha=0.2) - edge = patches.PathPatch(path, edgecolor=color, facecolor='none', lw=lw) - plt.gca().add_patch(patch) - plt.gca().add_patch(edge) - -def ellip_enclose(points, color, inc=1.2, lw=2, nst=2): - """ - Plot the minimum ellipse around a set of points. - - Based on: - https://github.com/joferkington/oost_paper_code/blob/master/error_ellipse.py - """ - - def eigsorted(cov): - vals, vecs = np.linalg.eigh(cov) - order = vals.argsort()[::-1] - return vals[order], vecs[:,order] - - x = points[:,0] - y = points[:,1] - cov = np.cov(x, y) - vals, vecs = eigsorted(cov) - theta = np.degrees(np.arctan2(*vecs[:,0][::-1])) - w, h = 2 * nst * np.sqrt(vals) - center = np.mean(points, 0) - ell = patches.Ellipse(center, width=inc*w, height=inc*h, angle=theta, - facecolor=color, alpha=0.2, lw=0) - edge = patches.Ellipse(center, width=inc*w, height=inc*h, angle=theta, - facecolor='none', edgecolor=color, lw=lw) - plt.gca().add_artist(ell) - plt.gca().add_artist(edge) diff --git a/python-setup/simcoon/ashby/__init__.py b/python-setup/simcoon/ashby/__init__.py new file mode 100644 index 000000000..8858cb20c --- /dev/null +++ b/python-setup/simcoon/ashby/__init__.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- + +"""simcoon.ashby — Material property charts, databases, and simcoon bridge. + +Quick start:: + + from simcoon.ashby import Material, load_builtin, ashby_plot + + mats = load_builtin() # ~60 curated engineering materials + ashby_plot(mats, "density", "E") # classic E-vs-density Ashby chart +""" + +from __future__ import annotations + +# ------------------------------------------------------------------ +# Core (no optional deps) +# ------------------------------------------------------------------ +from simcoon.ashby.material import Material, MaterialCollection, SymmetryType +from simcoon.ashby.data import load_builtin, load_csv +from simcoon.ashby.bridge import to_stiffness, to_compliance, to_solver_props + +# ------------------------------------------------------------------ +# Plotting (requires matplotlib; import errors deferred to call time) +# ------------------------------------------------------------------ +from simcoon.ashby.plotting import ( + ashby_plot, + # Legacy re-exports (backward compatibility) + unit_vector, + length, + uv_2, + poly_convexHull, + poly_enclose, + ellip_enclose, +) + +# ------------------------------------------------------------------ +# Database access (mp-api lazy — only imported when called) +# ------------------------------------------------------------------ +from simcoon.ashby.database import fetch_materials + +__all__ = [ + # Data model + "Material", + "MaterialCollection", + "SymmetryType", + # Dataset loaders + "load_builtin", + "load_csv", + # Plotting + "ashby_plot", + # Bridge to simcoon + "to_stiffness", + "to_compliance", + "to_solver_props", + # Database + "fetch_materials", + # Legacy backward-compat + "unit_vector", + "length", + "uv_2", + "poly_convexHull", + "poly_enclose", + "ellip_enclose", +] diff --git a/python-setup/simcoon/ashby/bridge.py b/python-setup/simcoon/ashby/bridge.py new file mode 100644 index 000000000..f354f4716 --- /dev/null +++ b/python-setup/simcoon/ashby/bridge.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- + +"""Bridge between Ashby material data and simcoon stiffness/compliance tensors.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from simcoon.ashby.material import Material, SymmetryType + + +def to_stiffness( + material: Material, + convention: str = "Enu", + unit_factor: float = 1e3, +) -> np.ndarray: + """Convert a :class:`Material` to a 6x6 stiffness tensor. + + Parameters + ---------- + material : Material + The material to convert. + convention : str + Convention string passed to simcoon's ``L_iso`` / ``L_cubic`` etc. + Default ``"Enu"`` (Young's modulus + Poisson's ratio). + unit_factor : float + Multiplier applied to moduli. Default ``1e3`` converts GPa + (database convention) to MPa (simcoon convention). + + Returns + ------- + numpy.ndarray + 6x6 stiffness matrix (Voigt notation). + """ + import simcoon as sim + + # If a full elastic tensor is provided, just scale and return it + if material.elastic_tensor is not None: + return np.array(material.elastic_tensor) * unit_factor + + sym = material.symmetry + + if sym == SymmetryType.ISOTROPIC: + if material.E is None or material.nu is None: + raise ValueError( + f"Material '{material.name}' lacks E and nu for isotropic stiffness." + ) + props = [material.E * unit_factor, material.nu] + return np.array(sim.L_iso(props, convention)) + + if sym == SymmetryType.CUBIC: + if material.E is None or material.nu is None or material.G is None: + raise ValueError( + f"Material '{material.name}' lacks E, nu, G for cubic stiffness." + ) + props = [material.E * unit_factor, material.nu, material.G * unit_factor] + return np.array(sim.L_cubic(props, "EnuG")) + + if sym == SymmetryType.TRANSVERSE_ISOTROPIC: + raise NotImplementedError( + "Transversely isotropic materials require directional properties " + "(EL, ET, nuTL, nuTT, GLT) which are not stored in the current " + "Material dataclass. Provide a full elastic_tensor instead." + ) + + if sym == SymmetryType.ORTHOTROPIC: + raise NotImplementedError( + "Orthotropic materials require 9 independent constants which are " + "not stored in the current Material dataclass. Provide a full " + "elastic_tensor instead." + ) + + raise ValueError(f"Unsupported symmetry type: {sym}") + + +def to_compliance( + material: Material, + convention: str = "Enu", + unit_factor: float = 1e3, +) -> np.ndarray: + """Convert a :class:`Material` to a 6x6 compliance tensor. + + Parameters follow the same conventions as :func:`to_stiffness`. + + Returns + ------- + numpy.ndarray + 6x6 compliance matrix (Voigt notation). + """ + import simcoon as sim + + if material.elastic_tensor is not None: + return np.linalg.inv(np.array(material.elastic_tensor) * unit_factor) + + sym = material.symmetry + + if sym == SymmetryType.ISOTROPIC: + if material.E is None or material.nu is None: + raise ValueError( + f"Material '{material.name}' lacks E and nu for isotropic compliance." + ) + props = [material.E * unit_factor, material.nu] + return np.array(sim.M_iso(props, convention)) + + if sym == SymmetryType.CUBIC: + if material.E is None or material.nu is None or material.G is None: + raise ValueError( + f"Material '{material.name}' lacks E, nu, G for cubic compliance." + ) + props = [material.E * unit_factor, material.nu, material.G * unit_factor] + return np.array(sim.M_cubic(props, "EnuG")) + + if sym == SymmetryType.TRANSVERSE_ISOTROPIC: + raise NotImplementedError( + "Transversely isotropic materials require directional properties." + ) + + if sym == SymmetryType.ORTHOTROPIC: + raise NotImplementedError( + "Orthotropic materials require 9 independent constants." + ) + + raise ValueError(f"Unsupported symmetry type: {sym}") + + +def to_solver_props(material: Material) -> dict[str, Any]: + """Build a dict with ``umat_name`` and ``props`` for ``sim.solver()``. + + The returned dict contains: + + - ``umat_name`` — simcoon constitutive-model name (e.g. ``"ELISO"``) + - ``props`` — numpy array of material properties in simcoon order + - ``nstatev`` — suggested number of state variables (1 for elastic) + + Properties are in MPa (converted from GPa via ×1000). + """ + sym = material.symmetry + uf = 1e3 # GPa → MPa + + if sym == SymmetryType.ISOTROPIC: + if material.E is None or material.nu is None: + raise ValueError( + f"Material '{material.name}' lacks E and nu." + ) + return { + "umat_name": "ELISO", + "props": np.array([material.E * uf, material.nu]), + "nstatev": 1, + } + + if sym == SymmetryType.CUBIC: + if material.E is None or material.nu is None or material.G is None: + raise ValueError( + f"Material '{material.name}' lacks E, nu, G." + ) + return { + "umat_name": "ELCUB", + "props": np.array([ + material.E * uf, material.nu, material.G * uf + ]), + "nstatev": 1, + } + + raise NotImplementedError( + f"to_solver_props not implemented for symmetry {sym}. " + "Provide the solver inputs manually." + ) diff --git a/python-setup/simcoon/ashby/data.py b/python-setup/simcoon/ashby/data.py new file mode 100644 index 000000000..d55169c39 --- /dev/null +++ b/python-setup/simcoon/ashby/data.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- + +"""Built-in dataset loaders for the simcoon Ashby module.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from simcoon.ashby.material import Material, MaterialCollection + + +def load_builtin() -> MaterialCollection: + """Load the bundled curated material dataset. + + Returns a :class:`MaterialCollection` of ~60 common engineering + materials. Requires only numpy (no optional dependencies). + """ + import importlib.resources as _res + + ref = _res.files("simcoon.ashby").joinpath("materials.json") + text = ref.read_text(encoding="utf-8") + entries: list[dict[str, Any]] = json.loads(text) + return MaterialCollection([Material.from_dict(e) for e in entries]) + + +def load_csv( + path: str | Path, + column_mapping: dict[str, str] | None = None, +) -> MaterialCollection: + """Load materials from a user-supplied CSV file. + + Parameters + ---------- + path : str or Path + Path to the CSV file. The first row must be a header. + column_mapping : dict, optional + Map CSV column names → :class:`Material` field names. Any CSV + column whose header (after mapping) matches a Material field is + used; other columns are ignored. + + Returns + ------- + MaterialCollection + """ + import csv + + path = Path(path) + mapping = column_mapping or {} + valid_fields = {f.name for f in Material.__dataclass_fields__.values()} + + materials: list[Material] = [] + with path.open(newline="", encoding="utf-8") as fh: + reader = csv.DictReader(fh) + for row in reader: + d: dict[str, Any] = {} + for csv_col, value in row.items(): + field_name = mapping.get(csv_col, csv_col) + if field_name not in valid_fields or value == "": + continue + # Attempt numeric conversion + try: + value = float(value) # type: ignore[assignment] + except (ValueError, TypeError): + pass + d[field_name] = value + materials.append(Material.from_dict(d)) + return MaterialCollection(materials) diff --git a/python-setup/simcoon/ashby/database.py b/python-setup/simcoon/ashby/database.py new file mode 100644 index 000000000..477ab8d7a --- /dev/null +++ b/python-setup/simcoon/ashby/database.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- + +"""Fetch materials from online databases (Materials Project).""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from simcoon.ashby.material import Material, MaterialCollection, SymmetryType + + +def fetch_materials( + *, + elements: list[str] | None = None, + num_elements: tuple[int, int] | None = None, + has_elastic: bool = True, + limit: int = 100, + api_key: str | None = None, + source: str = "materials_project", +) -> MaterialCollection: + """Fetch materials from an online database. + + Parameters + ---------- + elements : list of str, optional + Chemical elements to include (e.g. ``["Al", "O"]``). + num_elements : tuple of int, optional + ``(min, max)`` number of elements in the formula. + has_elastic : bool + If ``True`` (default), only return entries with elastic data. + limit : int + Maximum number of results. + api_key : str, optional + API key. For Materials Project this can also be set via the + ``MP_API_KEY`` environment variable. + source : str + Database source. Currently only ``"materials_project"`` is + supported. + + Returns + ------- + MaterialCollection + """ + if source == "materials_project": + return _fetch_from_mp( + elements=elements, + num_elements=num_elements, + has_elastic=has_elastic, + limit=limit, + api_key=api_key, + ) + raise ValueError(f"Unknown source: {source!r}. Use 'materials_project'.") + + +def _fetch_from_mp( + *, + elements: list[str] | None, + num_elements: tuple[int, int] | None, + has_elastic: bool, + limit: int, + api_key: str | None, +) -> MaterialCollection: + """Internal: query Materials Project via ``mp-api``.""" + try: + from mp_api.client import MPRester + except ImportError: + raise ImportError( + "The 'mp-api' package is required to fetch data from Materials " + "Project. Install it with:\n\n" + " pip install 'simcoon[ashby]'\n\n" + "or directly:\n\n" + " pip install mp-api\n" + ) from None + + kwargs: dict[str, Any] = {} + if elements is not None: + kwargs["elements"] = elements + if num_elements is not None: + kwargs["num_elements"] = num_elements + + fields = [ + "material_id", + "formula_pretty", + "density", + "symmetry", + ] + if has_elastic: + fields.extend([ + "bulk_modulus", + "shear_modulus", + "elastic_tensor", + ]) + kwargs["has_props"] = ["elasticity"] + + with MPRester(api_key) as mpr: + docs = mpr.materials.summary.search( + **kwargs, + fields=fields, + num_chunks=1, + ) + + materials: list[Material] = [] + for doc in docs[:limit]: + mat = _mp_doc_to_material(doc, has_elastic=has_elastic) + if mat is not None: + materials.append(mat) + + return MaterialCollection(materials) + + +def _mp_doc_to_material(doc: Any, has_elastic: bool) -> Material | None: + """Convert a single MP summary document to a :class:`Material`.""" + kw: dict[str, Any] = { + "name": getattr(doc, "formula_pretty", ""), + "formula": getattr(doc, "formula_pretty", ""), + "source_id": str(getattr(doc, "material_id", "")), + "source": "materials_project", + "category": "Crystal", + } + + density = getattr(doc, "density", None) + if density is not None: + kw["density"] = float(density) * 1000 # g/cm³ → kg/m³ + + if has_elastic: + K = None + G = None + + bulk = getattr(doc, "bulk_modulus", None) + if bulk is not None: + K = getattr(bulk, "vrh", None) + shear = getattr(doc, "shear_modulus", None) + if shear is not None: + G = getattr(shear, "vrh", None) + + if K is not None and G is not None and G > 0: + kw["K"] = float(K) # GPa + kw["G"] = float(G) # GPa + kw["E"] = 9.0 * K * G / (3.0 * K + G) + kw["nu"] = (3.0 * K - 2.0 * G) / (2.0 * (3.0 * K + G)) + else: + return None # Skip entries without usable elastic data + + et = getattr(doc, "elastic_tensor", None) + if et is not None: + original = getattr(et, "ieee_format", None) + if original is not None: + kw["elastic_tensor"] = np.array(original) + + # Detect symmetry from elastic tensor if available + kw["symmetry"] = _detect_symmetry(kw.get("elastic_tensor")) + + return Material.from_dict(kw) + + +def _detect_symmetry(elastic_tensor: np.ndarray | None) -> SymmetryType: + """Detect symmetry type using ``sim.check_symetries`` when possible.""" + if elastic_tensor is None: + return SymmetryType.ISOTROPIC # default assumption + + try: + import simcoon as sim + + result = sim.check_symetries(elastic_tensor, 1.0e-2) + umat = result.get("umat_type", "") + for member in SymmetryType: + if member.value == umat: + return member + except Exception: + pass + + return SymmetryType.UNKNOWN diff --git a/python-setup/simcoon/ashby/material.py b/python-setup/simcoon/ashby/material.py new file mode 100644 index 000000000..90f2b8883 --- /dev/null +++ b/python-setup/simcoon/ashby/material.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- + +"""Material data model for the simcoon Ashby module.""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + + +class SymmetryType(enum.Enum): + """Crystal/material symmetry types mapped to simcoon umat names.""" + + ISOTROPIC = "ELISO" + CUBIC = "ELCUB" + TRANSVERSE_ISOTROPIC = "ELIST" + ORTHOTROPIC = "ELORT" + ANISOTROPIC = "ELANI" + UNKNOWN = "UNKNOWN" + + +@dataclass +class Material: + """A single engineering material with mechanical/physical properties. + + Elastic moduli are stored in GPa, density in kg/m^3, strengths in MPa, + CTE in 1/K. + """ + + # Identity + name: str = "" + formula: str = "" + source_id: str = "" + source: str = "" + + # Elastic (GPa) + E: float | None = None + nu: float | None = None + G: float | None = None + K: float | None = None + elastic_tensor: np.ndarray | None = None # optional 6x6 + + # Physical + density: float | None = None # kg/m^3 + + # Thermal + CTE: float | None = None # 1/K + + # Strength (MPa) + yield_strength: float | None = None + tensile_strength: float | None = None + + # Hardening + hardening_type: str | None = None # "linear", "power_law", "johnson_cook" + hardening_params: dict[str, float] = field(default_factory=dict) + + # Classification + symmetry: SymmetryType = SymmetryType.ISOTROPIC + category: str = "" + tags: list[str] = field(default_factory=list) + + def has_elastic_props(self) -> bool: + """Return True if minimal elastic properties (E, nu) are available.""" + return self.E is not None and self.nu is not None + + def get_property(self, name: str) -> Any: + """Return the value of an arbitrary property by attribute name.""" + return getattr(self, name, None) + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> Material: + """Create a Material from a plain dictionary. + + Handles ``symmetry`` as a string (looked up in SymmetryType) and + ``elastic_tensor`` as a nested list (converted to ndarray). + """ + kw = dict(d) + + # Convert symmetry string -> enum + sym = kw.pop("symmetry", None) + if isinstance(sym, str): + try: + kw["symmetry"] = SymmetryType[sym.upper()] + except KeyError: + # Try matching by value (e.g. "ELISO") + for member in SymmetryType: + if member.value == sym: + kw["symmetry"] = member + break + else: + kw["symmetry"] = SymmetryType.UNKNOWN + elif isinstance(sym, SymmetryType): + kw["symmetry"] = sym + + # Convert elastic_tensor list -> ndarray + et = kw.get("elastic_tensor") + if et is not None and not isinstance(et, np.ndarray): + kw["elastic_tensor"] = np.array(et) + + # Only pass recognised fields + valid_fields = {f.name for f in cls.__dataclass_fields__.values()} + filtered = {k: v for k, v in kw.items() if k in valid_fields} + return cls(**filtered) + + +class MaterialCollection: + """A collection of :class:`Material` objects with filtering and grouping. + + Supports iteration, indexing, slicing, ``len()``, and ``append()``. + + Parameters + ---------- + materials : list of Material, optional + Initial list of materials. Defaults to an empty collection. + """ + + def __init__(self, materials: list[Material] | None = None): + self._materials: list[Material] = list(materials) if materials else [] + + # ------------------------------------------------------------------ + # Sequence-like interface + # ------------------------------------------------------------------ + def __len__(self) -> int: + return len(self._materials) + + def __iter__(self): + return iter(self._materials) + + def __getitem__(self, idx): + if isinstance(idx, slice): + return MaterialCollection(self._materials[idx]) + return self._materials[idx] + + def append(self, mat: Material) -> None: + self._materials.append(mat) + + def extend(self, mats) -> None: + self._materials.extend(mats) + + def __repr__(self) -> str: + return f"MaterialCollection({len(self._materials)} materials)" + + # ------------------------------------------------------------------ + # Filtering / grouping + # ------------------------------------------------------------------ + def filter(self, **kwargs) -> MaterialCollection: + """Return a new collection of materials matching all given criteria. + + Parameters + ---------- + **kwargs + Field name / value pairs. A material is included only if + every specified field equals the given value. + + Returns + ------- + MaterialCollection + + Examples + -------- + >>> metals = collection.filter(category="Metal") + >>> iso_metals = collection.filter(category="Metal", + ... symmetry=SymmetryType.ISOTROPIC) + """ + def _match(mat: Material) -> bool: + for key, val in kwargs.items(): + attr = getattr(mat, key, None) + if attr != val: + return False + return True + + return MaterialCollection([m for m in self._materials if _match(m)]) + + def group_by(self, field_name: str) -> dict[str, MaterialCollection]: + """Group materials by a field value. + + Parameters + ---------- + field_name : str + Attribute name of :class:`Material` to group by (e.g. + ``"category"``). + + Returns + ------- + dict[str, MaterialCollection] + Mapping from each distinct field value to a sub-collection. + """ + groups: dict[str, MaterialCollection] = {} + for mat in self._materials: + key = str(getattr(mat, field_name, "")) + if key not in groups: + groups[key] = MaterialCollection() + groups[key].append(mat) + return groups + + def get_property_arrays( + self, x_prop: str, y_prop: str + ) -> tuple[np.ndarray, np.ndarray, list[str]]: + """Extract aligned numpy arrays for two properties. + + Materials where either property is ``None`` are skipped. + + Parameters + ---------- + x_prop, y_prop : str + Attribute names of :class:`Material`. + + Returns + ------- + x : numpy.ndarray + Values of *x_prop*. + y : numpy.ndarray + Values of *y_prop*. + names : list of str + Corresponding material names. + """ + xs, ys, names = [], [], [] + for mat in self._materials: + xv = getattr(mat, x_prop, None) + yv = getattr(mat, y_prop, None) + if xv is not None and yv is not None: + xs.append(float(xv)) + ys.append(float(yv)) + names.append(mat.name) + return np.array(xs), np.array(ys), names diff --git a/python-setup/simcoon/ashby/materials.json b/python-setup/simcoon/ashby/materials.json new file mode 100644 index 000000000..37b86aca0 --- /dev/null +++ b/python-setup/simcoon/ashby/materials.json @@ -0,0 +1,848 @@ +[ + { + "name": "Mild steel (AISI 1020)", + "formula": "Fe", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 205, + "nu": 0.29, + "G": 79.5, + "K": 162.7, + "density": 7870, + "CTE": 11.7e-6, + "yield_strength": 350, + "tensile_strength": 420, + "hardening_type": "power_law", + "hardening_params": {"K": 530, "n": 0.26}, + "tags": ["structural", "low-carbon"] + }, + { + "name": "AISI 304 Stainless Steel", + "formula": "Fe-Cr-Ni", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 193, + "nu": 0.29, + "G": 74.8, + "K": 153.2, + "density": 8000, + "CTE": 17.3e-6, + "yield_strength": 215, + "tensile_strength": 505, + "hardening_type": "power_law", + "hardening_params": {"K": 1275, "n": 0.45}, + "tags": ["stainless", "austenitic", "corrosion-resistant"] + }, + { + "name": "AISI 316L Stainless Steel", + "formula": "Fe-Cr-Ni-Mo", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 193, + "nu": 0.30, + "G": 74.2, + "K": 160.8, + "density": 7990, + "CTE": 16.0e-6, + "yield_strength": 170, + "tensile_strength": 485, + "hardening_type": "power_law", + "hardening_params": {"K": 1100, "n": 0.44}, + "tags": ["stainless", "austenitic", "biocompatible"] + }, + { + "name": "Al 6061-T6", + "formula": "Al", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 68.9, + "nu": 0.33, + "G": 25.9, + "K": 67.5, + "density": 2700, + "CTE": 23.6e-6, + "yield_strength": 276, + "tensile_strength": 310, + "hardening_type": "power_law", + "hardening_params": {"K": 400, "n": 0.05}, + "tags": ["aluminum", "aerospace", "lightweight"] + }, + { + "name": "Al 7075-T6", + "formula": "Al-Zn-Mg-Cu", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 71.7, + "nu": 0.33, + "G": 26.9, + "K": 70.3, + "density": 2810, + "CTE": 23.4e-6, + "yield_strength": 503, + "tensile_strength": 572, + "hardening_type": "power_law", + "hardening_params": {"K": 630, "n": 0.04}, + "tags": ["aluminum", "aerospace", "high-strength"] + }, + { + "name": "Al 2024-T3", + "formula": "Al-Cu-Mg", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 73.1, + "nu": 0.33, + "G": 27.5, + "K": 71.7, + "density": 2780, + "CTE": 23.2e-6, + "yield_strength": 345, + "tensile_strength": 483, + "hardening_type": "power_law", + "hardening_params": {"K": 680, "n": 0.10}, + "tags": ["aluminum", "aerospace"] + }, + { + "name": "Copper C11000", + "formula": "Cu", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 117, + "nu": 0.34, + "G": 43.7, + "K": 121.9, + "density": 8940, + "CTE": 16.5e-6, + "yield_strength": 69, + "tensile_strength": 220, + "hardening_type": "power_law", + "hardening_params": {"K": 450, "n": 0.54}, + "tags": ["copper", "conductive"] + }, + { + "name": "Brass C26000", + "formula": "Cu-Zn", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 110, + "nu": 0.35, + "G": 40.7, + "K": 122.2, + "density": 8530, + "CTE": 19.9e-6, + "yield_strength": 125, + "tensile_strength": 340, + "hardening_type": "power_law", + "hardening_params": {"K": 725, "n": 0.49}, + "tags": ["brass", "copper-alloy"] + }, + { + "name": "Ti-6Al-4V", + "formula": "Ti-Al-V", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 113.8, + "nu": 0.34, + "G": 42.5, + "K": 118.5, + "density": 4430, + "CTE": 8.6e-6, + "yield_strength": 880, + "tensile_strength": 950, + "hardening_type": "johnson_cook", + "hardening_params": {"A": 862, "B": 331, "n": 0.34, "C": 0.012, "m": 0.8}, + "tags": ["titanium", "aerospace", "biocompatible"] + }, + { + "name": "Inconel 718", + "formula": "Ni-Cr-Fe", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 205, + "nu": 0.30, + "G": 78.8, + "K": 170.8, + "density": 8190, + "CTE": 13.0e-6, + "yield_strength": 1035, + "tensile_strength": 1240, + "hardening_type": "johnson_cook", + "hardening_params": {"A": 1241, "B": 622, "n": 0.6522, "C": 0.0134, "m": 1.3}, + "tags": ["superalloy", "high-temperature"] + }, + { + "name": "Nickel 200", + "formula": "Ni", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 204, + "nu": 0.31, + "G": 77.9, + "K": 177.2, + "density": 8890, + "CTE": 13.3e-6, + "yield_strength": 148, + "tensile_strength": 462, + "tags": ["nickel", "corrosion-resistant"] + }, + { + "name": "Magnesium AZ31B", + "formula": "Mg-Al-Zn", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 45, + "nu": 0.35, + "G": 16.7, + "K": 50.0, + "density": 1770, + "CTE": 26.0e-6, + "yield_strength": 200, + "tensile_strength": 260, + "tags": ["magnesium", "lightweight"] + }, + { + "name": "Zinc", + "formula": "Zn", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 108, + "nu": 0.25, + "G": 43.2, + "K": 72.0, + "density": 7140, + "CTE": 30.2e-6, + "yield_strength": 110, + "tensile_strength": 130, + "tags": ["zinc"] + }, + { + "name": "Tungsten", + "formula": "W", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 411, + "nu": 0.28, + "G": 160.5, + "K": 311.4, + "density": 19300, + "CTE": 4.5e-6, + "yield_strength": 750, + "tensile_strength": 980, + "tags": ["refractory", "high-density"] + }, + { + "name": "Tool Steel D2", + "formula": "Fe-Cr-C", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 210, + "nu": 0.30, + "G": 80.8, + "K": 175.0, + "density": 7700, + "CTE": 10.4e-6, + "yield_strength": 1650, + "tensile_strength": 1860, + "tags": ["tool-steel", "high-hardness"] + }, + { + "name": "Spring Steel (AISI 1095)", + "formula": "Fe-C", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 205, + "nu": 0.29, + "G": 79.5, + "K": 162.7, + "density": 7850, + "CTE": 11.0e-6, + "yield_strength": 510, + "tensile_strength": 1015, + "hardening_type": "power_law", + "hardening_params": {"K": 1200, "n": 0.15}, + "tags": ["spring", "high-carbon"] + }, + { + "name": "Gray Cast Iron", + "formula": "Fe-C-Si", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 110, + "nu": 0.26, + "G": 43.7, + "K": 76.4, + "density": 7200, + "CTE": 10.8e-6, + "tensile_strength": 250, + "tags": ["cast-iron", "brittle"] + }, + { + "name": "Ductile Cast Iron", + "formula": "Fe-C-Si", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 170, + "nu": 0.28, + "G": 66.4, + "K": 128.8, + "density": 7100, + "CTE": 11.0e-6, + "yield_strength": 310, + "tensile_strength": 480, + "tags": ["cast-iron", "ductile"] + }, + { + "name": "Gold", + "formula": "Au", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 78, + "nu": 0.44, + "G": 27.1, + "K": 217.0, + "density": 19320, + "CTE": 14.2e-6, + "yield_strength": 205, + "tensile_strength": 220, + "tags": ["precious-metal", "conductive"] + }, + { + "name": "Silver", + "formula": "Ag", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 83, + "nu": 0.37, + "G": 30.3, + "K": 106.4, + "density": 10490, + "CTE": 18.9e-6, + "yield_strength": 170, + "tensile_strength": 190, + "tags": ["precious-metal", "conductive"] + }, + { + "name": "Platinum", + "formula": "Pt", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 168, + "nu": 0.38, + "G": 60.9, + "K": 233.3, + "density": 21450, + "CTE": 8.8e-6, + "yield_strength": 125, + "tensile_strength": 165, + "tags": ["precious-metal"] + }, + { + "name": "Lead", + "formula": "Pb", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 16, + "nu": 0.44, + "G": 5.6, + "K": 44.4, + "density": 11340, + "CTE": 28.9e-6, + "yield_strength": 11, + "tensile_strength": 18, + "tags": ["heavy-metal", "soft"] + }, + { + "name": "Tin", + "formula": "Sn", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 50, + "nu": 0.36, + "G": 18.4, + "K": 59.5, + "density": 7290, + "CTE": 22.0e-6, + "yield_strength": 14, + "tensile_strength": 22, + "tags": ["soft"] + }, + { + "name": "Cobalt Alloy (Stellite 6)", + "formula": "Co-Cr-W", + "category": "Metal", + "symmetry": "ISOTROPIC", + "E": 210, + "nu": 0.30, + "G": 80.8, + "K": 175.0, + "density": 8390, + "CTE": 12.0e-6, + "yield_strength": 620, + "tensile_strength": 900, + "tags": ["cobalt", "wear-resistant"] + }, + { + "name": "Alumina (Al2O3)", + "formula": "Al2O3", + "category": "Ceramic", + "symmetry": "ISOTROPIC", + "E": 370, + "nu": 0.22, + "G": 151.6, + "K": 220.2, + "density": 3950, + "CTE": 7.4e-6, + "tensile_strength": 300, + "tags": ["oxide", "structural-ceramic"] + }, + { + "name": "Silicon Carbide (SiC)", + "formula": "SiC", + "category": "Ceramic", + "symmetry": "ISOTROPIC", + "E": 410, + "nu": 0.14, + "G": 179.8, + "K": 190.7, + "density": 3210, + "CTE": 4.0e-6, + "tensile_strength": 400, + "tags": ["carbide", "structural-ceramic"] + }, + { + "name": "Silicon Nitride (Si3N4)", + "formula": "Si3N4", + "category": "Ceramic", + "symmetry": "ISOTROPIC", + "E": 310, + "nu": 0.27, + "G": 122.0, + "K": 224.6, + "density": 3200, + "CTE": 3.2e-6, + "tensile_strength": 580, + "tags": ["nitride", "structural-ceramic"] + }, + { + "name": "Zirconia (ZrO2)", + "formula": "ZrO2", + "category": "Ceramic", + "symmetry": "ISOTROPIC", + "E": 200, + "nu": 0.31, + "G": 76.3, + "K": 174.0, + "density": 5680, + "CTE": 10.5e-6, + "tensile_strength": 420, + "tags": ["oxide", "toughened-ceramic"] + }, + { + "name": "Boron Carbide (B4C)", + "formula": "B4C", + "category": "Ceramic", + "symmetry": "ISOTROPIC", + "E": 460, + "nu": 0.17, + "G": 196.6, + "K": 232.3, + "density": 2520, + "CTE": 6.0e-6, + "tensile_strength": 350, + "tags": ["carbide", "armor"] + }, + { + "name": "Tungsten Carbide (WC-Co)", + "formula": "WC", + "category": "Ceramic", + "symmetry": "ISOTROPIC", + "E": 620, + "nu": 0.24, + "G": 250.0, + "K": 396.8, + "density": 15630, + "CTE": 5.2e-6, + "tensile_strength": 340, + "tags": ["carbide", "cutting-tool"] + }, + { + "name": "Soda-Lime Glass", + "formula": "SiO2-Na2O-CaO", + "category": "Ceramic", + "symmetry": "ISOTROPIC", + "E": 72, + "nu": 0.22, + "G": 29.5, + "K": 42.9, + "density": 2500, + "CTE": 9.0e-6, + "tensile_strength": 50, + "tags": ["glass", "amorphous"] + }, + { + "name": "Fused Silica", + "formula": "SiO2", + "category": "Ceramic", + "symmetry": "ISOTROPIC", + "E": 73, + "nu": 0.17, + "G": 31.2, + "K": 36.9, + "density": 2200, + "CTE": 0.55e-6, + "tensile_strength": 48, + "tags": ["glass", "low-expansion"] + }, + { + "name": "Porcelain", + "formula": "SiO2-Al2O3", + "category": "Ceramic", + "symmetry": "ISOTROPIC", + "E": 70, + "nu": 0.22, + "G": 28.7, + "K": 41.7, + "density": 2400, + "CTE": 6.0e-6, + "tensile_strength": 40, + "tags": ["traditional-ceramic"] + }, + { + "name": "Concrete", + "formula": "", + "category": "Ceramic", + "symmetry": "ISOTROPIC", + "E": 30, + "nu": 0.20, + "G": 12.5, + "K": 16.7, + "density": 2400, + "CTE": 10.0e-6, + "tensile_strength": 4, + "tags": ["construction"] + }, + { + "name": "HDPE", + "formula": "(C2H4)n", + "category": "Polymer", + "symmetry": "ISOTROPIC", + "E": 1.1, + "nu": 0.42, + "G": 0.39, + "K": 2.29, + "density": 960, + "CTE": 120e-6, + "yield_strength": 26, + "tensile_strength": 32, + "tags": ["thermoplastic", "polyethylene"] + }, + { + "name": "LDPE", + "formula": "(C2H4)n", + "category": "Polymer", + "symmetry": "ISOTROPIC", + "E": 0.2, + "nu": 0.45, + "G": 0.069, + "K": 0.67, + "density": 920, + "CTE": 200e-6, + "yield_strength": 10, + "tensile_strength": 15, + "tags": ["thermoplastic", "polyethylene", "flexible"] + }, + { + "name": "Polypropylene (PP)", + "formula": "(C3H6)n", + "category": "Polymer", + "symmetry": "ISOTROPIC", + "E": 1.5, + "nu": 0.40, + "G": 0.54, + "K": 2.50, + "density": 910, + "CTE": 100e-6, + "yield_strength": 35, + "tensile_strength": 40, + "tags": ["thermoplastic"] + }, + { + "name": "PVC (rigid)", + "formula": "(C2H3Cl)n", + "category": "Polymer", + "symmetry": "ISOTROPIC", + "E": 3.3, + "nu": 0.38, + "G": 1.20, + "K": 4.58, + "density": 1400, + "CTE": 80e-6, + "yield_strength": 45, + "tensile_strength": 55, + "tags": ["thermoplastic"] + }, + { + "name": "PMMA (Acrylic)", + "formula": "(C5O2H8)n", + "category": "Polymer", + "symmetry": "ISOTROPIC", + "E": 3.1, + "nu": 0.37, + "G": 1.13, + "K": 3.97, + "density": 1180, + "CTE": 75e-6, + "yield_strength": 60, + "tensile_strength": 72, + "tags": ["thermoplastic", "transparent"] + }, + { + "name": "Nylon 6,6", + "formula": "(C12H22N2O2)n", + "category": "Polymer", + "symmetry": "ISOTROPIC", + "E": 2.8, + "nu": 0.39, + "G": 1.01, + "K": 4.24, + "density": 1140, + "CTE": 80e-6, + "yield_strength": 70, + "tensile_strength": 85, + "tags": ["thermoplastic", "polyamide"] + }, + { + "name": "PET", + "formula": "(C10H8O4)n", + "category": "Polymer", + "symmetry": "ISOTROPIC", + "E": 2.8, + "nu": 0.38, + "G": 1.01, + "K": 3.89, + "density": 1380, + "CTE": 70e-6, + "yield_strength": 55, + "tensile_strength": 80, + "tags": ["thermoplastic", "polyester"] + }, + { + "name": "PTFE (Teflon)", + "formula": "(C2F4)n", + "category": "Polymer", + "symmetry": "ISOTROPIC", + "E": 0.5, + "nu": 0.46, + "G": 0.17, + "K": 2.08, + "density": 2170, + "CTE": 135e-6, + "yield_strength": 10, + "tensile_strength": 25, + "tags": ["thermoplastic", "low-friction"] + }, + { + "name": "Polycarbonate (PC)", + "formula": "(C16H14O3)n", + "category": "Polymer", + "symmetry": "ISOTROPIC", + "E": 2.4, + "nu": 0.37, + "G": 0.88, + "K": 3.08, + "density": 1200, + "CTE": 66e-6, + "yield_strength": 60, + "tensile_strength": 70, + "tags": ["thermoplastic", "transparent", "impact-resistant"] + }, + { + "name": "ABS", + "formula": "(C8H8·C4H6·C3H3N)n", + "category": "Polymer", + "symmetry": "ISOTROPIC", + "E": 2.3, + "nu": 0.38, + "G": 0.83, + "K": 3.19, + "density": 1050, + "CTE": 85e-6, + "yield_strength": 43, + "tensile_strength": 50, + "tags": ["thermoplastic", "impact-resistant"] + }, + { + "name": "Polystyrene (PS)", + "formula": "(C8H8)n", + "category": "Polymer", + "symmetry": "ISOTROPIC", + "E": 3.2, + "nu": 0.34, + "G": 1.19, + "K": 3.33, + "density": 1050, + "CTE": 70e-6, + "yield_strength": 34, + "tensile_strength": 45, + "tags": ["thermoplastic", "brittle"] + }, + { + "name": "Epoxy", + "formula": "", + "category": "Polymer", + "symmetry": "ISOTROPIC", + "E": 3.5, + "nu": 0.35, + "G": 1.30, + "K": 3.89, + "density": 1250, + "CTE": 55e-6, + "yield_strength": 60, + "tensile_strength": 75, + "tags": ["thermoset", "adhesive"] + }, + { + "name": "Polyurethane (rigid)", + "formula": "", + "category": "Polymer", + "symmetry": "ISOTROPIC", + "E": 2.5, + "nu": 0.39, + "G": 0.90, + "K": 3.79, + "density": 1200, + "CTE": 100e-6, + "yield_strength": 50, + "tensile_strength": 65, + "tags": ["thermoset"] + }, + { + "name": "Silicone Rubber", + "formula": "(SiO(CH3)2)n", + "category": "Polymer", + "symmetry": "ISOTROPIC", + "E": 0.005, + "nu": 0.49, + "G": 0.0017, + "K": 0.083, + "density": 1100, + "CTE": 250e-6, + "tensile_strength": 7, + "tags": ["elastomer", "flexible", "high-temperature"] + }, + { + "name": "Natural Rubber", + "formula": "(C5H8)n", + "category": "Polymer", + "symmetry": "ISOTROPIC", + "E": 0.003, + "nu": 0.499, + "G": 0.001, + "K": 0.5, + "density": 930, + "CTE": 200e-6, + "tensile_strength": 25, + "tags": ["elastomer", "flexible"] + }, + { + "name": "CFRP (unidirectional)", + "formula": "", + "category": "Composite", + "symmetry": "TRANSVERSE_ISOTROPIC", + "E": 140, + "nu": 0.30, + "density": 1600, + "CTE": 0.5e-6, + "tensile_strength": 1500, + "tags": ["carbon-fiber", "high-performance"] + }, + { + "name": "CFRP (woven)", + "formula": "", + "category": "Composite", + "symmetry": "ORTHOTROPIC", + "E": 70, + "nu": 0.10, + "density": 1550, + "CTE": 2.0e-6, + "tensile_strength": 600, + "tags": ["carbon-fiber", "quasi-isotropic"] + }, + { + "name": "GFRP (E-glass/epoxy)", + "formula": "", + "category": "Composite", + "symmetry": "TRANSVERSE_ISOTROPIC", + "E": 40, + "nu": 0.28, + "density": 1900, + "CTE": 12.0e-6, + "tensile_strength": 700, + "tags": ["glass-fiber"] + }, + { + "name": "Aramid/Epoxy (Kevlar)", + "formula": "", + "category": "Composite", + "symmetry": "TRANSVERSE_ISOTROPIC", + "E": 76, + "nu": 0.34, + "density": 1380, + "CTE": -4.0e-6, + "tensile_strength": 1400, + "tags": ["aramid", "ballistic"] + }, + { + "name": "Plywood", + "formula": "", + "category": "Composite", + "symmetry": "ORTHOTROPIC", + "E": 12.5, + "nu": 0.30, + "density": 600, + "CTE": 5.0e-6, + "tensile_strength": 40, + "tags": ["wood", "natural"] + }, + { + "name": "Polyurethane Foam", + "formula": "", + "category": "Foam", + "symmetry": "ISOTROPIC", + "E": 0.025, + "nu": 0.30, + "G": 0.0096, + "K": 0.021, + "density": 30, + "CTE": 55e-6, + "tensile_strength": 0.5, + "tags": ["polymer-foam", "insulation"] + }, + { + "name": "Aluminum Foam", + "formula": "Al", + "category": "Foam", + "symmetry": "ISOTROPIC", + "E": 1.5, + "nu": 0.31, + "G": 0.57, + "K": 1.32, + "density": 400, + "CTE": 23.0e-6, + "tensile_strength": 5, + "tags": ["metal-foam", "energy-absorption"] + }, + { + "name": "Cork", + "formula": "", + "category": "Foam", + "symmetry": "ISOTROPIC", + "E": 0.032, + "nu": 0.05, + "G": 0.015, + "K": 0.012, + "density": 180, + "CTE": 30e-6, + "tensile_strength": 1.5, + "tags": ["natural-foam", "sustainable"] + } +] diff --git a/python-setup/simcoon/ashby/plotting.py b/python-setup/simcoon/ashby/plotting.py new file mode 100644 index 000000000..7cb58facb --- /dev/null +++ b/python-setup/simcoon/ashby/plotting.py @@ -0,0 +1,435 @@ +# -*- coding: utf-8 -*- + +"""Ashby diagram plotting for the simcoon Ashby module. + +Includes the original legacy visualization helpers (verbatim) and a new +high-level ``ashby_plot()`` function. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +import math as m + +if TYPE_CHECKING: + from simcoon.ashby.material import MaterialCollection + +# ====================================================================== +# Legacy helper functions (preserved verbatim from the original ashby.py) +# ====================================================================== + +def unit_vector(pt_a, pt_b): + b_a = [pt_b[0]-pt_a[0],pt_b[1]-pt_a[1]] + distance = m.sqrt(b_a[0]**2+b_a[1]**2) + return np.array([b_a[0]*(1.0/distance),b_a[1]*(1.0/distance)]) + +def length(pt_a, pt_b): + b_a = [pt_b[0]-pt_a[0],pt_b[1]-pt_a[1]] + distance = m.sqrt(b_a[0]**2+b_a[1]**2) + return distance + +def uv_2(uv1, uv2): + b_a = [uv1[0]-uv2[0],uv1[1]-uv2[1]] + distance = m.sqrt(b_a[0]**2+b_a[1]**2) + return np.array([b_a[0]*(1.0/distance),b_a[1]*(1.0/distance)]) + +def poly_convexHull(points, color, coef_multi=0.1, rad=0.3, lw=2): + """ + Plot the convex hull around a set of points as a + shaded polygon. + """ + from scipy.spatial import ConvexHull + from matplotlib.path import Path + import matplotlib.patches as patches + import matplotlib.pyplot as plt + + pt_env = points + for i in range(0,len(points)): + pt_env = np.append(pt_env, points[i] + (2.0*coef_multi*np.random.rand(10,2)-coef_multi)*points[i], axis=0) + + hull_env = ConvexHull(pt_env) + hull_indices_env = hull_env.vertices + + u_v = np.zeros((len(hull_indices_env),2)) + + verts = np.zeros((len(hull_indices_env),2)) + dist = np.zeros((len(hull_indices_env),2)) + + for i in range(0,len(hull_indices_env)): + verts[i] = pt_env[hull_indices_env[i]] + u_v[i] = unit_vector(pt_env[hull_indices_env[i-1]], pt_env[hull_indices_env[i]]) + dist[i] = length(pt_env[hull_indices_env[i-1]], pt_env[hull_indices_env[i]]) + + verts2 = np.zeros((3*len(verts)+1,2)) + for i in range(0,len(hull_indices_env)-1): + verts2[i*3] = verts[i] - u_v[i]*dist[i]*rad + verts2[i*3+1] = verts[i] + verts2[i*3+2] = verts[i] + u_v[i+1]*dist[i+1]*rad + verts2[-4] = verts[-1] - u_v[-1]*dist[-1]*rad + verts2[-3] = verts[-1] + verts2[-2] = verts[-1] + u_v[0]*dist[0]*rad + verts2[-1] = verts2[0] + + codes = [Path.MOVETO,] + for j in range(len(verts)): + codes.extend([Path.CURVE3, Path.CURVE3, Path.LINETO,]) + + path = Path(verts2, codes) + patch = patches.PathPatch(path, facecolor=color, lw=0, alpha=0.2) + edge = patches.PathPatch(path, edgecolor=color, facecolor='none', lw=lw) + plt.gca().add_patch(patch) + plt.gca().add_patch(edge) + + +def poly_enclose(points, color, inc=1.2, rad=0.3, lw=2): + """ + Plot the convex hull around a set of points as a + shaded polygon. + """ + from scipy.spatial import ConvexHull + from matplotlib.path import Path + import matplotlib.patches as patches + import matplotlib.pyplot as plt + + hull = ConvexHull(points) + + + cent = np.mean(points, 0) + pts = [] + for pt in points[hull.vertices]: + pts.append(pt.tolist()) + + pts.insert(len(pts), pts[0]) + + + verts = inc*(np.array(pts)- cent) + cent + verts2 = np.zeros((3*verts.shape[0]-2,2)) + verts2[0::3] = verts + verts2[1::3,:] = (1-rad)*verts[0:-1,:] + rad*verts[1:,:] + verts2[2::3,:] = rad*verts[0:-1,:] + (1-rad)*verts[1:,:] + verts2[0:-1] = verts2[1:] + verts2[-1] = verts2[0] + + codes = [Path.MOVETO, Path.LINETO, Path.CURVE3,] + for j in range(len(pts)-2): + codes.extend([Path.CURVE3, Path.LINETO, Path.CURVE3,]) + codes.append(Path.CURVE3) + + + path = Path(verts2, codes) + patch = patches.PathPatch(path, facecolor=color, lw=0, alpha=0.2) + edge = patches.PathPatch(path, edgecolor=color, facecolor='none', lw=lw) + plt.gca().add_patch(patch) + plt.gca().add_patch(edge) + +def ellip_enclose(points, color, inc=1.2, lw=2, nst=2): + """ + Plot the minimum ellipse around a set of points. + + Based on: + https://github.com/joferkington/oost_paper_code/blob/master/error_ellipse.py + """ + import matplotlib.patches as patches + import matplotlib.pyplot as plt + + def eigsorted(cov): + vals, vecs = np.linalg.eigh(cov) + order = vals.argsort()[::-1] + return vals[order], vecs[:,order] + + x = points[:,0] + y = points[:,1] + cov = np.cov(x, y) + vals, vecs = eigsorted(cov) + theta = np.degrees(np.arctan2(*vecs[:,0][::-1])) + w, h = 2 * nst * np.sqrt(vals) + center = np.mean(points, 0) + ell = patches.Ellipse(center, width=inc*w, height=inc*h, angle=theta, + facecolor=color, alpha=0.2, lw=0) + edge = patches.Ellipse(center, width=inc*w, height=inc*h, angle=theta, + facecolor='none', edgecolor=color, lw=lw) + plt.gca().add_artist(ell) + plt.gca().add_artist(edge) + +# ====================================================================== +# New high-level API +# ====================================================================== + +CATEGORY_COLORS: dict[str, str] = { + "Metal": "#1f77b4", + "Ceramic": "#d62728", + "Polymer": "#2ca02c", + "Composite": "#ff7f0e", + "Foam": "#9467bd", + "Wood": "#8c564b", + "Elastomer": "#e377c2", + "Natural": "#7f7f7f", +} + +_PROP_LABELS: dict[str, str] = { + "E": "Young's modulus (GPa)", + "G": "Shear modulus (GPa)", + "K": "Bulk modulus (GPa)", + "nu": "Poisson's ratio", + "density": "Density (kg/m³)", + "CTE": "CTE (1/K)", + "yield_strength": "Yield strength (MPa)", + "tensile_strength": "Tensile strength (MPa)", +} + + +def ashby_plot( + data, + x_prop: str = "density", + y_prop: str = "E", + *, + group_by: str = "category", + envelope: str | None = "ellipse", + log: bool = True, + guidelines: list[dict] | None = None, + ax=None, + figsize: tuple[float, float] = (10, 7), + label_groups: bool = True, +): + """Create an Ashby-style material property chart. + + Parameters + ---------- + data : MaterialCollection or dict[str, MaterialCollection] + Materials to plot. If a single collection, it will be grouped + using *group_by*. + x_prop, y_prop : str + Material attribute names for the x and y axes. + group_by : str + Field used to group a single collection (default ``"category"``). + envelope : str or None + ``"ellipse"``, ``"convex_hull"``, ``"enclose"``, or ``None``. + log : bool + Use log-log axes (default ``True``). + guidelines : list of dict, optional + Performance-index guide lines. Each dict should contain + ``slope`` (in log-log space) and optionally ``label`` and + ``intercepts`` (list of floats, log10 of the y-intercept). + ax : matplotlib Axes, optional + Axes to draw on; a new figure is created if ``None``. + figsize : tuple + Figure size when creating a new figure. + label_groups : bool + Place group name labels at the centroid of each group. + + Returns + ------- + matplotlib.axes.Axes + """ + import matplotlib.pyplot as plt + import matplotlib.patches as mpatches + + from simcoon.ashby.material import MaterialCollection as _MC + + # Organise into groups + if isinstance(data, dict): + groups = data + else: + groups = data.group_by(group_by) + + if ax is None: + _, ax = plt.subplots(figsize=figsize) + + for gname, gcoll in groups.items(): + x, y, names = gcoll.get_property_arrays(x_prop, y_prop) + if len(x) == 0: + continue + + color = CATEGORY_COLORS.get(gname, None) + ax.scatter(x, y, label=gname, c=color, s=30, zorder=3) + + # Envelope (need ≥3 points for hull/ellipse) + if envelope and len(x) >= 3: + pts = np.column_stack([x, y]) + if log: + pts = np.column_stack([np.log10(x), np.log10(y)]) + _color = color or ax._get_lines.get_next_color() + if envelope == "ellipse": + _draw_ellipse(ax, pts, _color, log=log) + elif envelope == "convex_hull": + _draw_convex_hull(ax, pts, _color, log=log) + elif envelope == "enclose": + _draw_enclose(ax, pts, _color, log=log) + + # Group label at centroid + if label_groups and len(x) > 0: + cx, cy = np.median(x), np.median(y) + ax.annotate( + gname, + (cx, cy), + fontsize=9, + fontweight="bold", + ha="center", + va="center", + alpha=0.7, + zorder=4, + ) + + if log: + ax.set_xscale("log") + ax.set_yscale("log") + + ax.set_xlabel(_PROP_LABELS.get(x_prop, x_prop)) + ax.set_ylabel(_PROP_LABELS.get(y_prop, y_prop)) + ax.legend(loc="best", framealpha=0.9) + ax.grid(True, which="both", ls=":", alpha=0.4) + + # Performance index guidelines + if guidelines: + _draw_guidelines(ax, guidelines) + + return ax + + +# ------------------------------------------------------------------ +# Internal drawing helpers +# ------------------------------------------------------------------ + +def _draw_ellipse(ax, pts_log, color, log=True): + """Draw a covariance-based ellipse around *pts_log*.""" + import matplotlib.patches as mpatches + + cov = np.cov(pts_log[:, 0], pts_log[:, 1]) + vals, vecs = np.linalg.eigh(cov) + order = vals.argsort()[::-1] + vals, vecs = vals[order], vecs[:, order] + + theta = np.degrees(np.arctan2(*vecs[:, 0][::-1])) + w, h = 2 * 2.0 * np.sqrt(np.maximum(vals, 0)) + center = np.mean(pts_log, axis=0) + + if log: + # Draw in data (log) coordinates via a transform + from matplotlib.transforms import Affine2D + + tr = Affine2D().rotate_deg(theta).translate(*center) + tr = tr + ax.transData + # We cannot use log-scale patches directly; instead draw in + # log-space then transform back. + cx, cy = 10 ** center[0], 10 ** center[1] + # Approximate: just draw on log axes via the axis transform + ell = mpatches.Ellipse( + (10 ** center[0], 10 ** center[1]), + width=10 ** (center[0] + w / 2) - 10 ** (center[0] - w / 2), + height=10 ** (center[1] + h / 2) - 10 ** (center[1] - h / 2), + angle=theta, + facecolor=color, + alpha=0.15, + lw=0, + zorder=1, + ) + edge = mpatches.Ellipse( + (10 ** center[0], 10 ** center[1]), + width=10 ** (center[0] + w / 2) - 10 ** (center[0] - w / 2), + height=10 ** (center[1] + h / 2) - 10 ** (center[1] - h / 2), + angle=theta, + facecolor="none", + edgecolor=color, + lw=1.5, + zorder=1, + ) + else: + ell = mpatches.Ellipse( + center, w, h, angle=theta, facecolor=color, alpha=0.15, lw=0, zorder=1 + ) + edge = mpatches.Ellipse( + center, w, h, angle=theta, facecolor="none", edgecolor=color, lw=1.5, zorder=1 + ) + ax.add_patch(ell) + ax.add_patch(edge) + + +def _draw_convex_hull(ax, pts_log, color, log=True): + """Draw a convex hull around *pts_log*.""" + from scipy.spatial import ConvexHull + import matplotlib.patches as mpatches + from matplotlib.path import Path + + hull = ConvexHull(pts_log) + verts = pts_log[hull.vertices] + if log: + verts = 10 ** verts + verts = np.vstack([verts, verts[0]]) + path = Path(verts) + patch = mpatches.PathPatch(path, facecolor=color, alpha=0.15, lw=0, zorder=1) + edge = mpatches.PathPatch(path, edgecolor=color, facecolor="none", lw=1.5, zorder=1) + ax.add_patch(patch) + ax.add_patch(edge) + + +def _draw_enclose(ax, pts_log, color, log=True): + """Draw a smooth enclosing hull via the legacy ``poly_enclose``.""" + from scipy.spatial import ConvexHull + from matplotlib.path import Path + import matplotlib.patches as mpatches + + hull = ConvexHull(pts_log) + cent = np.mean(pts_log, 0) + pts = [pts_log[v].tolist() for v in hull.vertices] + pts.append(pts[0]) + + inc = 1.2 + rad = 0.3 + verts = inc * (np.array(pts) - cent) + cent + verts2 = np.zeros((3 * verts.shape[0] - 2, 2)) + verts2[0::3] = verts + verts2[1::3, :] = (1 - rad) * verts[:-1, :] + rad * verts[1:, :] + verts2[2::3, :] = rad * verts[:-1, :] + (1 - rad) * verts[1:, :] + verts2[:-1] = verts2[1:] + verts2[-1] = verts2[0] + + codes = [Path.MOVETO, Path.LINETO, Path.CURVE3] + for _ in range(len(pts) - 2): + codes.extend([Path.CURVE3, Path.LINETO, Path.CURVE3]) + codes.append(Path.CURVE3) + + if log: + verts2 = 10 ** verts2 + + path = Path(verts2, codes) + patch = mpatches.PathPatch(path, facecolor=color, alpha=0.15, lw=0, zorder=1) + edge = mpatches.PathPatch(path, edgecolor=color, facecolor="none", lw=1.5, zorder=1) + ax.add_patch(patch) + ax.add_patch(edge) + + +def _draw_guidelines(ax, guidelines): + """Draw performance-index lines on a log-log Ashby plot.""" + xlim = ax.get_xlim() + log_x = np.linspace(np.log10(xlim[0]), np.log10(xlim[1]), 200) + + for gl in guidelines: + slope = gl["slope"] + label = gl.get("label", "") + intercepts = gl.get("intercepts", [0.0]) + for c in intercepts: + log_y = slope * log_x + c + ax.plot( + 10 ** log_x, + 10 ** log_y, + "--", + color="gray", + alpha=0.5, + lw=1, + zorder=0, + ) + if label and intercepts: + mid = len(log_x) // 2 + c = intercepts[0] + ax.annotate( + label, + (10 ** log_x[mid], 10 ** (slope * log_x[mid] + c)), + fontsize=8, + color="gray", + rotation=np.degrees(np.arctan(slope)), + ha="center", + va="bottom", + zorder=0, + ) From 1f61f78ec505944832357e0960c93a1289889153 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Mon, 9 Feb 2026 09:08:33 +0100 Subject: [PATCH 2/4] Delete solver.py --- python-setup/simcoon/solver.py | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 python-setup/simcoon/solver.py diff --git a/python-setup/simcoon/solver.py b/python-setup/simcoon/solver.py deleted file mode 100644 index 0c40c9174..000000000 --- a/python-setup/simcoon/solver.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import List, Optional - -class Problem(ProblemBase): - """Base class to define a problem that generate a linear system and to solve - the linear system with some defined boundary conditions. - - The linear problem is written under the form: - A*X = B+D - where: - * A is a square matrix build with the associated assembly object calling - assembly.get_global_matrix() - * X is the column vector containing the degrees of freedom (solution after solving) - * B is a column vector used to set Neumann boundary conditions - * D is a column vector build with the associated assembly object calling - assembly.get_global_vector() - - Parameters - ---------- - A: scipy sparse matrix - Matrix that define the discretized linear system to solve. - B: np.ndarray or 0 - if 0, B is initialized to a zeros array with de adequat shape. - D: np.ndarray or 0 - if 0, D is ignored. - mesh: fedoo Mesh - mesh associated to the problem. - name: str (default = "MainProblem") - name of the problem. - space: ModelingSpace(Optional) - ModelingSpace on which the problem is defined. - name: str - name of the problem. - """ \ No newline at end of file From 4d45ff4bf497cd77ef023b10bf8027cc29bad4c9 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Mon, 9 Feb 2026 09:08:39 +0100 Subject: [PATCH 3/4] Update pyproject.toml --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index a9308cbed..e5d3eac6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,11 @@ Changelog = "https://github.com/3MAH/simcoon/releases" dev = [ "pytest>=7.0", ] +ashby = [ + "mp-api>=0.39", + "scipy>=1.7", + "matplotlib>=3.5", +] # ============================================================================= # scikit-build-core configuration From 946b69e37a8c4b0daf5aec2d32495b392c67aba5 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Mon, 9 Feb 2026 09:08:51 +0100 Subject: [PATCH 4/4] Add Ashby docs and example materials Add documentation for the new simcoon.ashby subpackage (index, material, data, plotting, bridge, database), documenting the data model, built-in dataset and CSV loader, plotting utilities, Simcoon bridge (stiffness/compliance conversion), and Materials Project integration. Update docs/index.rst to include the ashby docs. Also add examples/ashby/README.rst and an ashby_diagram.py example that demonstrates loading the dataset, plotting an Ashby diagram, adding guide lines/envelopes, and converting a material to a simcoon stiffness tensor. Notes in docs mention optional extras (simcoon[ashby]) and MP API usage. --- docs/ashby/bridge.rst | 67 ++++++++++++++++ docs/ashby/data.rst | 63 +++++++++++++++ docs/ashby/database.rst | 71 +++++++++++++++++ docs/ashby/index.rst | 81 ++++++++++++++++++++ docs/ashby/material.rst | 131 ++++++++++++++++++++++++++++++++ docs/ashby/plotting.rst | 80 +++++++++++++++++++ docs/index.rst | 1 + examples/ashby/README.rst | 12 +++ examples/ashby/ashby_diagram.py | 100 ++++++++++++++++++++++++ 9 files changed, 606 insertions(+) create mode 100644 docs/ashby/bridge.rst create mode 100644 docs/ashby/data.rst create mode 100644 docs/ashby/database.rst create mode 100644 docs/ashby/index.rst create mode 100644 docs/ashby/material.rst create mode 100644 docs/ashby/plotting.rst create mode 100644 examples/ashby/README.rst create mode 100644 examples/ashby/ashby_diagram.py diff --git a/docs/ashby/bridge.rst b/docs/ashby/bridge.rst new file mode 100644 index 000000000..32a6f862c --- /dev/null +++ b/docs/ashby/bridge.rst @@ -0,0 +1,67 @@ +Simcoon Bridge +============== + +The bridge module converts ``Material`` objects into simcoon-compatible +stiffness and compliance tensors. This lets you go directly from a material +database query to a simcoon simulation. + +.. autofunction:: simcoon.ashby.bridge.to_stiffness +.. autofunction:: simcoon.ashby.bridge.to_compliance +.. autofunction:: simcoon.ashby.bridge.to_solver_props + +Unit conversion +--------------- + +Material properties in the Ashby module are stored in **GPa** (elastic moduli) +and **MPa** (strengths), following common engineering-data conventions. +Simcoon's internal convention is **MPa** for all stresses and moduli. + +The ``unit_factor`` parameter (default ``1e3``) handles the GPa → MPa +conversion automatically. If your material data is already in MPa, pass +``unit_factor=1``. + +Example +------- + +.. code-block:: python + + from simcoon.ashby import load_builtin, to_stiffness, to_compliance, to_solver_props + + mats = load_builtin() + al = [m for m in mats if "6061" in m.name][0] + + # 6x6 stiffness tensor (MPa) + L = to_stiffness(al) + + # 6x6 compliance tensor (1/MPa) + M = to_compliance(al) + + # Ready-made dict for sim.solver() + sp = to_solver_props(al) + # {'umat_name': 'ELISO', 'props': array([68900., 0.33]), 'nstatev': 1} + +Supported symmetries +-------------------- + +.. list-table:: + :header-rows: 1 + :widths: 30 30 40 + + * - SymmetryType + - simcoon function + - Required Material fields + * - ``ISOTROPIC`` + - ``sim.L_iso`` / ``sim.M_iso`` + - ``E``, ``nu`` + * - ``CUBIC`` + - ``sim.L_cubic`` / ``sim.M_cubic`` + - ``E``, ``nu``, ``G`` + * - ``TRANSVERSE_ISOTROPIC`` + - (full tensor pass-through) + - ``elastic_tensor`` (6x6) + * - ``ORTHOTROPIC`` + - (full tensor pass-through) + - ``elastic_tensor`` (6x6) + +For transversely isotropic and orthotropic materials the bridge requires the +full ``elastic_tensor`` field to be populated (e.g. from Materials Project). diff --git a/docs/ashby/data.rst b/docs/ashby/data.rst new file mode 100644 index 000000000..cc2ab67ed --- /dev/null +++ b/docs/ashby/data.rst @@ -0,0 +1,63 @@ +Dataset Loaders +=============== + +Functions for loading material datasets, either from the bundled JSON file +or from user-supplied CSV files. + +.. autofunction:: simcoon.ashby.data.load_builtin + +.. autofunction:: simcoon.ashby.data.load_csv + +Built-in dataset +---------------- + +The bundled ``materials.json`` contains ~60 curated engineering materials +drawn from standard references. The dataset covers five material families: + +.. list-table:: + :header-rows: 1 + :widths: 25 10 65 + + * - Category + - Count + - Examples + * - Metal + - 24 + - Mild steel, AISI 304/316L, Al 6061-T6/7075-T6, Ti-6Al-4V, Inconel 718, Cu, Brass, W, … + * - Ceramic + - 10 + - Alumina, SiC, Si₃N₄, ZrO₂, B₄C, WC, soda-lime glass, fused silica, … + * - Polymer + - 15 + - HDPE, LDPE, PP, PVC, PMMA, Nylon 6.6, PET, PTFE, PC, ABS, epoxy, … + * - Composite + - 5 + - CFRP (UD), CFRP (woven), GFRP, Aramid/epoxy, plywood + * - Foam + - 3 + - PU foam, aluminium foam, cork + +Every entry includes at least ``E``, ``nu``, and ``density``. Many metals +also provide ``yield_strength``, ``tensile_strength``, and hardening +parameters. + +Loading a CSV file +------------------ + +Any CSV file with a header row can be imported. Columns whose names match +``Material`` fields are used automatically; others are ignored. + +.. code-block:: python + + from simcoon.ashby import load_csv + + # Direct match — CSV headers = Material field names + mats = load_csv("my_materials.csv") + + # With column renaming + mats = load_csv("lab_data.csv", column_mapping={ + "Material": "name", + "Youngs_GPa": "E", + "Poisson": "nu", + "Rho_kg_m3": "density", + }) diff --git a/docs/ashby/database.rst b/docs/ashby/database.rst new file mode 100644 index 000000000..6cddd44ae --- /dev/null +++ b/docs/ashby/database.rst @@ -0,0 +1,71 @@ +Database Integration +==================== + +The ``database`` module provides functions to fetch material data from +online databases. Currently the `Materials Project `_ +is supported via the ``mp-api`` package. + +.. autofunction:: simcoon.ashby.database.fetch_materials + +Requirements +------------ + +Database queries require the ``mp-api`` package: + +.. code-block:: bash + + pip install 'simcoon[ashby]' + +You also need a Materials Project API key. Sign up at +https://materialsproject.org and set the key either as an environment +variable: + +.. code-block:: bash + + export MP_API_KEY="your_key_here" + +or pass it directly: + +.. code-block:: python + + from simcoon.ashby import fetch_materials + mats = fetch_materials(elements=["Al"], api_key="your_key_here") + +Example +------- + +.. code-block:: python + + from simcoon.ashby import fetch_materials, ashby_plot + + # Fetch aluminium-containing compounds with elastic data + al_mats = fetch_materials(elements=["Al", "O"], limit=50) + print(f"Fetched {len(al_mats)} materials") + + # Inspect the first entry + m = al_mats[0] + print(m.name, m.source_id, m.E, m.nu) + + # Plot an Ashby diagram of the fetched data + ashby_plot(al_mats, "density", "E", group_by="category") + +Combining with the built-in dataset +------------------------------------ + +.. code-block:: python + + from simcoon.ashby import load_builtin, fetch_materials, MaterialCollection + + builtin = load_builtin() + online = fetch_materials(elements=["Ti"], limit=30) + + combined = MaterialCollection(list(builtin) + list(online)) + +Symmetry detection +------------------ + +When the Materials Project returns a full elastic tensor, the module +automatically calls ``simcoon.check_symetries()`` to determine the material +symmetry (isotropic, cubic, orthotropic, etc.). This information is stored +in the ``symmetry`` field and used by the :doc:`bridge` to select the +correct simcoon tensor constructor. diff --git a/docs/ashby/index.rst b/docs/ashby/index.rst new file mode 100644 index 000000000..3dab0adf2 --- /dev/null +++ b/docs/ashby/index.rst @@ -0,0 +1,81 @@ +The Ashby Module +================ + +The ``simcoon.ashby`` subpackage provides tools for working with material +property databases and creating Ashby-style material selection charts. It +bridges the gap between material data (from curated datasets or online +databases like Materials Project) and simcoon's constitutive modelling +functions. + +Highlights +---------- + +- **Built-in dataset** of ~60 common engineering materials (metals, ceramics, + polymers, composites, foams) with elastic, thermal, and strength properties. +- **Ashby diagram plotting** with automatic grouping by material family, + envelope drawing (ellipse, convex hull), and performance-index guide lines. +- **Materials Project integration** — fetch elastic properties for thousands + of crystalline compounds directly from the Materials Project API. +- **Simcoon bridge** — convert any material to a 6x6 stiffness or compliance + tensor ready for use with ``sim.L_iso()``, ``sim.solver()``, etc. + +Installation +------------ + +The core data model (``Material``, ``load_builtin()``) requires only +**numpy**, which is already a simcoon dependency. + +For plotting and database features, install the optional ``ashby`` extras: + +.. code-block:: bash + + pip install 'simcoon[ashby]' + +This pulls in ``matplotlib``, ``scipy``, and ``mp-api``. + +Quick start +----------- + +.. code-block:: python + + from simcoon.ashby import load_builtin, ashby_plot, to_stiffness + + # 1. Load the curated material dataset + mats = load_builtin() # ~60 materials, no optional deps + + # 2. Create an Ashby diagram + ax = ashby_plot(mats, "density", "E") # E vs density, log-log + + # 3. Convert a material to a simcoon stiffness tensor + steel = mats.filter(category="Metal")[0] + L = to_stiffness(steel) # 6x6 ndarray in MPa + +Package structure +----------------- + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Module + - Description + * - :doc:`material` + - ``Material`` dataclass, ``SymmetryType`` enum, ``MaterialCollection`` + * - :doc:`data` + - Built-in dataset loader (``load_builtin``) and CSV importer (``load_csv``) + * - :doc:`plotting` + - ``ashby_plot()`` and legacy visualization helpers + * - :doc:`bridge` + - Convert materials to simcoon stiffness/compliance tensors + * - :doc:`database` + - Fetch materials from the Materials Project API + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + material + data + plotting + bridge + database diff --git a/docs/ashby/material.rst b/docs/ashby/material.rst new file mode 100644 index 000000000..eb66d66bf --- /dev/null +++ b/docs/ashby/material.rst @@ -0,0 +1,131 @@ +Material Data Model +=================== + +The data model lives in ``simcoon.ashby.material`` and is imported directly +from the top-level ``simcoon.ashby`` namespace. + +SymmetryType +------------ + +.. autoclass:: simcoon.ashby.material.SymmetryType + :members: + :undoc-members: + +The enum values map directly to simcoon's constitutive-model names: + +.. list-table:: + :header-rows: 1 + :widths: 35 20 45 + + * - Member + - Value + - Description + * - ``ISOTROPIC`` + - ``"ELISO"`` + - Isotropic elastic (2 constants: E, nu) + * - ``CUBIC`` + - ``"ELCUB"`` + - Cubic elastic (3 constants: E, nu, G) + * - ``TRANSVERSE_ISOTROPIC`` + - ``"ELIST"`` + - Transversely isotropic (5 constants) + * - ``ORTHOTROPIC`` + - ``"ELORT"`` + - Orthotropic (9 constants) + * - ``ANISOTROPIC`` + - ``"ELANI"`` + - Fully anisotropic (21 constants) + * - ``UNKNOWN`` + - ``"UNKNOWN"`` + - Symmetry not determined + +Material +-------- + +.. autoclass:: simcoon.ashby.material.Material + :members: + :undoc-members: + +**Units convention** + +.. list-table:: + :header-rows: 1 + :widths: 30 25 45 + + * - Property + - Unit + - Notes + * - ``E``, ``G``, ``K`` + - GPa + - Converted to MPa (×1000) when passed to simcoon + * - ``density`` + - kg/m³ + - + * - ``CTE`` + - 1/K + - + * - ``yield_strength``, ``tensile_strength`` + - MPa + - + +**Hardening models** + +The ``hardening_type`` field selects a hardening law and ``hardening_params`` +provides the model-specific parameters: + +- ``"linear"`` — :math:`\sigma = \sigma_y + H\,\varepsilon_p` with + ``{"H": float}`` +- ``"power_law"`` (Hollomon) — :math:`\sigma = K\,\varepsilon^n` with + ``{"K": float, "n": float}`` +- ``"johnson_cook"`` — + :math:`\sigma = (A + B\,\varepsilon^n)(1 + C\,\ln\dot\varepsilon^*)(1 - T^{*m})` + with ``{"A": float, "B": float, "n": float, "C": float, "m": float}`` + +**Creating materials** + +.. code-block:: python + + from simcoon.ashby import Material, SymmetryType + + # From keyword arguments + steel = Material( + name="Mild steel", + E=205, nu=0.29, G=79.5, + density=7870, + symmetry=SymmetryType.ISOTROPIC, + category="Metal", + ) + + # From a dictionary (e.g. loaded from JSON) + mat = Material.from_dict({ + "name": "Al 6061-T6", + "E": 68.9, "nu": 0.33, + "density": 2700, + "symmetry": "ISOTROPIC", + }) + +MaterialCollection +------------------ + +.. autoclass:: simcoon.ashby.material.MaterialCollection + :members: + :undoc-members: + +**Filtering and grouping** + +.. code-block:: python + + from simcoon.ashby import load_builtin + + mats = load_builtin() + + # Filter by any field + metals = mats.filter(category="Metal") + stiff = mats.filter(symmetry=SymmetryType.ISOTROPIC) + + # Group into sub-collections + by_cat = mats.group_by("category") + # {'Metal': MaterialCollection(24), 'Ceramic': MaterialCollection(10), ...} + + # Extract arrays for plotting + x, y, names = metals.get_property_arrays("density", "E") diff --git a/docs/ashby/plotting.rst b/docs/ashby/plotting.rst new file mode 100644 index 000000000..f6bc9aa31 --- /dev/null +++ b/docs/ashby/plotting.rst @@ -0,0 +1,80 @@ +Plotting +======== + +The plotting module provides the high-level ``ashby_plot()`` function for +creating Ashby material-property charts, as well as the original legacy +helper functions for drawing envelopes around point clouds. + +Requires ``matplotlib`` (and ``scipy`` for convex-hull envelopes). +Install with: + +.. code-block:: bash + + pip install 'simcoon[ashby]' + +ashby_plot +---------- + +.. autofunction:: simcoon.ashby.plotting.ashby_plot + +**Basic usage** + +.. code-block:: python + + from simcoon.ashby import load_builtin, ashby_plot + + mats = load_builtin() + ax = ashby_plot(mats, "density", "E") + +**Pre-grouped data** + +.. code-block:: python + + groups = mats.group_by("category") + ax = ashby_plot(groups, "density", "E", envelope="convex_hull") + +**Performance-index guide lines** + +Guide lines represent constant values of a material index in log-log space. +For a tie-rod stiffness index :math:`E/\rho`, the slope is 1 in log-log +space: + +.. code-block:: python + + ax = ashby_plot(mats, "density", "E", guidelines=[ + {"slope": 1.0, "label": "E/ρ = const", "intercepts": [-1.0, 0.0, 1.0]}, + ]) + +**Envelope styles** + +The ``envelope`` parameter controls how material families are outlined: + +- ``"ellipse"`` — covariance-based ellipse (default) +- ``"convex_hull"`` — convex hull polygon +- ``"enclose"`` — smoothed convex hull (Bézier curves) +- ``None`` — scatter points only + +Category colours +---------------- + +The ``CATEGORY_COLORS`` dictionary maps standard material families to +colours used by ``ashby_plot()``: + +.. code-block:: python + + from simcoon.ashby.plotting import CATEGORY_COLORS + # {'Metal': '#1f77b4', 'Ceramic': '#d62728', 'Polymer': '#2ca02c', ...} + +Legacy functions +---------------- + +The following functions are preserved verbatim from the original +``simcoon.ashby`` module for backward compatibility. They operate directly +on ``matplotlib.pyplot`` (the current axes) and on numpy point arrays. + +.. autofunction:: simcoon.ashby.plotting.poly_convexHull +.. autofunction:: simcoon.ashby.plotting.poly_enclose +.. autofunction:: simcoon.ashby.plotting.ellip_enclose +.. autofunction:: simcoon.ashby.plotting.unit_vector +.. autofunction:: simcoon.ashby.plotting.length +.. autofunction:: simcoon.ashby.plotting.uv_2 diff --git a/docs/index.rst b/docs/index.rst index 44fb4c7e7..db2c5937d 100755 --- a/docs/index.rst +++ b/docs/index.rst @@ -67,6 +67,7 @@ New users should begin with: external simulation/index examples/index + ashby/index continuum_mechanics/index homogenization/index cpp_api/index diff --git a/examples/ashby/README.rst b/examples/ashby/README.rst new file mode 100644 index 000000000..22504d19c --- /dev/null +++ b/examples/ashby/README.rst @@ -0,0 +1,12 @@ +Ashby Material Selection Examples +--------------------------------- + +Examples demonstrating the ``simcoon.ashby`` subpackage for material property +exploration and selection. + +This gallery contains examples showing: + +- **Material datasets** — Loading the built-in curated dataset and importing custom CSV data +- **Ashby diagrams** — Creating Young's modulus vs density charts with envelopes and guide lines +- **Simcoon bridge** — Converting materials to stiffness/compliance tensors for simulation +- **Materials Project** — Fetching and combining materials from online databases diff --git a/examples/ashby/ashby_diagram.py b/examples/ashby/ashby_diagram.py new file mode 100644 index 000000000..024a94a96 --- /dev/null +++ b/examples/ashby/ashby_diagram.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""Example: Ashby material property chart with simcoon bridge. + +Demonstrates the full workflow: +1. Load the built-in curated material dataset +2. Create a Young's modulus vs density Ashby diagram with envelopes +3. Add performance-index guide lines (E/rho = const for light-stiff design) +4. Pick a candidate material and convert to a simcoon stiffness tensor +5. (Optional) Fetch additional materials from Materials Project +""" + +import numpy as np + +# ===================================================================== +# 1. Load built-in dataset +# ===================================================================== + +from simcoon.ashby import load_builtin, Material, MaterialCollection + +mats = load_builtin() +print(f"Loaded {len(mats)} materials") +print(f"Categories: {sorted(set(m.category for m in mats))}") + +# Quick peek at the first few metals +metals = mats.filter(category="Metal") +print(f"\nMetals ({len(metals)}):") +for m in metals[:5]: + print(f" {m.name:30s} E={m.E:7.1f} GPa rho={m.density:.0f} kg/m3") + +# ===================================================================== +# 2. Ashby diagram: E vs density with ellipse envelopes +# ===================================================================== + +from simcoon.ashby import ashby_plot +import matplotlib.pyplot as plt + +ax = ashby_plot( + mats, + x_prop="density", + y_prop="E", + envelope="ellipse", + guidelines=[ + { + "slope": 1.0, + "label": "E/ρ = const (tie rod)", + "intercepts": [-1.0, 0.0, 1.0], + }, + { + "slope": 0.5, + "label": "E^0.5/ρ = const (beam)", + "intercepts": [-0.5, 0.5, 1.5], + }, + ], +) +ax.set_title("Ashby Diagram — Young's Modulus vs Density") +plt.tight_layout() +plt.savefig("ashby_E_vs_density.png", dpi=150) +print("\nSaved ashby_E_vs_density.png") +plt.show() + +# ===================================================================== +# 3. Pick a candidate and convert to simcoon stiffness tensor +# ===================================================================== + +from simcoon.ashby import to_stiffness, to_solver_props + +# Select Al 6061-T6 +al = [m for m in mats if "6061" in m.name][0] +print(f"\nSelected material: {al.name}") +print(f" E = {al.E} GPa") +print(f" nu = {al.nu}") +print(f" G = {al.G} GPa") + +# Stiffness tensor (6x6 in MPa) +L = to_stiffness(al) +print(f"\nStiffness tensor L (MPa):\n{np.array2string(L, precision=1, suppress_small=True)}") + +# Solver properties +sp = to_solver_props(al) +print(f"\nSolver props: umat_name={sp['umat_name']!r}, props={sp['props']}") + +# ===================================================================== +# 4. (Optional) Fetch from Materials Project +# ===================================================================== +# Uncomment the block below if you have mp-api installed and an API key. +# +# from simcoon.ashby import fetch_materials +# +# mp_mats = fetch_materials(elements=["Al", "O"], limit=20) +# print(f"\nFetched {len(mp_mats)} materials from Materials Project") +# for m in mp_mats[:5]: +# print(f" {m.source_id} {m.name:20s} E={m.E:.1f} GPa") +# +# # Merge with built-in dataset and re-plot +# combined = MaterialCollection(list(mats) + list(mp_mats)) +# ashby_plot(combined, "density", "E") +# plt.title("Enriched Ashby Diagram") +# plt.show()