"""
Parametric O-Rings
name: o_rings.py
by: Gumyr
date: June 29th 2026
desc: This python/build123d code provides parameterized O-rings and gland profiles.
license:
Copyright 2026 Gumyr
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import csv
import math
from bisect import bisect_left
from collections import defaultdict
from importlib import resources
from typing import Literal
from build123d.build_enums import Align, Mode
from build123d.geometry import Axis, Color, Pos, RotationLike
from build123d.objects_part import BasePartObject
from build123d.objects_sketch import Rectangle
from build123d.operations_generic import fillet
from build123d.topology import Sketch, Solid
import bd_warehouse
from bd_warehouse.fastener import (
evaluate_parameter_dict_of_dict,
isolate_fastener_type,
read_fastener_parameters_from_csv,
)
def read_parameters_from_csv(filename: str) -> dict:
"""Parse a metric O-ring gland CSV parameter file."""
parameters = {}
data_resource = resources.files(bd_warehouse) / f"data/{filename}"
with data_resource.open(encoding="utf-8", newline="") as csvfile:
reader = csv.DictReader(csvfile)
fieldnames = reader.fieldnames
if not fieldnames:
raise ValueError(f"No header found in {filename}")
for row in reader:
key = row[fieldnames[0]]
row.pop(fieldnames[0])
parameters[key] = {
str(k): (None if v.strip() == "-" else float(v)) for k, v in row.items()
}
return parameters
def _build_o_ring_indexes(
parameter_data: dict[str, dict[str, str]],
) -> tuple[
dict[str, dict[str, dict[str, float]]],
dict[str, dict[float, list[tuple[float, str]]]],
]:
"""Build evaluated parameter and width-family indexes for each standard."""
if not parameter_data:
raise ValueError("O-ring parameter data is empty")
first_row = next(iter(parameter_data.values()))
standards = {
parameter.split(":", maxsplit=1)[0]
for parameter in first_row
if ":" in parameter
}
if not standards:
raise ValueError("O-ring parameter headers contain no standards")
parameters_by_type = {}
width_families_by_type = {}
for standard in sorted(standards):
standard_parameters = evaluate_parameter_dict_of_dict(
isolate_fastener_type(standard, parameter_data)
)
if not standard_parameters:
raise ValueError(f"O-ring parameter data for {standard} is empty")
width_families = defaultdict(list)
for size_code, dimensions in standard_parameters.items():
try:
width_families[dimensions["w"]].append((dimensions["id"], size_code))
except KeyError as error:
raise ValueError(
f"O-ring parameter data for {standard}:{size_code} is incomplete"
) from error
for entries in width_families.values():
entries.sort()
parameters_by_type[standard] = standard_parameters
width_families_by_type[standard] = dict(width_families)
return parameters_by_type, width_families_by_type
[docs]
class ORing(BasePartObject): # pylint: disable=too-many-instance-attributes
"""Parametric O-ring for static and dynamic sealing applications.
O-rings are toroidal elastomeric seals installed in a gland and compressed
between mating components to prevent fluid or gas leakage. This class creates
nominal general-industrial Class A geometry from ISO 3601-1 and provides
:meth:`static_gland_profile` and :meth:`dynamic_gland_profile` helpers for
designing a corresponding gland. The gland dimensions are initial design
recommendations from the Apple Rubber Seal Design Guide; they have not been
verified against ISO 3601-2.
Args:
size: Three-digit O-ring size code, such as ``"025"``. A leading hyphen
and omitted leading zeroes are accepted and normalized.
o_ring_type: Dimensional standard used to interpret ``size``. Defaults to
``"iso3601"``.
rotation: Sequence of angles about the X, Y, and Z axes. Defaults to
``(0, 0, 0)``.
align: Align MIN, CENTER, or MAX of the O-ring. Defaults to None.
mode: Combination mode. Defaults to Mode.ADD.
Raises:
ValueError: ``size`` or ``o_ring_type`` is invalid.
"""
o_ring_data = read_fastener_parameters_from_csv("o-ring_parameters.csv")
o_ring_parameters, o_ring_width_families = _build_o_ring_indexes(o_ring_data)
o_ring_static_gland_data = read_parameters_from_csv(
"o-ring_static_gland_parameters.csv"
)
o_ring_dynamic_gland_data = read_parameters_from_csv(
"o-ring_dynamic_gland_parameters.csv"
)
@staticmethod
def _normalize_size(size: str) -> str:
"""Normalize an O-ring size code to three digits."""
normalized_size = size.strip().removeprefix("-")
if not normalized_size.isdigit() or len(normalized_size) > 3:
raise ValueError(f"{size!r} invalid, size must be a three-digit code")
return normalized_size.zfill(3)
[docs]
@classmethod
def types(cls) -> set[str]:
"""Return the available O-ring standards."""
return set(cls.o_ring_parameters)
[docs]
@classmethod
def sizes(cls, o_ring_type: str = "iso3601") -> list[str]:
"""Return the available size codes for an O-ring standard."""
if o_ring_type not in cls.types():
raise ValueError(f"{o_ring_type} invalid, must be one of {cls.types()}")
return list(cls.o_ring_parameters[o_ring_type])
[docs]
@classmethod
def parameters(cls, size: str, o_ring_type: str = "iso3601") -> dict[str, float]:
"""Return the evaluated metric parameters for a size and standard."""
normalized_size = cls._normalize_size(size)
if o_ring_type not in cls.types():
raise ValueError(f"{o_ring_type} invalid, must be one of {cls.types()}")
try:
parameters = cls.o_ring_parameters[o_ring_type][normalized_size]
except KeyError as error:
raise ValueError(
f"{size!r} invalid, must be one of {cls.sizes(o_ring_type)}"
) from error
return parameters.copy()
[docs]
@classmethod
def select_by_size(cls, size: str) -> dict[type["ORing"], list[str]]:
"""Return the standards that provide the given size code."""
normalized_size = cls._normalize_size(size)
matching_types = [
o_ring_type
for o_ring_type in sorted(cls.types())
if normalized_size in cls.sizes(o_ring_type)
]
return {cls: matching_types} if matching_types else {}
@classmethod
def _find_neighbors(
cls,
target: float,
tolerance: float,
o_ring_type: str,
by_diameter: bool = True,
) -> dict[str, tuple[str | None, ...]]:
if target <= 0:
raise ValueError("target must be greater than zero")
if tolerance < 0:
raise ValueError("tolerance must be greater than or equal to zero")
if o_ring_type not in cls.types():
raise ValueError(f"{o_ring_type} invalid, must be one of {cls.types()}")
results = {}
for width, diameter_entries in cls.o_ring_width_families[o_ring_type].items():
entries = (
diameter_entries
if by_diameter
else [
(math.pi * (inner_diameter + width), size_code)
for inner_diameter, size_code in diameter_entries
]
)
values = [entry[0] for entry in entries]
index = bisect_left(values, target)
if index < len(entries) and values[index] == target:
results[str(width)] = (entries[index][1],)
continue
lower = (
entries[index - 1][1]
if index > 0 and abs(values[index - 1] - target) / target <= tolerance
else None
)
upper = (
entries[index][1]
if index < len(entries)
and abs(values[index] - target) / target <= tolerance
else None
)
if lower is not None or upper is not None:
results[str(width)] = (lower, upper)
return results
[docs]
@classmethod
def select_by_inner_diameter(
cls,
inner_diameter: float,
tolerance: float = 0.05,
o_ring_type: str = "iso3601",
) -> dict:
"""select_by_inner_diameter
Find closest matches to the given inner_diameter within tolerance. The
results are returned in a dict with the key being the O-ring width and the
values being a tuple of O-ring size codes in (lower, upper) pairs. Should
there be an exact match, a (match,) tuple will be generated.
Args:
inner_diameter (float): inner diameter in mm
tolerance (float, optional): ± deviation from target. Defaults to 0.05.
Returns:
dict: width as key and tuple of o-ring dash numbers as values
Example:
>>> ORing.select_by_inner_diameter(650)
{'5.33': ('394', '395'), '6.99': ('474', '475')}
"""
return cls._find_neighbors(inner_diameter, tolerance, o_ring_type)
[docs]
@classmethod
def select_by_length(
cls,
length: float,
tolerance: float = 0.05,
o_ring_type: str = "iso3601",
) -> dict:
"""Find O-rings for a target installed centre-line length.
For each cross-section family, the result contains the nearest free O-ring
lengths below and above ``length`` as a ``(lower, upper)`` pair. A lower
candidate will be stretched when installed on the target path, while an
upper candidate will be circumferentially compressed. An exact free-length
match is returned as a one-item tuple.
For typical piston or male-gland applications, use about 2% installed
stretch as a design target. The usual range is 1% to 5%, with 5% treated as
an upper limit rather than a target. Stretch above 5% can accelerate ageing
and reduce the O-ring cross-section and resulting seal compression. Rotary
shaft seals generally require no installed stretch.
The installed stretch for a candidate is calculated as::
stretch = length / candidate.length - 1
Gland squeeze calculations should account for the reduction in cross-section
caused by stretching.
Args:
length: Target installed centre-line length in millimetres.
tolerance: Maximum fractional difference between the free and installed
lengths. The default 0.05 searches within 5% of the target.
o_ring_type: Dimensional standard used for selection. Defaults to
``"iso3601"``.
Returns:
Cross-section widths mapped to lower and upper O-ring size codes.
Example:
Select a size for a 101.42 mm installed path and verify that size 025
is stretched by approximately 2%:
>>> installed_length = 101.42
>>> matches = ORing.select_by_length(installed_length)
>>> matches["1.78"]
('025', '026')
>>> candidate = ORing(matches["1.78"][0])
>>> round(installed_length / candidate.length - 1, 3)
0.02
"""
return cls._find_neighbors(length, tolerance, o_ring_type, by_diameter=False)
[docs]
@classmethod
def nominal_widths(cls, o_ring_type: str = "iso3601") -> list[float]:
"""Return the nominal cross-section widths for an O-ring standard."""
if o_ring_type not in cls.types():
raise ValueError(f"{o_ring_type} invalid, must be one of {cls.types()}")
return sorted(cls.o_ring_width_families[o_ring_type])
[docs]
@classmethod
def gland_width_for(
cls,
o_ring_width: float,
application: Literal["static", "dynamic"] = "static",
number_backing_rings: Literal[0, 1, 2] = 0,
) -> float:
"""Return the recommended gland width for an O-ring cross-section.
Args:
o_ring_width: Nominal O-ring cross-section diameter in millimetres.
application: ``"static"`` or ``"dynamic"`` gland design. Defaults
to ``"static"``.
number_backing_rings: Number of anti-extrusion backing rings
accommodated by the gland. Defaults to 0.
Returns:
The nominal gland width in millimetres.
Raises:
ValueError: The application, backing-ring count, or O-ring
cross-section is invalid or unsupported.
"""
if application not in ("static", "dynamic"):
raise ValueError("application must be 'static' or 'dynamic'")
if number_backing_rings not in (0, 1, 2):
raise ValueError("number_backing_rings must be 0, 1, or 2")
if not math.isfinite(o_ring_width) or o_ring_width <= 0:
raise ValueError("o_ring_width must be greater than zero")
gland_table = (
cls.o_ring_static_gland_data
if application == "static"
else cls.o_ring_dynamic_gland_data
)
try:
gland_data = gland_table[f"{o_ring_width:g}"]
except KeyError as error:
raise ValueError(
f"No {application} gland data for an O-ring with a "
f"{o_ring_width} mm cross-section"
) from error
gland_width = gland_data[f"g_w_{number_backing_rings}"]
if gland_width is None:
raise ValueError(
f"{number_backing_rings} backing rings are not supported for "
f"an O-ring with a {o_ring_width} mm cross-section"
)
return gland_width
def __init__( # pylint: disable=too-many-positional-arguments
self,
size: str,
o_ring_type: str = "iso3601",
rotation: RotationLike = (0, 0, 0),
align: None | Align | tuple[Align, Align, Align] = None,
mode: Mode = Mode.ADD,
):
self.o_ring_size = self._normalize_size(size)
self.o_ring_type = o_ring_type
dimensions = self.parameters(self.o_ring_size, self.o_ring_type)
self.dash_number = self.o_ring_size #: ISO 3601 size code
self.inner_diameter = dimensions["id"] #: inner diameter
self.inner_diameter_tolerance = dimensions["id_tol"]
self.width = dimensions["w"] #: cross-section diameter
self.width_tolerance = dimensions["w_tol"]
self.major_radius = (
self.inner_diameter + self.width
) / 2 #: torus major radius
self.minor_radius = self.width / 2 #: torus minor radius
self.length = 2 * math.pi * self.major_radius #: center line length
o_ring = Solid.make_torus(self.major_radius, self.minor_radius)
self.gland_width = None
self.gland_width_tol = 0.13
self.gland_depth = None
self.gland_depth_tol = None
self.gland_radius = None
self.gland_radius_tol = None
self.gland_profile: Sketch | None = None
super().__init__(part=o_ring, rotation=rotation, align=align, mode=mode)
self.label = f"ORing-{self.o_ring_type}-{self.o_ring_size}"
self.color = Color(0x202020)
@property
def info(self) -> str:
"""Return identifying information for this O-ring."""
return f"ORing({self.o_ring_type}): {self.o_ring_size}"
def _gland_data_for_width(
self,
gland_table: dict[str, dict[str, float | None]],
application: str,
) -> dict[str, float | None]:
"""Return gland parameters or report an unsupported cross-section."""
try:
return gland_table[f"{self.width:g}"]
except KeyError as error:
raise ValueError(
f"No {application} gland data for an O-ring with a "
f"{self.width} mm cross-section"
) from error
def _make_gland_profile(
self,
gland_data: dict[str, float | None],
*,
depth_min_key: str,
depth_max_key: str,
number_backing_rings: Literal[0, 1, 2],
) -> Sketch:
"""Create and record a gland profile from evaluated gland parameters."""
if number_backing_rings not in (0, 1, 2):
raise ValueError("number_backing_rings must be 0, 1, or 2")
gland_w = gland_data[f"g_w_{number_backing_rings}"]
if gland_w is None:
raise ValueError(
f"{number_backing_rings} backing rings are not supported for "
f"an O-ring with a {self.width} mm cross-section"
)
depth_min = gland_data[depth_min_key]
depth_max = gland_data[depth_max_key]
if depth_min is None or depth_max is None: # pragma: no cover
raise ValueError("Gland depth data is incomplete")
depth_avg = (depth_max + depth_min) / 2
groove_r_min = gland_data["g_r_min"]
groove_r_max = gland_data["g_r_max"]
if groove_r_min is None or groove_r_max is None: # pragma: no cover
raise ValueError("Gland radius data is incomplete")
groove_r_avg = (groove_r_max + groove_r_min) / 2
gland_profile = Rectangle(gland_w, depth_avg, align=(Align.CENTER, Align.MAX))
gland_profile = fillet(
gland_profile.vertices().group_by(Axis.Y)[0], groove_r_avg
)
self.gland_width = gland_w
self.gland_depth = depth_avg
self.gland_depth_tol = depth_max - depth_avg
self.gland_radius = groove_r_avg
self.gland_radius_tol = groove_r_max - groove_r_avg
self.gland_profile = gland_profile
return gland_profile
[docs]
def static_gland_profile(
self, axial: bool = True, number_backing_rings: Literal[0, 1, 2] = 0
) -> Sketch:
"""Create the recommended profile for a static O-ring gland.
Args:
axial: Select axial squeeze when True or radial squeeze when False.
Defaults to True.
number_backing_rings: Number of anti-extrusion backing rings accommodated
by the gland. Defaults to 0.
Returns:
The gland cross-section with its opening on the local X axis.
Raises:
ValueError: The backing-ring count is invalid or unsupported for the
O-ring cross-section.
"""
gland_data = self._gland_data_for_width(self.o_ring_static_gland_data, "static")
axial_radial = "a" if axial else "r"
return self._make_gland_profile(
gland_data,
depth_min_key=f"d_{axial_radial}_min",
depth_max_key=f"d_{axial_radial}_max",
number_backing_rings=number_backing_rings,
)
[docs]
def dynamic_gland_profile(
self, number_backing_rings: Literal[0, 1, 2] = 0
) -> Sketch:
"""Create the recommended profile for a dynamic radial O-ring gland.
The dimensions are intended as initial design guidance for reciprocating or
oscillating seals. Dynamic seal performance also depends on material,
lubrication, surface finish, pressure, temperature, stretch, and friction.
Args:
number_backing_rings: Number of anti-extrusion backing rings accommodated
by the gland. Defaults to 0.
Returns:
The gland cross-section with its opening on the local X axis.
Raises:
ValueError: The backing-ring count is invalid or unsupported for the
O-ring cross-section.
"""
gland_data = self._gland_data_for_width(
self.o_ring_dynamic_gland_data, "dynamic"
)
return self._make_gland_profile(
gland_data,
depth_min_key="d_min",
depth_max_key="d_max",
number_backing_rings=number_backing_rings,
)
if __name__ == "__main__":
from ocp_vscode import show_all
external_ring = ORing("40")
rings = []
stack_height = 0.0
for size in reversed(ORing.sizes()):
try:
ring = Pos(Z=stack_height) * ORing(size)
rings.append(ring)
stack_height += ring.width
except Exception as error: # pylint: disable=broad-exception-caught
print(f"{size} failed: {error}")
show_all()