A hierarchical material library for CAD applications and Monte Carlo particle transport, with build123d integration.
- Hierarchical Materials: Chain grades, tempers, treatments, and vendors
- Property Inheritance: Children inherit parent properties unless overridden
- Lazy Loading: Categories load on first access
- TOML Data Storage: Easy-to-edit material definitions
- Formula Parsing + Molar Mass: Computed from the chemical formula via
Material.molar_mass, with fractional stoichiometry (Lu1.8Y0.2SiO5) and dopant suffix stripping (LYSO:Ce). Atomic weights mirror the Rustrs-materialscrate for Python ↔ Rust parity. - build123d Integration: Apply materials to shapes with automatic mass calculation
- PBR Rendering via
material.vis: Scalars (roughness, metallic, base_color) and lazy-fetched textures from mat-vis - periodictable Integration: Auto-fill composition from chemical formulas for compounds; auto-fill density for pure elements
- Factory Functions: Temperature/pressure-dependent materials (water, air, saline)
- Separation of Concerns: Optical properties (physics) separate from
vis(visualization) - Python 3.10 – 3.13 supported, core library depends only on
pint
# From PyPI (recommended)
pip install py-materials
# or: uv add py-materials# From the main branch (development)
pip install git+https://github.com/MorePET/mat.git@main
# With optional extras
pip install "py-materials[periodictable]"# auto-fill from chemical formulas
pip install "py-materials[build123d]"# build123d Shape integration (Python <= 3.12)
pip install "py-materials[all]"# everything aboveCreate materials with convenient parameters:
frompymatimportMaterial# Using convenience parameterssteel=Material(name="Steel", density=7.85)
assertsteel.density==7.85# With visualization coloraluminum=Material(name="Aluminum", density=2.7, color=(0.88, 0.88, 0.88))
assertaluminum.vis.base_color[:3] == (0.88, 0.88, 0.88)
# With formulalyso=Material(name="LYSO", formula="Lu1.8Y0.2SiO5", density=7.1)
assertlyso.formula=="Lu1.8Y0.2SiO5"Define multiple properties at once using property group dictionaries:
frompymatimportMaterial# Define steel with multiple property groupssteel=Material(
name="Stainless Steel 304",
mechanical={"density": 8.0, "youngs_modulus": 193, "yield_strength": 170},
thermal={"melting_point": 1450, "thermal_conductivity": 15.1},
vis={"base_color": (0.75, 0.75, 0.77, 1.0), "metallic": 1.0},
)
assertsteel.properties.mechanical.density==8.0assertsteel.properties.mechanical.youngs_modulus==193assertsteel.properties.thermal.melting_point==1450assertsteel.vis.metallic==1.0Apply materials to build123d shapes for visualization and mass calculation:
frombuild123dimportBoxfrompymatimportMaterial# Create materialsteel=Material(name="Steel", density=7.85, color=(0.7, 0.7, 0.7))
# Create shape and apply materialbox=Box(10, 10, 10)
steel.apply_to(box)
assertbox.material.name=="Steel"assertbox.mass>0assertbox.colorisnotNoneMaterial.molar_mass is a computed property that parses the
chemical formula and looks up each element's atomic weight.
It supports fractional stoichiometry and strips dopant
notation like LYSO:Ce so doped-crystal aliases work
unchanged.
Nothing is stored — it recomputes on each access. That's
intentional: molar mass is definitionally derived from
formula and should never drift. Missing or unknown-element
formulas return None. See
docs/decisions/0001-derived-chemistry-properties-live-on-material.md.
frompymatimportMaterial# Pure elementiron=Material(name="Iron", formula="Fe")
assertiron.molar_mass==55.85# Simple compoundalumina=Material(name="Alumina", formula="Al2O3")
assertabs(alumina.molar_mass-101.96) <0.01# Fractional stoichiometry (a PET-scanner scintillator)lyso=Material(name="LYSO", formula="Lu1.8Y0.2SiO5")
assertabs(lyso.molar_mass-440.87) <0.1# Dopant suffix is strippedlyso_ce=Material(name="LYSO:Ce", formula="Lu1.8Y0.2SiO5:Ce")
assertlyso_ce.molar_mass==lyso.molar_mass# Unit-aware companion accessor (Pint Quantity)qty=iron.molar_mass_qtyassertqty.to("kg/mol").magnitude==pytest.approx(0.05585, abs=1e-4)
# Gracefully returns None when no formula is setunknown=Material(name="Unknown Alloy")
assertunknown.molar_massisNoneFor callers that don't need a full Material object —
e.g. quick stoichiometry calculations inside a Monte Carlo
transport loop — the low-level pymat.elements module
exposes the same machinery directly.
The ATOMIC_WEIGHT table is a line-for-line mirror of the
Rust rs-materials crate, so Python and Rust Monte Carlo
engines get identical molar masses byte-for-byte.
frompymat.elementsimport (
ATOMIC_WEIGHT,
compute_molar_mass,
parse_formula,
)
# Atomic weight lookupassertATOMIC_WEIGHT["Fe"] ==55.85assertATOMIC_WEIGHT["Lu"] ==175.0# Formula parser: fractional stoichiometry + repeat handlingcounts=parse_formula("Lu1.8Y0.2SiO5")
assertcounts== {"Lu": 1.8, "Y": 0.2, "Si": 1.0, "O": 5.0}
# Molar mass directly from a formula stringassertabs(compute_molar_mass("Al2O3") -101.96) <0.01Build hierarchies with grades, tempers, and treatments:
frompymatimportMaterial# Create base stainless steelstainless=Material(name="Stainless Steel", density=8.0, thermal={"melting_point": 1450})
# Add grades304=stainless.grade_("304", name="SS 304", mechanical={"yield_strength": 170})
asserts304.density==8.0# Inheritedasserts304.properties.mechanical.yield_strength==170# Add treatmentpassivated=s304.treatment_("passivated", name="SS 304 Passivated")
assert (
passivated.path=="stainless_steel.304.passivated"
) # name -> lowercase with underscoresassertpassivated.density==8.0# Inherited through chainLoad materials and access them directly from the library:
frompymatimportaluminum, lyso, stainless# Direct access to materialss316L=stainless.s316Lasserts316L.grade=="316L"al6061=aluminum.a6061assertal6061.density==2.7# Inherited from aluminumlyso_crystal=lysoassert"LYSO"inlyso_crystal.nameMaterials reached via pymat["..."] or category imports
(from pymat import stainless) are shared instances — the
same object every caller in the process sees. Mutating
m.vis.roughness = 0.6 or flipping m.vis.finish on those
leaks into every other consumer.
The safe pattern uses two methods that pair cleanly:
importpymatfrompymat.visimportto_threejssteel=pymat["Stainless Steel 304"] # registry singleton — DON'T mutate# Derive an independent variant (1) and attach it to a fresh Material (2)polished_vis=steel.vis.override(roughness=0.05, finish="polished")
shiny=steel.with_vis(polished_vis)
three=to_threejs(shiny) # uses the polished valuesassertthree["roughness"] ==0.05assertsteel.vis.material_id=="Metal012"# registry untouched# ``Vis.override(**deltas) -> Vis`` → deep-copied Vis with deltas# (finishes deep-copied; identity changes invalidate the texture# cache atomically; unknown kwargs raise TypeError).# ``Material.with_vis(vis) -> Material`` → registry-detached Material# with the supplied Vis attached. Equivalent to ``Material.copy()``# followed by slotting the new vis in.# ``Material.copy() -> Material`` → generic detach if you need to# tweak more than just the Vis.# Materials *you* construct directly (Material(name="custom",# vis={...})) are not shared and can be mutated freely.Understand the difference between measured optical properties (physics) and rendering properties (visualization):
frompymatimportMaterial# Create transparent materialglass=Material(
name="Optical Glass",
color=(0.9, 0.9, 0.9, 0.8), # Visual: 80% opaque whiteoptical={"transparency": 95, "refractive_index": 1.517}, # Physics: 95% transmissionvis={"transmission": 0.8}, # Rendering: how transparent it looks
)
# Physics properties (measured)assertglass.properties.optical.transparency==95assertglass.properties.optical.refractive_index==1.517# Visualization properties (rendering)assertglass.vis.base_color[3] ==0.8# Alphaassertglass.vis.transmission==0.8Define detector crystals with optical physics properties:
frompymatimportMateriallyso_crystal=Material(
name="LYSO:Ce Crystal",
density=7.1,
optical={
"refractive_index": 1.82,
"transparency": 92,
"light_yield": 32000, # photons/MeV"decay_time": 41, # ns"emission_peak": 420, # nm
},
vis={"base_color": (0.0, 1.0, 1.0, 0.85), "transmission": 0.85},
)
assertlyso_crystal.properties.optical.light_yield==32000assertlyso_crystal.properties.optical.decay_time==41assertlyso_crystal.vis.transmission==0.85Use factory functions for materials with properties that depend on external parameters:
frompymat.factoriesimportwater# Water at different temperaturescold_water=water(4) # Max densityroom_water=water(20) # Room temperaturehot_water=water(80) # Heatedassertcold_water.density>room_water.densityassertroom_water.density>hot_water.density# Verify realistic valuesassert0.99<cold_water.density<1.01assert0.95<hot_water.density<0.98Create air material at specific temperature and pressure:
frompymat.factoriesimportairsea_level=air(15, 1.0) # 15°C, 1 atmhigh_altitude=air(15, 0.5) # 15°C, 0.5 atm (5500m)assertsea_level.density>high_altitude.densityCreate solutions with specific concentration and temperature:
frompymat.factoriesimportsaline, water# Physiological saline at body temperaturephantom=saline(0.9, temperature_c=37)
# Saline is slightly denser than pure water at same temperaturepure_water_37=water(37)
assertphantom.density>pure_water_37.density# Seawater (3.5% NaCl) at 20°Cseawater=saline(3.5, temperature_c=20)
# Higher concentration = higher densityassertseawater.density>phantom.densityAccess various metal materials from the metals category:
frompymatimportaluminum, copper, stainless# Stainless steel variantss304=stainless.s304s316L=stainless.s316Lasserts304.density==s316L.density# Same base density# Aluminum alloysal6061=aluminum.a6061_=aluminum.a7075assertal6061.density==2.7# Coppercopper_material=copperassertcopper_material.density==8.96Access plastic materials for 3D printing and engineering:
frompymatimportpc, peek, pla, pmma# Engineering plasticsassertpeek.properties.manufacturing.print_nozzle_temp==360# 3D printing plasticsassertpla.properties.manufacturing.printable_fdmisTrue# Transparent plasticsassertpmma.properties.optical.transparency==92assertpc.properties.optical.transparency==89Access scintillator materials for radiation detectors:
frompymatimportbgo, lyso, nai# LYSO crystalassertlyso.properties.optical.light_yield==32000assertlyso.properties.optical.refractive_index==1.82# BGO crystalassertbgo.properties.optical.light_yield==8500# NaI crystalassertnai.properties.optical.light_yield==38000Access gases for simulation and detector design:
frompymatimportair, argon, helium, nitrogen, xenon# Common gases at STPassert0.0012<air.density<0.0013# g/cm³assertnitrogen.density>helium.density# Helium is lightestassertxenon.density>argon.density# Heavier noble gases# Detector gasesassertargon.properties.compliance.radiation_resistantisTrueChild materials inherit properties from parents unless overridden:
frompymatimportMaterial# Create material hierarchyroot=Material(
name="Base", density=7.8, thermal={"melting_point": 1500, "thermal_conductivity": 50}
)
grade1=root.grade_("G1", mechanical={"yield_strength": 400})
assertgrade1.density==7.8# Inheritedassertgrade1.properties.mechanical.yield_strength==400# New propertyassertgrade1.properties.thermal.melting_point==1500# Inherited# Override inherited propertygrade2=root.grade_("G2", thermal={"melting_point": 1600})
assertgrade2.properties.thermal.melting_point==1600# OverriddenMaterials with density automatically calculate shape mass:
frombuild123dimportBoxfrompymatimportaluminum, stainless# 10x10x10 mm³ box = 1000 mm³ = 1 cm³steel_box=Box(10, 10, 10)
stainless.apply_to(steel_box)
# Density = 8.0 g/cm³, Volume = 1 cm³ → Mass = 8.0 gassert7.9<steel_box.mass<8.1# Aluminum boxal_box=Box(10, 10, 10)
aluminum.apply_to(al_box)
# Density = 2.7 g/cm³ → Mass = 2.7 gassert2.6<al_box.mass<2.8Materials render with appropriate colors for visualization:
frombuild123dimportBoxfrompymatimportaluminum, lyso, stainless# Create shapessteel_part=Box(10, 10, 10)
al_part=Box(10, 10, 10)
crystal=Box(10, 10, 10)
# Apply materialsstainless.apply_to(steel_part)
aluminum.apply_to(al_part)
lyso.apply_to(crystal)
# Verify colors are setassertsteel_part.colorisnotNoneassertal_part.colorisnotNoneassertcrystal.colorisnotNone# Colors should differassertsteel_part.color!=al_part.colorassertcrystal.color!=steel_part.colorEvery curated value carries a _sources entry pointing at a
primary paper or handbook. Material.source_of(path) returns
the Source for a property; short aliases ("density") and
fully-qualified paths ("optical.light_yield") both resolve.
frompymatimportbgo, inconel625# BGO's light yield comes from Weber & Monchamp's 1973 discovery paper.src=bgo.source_of("optical.light_yield")
assertsrc.kind=="doi"assertsrc.ref=="10.1063/1.1662183"assertsrc.license=="proprietary-reference-only"# Inconel 625's density traces to MIL-HDBK-5J Table 6.3.3.0(b).ms=inconel625.source_of("mechanical.density")
assertms.kind=="handbook"assertms.ref=="mil-hdbk-5j:p6-35"assertms.license=="PD-USGov"Material.cite(path) returns a BibTeX entry for one property;
Material.cite() (no arg) returns every source the material
uses, deduplicated.
frompymatimportbgobib=bgo.cite("optical.light_yield")
assert"@"inbib# BibTeX entry type markerassert"10.1063/1.1662183"inbib# the DOI is embeddedAdd a <prop>_stddev sibling key to a property and the loader
folds it into a ufloat (from the uncertainties package) at
load time. The build123d boundary still receives a plain float
(the nominal value) — Material.density_g_mm3 strips the
uncertainty so CAD math doesn't surprise downstream consumers.
frompymatimportload_tomlp=tmp_path/"steel.toml"p.write_text(
"[steel]\n"'name = "Steel"\n'"[steel.mechanical]\n""density_value = 7.85\n"'density_unit = "g/cm^3"\n'"density_stddev = 0.05\n"
)
steel=load_toml(p)["steel"]
d=steel.properties.mechanical.density# `d` is a ufloat — arithmetic on it propagates uncertaintyassertd.nominal_value==7.85assertd.std_dev==0.05# Plain-float view for build123d-style consumersassertsteel.density_g_mm3==0.00785# 7.85 g/cm³ → g/mm³Alloy specs (AMS, ASTM, EN AW) typically give a tolerance window
per element. The loader accepts {nominal, min, max} per element
and folds it into a ufloat whose ±σ spans the spec window.
frompymatimportaluminum# 6063 has min/max windows from the AZoM/ASM spec sheeta6063=aluminum.a6063# Si is 0.2-0.6 wt% (nominal 0.4); the loader stores it as a ufloatsi=a6063.composition["Si"]
assertsi.nominal_value==0.004# ±σ is half the spec windowassertsi.std_dev==0.002For values that vary with T, attach a <prop>_curve sibling
with temps_K knots and values. Then call <prop>_at(T) on
the property group with a Pint temperature Quantity. Linear
interpolation between knots; values clamp at the boundaries.
frompymatimportload_tomlfrompymat.unitsimporturegp=tmp_path/"ofhc.toml"p.write_text(
"[copper]\n"'name = "Copper"\n'"[copper.thermal]\n""thermal_conductivity_value = 391\n"'thermal_conductivity_unit = "W/(m*K)"\n'"thermal_conductivity_curve = { ""temps_K = [77, 295, 500], ""values = [600, 391, 350] ""}\n"
)
copper=load_toml(p)["copper"]
# Scalar still reads as the RT valueassertcopper.properties.thermal.thermal_conductivity==391# _at(T) follows the curvek77=copper.properties.thermal.thermal_conductivity_at(77*ureg.kelvin)
assertk77.to("W/(m*K)").magnitude==600# Linear interp between 77 K and 295 Kk186=copper.properties.thermal.thermal_conductivity_at(186*ureg.kelvin)
assert495<k186.to("W/(m*K)").magnitude<496The 3.11 release added GAGG:Ce, LSO:Ce, LaBr3:Ce, CeBr3, and SrI2:Eu, each cited to its primary measurement paper.
frompymatimportcebr3, gagg, sri2# GAGG:Ce — the dotted child carries the doped scalarsassertgagg.Ce.properties.optical.light_yield==40000assertgagg.Ce.properties.optical.decay_time==53# CeBr3 — fast, bright, intrinsic 4% resolution at 662 keVassertcebr3.density==5.2assertcebr3.properties.optical.light_yield==68000# SrI2:Eu — highest light yield in the catalogassertsri2.Eu.properties.optical.light_yield==115000Inconel 625 and 718 ship with MIL-HDBK-5J A-basis design allowables and Special Metals technical-bulletin thermal data.
frompymatimportinconel625, inconel718assertinconel625.density==8.44assertinconel625.properties.mechanical.tensile_strength==820# MPa, A-basisassertinconel718.density==8.22# Inconel 718 STA — much higher Ftu than 625 annealedassertinconel718.properties.mechanical.tensile_strength==1241- Metals: Stainless steel, aluminum, copper, tungsten, lead, titanium, brass
- Scintillators: LYSO, BGO, NaI, CsI, LaBr3, PWO, plastic scintillators
- Plastics: PEEK, Delrin, Ultem, PTFE, ESR, Nylon, PLA, ABS, PETG, TPU, PMMA, PE, PC
- Ceramics: Alumina, Macor, Zirconia, Glass (borosilicate, fused silica, BK7)
- Electronics: FR4, Rogers, Kapton, copper PCB, solder, ferrite
- Liquids: Water, Heavy Water, Mineral Oil, Glycerol, Silicone Oil
- Gases: Air, Nitrogen, Oxygen, Argon, CO₂, Helium, Hydrogen, Neon, Xenon, Methane, Vacuum
Each material can have properties organized in these domains:
- Mechanical: density, Young's modulus, yield strength, hardness
- Thermal: melting point, thermal conductivity, expansion coefficient
- Electrical: resistivity, conductivity, dielectric constant
- Optical: refractive index, transparency, light yield (PHYSICS - measured values)
- Vis (on
material.vis): base_color, metallic, roughness, ior, transmission, textures, finishes — visual/rendering layer, fetches PBR textures from mat-vis on demand - Manufacturing: machinability, printability, weldability
- Compliance: RoHS, REACH, food-safe, biocompatible
- Sourcing: cost, availability, suppliers
Create materials using property group dictionaries:
frompymatimportMaterialmy_material=Material(
name="Custom Alloy",
mechanical={
"density": 8.1,
"youngs_modulus": 200,
"yield_strength": 450
},
vis={
"base_color": (0.7, 0.7, 0.75, 1.0),
"metallic": 1.0,
"roughness": 0.4
}
)frompymatimportload_tomlmaterials=load_toml("my_materials.toml")
my_material=materials["my_material"]enrich_from_periodictable reads the material's formula, populates
the composition dict (element → atom count), and sets the density
only for pure elements — compound density is not derivable from
periodictable's dataset and requires a crystallographic source like
Materials Project. Molar mass is always available regardless, via the
computed Material.molar_mass property (see the Quick Start).
frompymatimportMaterial, enrich_from_periodictable# Pure element — density is setiron=Material(name="Iron", formula="Fe")
enrich_from_periodictable(iron)
assertiron.density==7.874# set from periodictableassertiron.molar_mass==55.85# computed from formula# Compound — composition is set, density is notalumina=Material(name="Alumina", formula="Al2O3")
enrich_from_periodictable(alumina)
assertalumina.composition== {"Al": 2, "O": 3}
assertalumina.molar_mass==101.96# computed from formulaassertalumina.densityisNone# use enrich_from_matproj for compoundsImportant: Understand the distinction between physics and visualization:
Optical Properties (
properties.optical.*): Measured/calculated physical valuestransparency: % light transmission (measured)refractive_index: optical property (measured)light_yield: scintillator brightness (measured)
Visual Properties (
material.vis.*): Rendering/visualization parametersbase_color: RGBA values (0-1) for displaytransmission: how transparent it LOOKS in rendersmetallic: surface finish appearanceroughness: surface roughness appearancesource+material_id+tier: mat-vis appearance identity (since 3.1; the three fields match mat-vis-client's positional-arg signature — see ADR-0002)textures: PBR texture maps (lazy-fetched from mat-vis oncesource+material_idare set)finishes: named alternate looks (e.g.brushed/polished/oxidized) — each entry is an inline{source, id}tablemtlx(property): MaterialX document accessor (.xml,.export(path),.original)client(property): escape hatch to the sharedMatVisClientsingleton — for any operation not material-keyed
These can differ intentionally! A material might be physically transparent (95% optical transmission) but rendered opaque (0% vis.transmission) for CAD clarity.
MIT
Non-trivial architectural decisions live under docs/decisions/ as
lightweight ADRs. They explain why the code is shaped the way it is
and the conditions under which the decision should be revisited.
- ADR-0001 — Derived chemistry properties live on
Material(whymolar_massis a computed@property, not a stored field)
- GitHub: https://github.com/MorePET/mat
- Issues: https://github.com/MorePET/mat/issues
- PyPI: https://pypi.org/project/py-materials/
- Rust crate (
rs-materials): https://crates.io/crates/rs-materials