Skip to content

Commit d1d5b72

Browse files
author
Uli Köhler
committed
Implement comprehensive density unit conversion with tests
- Expand Density.py with SI, chemistry, imperial and metric ton units - Add @normalize_args + DensityKgPerM3 to Cylinder.py weight functions - Import density normalization from Density.py in Viscosity.py (remove duplication) - Fix numpy string scalar handling in _normalize.py (np.generic -> np.number) - Add comprehensive tests for all units, iterables, and integration
1 parent 1dc32c2 commit d1d5b72

5 files changed

Lines changed: 205 additions & 34 deletions

File tree

UliEngineering/Math/Geometry/Cylinder.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
"""Geometry functions for cylinders and hollow cylinders."""
44
import math
55
from .Circle import circle_area
6-
from UliEngineering.EngineerIO.Decorators import returns_unit
6+
from UliEngineering.EngineerIO.Decorators import normalize_args, returns_unit
77
from UliEngineering.EngineerIO import normalize_numeric
88
from UliEngineering.EngineerIO.Types import NormalizableArgument
9+
from UliEngineering.Physics.Density import DensityKgPerM3
910
import numpy as np
1011

1112
__all__ = [
@@ -44,37 +45,31 @@ def hollow_cylinder_volume(outer_radius: NormalizableArgument, inner_radius: Nor
4445
height = normalize_numeric(height) if isinstance(height, str) else height
4546
return cylinder_volume(outer_radius, height) - cylinder_volume(inner_radius, height)
4647

47-
def cylinder_weight_by_diameter(diameter: NormalizableArgument, length: NormalizableArgument, density: NormalizableArgument = 8000):
48+
@normalize_args
49+
def cylinder_weight_by_diameter(diameter: NormalizableArgument, length: NormalizableArgument, density: DensityKgPerM3 = 8000):
4850
"""Compute the weight of a cylinder by its diameter, length and density.
4951
5052
The density is in kg/m³, the diameter and length must be given in mm.
5153
The default density is an approximation for steel.
5254
"""
53-
diameter = normalize_numeric(diameter) if isinstance(diameter, str) else diameter
54-
length = normalize_numeric(length) if isinstance(length, str) else length
55-
density = normalize_numeric(density) if isinstance(density, str) else density
5655
return cylinder_volume(diameter/2., length) * density
5756

58-
def cylinder_weight_by_radius(radius: NormalizableArgument, length: NormalizableArgument, density: NormalizableArgument = 8000):
57+
@normalize_args
58+
def cylinder_weight_by_radius(radius: NormalizableArgument, length: NormalizableArgument, density: DensityKgPerM3 = 8000):
5959
"""Compute the weight of a cylinder by its radius, length and density.
6060
6161
The density is in kg/m³, the radius and length must be given in mm.
6262
The default density is an approximation for steel.
6363
"""
64-
radius = normalize_numeric(radius) if isinstance(radius, str) else radius
65-
length = normalize_numeric(length) if isinstance(length, str) else length
66-
density = normalize_numeric(density) if isinstance(density, str) else density
6764
return cylinder_volume(radius, length) * density
6865

69-
def cylinder_weight_by_cross_sectional_area(area: NormalizableArgument, length: NormalizableArgument, density: NormalizableArgument = 8000):
66+
@normalize_args
67+
def cylinder_weight_by_cross_sectional_area(area: NormalizableArgument, length: NormalizableArgument, density: DensityKgPerM3 = 8000):
7068
"""Compute the weight of a cylinder by its cross-sectional area, length and density.
7169
7270
The density is in kg/m³, the area and length must be given in mm² and mm.
7371
The default density is an approximation for steel.
7472
"""
75-
area = normalize_numeric(area) if isinstance(area, str) else area
76-
length = normalize_numeric(length) if isinstance(length, str) else length
77-
density = normalize_numeric(density) if isinstance(density, str) else density
7873
return area * length * density
7974

8075
@returns_unit("m")

UliEngineering/Physics/Density.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,32 @@ def normalize_density_kg_per_m3(density: NormalizableArgument) -> NormalizedComp
6262
return normalize_with_known_units(
6363
density,
6464
{
65+
# SI units
66+
"kg/m³": 1.0,
6567
"kg/m^3": 1.0,
6668
"kg/m3": 1.0,
69+
# Chemistry / common lab units
70+
"g/cm³": 1000.0,
6771
"g/cm^3": 1000.0,
6872
"g/cm3": 1000.0,
73+
"kg/L": 1000.0,
74+
"kg/l": 1000.0,
75+
"g/mL": 1000.0,
76+
"g/ml": 1000.0,
6977
"g/L": 1.0,
7078
"g/l": 1.0,
79+
# Imperial units
80+
"lb/ft³": 16.0184634,
81+
"lb/ft3": 16.0184634,
82+
"lb/in³": 27679.9047,
83+
"lb/in3": 27679.9047,
84+
"lb/gal": 119.826427,
85+
"oz/in³": 1729.994,
86+
"oz/in3": 1729.994,
87+
# Tonne / metric ton
88+
"t/m³": 1000.0,
89+
"t/m^3": 1000.0,
90+
"t/m3": 1000.0,
7191
},
7292
quantity_name="density",
7393
)

UliEngineering/Physics/Viscosity.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from UliEngineering.EngineerIO.Decorators import returns_unit
2929
from UliEngineering.EngineerIO.Types import NormalizableArgument, NormalizedComputable
3030
from UliEngineering.Physics.Temperature import TemperatureKelvin, normalize_temperature
31+
from UliEngineering.Physics.Density import normalize_density_kg_per_m3, DensityKgPerM3
3132
from ._normalize import normalize_with_known_units
3233

3334
__all__ = [
@@ -73,8 +74,8 @@
7374
def normalize_dynamic_viscosity(viscosity: NormalizableArgument) -> NormalizedComputable:
7475
return normalize_with_known_units(viscosity, {"Pa·s": 1.0, "Pa s": 1.0, "Pas": 1.0, "mPa·s": 1e-3, "cP": 1e-3, "P": 0.1}, quantity_name="dynamic viscosity")
7576

76-
def normalize_density(density: NormalizableArgument) -> NormalizedComputable:
77-
return normalize_with_known_units(density, {"kg/m³": 1.0, "kg/m3": 1.0, "kg/m^3": 1.0, "g/cm³": 1000.0, "g/cm3": 1000.0, "g/L": 1.0}, quantity_name="density")
77+
# Re-export density normalizer from Density.py for backward compatibility
78+
normalize_density = normalize_density_kg_per_m3
7879

7980
def normalize_length(length: NormalizableArgument) -> NormalizedComputable:
8081
return normalize_with_known_units(length, {"m": 1.0, "mm": 1e-3, "cm": 1e-2, "km": 1e3, "µm": 1e-6, "nm": 1e-9}, quantity_name="length")
@@ -89,7 +90,7 @@ def normalize_shear_rate(shear_rate: NormalizableArgument) -> NormalizedComputab
8990
return normalize_with_known_units(shear_rate, {"s⁻¹": 1.0, "/s": 1.0}, quantity_name="shear rate")
9091

9192
DynamicViscosityPas = Annotated[NormalizedComputable, normalize_dynamic_viscosity]
92-
DensityKgM3 = Annotated[NormalizedComputable, normalize_density]
93+
DensityKgM3 = DensityKgPerM3
9394
LengthMeter = Annotated[NormalizedComputable, normalize_length]
9495
PressurePascal = Annotated[NormalizedComputable, normalize_pressure]
9596
VelocityMS = Annotated[NormalizedComputable, normalize_velocity]

UliEngineering/Physics/_normalize.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def normalize_with_known_units(
3333
dtype=float,
3434
)
3535

36-
if isinstance(value, (int, float, np.generic)):
36+
if isinstance(value, (int, float, np.number)):
3737
return float(value) * default_factor
3838

3939
if isinstance(value, bytes):

tests/Physics/TestDensity.py

Lines changed: 172 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,203 @@
11
#!/usr/bin/env python3
22
# -*- coding: utf-8 -*-
3+
import unittest
4+
import numpy as np
35
from numpy.testing import assert_allclose, assert_approx_equal
6+
47
from UliEngineering.Physics.Density import normalize_density_kg_per_m3, DensityKgPerM3
5-
import unittest
8+
from UliEngineering.Units import InvalidUnitInContextException
69

710

811
class TestDensityNormalization(unittest.TestCase):
9-
def test_normalize_density(self):
10-
# Test kg/m^3 normalization (SI unit)
12+
def test_si_units(self):
13+
"""Test SI base units and unicode variants."""
1114
assert_approx_equal(normalize_density_kg_per_m3("1 kg/m^3"), 1.0)
12-
assert_approx_equal(normalize_density_kg_per_m3("1000 kg/m^3"), 1000.0)
1315
assert_approx_equal(normalize_density_kg_per_m3("1 kg/m3"), 1.0)
14-
# Test g/cm^3 to kg/m^3 conversion
16+
assert_approx_equal(normalize_density_kg_per_m3("1 kg/m³"), 1.0)
17+
assert_approx_equal(normalize_density_kg_per_m3("1000 kg/m^3"), 1000.0)
18+
assert_approx_equal(normalize_density_kg_per_m3("2.5 kg/m³"), 2.5)
19+
20+
def test_chemistry_units(self):
21+
"""Test common chemistry/lab density units."""
22+
# g/cm³ variants
1523
assert_approx_equal(normalize_density_kg_per_m3("1 g/cm^3"), 1000.0)
1624
assert_approx_equal(normalize_density_kg_per_m3("2 g/cm^3"), 2000.0)
1725
assert_approx_equal(normalize_density_kg_per_m3("2.7 g/cm^3"), 2700.0)
18-
# Test g/cm3 to kg/m^3 conversion
1926
assert_approx_equal(normalize_density_kg_per_m3("1 g/cm3"), 1000.0)
20-
# Test g/L to kg/m^3 conversion
27+
assert_approx_equal(normalize_density_kg_per_m3("1 g/cm³"), 1000.0)
28+
# kg/L variants
29+
assert_approx_equal(normalize_density_kg_per_m3("1 kg/L"), 1000.0)
30+
assert_approx_equal(normalize_density_kg_per_m3("1 kg/l"), 1000.0)
31+
assert_approx_equal(normalize_density_kg_per_m3("2.5 kg/L"), 2500.0)
32+
# g/mL variants
33+
assert_approx_equal(normalize_density_kg_per_m3("1 g/mL"), 1000.0)
34+
assert_approx_equal(normalize_density_kg_per_m3("1 g/ml"), 1000.0)
35+
# g/L variants
2136
assert_approx_equal(normalize_density_kg_per_m3("1 g/L"), 1.0)
2237
assert_approx_equal(normalize_density_kg_per_m3("1000 g/L"), 1000.0)
2338
assert_approx_equal(normalize_density_kg_per_m3("1 g/l"), 1.0)
2439

25-
def test_normalize_density_iterables(self):
26-
assert_allclose(normalize_density_kg_per_m3(["1 g/cm^3", "2 g/cm^3"]), [1000.0, 2000.0])
27-
assert_allclose(normalize_density_kg_per_m3(["1 kg/m^3", "2 g/L"]), [1.0, 2.0])
40+
def test_imperial_units(self):
41+
"""Test imperial density units."""
42+
# lb/ft³
43+
assert_approx_equal(normalize_density_kg_per_m3("1 lb/ft³"), 16.0184634)
44+
assert_approx_equal(normalize_density_kg_per_m3("1 lb/ft3"), 16.0184634)
45+
# lb/in³
46+
assert_approx_equal(normalize_density_kg_per_m3("1 lb/in³"), 27679.9047)
47+
assert_approx_equal(normalize_density_kg_per_m3("1 lb/in3"), 27679.9047)
48+
# lb/gal (US)
49+
assert_approx_equal(normalize_density_kg_per_m3("1 lb/gal"), 119.826427)
50+
# oz/in³
51+
assert_approx_equal(normalize_density_kg_per_m3("1 oz/in³"), 1729.994)
52+
assert_approx_equal(normalize_density_kg_per_m3("1 oz/in3"), 1729.994)
53+
54+
def test_metric_ton_units(self):
55+
"""Test tonne per cubic meter units."""
56+
assert_approx_equal(normalize_density_kg_per_m3("1 t/m³"), 1000.0)
57+
assert_approx_equal(normalize_density_kg_per_m3("1 t/m^3"), 1000.0)
58+
assert_approx_equal(normalize_density_kg_per_m3("1 t/m3"), 1000.0)
59+
assert_approx_equal(normalize_density_kg_per_m3("2.5 t/m³"), 2500.0)
60+
61+
def test_bare_numbers(self):
62+
"""Test that bare numbers pass through unchanged."""
63+
assert_approx_equal(normalize_density_kg_per_m3(1.0), 1.0)
64+
assert_approx_equal(normalize_density_kg_per_m3(1000), 1000.0)
65+
assert_approx_equal(normalize_density_kg_per_m3(0.5), 0.5)
66+
67+
def test_numpy_array_input(self):
68+
"""Test numpy array input."""
69+
arr = np.array(["1 g/cm^3", "2 kg/m^3"])
70+
result = normalize_density_kg_per_m3(arr)
71+
assert_allclose(result, [1000.0, 2.0])
72+
73+
def test_list_input(self):
74+
"""Test list input."""
75+
result = normalize_density_kg_per_m3(["1 g/cm^3", "2 g/cm^3"])
76+
assert_allclose(result, [1000.0, 2000.0])
77+
result = normalize_density_kg_per_m3(["1 kg/m^3", "2 g/L"])
78+
assert_allclose(result, [1.0, 2.0])
79+
80+
def test_tuple_input(self):
81+
"""Test tuple input."""
82+
result = normalize_density_kg_per_m3(("1 g/cm^3", "2 kg/m^3"))
83+
assert_allclose(result, [1000.0, 2.0])
84+
85+
def test_bytes_input(self):
86+
"""Test bytes input."""
87+
assert_approx_equal(normalize_density_kg_per_m3(b"1 kg/m^3"), 1.0)
88+
assert_approx_equal(normalize_density_kg_per_m3(b"1 g/cm^3"), 1000.0)
89+
90+
def test_invalid_unit_raises(self):
91+
"""Test that invalid units raise InvalidUnitInContextException."""
92+
with self.assertRaises(InvalidUnitInContextException):
93+
normalize_density_kg_per_m3("1 lb/m^3")
94+
with self.assertRaises(InvalidUnitInContextException):
95+
normalize_density_kg_per_m3("1 g/mm^3")
96+
97+
def test_missing_numeric_part_raises(self):
98+
"""Test that missing numeric part raises ValueError."""
99+
with self.assertRaises(ValueError):
100+
normalize_density_kg_per_m3("kg/m^3")
101+
102+
def test_none_raises(self):
103+
"""Test that None raises ValueError."""
104+
with self.assertRaises(ValueError):
105+
normalize_density_kg_per_m3(None)
28106

29107
def test_type_annotation_exists(self):
30-
"""Test that the new type annotation is available."""
108+
"""Test that the type annotation is available."""
31109
self.assertIsNotNone(DensityKgPerM3)
32110

33-
def test_density_type_various_units(self):
34-
"""Test DensityKgPerM3 type with various unit inputs."""
111+
def test_comprehensive_unit_cases(self):
112+
"""Test all supported units in one parameterized block."""
35113
test_cases = [
114+
# SI
36115
("1 kg/m^3", 1.0),
37116
("1 kg/m3", 1.0),
38-
("1000 kg/m^3", 1000.0),
117+
("1 kg/m³", 1.0),
118+
# Chemistry
39119
("1 g/cm^3", 1000.0),
40-
("2 g/cm^3", 2000.0),
41120
("1 g/cm3", 1000.0),
121+
("1 g/cm³", 1000.0),
122+
("1 kg/L", 1000.0),
123+
("1 kg/l", 1000.0),
124+
("1 g/mL", 1000.0),
125+
("1 g/ml", 1000.0),
42126
("1 g/L", 1.0),
43-
("1000 g/L", 1000.0),
127+
("1 g/l", 1.0),
128+
# Imperial
129+
("1 lb/ft³", 16.0184634),
130+
("1 lb/ft3", 16.0184634),
131+
("1 lb/in³", 27679.9047),
132+
("1 lb/in3", 27679.9047),
133+
("1 lb/gal", 119.826427),
134+
("1 oz/in³", 1729.994),
135+
("1 oz/in3", 1729.994),
136+
# Tonne
137+
("1 t/m³", 1000.0),
138+
("1 t/m^3", 1000.0),
139+
("1 t/m3", 1000.0),
44140
]
45141
for input_val, expected in test_cases:
46142
with self.subTest(input=input_val):
47143
result = normalize_density_kg_per_m3(input_val)
48-
assert_approx_equal(result, expected)
144+
assert_approx_equal(result, expected)
145+
146+
147+
class TestDensityIntegration(unittest.TestCase):
148+
"""Integration tests for density normalization in downstream functions."""
149+
150+
def test_cylinder_weight_with_density_units(self):
151+
"""Test that cylinder weight functions accept density with units."""
152+
from UliEngineering.Math.Geometry.Cylinder import (
153+
cylinder_weight_by_diameter,
154+
cylinder_weight_by_radius,
155+
cylinder_weight_by_cross_sectional_area,
156+
)
157+
158+
# radius=1mm, length=1mm, density=2700 kg/m^3 (aluminium)
159+
# volume = pi * (1)^2 * 1 = pi mm^3
160+
# weight = volume * density = pi * 2700
161+
expected = np.pi * 2700.0
162+
163+
# With SI density unit
164+
w = cylinder_weight_by_radius(1.0, 1.0, "2700 kg/m^3")
165+
self.assertAlmostEqual(w, expected, delta=0.1)
166+
167+
# With g/cm^3 (2.7 g/cm^3 = 2700 kg/m^3)
168+
w = cylinder_weight_by_radius(1.0, 1.0, "2.7 g/cm^3")
169+
self.assertAlmostEqual(w, expected, delta=0.1)
170+
171+
# With kg/L (2.7 kg/L = 2700 kg/m^3)
172+
w = cylinder_weight_by_radius(1.0, 1.0, "2.7 kg/L")
173+
self.assertAlmostEqual(w, expected, delta=0.1)
174+
175+
# diameter=2mm, length=1mm => same volume and weight
176+
w = cylinder_weight_by_diameter(2.0, 1.0, "2.7 g/cm^3")
177+
self.assertAlmostEqual(w, expected, delta=0.1)
178+
179+
# cross-sectional area = pi * r^2 = pi mm^2
180+
w = cylinder_weight_by_cross_sectional_area(np.pi, 1.0, "2.7 g/cm^3")
181+
self.assertAlmostEqual(w, expected, delta=0.1)
182+
183+
def test_viscosity_functions_with_density_units(self):
184+
"""Test that viscosity functions accept density with units."""
185+
from UliEngineering.Physics.Viscosity import (
186+
kinematic_viscosity,
187+
reynolds_number,
188+
)
189+
190+
# kinematic_viscosity(0.001 Pa·s, 1000 kg/m³) => 1e-6 m²/s
191+
nu = kinematic_viscosity(0.001, "1 g/cm^3")
192+
self.assertAlmostEqual(nu, 1e-6, delta=1e-12)
193+
194+
nu = kinematic_viscosity(0.001, "1 kg/L")
195+
self.assertAlmostEqual(nu, 1e-6, delta=1e-12)
196+
197+
# Reynolds number: Re = rho * v * L / eta
198+
Re = reynolds_number("1 g/cm^3", 1.0, 0.1, 0.001)
199+
self.assertAlmostEqual(Re, 100000.0, delta=0.1)
200+
201+
202+
if __name__ == '__main__':
203+
unittest.main()

0 commit comments

Comments
 (0)