From af7099238b4e8179ec2e00f22b44ff667bdeda81 Mon Sep 17 00:00:00 2001
From: James Bayley <36523314+jamesbayley@users.noreply.github.com>
Date: Wed, 21 Feb 2024 14:47:40 +0000
Subject: [PATCH] Implement initial behaviour
---
gazelle/generics.py | 39 ++++
gazelle/steel/__init__.py | 0
gazelle/steel/errors.py | 22 ++
gazelle/steel/fabricator.py | 238 +++++++++++++++++++
gazelle/steel/sections/__init__.py | 0
gazelle/steel/sections/abc.py | 107 +++++++++
gazelle/steel/sections/collections.py | 28 +++
gazelle/steel/sections/hollow.py | 180 +++++++++++++++
gazelle/steel/sections/universal.py | 107 +++++++++
gazelle/units.py | 314 ++++++++++++++++++++++++++
10 files changed, 1035 insertions(+)
create mode 100644 gazelle/generics.py
create mode 100644 gazelle/steel/__init__.py
create mode 100644 gazelle/steel/errors.py
create mode 100644 gazelle/steel/fabricator.py
create mode 100644 gazelle/steel/sections/__init__.py
create mode 100644 gazelle/steel/sections/abc.py
create mode 100644 gazelle/steel/sections/collections.py
create mode 100644 gazelle/steel/sections/hollow.py
create mode 100644 gazelle/steel/sections/universal.py
create mode 100644 gazelle/units.py
diff --git a/gazelle/generics.py b/gazelle/generics.py
new file mode 100644
index 0000000..48036c5
--- /dev/null
+++ b/gazelle/generics.py
@@ -0,0 +1,39 @@
+# Gazelle: a fast, cross-platform engine for structural analysis & design.
+# Copyright (C) 2024 James S. Bayley
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+
+from typing import Callable, TypeVar
+
+
+T = TypeVar("T")
+
+def mutate(data: T, transforms: list[Callable[[T], T]]) -> T:
+ """
+ Apply list of inplace transformations to object.
+
+ Args:
+ data: The original object to be transformed.
+ transforms: The transformation functions to apply to the original object.
+
+ Returns:
+ The original object modified 'inplace' by the list of transformations.
+
+ """
+
+ for transform in transforms:
+ data = transform(data)
+
+ return data
diff --git a/gazelle/steel/__init__.py b/gazelle/steel/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/gazelle/steel/errors.py b/gazelle/steel/errors.py
new file mode 100644
index 0000000..9351f62
--- /dev/null
+++ b/gazelle/steel/errors.py
@@ -0,0 +1,22 @@
+class SteelSectionCategoryNotFound(BaseException):
+ """
+ A specified steel section category is either
+ currently unsupported in our application, or
+ simply does not exist in the Blue Book data.
+ """
+
+
+class SteelSectionDesignationNotFound(BaseException):
+ """
+ A specified steel section designation is
+ either currently unsupported in our application,
+ or simply does not exist in the Blue Book data.
+ """
+
+
+class SteelFabricatorNotSupported(BaseException):
+ """
+ The specified steel fabricator either is either
+ currently unsupported in our application, or
+ simply does not exist.
+ """
diff --git a/gazelle/steel/fabricator.py b/gazelle/steel/fabricator.py
new file mode 100644
index 0000000..df23c96
--- /dev/null
+++ b/gazelle/steel/fabricator.py
@@ -0,0 +1,238 @@
+# Gazelle: a fast, cross-platform engine for structural analysis & design.
+# Copyright (C) 2024 James S. Bayley
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+
+"""
+The Steel for Life 'Blue Book' is the standard tome used by Structural Engineers
+in the UK to design and specify structural steel sections. The book contains
+a plethora of design data that is fundamental to steel design in accordance with
+Eurocode 3 (EC3).
+
+This module provides a convenient API interface to interact with, and transform,
+the data captured within the Blue Book. Data has been sourced from the official
+website: https://www.steelforlifebluebook.co.uk.
+"""
+
+import json
+import pathlib
+from enum import Enum
+from typing import Generic, Tuple
+
+from gazelle.steel.errors import (
+ SteelSectionCategoryNotFound,
+ SteelSectionDesignationNotFound
+)
+from gazelle.steel.sections.abc import (
+ SectionDesignation,
+ SectionProperties
+)
+from gazelle.steel.sections.collections import (
+ S,
+ SteelSections
+)
+from gazelle.steel.sections.hollow import (
+ ColdFormedCircularHollowSection,
+ ColdFormedRectangularHollowSection,
+ ColdFormedSquareHollowSection,
+ HotFormedCircularHollowSection,
+ HotFormedEllipticalHollowSection,
+ HotFormedRectangularHollowSection,
+ HotFormedSquareHollowSection
+)
+
+from gazelle.steel.sections.universal import (
+ UniversalBeam,
+ UniversalColumn,
+ UniversalBearingPile
+)
+from gazelle.units import (
+ Metre
+)
+
+
+class SectionCategory(Enum):
+ UB = "Universal Beam"
+ UC = "Universal Column"
+ PFC = "Parallel Flange Channel"
+ UBP = "Universal Bearing Pile"
+ EQUAL_L = "Equal L Section"
+ UNEQUAL_L = "Unequal L Section"
+ COLD_FORMED_CHS = "Cold Formed Circular Hollow Section"
+ COLD_FORMED_RHS = "Cold Formed Rectangular Hollow Section"
+ COLD_FORMED_SHS = "Cold Formed Square Hollow Section"
+ HOT_FORMED_CHS = "Hot Formed Circular Hollow Section"
+ HOT_FORMED_RHS = "Hot Formed Rectangular Hollow Section"
+ HOT_FORMED_SHS = "Hot Formed Square Hollow Section"
+ HOT_FORMED_EHS = "Hot Formed Elliptical Hollow Section"
+ T_SPLIT_FROM_UB = "T Split From UB Section"
+ T_SPLIT_FROM_UC = "T Split From UC Section"
+
+
+class SteelFabricator(Generic[S]):
+ """
+ The Steel Fabricator class handles the configuration and instantiation
+ of structural steel objects. The class adopts the Static Factory Pattern
+ providing a single convenient API to generate Steel for Life 'Blue Book'
+ sections.
+ """
+
+ @classmethod
+ def _load_section_data_for(cls, category: SectionCategory) -> dict[SectionDesignation, SectionProperties]:
+ """
+ Returns the properties and dimensions for all sections in a given category,
+ as defined in the Steel for Life 'Blue Book'.
+
+ Args:
+ category: The selected 'Blue Book' steel section category.
+
+ Returns:
+ The full collection of steel sections in the category, as a dictionary.
+
+ Raises:
+ SectionCategoryNotFound: If the specified category is unsupported.
+ FileNotFoundError: If the requested Blue Book JSON data file is missing.
+ """
+
+ dir_path = pathlib.Path(__file__).parent.parent / ".d/bluebook/properties"
+
+ categories = {
+ SectionCategory.UB: dir_path / "ub.json",
+ SectionCategory.UC: dir_path / "uc.json",
+ SectionCategory.PFC: dir_path / "pfc.json",
+ SectionCategory.UBP: dir_path / "ubp.json",
+ SectionCategory.EQUAL_L: dir_path / "equal-l.json",
+ SectionCategory.UNEQUAL_L: dir_path / "unequal-l.json",
+ SectionCategory.COLD_FORMED_CHS: dir_path / "cf-chs.json",
+ SectionCategory.COLD_FORMED_RHS: dir_path / "cf-rhs.json",
+ SectionCategory.COLD_FORMED_SHS: dir_path / "cf-shs.json",
+ SectionCategory.HOT_FORMED_CHS: dir_path / "hf-chs.json",
+ SectionCategory.HOT_FORMED_RHS: dir_path / "hf-rhs.json",
+ SectionCategory.HOT_FORMED_SHS: dir_path / "hf-shs.json",
+ SectionCategory.HOT_FORMED_EHS: dir_path / "hf-ehs.json",
+ SectionCategory.T_SPLIT_FROM_UB: dir_path / "t-split-from-ub.json",
+ SectionCategory.T_SPLIT_FROM_UC: dir_path / "t-split-from-uc.json",
+ }
+
+ if category not in categories:
+ raise SteelSectionCategoryNotFound(f"{category.name}.")
+
+ file = categories[category]
+
+ if not file.exists():
+ raise FileNotFoundError(f"Section Category: {category}.")
+
+ with open(file, "r", encoding="utf-8") as sections:
+ return json.load(sections)
+
+
+ # @classmethod
+ # def _inject_carbon_data(cls, definitions: dict[SectionDesignation, SectionProperties]) -> dict[SectionDesignation, SectionProperties]:
+ # """
+ # For each structural section listed in the section definitions,
+ # proceed to compute the mass of carbon per unit length and then
+ # update the section definition with this new property.
+
+ # :param SectionDefinitions definitions: A dictionary structure containing the
+ # full collection of steel section data for a given Blue Book category.
+ # :return: The original SectionDefinitions object modified 'inplace' to augment
+ # the dictionary structure with 'Carbon Per Metre (CO2/m)' properties.
+ # """
+
+ # for section_properties in definitions.values():
+ # carbon_per_metre = (
+ # section_properties["Mass Per Metre (kg/m)"] *
+ # MILD_STEEL.carbon_per_kg
+ # )
+ # section_properties["Carbon Per Metre (CO2/m)"] = carbon_per_metre
+
+ # return definitions
+
+
+ @classmethod
+ def _get_sections_and_constructor_for(cls, category: SectionCategory) -> Tuple[S, dict[SectionDesignation, SectionProperties]]:
+ """
+ :param SectionCategory category: Selected 'Blue Book' steel section category.
+ :return: A tuple containing the constructor and steel section collection.
+ :raises SectionCategoryNotFound: If the specified category is not supported.
+ """
+
+ constructors: dict[SectionCategory, S] = {
+ SectionCategory.UB: UniversalBeam,
+ SectionCategory.UC: UniversalColumn,
+ SectionCategory.UBP: UniversalBearingPile,
+ SectionCategory.HOT_FORMED_RHS: HotFormedRectangularHollowSection,
+ SectionCategory.HOT_FORMED_SHS: HotFormedSquareHollowSection,
+ SectionCategory.HOT_FORMED_CHS: HotFormedCircularHollowSection,
+ SectionCategory.HOT_FORMED_EHS: HotFormedEllipticalHollowSection,
+ SectionCategory.COLD_FORMED_RHS: ColdFormedRectangularHollowSection,
+ SectionCategory.COLD_FORMED_SHS: ColdFormedSquareHollowSection,
+ SectionCategory.COLD_FORMED_CHS: ColdFormedCircularHollowSection
+ }
+
+ if category not in constructors:
+ raise SteelSectionCategoryNotFound(f"{category}.")
+
+ ctor = constructors[category]
+ sections = cls._load_section_data_for(category)
+ # mutate(sections, [cls._inject_carbon_data])
+
+ return ctor, sections
+
+
+ @classmethod
+ def make_section(cls, category: SectionCategory, designation: SectionDesignation, length: Metre = Metre(1.0)) -> S:
+ """
+ Instantiate a specific steel section from a given category. For the full
+ list of available steel section designations, the user is advised to
+ consult the Steel for Life 'Blue Book' website: https://www.steelforlifebluebook.co.uk.
+
+ Args:
+ category: Selected 'Blue Book' steel section category.
+ designation: Unique identifier for the steel section.
+ length: The desired steel section length (defaults to 1.0m).
+
+ Returns:
+ Single steel section of given category with the specified designation.
+
+ Raises:
+ SectionDesignationNotFound: If unsupported designation is provided.
+ """
+
+ (ctor, sections) = cls._get_sections_and_constructor_for(category)
+
+ if designation not in sections:
+ raise SteelSectionDesignationNotFound(f"{designation}.")
+
+ return ctor(designation, sections[designation], length)
+
+
+ @classmethod
+ def make_all_sections(cls, category: SectionCategory, length: Metre = Metre(1.0)) -> SteelSections[S]:
+ """
+ Instantiates the full collection of steel sections for a given category.
+ For the full list of available steel section categories, the user is
+ advised to consult the Steel for Life 'Blue Book' website: https://www.steelforlifebluebook.co.uk.
+
+ Args:
+ category: Selected 'Blue Book' steel section category.
+ length: The desired length for all steel sections.
+
+ Returns:
+ A list of steel section objects.
+ """
+
+ (ctor, sections) = cls._get_sections_and_constructor_for(category)
+ return SteelSections([ctor(d, sections[d], length) for d in sections.keys()])
diff --git a/gazelle/steel/sections/__init__.py b/gazelle/steel/sections/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/gazelle/steel/sections/abc.py b/gazelle/steel/sections/abc.py
new file mode 100644
index 0000000..2ebb3a6
--- /dev/null
+++ b/gazelle/steel/sections/abc.py
@@ -0,0 +1,107 @@
+"""
+Abstract base class representations common to all steel sections.
+
+Typically, users will not use this module directly. All 'concrete'
+steel sections implemented elsewhere within the package inherit
+from classes within this module. Users can reliably depend on the
+attributes and methods defined herein as the public interface
+for all steel sections and collections of steel sections.
+
+Nevertheless, specific steel section types do provide more
+specialised, refined implementations and additional attributes.
+Therefore, if a particular attribute has not been defined
+within the abstract base classes, it is worth checking the
+specific section implementations.
+
+The real advantage of this module is that it provides generic
+behaviours that are common to all steel sections, regardless
+of their specific geometries. For example, all steel sections
+have a 'mass per metre length', hence it is sensible to capture
+this behaviour at the top of the class hierarchy.
+"""
+
+from abc import ABC, abstractmethod
+from typing import NewType, Union
+
+from gazelle.units import (
+ Area,
+ Carbon,
+ Centimetre,
+ Kilogram,
+ Mass,
+ Metre,
+ SurfaceArea,
+ Tonne
+)
+
+
+SectionDesignation = NewType("SectionDesignation", str)
+SectionProperties = NewType("SectionProperties", dict[str, Union[bool, float]])
+
+
+class SteelSection(ABC):
+ """An abstract class for a steel section."""
+
+ def __init__(self, des: SectionDesignation, props: SectionProperties, length: Metre):
+ """Initialise the section."""
+ self._properties = props
+ self.designation = des
+ self.length = length
+
+ @property
+ def is_nonstandard(self) -> bool:
+ """Whether the section is non-standard (i.e., uncommon in the UK)."""
+ return self._properties["Is Non-Standard"]
+
+ @property
+ def cross_sectional_area(self) -> Area[Centimetre]:
+ """The cross-sectional area of the section."""
+ return Area(self._properties["Area of Section, A (cm2)"], Centimetre)
+
+ @property
+ def total_mass(self) -> Kilogram:
+ """The total mass of the section."""
+ return self.mass_per_metre_length * self.length
+
+ @property
+ def mass_per_metre_length(self) -> Mass.PerUnitLength[Kilogram, Metre]:
+ """The mass per metre length of the section."""
+ return Kilogram(self._properties["Mass Per Metre (kg/m)"]).per_unit_length(Metre)
+
+ @property
+ def surface_area_per_metre(self) -> SurfaceArea.PerUnitLength[Metre, Metre]:
+ """The surface area per metre length of the section."""
+ return SurfaceArea(self._properties["Surface Area Per Metre (m2)"], Metre) \
+ .per_unit_length(Metre)
+
+ @property
+ def surface_area_per_tonne(self) -> SurfaceArea.PerUnitMass[Metre, Tonne]:
+ """The surface area per tonne of the section."""
+ return SurfaceArea(self._properties["Surface Area Per Tonne (m2)"], Metre) \
+ .per_unit_mass(Tonne)
+
+ @property
+ def carbon_per_metre(self) -> Carbon.PerUnitLength[Metre]:
+ """The carbon per metre length of the section."""
+ return Carbon(self._properties["Carbon Per Metre (CO2/m)"]) \
+ .per_unit_length(Metre)
+
+ def to_json(self):
+ """Return a JSON representation of the section."""
+ return {
+ "sectionClassification": str(self),
+ "isNonstandard": self.is_nonstandard,
+ "sectionProperties": {
+ "length": self.length.to_json(),
+ "massPerMetreLength": self.mass_per_metre_length.to_json(),
+ "totalMass": self.total_mass.to_json(),
+ "crossSectionalArea": self.cross_sectional_area.to_json(),
+ "surfaceAreaPerMetre": self.surface_area_per_metre.to_json(),
+ "surfaceAreaPerTonne": self.surface_area_per_tonne.to_json(),
+ # "carbonPerMetre": self.carbon_per_metre.to_json()
+ }
+ }
+
+ @abstractmethod
+ def __str__(self) -> str:
+ pass
diff --git a/gazelle/steel/sections/collections.py b/gazelle/steel/sections/collections.py
new file mode 100644
index 0000000..0ea6e72
--- /dev/null
+++ b/gazelle/steel/sections/collections.py
@@ -0,0 +1,28 @@
+from typing import Generic, TypeVar
+
+from gazelle.steel.sections.abc import (
+ SteelSection
+)
+
+
+S = TypeVar("S", bound=SteelSection)
+
+
+class SteelSections(Generic[S]):
+ """A collection of steel sections."""
+
+ def __init__(self, sections: list[S]):
+ """Initialise the collection."""
+ self.sections = sections
+
+ def __len__(self):
+ """Return the number of sections in the collection."""
+ return len(self.sections)
+
+ def __getitem__(self, index: int):
+ """Return the section at the given index."""
+ return self.sections[index]
+
+ def to_json(self):
+ """Return a JSON representation of the collection."""
+ return {section.designation: section.to_json() for section in self.sections}
diff --git a/gazelle/steel/sections/hollow.py b/gazelle/steel/sections/hollow.py
new file mode 100644
index 0000000..7988ba0
--- /dev/null
+++ b/gazelle/steel/sections/hollow.py
@@ -0,0 +1,180 @@
+from abc import abstractmethod, ABC
+
+from gazelle.steel.sections.abc import SteelSection
+from gazelle.units import Millimetre
+
+
+class RectangularHollowSection(SteelSection, ABC):
+ """A rectangular hollow section."""
+
+ @property
+ def width(self) -> Millimetre:
+ """The width of the section."""
+ return Millimetre(self.designation.split("x")[0])
+
+ @property
+ def depth(self) -> Millimetre:
+ """The depth of the section."""
+ return Millimetre(self.designation.split("x")[1])
+
+ @property
+ def flange_thickness(self) -> Millimetre:
+ """The thickness of each vertical flange."""
+ return Millimetre(self.designation.split("x")[2])
+
+ @property
+ def web_thickness(self) -> Millimetre:
+ """The thickness of each horizontal web."""
+ return Millimetre(self.designation.split("x")[2])
+
+ def to_json(self):
+ """Return a JSON representation of the section."""
+ return {
+ "sectionClassification": str(self),
+ "isNonstandard": self.is_nonstandard,
+ "sectionProperties": {
+ "width": self.width.to_json(),
+ "depth": self.depth.to_json(),
+ "length": self.length.to_json(),
+ "massPerMetreLength": self.mass_per_metre_length.to_json(),
+ "totalMass": self.total_mass.to_json(),
+ "crossSectionalArea": self.cross_sectional_area.to_json(),
+ "surfaceAreaPerMetre": self.surface_area_per_metre.to_json(),
+ "surfaceAreaPerTonne": self.surface_area_per_tonne.to_json(),
+ "carbonPerMetre": self.carbon_per_metre.to_json(),
+ "flangeThickness": self.flange_thickness.to_json(),
+ "webThickness": self.web_thickness.to_json(),
+
+ }
+ }
+
+ @abstractmethod
+ def __str__(self):
+ pass
+
+
+class EllipticalHollowSection(SteelSection, ABC):
+ """An elliptical hollow section."""
+
+ @property
+ def width(self) -> Millimetre:
+ """The width of the section."""
+ return Millimetre(self.designation.split("x")[0])
+
+ @property
+ def depth(self) -> Millimetre:
+ """The depth of the section."""
+ return Millimetre(self.designation.split("x")[1])
+
+ @property
+ def plate_thickness(self) -> Millimetre:
+ """The thickness of each vertical flange."""
+ return Millimetre(self.designation.split("x")[1])
+
+ def to_json(self):
+ """Return a JSON representation of the section."""
+ return {
+ "sectionClassification": str(self),
+ "isNonstandard": self.is_nonstandard,
+ "sectionProperties": {
+ "width": self.width.to_json(),
+ "depth": self.depth.to_json(),
+ "length": self.length.to_json(),
+ "massPerMetreLength": self.mass_per_metre_length.to_json(),
+ "totalMass": self.total_mass.to_json(),
+ "crossSectionalArea": self.cross_sectional_area.to_json(),
+ "surfaceAreaPerMetre": self.surface_area_per_metre.to_json(),
+ "surfaceAreaPerTonne": self.surface_area_per_tonne.to_json(),
+ "carbonPerMetre": self.carbon_per_metre.to_json(),
+ "plateThickness": self.plate_thickness.to_json(),
+ }
+ }
+
+ @abstractmethod
+ def __str__(self):
+ pass
+
+
+class CircularHollowSection(SteelSection, ABC):
+ """A circular hollow section."""
+
+ @property
+ def diameter(self) -> Millimetre:
+ """The width of the section."""
+ return Millimetre(self.designation.split("x")[0])
+
+ @property
+ def plate_thickness(self) -> Millimetre:
+ """The thickness of each vertical flange."""
+ return Millimetre(self.designation.split("x")[1])
+
+ def to_json(self):
+ """Return a JSON representation of the section."""
+ return {
+ "sectionClassification": str(self),
+ "isNonstandard": self.is_nonstandard,
+ "sectionProperties": {
+ "length": self.length.to_json(),
+ "diameter": self.diameter.to_json(),
+ "massPerMetreLength": self.mass_per_metre_length.to_json(),
+ "totalMass": self.total_mass.to_json(),
+ "crossSectionalArea": self.cross_sectional_area.to_json(),
+ "surfaceAreaPerMetre": self.surface_area_per_metre.to_json(),
+ "surfaceAreaPerTonne": self.surface_area_per_tonne.to_json(),
+ "carbonPerMetre": self.carbon_per_metre.to_json(),
+ "plateThickness": self.plate_thickness.to_json(),
+ }
+ }
+
+ @abstractmethod
+ def __str__(self):
+ pass
+
+
+class HotFormedRectangularHollowSection(RectangularHollowSection):
+ """A hot-formed rectangular hollow section."""
+
+ def __str__(self):
+ return f"Hot Formed Rectangular Hollow Section (HF-RHS): {self.designation}"
+
+
+class ColdFormedRectangularHollowSection(RectangularHollowSection):
+ """A cold-formed rectangular hollow section."""
+
+ def __str__(self):
+ return f"Cold Formed Rectangular Hollow Section (CF-RHS): {self.designation}"
+
+
+class HotFormedSquareHollowSection(RectangularHollowSection):
+ """A hot-formed square hollow section."""
+
+ def __str__(self):
+ return f"Hot Formed Square Hollow Section (HF-SHS): {self.designation}"
+
+
+class ColdFormedSquareHollowSection(RectangularHollowSection):
+ """A cold-formed square hollow section."""
+
+ def __str__(self):
+ return f"Cold Formed Square Hollow Section (CF-SHS): {self.designation}"
+
+
+class HotFormedCircularHollowSection(CircularHollowSection):
+ """A hot-formed circular hollow section."""
+
+ def __str__(self):
+ return f"Hot Formed Circular Hollow Section (HF-CHS): {self.designation}"
+
+
+class ColdFormedCircularHollowSection(CircularHollowSection):
+ """A cold-formed circular hollow section."""
+
+ def __str__(self):
+ return f"Cold Formed Circular Hollow Section (CF-CHS): {self.designation}"
+
+
+class HotFormedEllipticalHollowSection(EllipticalHollowSection):
+ """A hot-formed elliptical hollow section."""
+
+ def __str__(self):
+ return f"Hot Formed Elliptical Hollow Section (HF-EHS): {self.designation}"
diff --git a/gazelle/steel/sections/universal.py b/gazelle/steel/sections/universal.py
new file mode 100644
index 0000000..2942f61
--- /dev/null
+++ b/gazelle/steel/sections/universal.py
@@ -0,0 +1,107 @@
+"""
+The collection of steel section types, as
+defined in the Steel for Life 'Blue Book'.
+"""
+
+from abc import abstractmethod, ABC
+
+from gazelle.units import (
+ Centimetre,
+ Millimetre
+)
+from gazelle.steel.sections.abc import (
+ SteelSection
+)
+
+
+class UniversalSection(SteelSection, ABC):
+ @property
+ def depth(self) -> Millimetre:
+ return Millimetre(self._properties["Depth of Section, h (mm)"])
+
+ @property
+ def width(self) -> Millimetre:
+ return Millimetre(self._properties["Width of Section, b (mm)"])
+
+ @property
+ def web_thickness(self) -> Millimetre:
+ return Millimetre(self._properties["Web Thickness, tw (mm)"])
+
+ @property
+ def flange_thickness(self) -> Millimetre:
+ return Millimetre(self._properties["Flange Thickness, tf (mm)"])
+
+ @property
+ def root_radius(self) -> Millimetre:
+ return Millimetre(self._properties["Root Radius, r (mm)"])
+
+ @property
+ def depth_between_fillets(self) -> Millimetre:
+ return Millimetre(self._properties["Depth Between Fillets, d (mm)"])
+
+ @property
+ def end_clearance_for_detailing(self) -> Millimetre:
+ return Millimetre(self._properties["End Clearance Dimension for Detailing, C (mm)"])
+
+ @property
+ def long_notch_dimension(self) -> Millimetre:
+ return Millimetre(self._properties["Longitudinal Notch Dimension, N (mm)"])
+
+ @property
+ def vertical_notch_dimension(self) -> Millimetre:
+ return Millimetre(self._properties["Vertical Notch Dimension, n (mm)"])
+
+ @property
+ def radius_of_gyration_yy(self) -> Centimetre:
+ return Centimetre(self._properties["Radius of Gyration, Y-Y (cm)"])
+
+ @property
+ def radius_of_gyration_zz(self) -> Centimetre:
+ return Centimetre(self._properties["Radius of Gyration, Z-Z (cm)"])
+
+ def to_json(self):
+ return {
+ "sectionClassification": str(self),
+ "isNonstandard": self.is_nonstandard,
+ "sectionProperties": {
+ "length": self.length.to_json(),
+ "massPerMetreLength": self.mass_per_metre_length.to_json(),
+ "totalMass": self.total_mass.to_json(),
+ "crossSectionalArea": self.cross_sectional_area.to_json(),
+ "surfaceAreaPerMetre": self.surface_area_per_metre.to_json(),
+ "surfaceAreaPerTonne": self.surface_area_per_tonne.to_json(),
+ "carbonPerMetre": self.carbon_per_metre.to_json(),
+ "depth": self.depth.to_json(),
+ "width": self.width.to_json(),
+ "webThickness": self.web_thickness.to_json(),
+ "flangeThickness": self.flange_thickness.to_json(),
+ "rootRadius": self.root_radius.to_json(),
+ "depthBetweenFillets": self.depth_between_fillets.to_json(),
+ "endClearanceForDetailing": self.end_clearance_for_detailing.to_json(),
+ "longitudinalNotchDimension": self.long_notch_dimension.to_json(),
+ "verticalNotchDimension": self.vertical_notch_dimension.to_json(),
+ "radiusOfGyration": {
+ "yy": self.radius_of_gyration_yy.to_json(),
+ "zz": self.radius_of_gyration_zz.to_json(),
+ },
+ },
+ }
+
+ @abstractmethod
+ def __str__(self):
+ pass
+
+
+class UniversalBeam(UniversalSection):
+ def __str__(self):
+ return f"Universal Beam (UB) Section: {self.designation}"
+
+
+class UniversalColumn(UniversalSection):
+ def __str__(self):
+ return f"Universal Column (UC) Section: {self.designation}"
+
+
+class UniversalBearingPile(UniversalSection):
+ def __str__(self):
+ return f"Universal Bearing Pile (UBP) Section: {self.designation}"
diff --git a/gazelle/units.py b/gazelle/units.py
new file mode 100644
index 0000000..b98b9cc
--- /dev/null
+++ b/gazelle/units.py
@@ -0,0 +1,314 @@
+# Gazelle: a fast, cross-platform engine for structural analysis & design.
+# Copyright (C) 2024 James S. Bayley
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+
+from abc import ABC
+from math import pi
+from typing import Generic, TypeVar, Type, Union
+
+
+M = TypeVar("M", bound="Mass")
+L = TypeVar("L", bound="Length")
+L2 = TypeVar("L2", bound="Length")
+T = TypeVar("T", bound="Dimension")
+U = TypeVar("U", bound="Dimension")
+W = TypeVar("W", bound="Dimension")
+
+
+class Ratio:
+ def __init__(self, value: float):
+ if value >= 0.0:
+ self._dtype = type(self)
+ self.value = value
+ self.units = None
+ else:
+ raise ValueError(f"{self.__class__.__name__} < 0.")
+
+ def to_json(self):
+ return {"value": f"{self.value:.2f}", "units": self.units}
+
+
+class Dimension(Ratio, ABC):
+ def __init__(self, value: float):
+ super().__init__(value)
+
+ def __repr__(self) -> str:
+ return f"{self.value:.2f} {self.units}"
+
+
+class Length(Dimension, ABC):
+ def __init__(self, value: float):
+ super().__init__(value)
+
+ def __mul__(self: L, other: L) -> "Area[L]":
+ if type(self) is type(other):
+ return Area(self.value * other.value, type(self))
+ raise TypeError(f"Incompatible: {type(self)} and {type(other)}.")
+
+
+class Mass(Dimension, ABC):
+ def __init__(self, value: float):
+ super().__init__(value)
+
+ def __truediv__(self: M, volume: "Volume[L]") -> "Density[M, L]":
+ return Density(self, volume)
+
+ def per_unit_length(self, unit_length: Type[L]) -> "Mass.PerUnitLength[L]":
+ return Mass.PerUnitLength(self, unit_length)
+
+ class PerUnitLength(Dimension, Generic[M, L]):
+ def __init__(self, mass: M, unit_length: Type[L]):
+ super().__init__(mass.value)
+ self._dtype = (mass._dtype, unit_length(1)._dtype)
+ self.units = f"{self._dtype[0](1).units}/{self._dtype[1](1).units}"
+
+ def __mul__(self, other: L) -> M:
+ if self._dtype[1] is other._dtype:
+ return self._dtype[0](self.value * other.value)
+ raise TypeError(f"Incompatible: {self._dtype[1]} and {other._dtype}.")
+
+
+class Area(Dimension, Generic[L]):
+ def __init__(self, value: float, dtype: Type[L]):
+ super().__init__(value)
+ self._dtype = dtype
+ self.units = f"{self._dtype(1).units}^2"
+
+ @classmethod
+ def from_rectangle(cls, width: L, depth: L) -> "Area[L]":
+ if type(width) is type(depth):
+ return width * depth
+ raise TypeError(f"Incompatible: {type(width)} and {type(depth)}.")
+
+ @classmethod
+ def from_circle(cls, diameter: L) -> "Area[L]":
+ area = (pi * diameter.value**2.0) / 4.0
+ return cls(area, type(diameter))
+
+ def __mul__(self: "Area[L]", length: L) -> "Volume[L]":
+ if self._dtype is type(length):
+ return Volume(self.value * length.value, self._dtype)
+ raise TypeError(f"Incompatible: {type(length)} and {self._dtype}.")
+
+
+class SurfaceArea(Dimension, Generic[L]):
+ def __init__(self, value: float, dtype: Type[L]):
+ super().__init__(value)
+ self._dtype = dtype
+ self.units = f"{self._dtype(1).units}^2"
+
+ def per_unit_length(
+ self, unit_length: Type[L2]
+ ) -> "SurfaceArea.PerUnitLength[L, L2]":
+ return SurfaceArea.PerUnitLength(self, unit_length)
+
+ def per_unit_mass(self, unit_mass: Type[M]) -> "SurfaceArea.PerUnitMass[L, M]":
+ return SurfaceArea.PerUnitMass(self, unit_mass)
+
+ @classmethod
+ def from_cuboid(cls, width: L, depth: L, length: L) -> "SurfaceArea[L]":
+ if type(width) is type(depth) and type(width) is type(length):
+ perimeter = (2 * width.value) + (2 * depth.value)
+ surface_area = perimeter * length.value
+ return cls(surface_area, type(width))
+ raise TypeError(f"Incompatible: {type(width)}, {type(depth)}, {type(length)}.")
+
+ @classmethod
+ def from_cylinder(cls, diameter: L, length: L) -> "SurfaceArea[L]":
+ if type(diameter) is type(length):
+ surface_area = pi * diameter.value * length.value
+ return cls(surface_area, type(diameter))
+ raise TypeError(f"Incompatible: {type(diameter)} and {type(length)}.")
+
+ class PerUnitLength(Dimension, Generic[L, L2]):
+ def __init__(self, surface_area: "SurfaceArea[L]", unit_length: Type[L2]):
+ super().__init__(surface_area.value)
+ self._dtype = (surface_area._dtype, unit_length(1)._dtype)
+ self.units = f"{self._dtype[0](1).units}^2/{self._dtype[1](1).units}"
+
+ def __mul__(self, length: L2) -> "SurfaceArea[L]":
+ if self._dtype[1] is length._dtype:
+ return SurfaceArea(self.value * length.value, self._dtype[0])
+ raise TypeError(f"Incompatible: {self._dtype[1]} and {length._dtype}.")
+
+ class PerUnitMass(Dimension, Generic[L, M]):
+ def __init__(self, surface_area: "SurfaceArea[L]", unit_mass: Type[M]):
+ super().__init__(surface_area.value)
+ self._dtype = (surface_area._dtype, unit_mass(1)._dtype)
+ self.units = f"{self._dtype[0](1).units}^2/{self._dtype[1](1).units}"
+
+ def __mul__(self, mass: M) -> "SurfaceArea[L]":
+ if self._dtype[1] is mass._dtype:
+ return SurfaceArea(self.value * mass.value, self._dtype[0])
+ raise TypeError(f"Incompatible: {self._dtype[1]} and {mass._dtype}.")
+
+
+class Volume(Dimension, Generic[L]):
+ def __init__(self, value: float, dtype: Type[L]):
+ super().__init__(value)
+ self._dtype = dtype
+ self.units = f"{self._dtype(1).units}^3"
+
+ @classmethod
+ def from_area(cls, area: Area[L], length: L) -> "Volume[L]":
+ if area._dtype is type(length):
+ return area * length
+ raise TypeError(f"Incompatible types: {area._dtype} and {type(length)}.")
+
+ @classmethod
+ def from_cuboid(cls, width: L, depth: L, length: L) -> "Volume[L]":
+ if type(width) is type(depth) and type(width) is type(length):
+ return width * depth * length
+ raise TypeError(f"Incompatible: {type(width)}, {type(depth)}, {type(length)}.")
+
+ @classmethod
+ def from_cylinder(cls, diameter: L, length: L) -> "Volume[L]":
+ if type(diameter) is type(length):
+ return Area.from_circle(diameter) * length
+ raise TypeError(f"Incompatible types: {diameter._dtype} and {length._dtype}.")
+
+ def __truediv__(self, other: Union[Area[L], L]) -> Union[Area[L], L]:
+ if self._dtype is not other._dtype:
+ raise TypeError(f"Incompatible: {self._dtype} and {other._dtype}.")
+
+ if isinstance(other, Area):
+ return self._dtype(self.value / other.value)
+
+ if isinstance(other, Length):
+ return Area(self.value / other.value, self._dtype)
+
+ raise NotImplementedError(f"Unexpected: {type(other)}.")
+
+
+class Carbon(Dimension):
+ def __init__(self, value):
+ super().__init__(value)
+ self.units = "CO2"
+
+ def per_unit_length(self, unit_length: Type[L]) -> "Carbon.PerUnitLength[L]":
+ return Carbon.PerUnitLength(self, unit_length)
+
+ class PerUnitLength(Dimension, Generic[L]):
+ def __init__(self, CO2: "Carbon", unit_length: Type[L]):
+ super().__init__(CO2.value)
+ self._dtype = (CO2._dtype, unit_length(1)._dtype)
+ self.units = f"{self._dtype[0](1).units}/{self._dtype[1](1).units}"
+
+ def __mul__(self, other: L) -> "Carbon":
+ if self._dtype[1] is other._dtype:
+ return Carbon(self.value * other.value)
+ raise TypeError(f"Incompatible: {self._dtype[1]} and {other._dtype}.")
+
+
+class Density(Dimension, Generic[M, L]):
+ def __init__(self, mass: M, volume: Volume[L]):
+ super().__init__(mass.value / volume.value)
+ self._dtype = (mass._dtype, volume._dtype)
+ self.units = f"{self._dtype[0](1).units}/{self._dtype[1](1).units}^3"
+
+ def __mul__(self, other: Volume[L]) -> M:
+ if self._dtype[1] is other._dtype:
+ return self._dtype[0](self.value * other.value)
+ raise TypeError(f"Incompatible: {self._dtype[1]} and {other._dtype}.")
+
+
+class Millimetre(Length):
+ def __init__(self, value):
+ super().__init__(value)
+ self.units = "mm"
+
+ def to_centimetre(self) -> "Centimetre":
+ return Centimetre(self.value / 10.0)
+
+ def to_metre(self) -> "Metre":
+ return Metre(self.value / 1000.0)
+
+
+class Centimetre(Length):
+ def __init__(self, value):
+ super().__init__(value)
+ self.units = "cm"
+
+ def to_millimetre(self) -> "Metre":
+ return Millimetre(self.value * 10.0)
+
+ def to_metre(self) -> "Metre":
+ return Metre(self.value / 100.0)
+
+
+class Metre(Length):
+ def __init__(self, value):
+ super().__init__(value)
+ self.units = "m"
+
+ def to_millimetre(self) -> Millimetre:
+ return Millimetre(self.value * 1000.0)
+
+ def to_centimetre(self) -> Centimetre:
+ return Centimetre(self.value * 100.0)
+
+
+class Kilogram(Mass):
+ def __init__(self, value):
+ super().__init__(value)
+ self.units = "kg"
+
+ def to_tonne(self) -> "Tonne":
+ return Tonne(self.value / 1000.0)
+
+
+class Tonne(Mass):
+ def __init__(self, value):
+ super().__init__(value)
+ self.units = "tonne"
+
+ def to_kilogram(self) -> "Kilogram":
+ return Kilogram(self.value * 1000.0)
+
+
+if __name__ == "__main__":
+ w = Centimetre(2.5)
+ d = Centimetre(1.5)
+ m = Metre(4)
+ a = w * d
+ kg = Kilogram(5)
+ v = a * Centimetre(1)
+ den = kg / v
+ mpul = kg.per_unit_length(Metre)
+ length = Metre(2)
+ CO2 = Carbon(20)
+ cpul = CO2.per_unit_length(Millimetre)
+ surface_area = SurfaceArea(20.0, Millimetre)
+ surface_area_per_metre = surface_area.per_unit_length(Metre)
+ surface_area_per_metre.to_json()
+
+ print(f"Width: {w}.")
+ print(f"Depth: {d}.")
+ print(f"Area: {a}.")
+ print(f"Mass: {kg}.")
+ print(f"Volume: {v}.")
+ print(f"Density: {den}.")
+ print(f"Density x Volume: {den * v}.")
+ print(f"Length: {v / a}.")
+ print(f"Vol / Width: {v / w}.")
+ print(f"Kilogram Per Metre: {mpul}.")
+ print(f"5kg/m x 2m: {mpul * length}")
+ print(f"Carbon: {CO2}.")
+ print(f"Carbon Per Metre Length: {cpul}.")
+ print(f"Surface Area: {surface_area}.")
+ print(f"Surface Area Per Metre Length: {surface_area_per_metre}.")
+ print(f"{surface_area_per_metre.to_json()}")
+ print(f"{cpul.to_json()}")