From 2081df7c28ab8e43e47dd524a588e5069c58b6e0 Mon Sep 17 00:00:00 2001 From: bakpaul Date: Wed, 1 Jul 2026 14:25:05 +0200 Subject: [PATCH 01/10] Add SOFAUnits, a utilitary lib for unit convertion --- examples/SOFAUnits/CustomUnitsExample.py | 82 +++++++ examples/SOFAUnits/SimulationParameters.py | 54 +++++ examples/SOFAUnits/tests/conftest.py | 17 ++ .../tests/test_simulation_parameters.py | 141 +++++++++++ examples/SOFAUnits/tests/test_units.py | 227 ++++++++++++++++++ examples/SOFAUnits/units.py | 213 ++++++++++++++++ 6 files changed, 734 insertions(+) create mode 100644 examples/SOFAUnits/CustomUnitsExample.py create mode 100644 examples/SOFAUnits/SimulationParameters.py create mode 100644 examples/SOFAUnits/tests/conftest.py create mode 100644 examples/SOFAUnits/tests/test_simulation_parameters.py create mode 100644 examples/SOFAUnits/tests/test_units.py create mode 100644 examples/SOFAUnits/units.py diff --git a/examples/SOFAUnits/CustomUnitsExample.py b/examples/SOFAUnits/CustomUnitsExample.py new file mode 100644 index 00000000..a0ee60b7 --- /dev/null +++ b/examples/SOFAUnits/CustomUnitsExample.py @@ -0,0 +1,82 @@ +# Choose in your script to activate or not the GUI +USE_GUI = True + +from units import * +from SimulationParameters import SOFAParameters + +def main(): + # Required import for python + import Sofa + import SofaImGui + + root = Sofa.Core.Node("root") + createScene(root) + Sofa.Simulation.initRoot(root) + + if not USE_GUI: + for iteration in range(10): + Sofa.Simulation.animate(root, root.dt.value) + else: + import Sofa.Gui + Sofa.Gui.GUIManager.Init("myscene", "imgui") + Sofa.Gui.GUIManager.createGUI(root, __file__) + Sofa.Gui.GUIManager.SetDimension(1080, 1080) + Sofa.Gui.GUIManager.MainLoop(root) + Sofa.Gui.GUIManager.closeGUI() + + +def createScene(root): + SceneUnit = SOFAParameters(s, mm, g) + + root.gravity=[0, SceneUnit(-9.81, N/kg), 0] + root.dt=0.02 + + root.addObject("RequiredPlugin", pluginName=[ 'Sofa.Component.Collision.Detection.Algorithm', + 'Sofa.Component.Collision.Detection.Intersection', + 'Sofa.Component.Collision.Geometry', + 'Sofa.Component.Collision.Response.Contact', + 'Sofa.Component.Constraint.Projective', + 'Sofa.Component.IO.Mesh', + 'Sofa.Component.LinearSolver.Iterative', + 'Sofa.Component.Mapping.Linear', + 'Sofa.Component.Mass', + 'Sofa.Component.IntegrationScheme.Backward', + 'Sofa.Component.SolidMechanics.FEM.Elastic', + 'Sofa.Component.StateContainer', + 'Sofa.Component.Topology.Container.Dynamic', + 'Sofa.Component.Visual', + 'Sofa.GL.Component.Rendering3D' + ]) + + root.addObject('DefaultAnimationLoop') + + root.addObject('VisualStyle', displayFlags="showCollisionModels") + root.addObject('CollisionPipeline', name="CollisionPipeline") + root.addObject('BruteForceBroadPhase', name="BroadPhase") + root.addObject('BVHNarrowPhase', name="NarrowPhase") + root.addObject('CollisionResponse', name="CollisionResponse", response="PenalityContactForceField") + root.addObject('DiscreteIntersection') + + root.addObject('MeshOBJLoader', name="LiverSurface", filename="mesh/liver-smooth.obj") + + liver = root.addChild('Liver') + liver.addObject('EulerImplicitIntegrationScheme', name="cg_odesolver", rayleighStiffness="0.1", rayleighMass="0.1") + liver.addObject('CGLinearSolver', name="linear_solver", iterations="25", tolerance="1e-09", threshold="1e-09") + liver.addObject('MeshGmshLoader', name="meshLoader", filename="mesh/liver.msh") + liver.addObject('TetrahedronSetTopologyContainer', name="topo", src="@meshLoader") + liver.addObject('MechanicalObject', name="dofs", src="@meshLoader") + liver.addObject('TetrahedronSetGeometryAlgorithms', template="Vec3d", name="GeomAlgo") + liver.addObject('DiagonalMass', name="Mass", massDensity=SceneUnit(1.0,g/(cm**3))) + liver.addObject('TetrahedralCorotationalFEMForceField', template="Vec3d", name="FEM", method="large", poissonRatio="0.3", youngModulus=SceneUnit(3,kPa), computeGlobalMatrix="0") + liver.addObject('FixedProjectiveConstraint', name="FixedConstraint", indices="3 39 64") + + visu = liver.addChild('Visu') + visu.addObject('OglModel', name="VisualModel", src="@../../LiverSurface") + visu.addObject('BarycentricMapping', name="VisualMapping", input="@../dofs", output="@VisualModel") + + return root + + +# Function used only if this script is called from a python environment +if __name__ == '__main__': + main() diff --git a/examples/SOFAUnits/SimulationParameters.py b/examples/SOFAUnits/SimulationParameters.py new file mode 100644 index 00000000..8a3da88a --- /dev/null +++ b/examples/SOFAUnits/SimulationParameters.py @@ -0,0 +1,54 @@ + +from units import * + +class BaseParameterSet(): + + units : dict + + def __init__(self, *args, **kwargs): + self.units = {} + for arg in args: + if isinstance(arg, Unit): + if len(arg.numerator) == 1 and len(arg.denumerator) == 0: + self.units[arg.numerator[0].abrev] = arg + else: + raise TypeError("Only primary unit (with an optionnal ratio) can be defined by the user.") + for arg in kwargs: + if isinstance(kwargs[arg], Unit): + if len(kwargs[arg].numerator) == 1 and len(kwargs[arg].denumerator) == 0: + self.units[kwargs[arg].numerator[0].abrev] = kwargs[arg] + else: + raise TypeError("Only primary unit (with an optionnal ratio) can be defined by the user.") + + def convert(self, value : float, unit: DerivedUnit): + u_key = unit.getKey() + + reconstructedUnit = neutralUnit + for nkey in u_key["num"]: + try: + for _ in range(u_key["num"][nkey]): + reconstructedUnit *= self.units[nkey] + except: + raise RuntimeError(f"The unit {nkey} is not defined in the parameter set.") + for nkey in u_key["denum"]: + try: + for _ in range(u_key["denum"][nkey]): + reconstructedUnit /= self.units[nkey] + except: + raise RuntimeError(f"The unit {nkey} is not defined in the parameter set.") + + + return unit.ratio / reconstructedUnit.ratio * value + + def __call__(self, value : float, unit: DerivedUnit): + return self.convert(value=value, unit= unit) + + + +class SOFAParameters(BaseParameterSet): + def __init__(self, time = s, position = m, mass = kg ): + BaseParameterSet.__init__(self, time, position, mass) + self.time = time + self.position = position + self.mass = mass + diff --git a/examples/SOFAUnits/tests/conftest.py b/examples/SOFAUnits/tests/conftest.py new file mode 100644 index 00000000..db30d8c5 --- /dev/null +++ b/examples/SOFAUnits/tests/conftest.py @@ -0,0 +1,17 @@ +""" +Allows the test files in this folder to `import units` and +`import SimulationParameters` even though this folder is a sub-folder of +the project root (where those two modules actually live). + +pytest imports conftest.py before collecting/importing the test modules in +the same directory, so inserting the parent folder into sys.path here is +enough - the test files themselves don't need to know anything about the +project layout. +""" +import os +import sys + +_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) + +if _PROJECT_ROOT not in sys.path: + sys.path.insert(0, _PROJECT_ROOT) diff --git a/examples/SOFAUnits/tests/test_simulation_parameters.py b/examples/SOFAUnits/tests/test_simulation_parameters.py new file mode 100644 index 00000000..5739032d --- /dev/null +++ b/examples/SOFAUnits/tests/test_simulation_parameters.py @@ -0,0 +1,141 @@ +""" +Unit tests for SimulationParameters.py + +Run with: pytest test_simulation_parameters.py -v +""" +import pytest + +from units import s, m, mm, ms, kg, N, Pa, kPa, kN, tho +from SimulationParameters import BaseParameterSet, SOFAParameters + + +# --------------------------------------------------------------------------- +# BaseParameterSet construction +# --------------------------------------------------------------------------- + +class TestBaseParameterSetConstruction: + def test_positional_primary_units_are_registered(self): + bp = BaseParameterSet(s, m, kg) + assert set(bp.units.keys()) == {"s", "m", "kg"} + assert bp.units["s"] is s + assert bp.units["m"] is m + assert bp.units["kg"] is kg + + def test_keyword_primary_units_are_registered(self): + bp = BaseParameterSet(time=s, position=mm, mass=kg) + assert set(bp.units.keys()) == {"s", "m", "kg"} + assert bp.units["m"] is mm + + def test_non_unit_positional_args_are_ignored(self): + bp = BaseParameterSet(s, "not a unit", 42) + assert set(bp.units.keys()) == {"s"} + + def test_non_unit_keyword_args_are_ignored(self): + bp = BaseParameterSet(time=s, other="not a unit") + assert set(bp.units.keys()) == {"s"} + + def test_derived_unit_positional_arg_raises_type_error(self): + with pytest.raises(TypeError): + BaseParameterSet(N) + + def test_derived_unit_keyword_arg_raises_type_error(self): + with pytest.raises(TypeError): + BaseParameterSet(force=N) + + def test_empty_construction_gives_empty_units(self): + bp = BaseParameterSet() + assert bp.units == {} + + +# --------------------------------------------------------------------------- +# BaseParameterSet.convert() +# --------------------------------------------------------------------------- + +class TestConvert: + def test_convert_with_all_base_units_matching(self): + bp = BaseParameterSet(s, m, kg) + assert bp.convert(10, N) == pytest.approx(10.0) + + def test_convert_scales_with_position_unit(self): + # Simulation uses millimeters for position: 1 "simulation force unit" + # corresponds to 1000 N because of the mm scaling. + bp = BaseParameterSet(s, mm, kg) + assert bp.convert(10, N) == pytest.approx(10000.0) + + def test_convert_pressure_with_base_units(self): + bp = BaseParameterSet(s, m, kg) + assert bp.convert(10, kPa) == pytest.approx(10000.0) + + def test_convert_pressure_with_mm_position(self): + bp = BaseParameterSet(s, mm, kg) + assert bp.convert(10, kPa) == pytest.approx(10.0) + + def test_convert_raises_when_required_unit_missing(self): + bp = BaseParameterSet(s, kg) # no 'm' defined + with pytest.raises(RuntimeError): + bp.convert(1, N) + + def test_convert_raises_mentions_missing_unit_abrev(self): + bp = BaseParameterSet(s, kg) + with pytest.raises(RuntimeError, match="m"): + bp.convert(1, N) + + +# --------------------------------------------------------------------------- +# SOFAParameters +# --------------------------------------------------------------------------- + +class TestSOFAParameters: + def test_defaults_are_s_m_kg(self): + sp = SOFAParameters() + assert sp.time is s + assert sp.position is m + assert sp.mass is kg + assert set(sp.units.keys()) == {"s", "m", "kg"} + + def test_custom_units_are_stored_as_attributes(self): + sp = SOFAParameters(time=s, position=mm, mass=kg) + assert sp.position is mm + assert set(sp.units.keys()) == {"s", "m", "kg"} + + def test_convert_uses_configured_units(self): + sp = SOFAParameters(time=s, position=mm, mass=kg) + assert sp.convert(10, N) == pytest.approx(10000.0) + assert sp.convert(10, kPa) == pytest.approx(10.0) + + def test_convert_with_default_units(self): + sp = SOFAParameters() + assert sp.convert(10, N) == pytest.approx(10.0) + assert sp.convert(10, kPa) == pytest.approx(10000.0) + + + +class TestConvertExponents: + + + def test_convert_force_with_scaled_time_unit(self): + bp = BaseParameterSet(ms, m, kg) + # 1 sim force unit = 1 kg * 1 m / (1 ms)^2 = 1e6 N + # -> converting 1 N (SI) into sim units should give 1e-6 + assert bp.convert(1, N) == pytest.approx(1e-6) + + + def test_convert_torque_with_scaled_length_unit(self): + bp = BaseParameterSet(s, mm, kg) + # 1 sim torque unit = 1 kg * (1 mm)^2 / (1 s)^2 = 1e-6 tho + # -> converting 1 tho (SI) into sim units should give 1e6 + assert bp.convert(1, tho) == pytest.approx(1e6) + + def test_convert_when_scaled_dimension_only_has_exponent_one(self): + # Sanity check / contrast case: this is why the bug doesn't show + # up anywhere in SimulationParameters.py as shipped - position is + # scaled (mm) but only ever appears as m^1 in N and Pa, so the + # missing exponent handling never bites here. + bp = BaseParameterSet(s, mm, kg) + assert bp.convert(10, N) == pytest.approx(10000.0) + assert bp.convert(10, kPa) == pytest.approx(10.0) + + +if __name__ == "__main__": + import sys + sys.exit(pytest.main([__file__, "-v"])) diff --git a/examples/SOFAUnits/tests/test_units.py b/examples/SOFAUnits/tests/test_units.py new file mode 100644 index 00000000..4b2cc612 --- /dev/null +++ b/examples/SOFAUnits/tests/test_units.py @@ -0,0 +1,227 @@ +""" +Unit tests for units.py + +These tests describe the CURRENT behavior of the module, including a couple +of quirks (documented in comments) that were not changed, per request. +Run with: pytest test_units.py -v +""" +import math +import pytest + +from units import ( + Unit, NeutralUnit, PrimaryUnit, DerivedUnit, ScaledUnit, + neutralUnit, m, s, kg, + v, a, N, Pa, tho, + nm, mm, cm, km, + ms, µs, + g, mg, t, + nN, mN, kN, MN, + kPa, MPa, +) + + +# --------------------------------------------------------------------------- +# PrimaryUnit / NeutralUnit +# --------------------------------------------------------------------------- + +class TestPrimaryUnit: + def test_abrev_is_stored(self): + assert m.abrev == "m" + assert s.abrev == "s" + assert kg.abrev == "kg" + + def test_primary_unit_is_its_own_numerator(self): + assert m.numerator == [m] + assert m.denumerator == [] + + def test_primary_unit_ratio_is_one(self): + assert m.ratio == 1.0 + + def test_new_primary_unit_construction(self): + x = PrimaryUnit("x") + assert x.abrev == "x" + assert x.numerator == [x] + assert x.denumerator == [] + assert x.ratio == 1.0 + + +class TestNeutralUnit: + def test_neutral_unit_has_no_num_or_denum(self): + assert neutralUnit.numerator == [] + assert neutralUnit.denumerator == [] + + def test_neutral_unit_ratio_is_one(self): + assert neutralUnit.ratio == 1.0 + + def test_multiplying_by_neutral_unit_is_identity(self): + result = neutralUnit * m + assert result.ratio == 1.0 + assert [u.abrev for u in result.numerator] == ["m"] + assert result.denumerator == [] + + +# --------------------------------------------------------------------------- +# getKey() +# --------------------------------------------------------------------------- + +class TestGetKey: + def test_simple_primary_key(self): + key = m.getKey() + assert key == {"num": {"m": 1}, "denum": {}} + + def test_repeated_unit_is_counted(self): + area = m * m + key = area.getKey() + assert key["num"] == {"m": 2} + assert key["denum"] == {} + + def test_derived_unit_key_newton(self): + # N = kg * m / s^2 + key = N.getKey() + assert key["num"] == {"kg": 1, "m": 1} + assert key["denum"] == {"s": 2} + + def test_derived_unit_key_pascal(self): + # Pa = N / m^2 = kg / (s^2 * m) + key = Pa.getKey() + assert key["num"] == {"kg": 1} + assert key["denum"] == {"s": 2, "m": 1} + + +# --------------------------------------------------------------------------- +# Multiplication / division +# --------------------------------------------------------------------------- + +class TestMultiplication: + def test_multiply_two_primary_units(self): + result = m * m + assert isinstance(result, DerivedUnit) + assert [u.abrev for u in result.numerator] == ["m", "m"] + assert result.denumerator == [] + assert result.ratio == 1.0 + + def test_multiply_combines_ratios(self): + result = kN * kN # 1000 * 1000 + assert result.ratio == pytest.approx(1e6) + + def test_rmul_matches_mul(self): + assert (m * s).getKey() == (s * m).getKey() + + +class TestDivision: + def test_divide_two_primary_units(self): + result = m / s + assert [u.abrev for u in result.numerator] == ["m"] + assert [u.abrev for u in result.denumerator] == ["s"] + assert result.ratio == 1.0 + + def test_divide_ratios(self): + result = km / ms # 1000 / 0.001 + assert result.ratio == pytest.approx(1e6) + + def test_velocity_acceleration_force_chain(self): + # sanity check against the derived-unit constants defined in units.py + assert [u.abrev for u in v.numerator] == ["m"] + assert [u.abrev for u in v.denumerator] == ["s"] + + assert [u.abrev for u in a.numerator] == ["m"] + assert [u.abrev for u in a.denumerator] == ["s", "s"] + + assert [u.abrev for u in N.numerator] == ["kg", "m"] + assert [u.abrev for u in N.denumerator] == ["s", "s"] + +class TestSimplify: + def test_simplify_cancels_matching_units(self): + # (m/s) * (s/m) should fully cancel to a dimensionless unit + result = (m / s) * (s / m) + assert result.numerator == [] + assert result.denumerator == [] + assert result.ratio == 1.0 + + def test_simplify_cancels_only_one_occurrence(self): + # (m*m) / m -> should cancel exactly one 'm', leaving one 'm' in numerator + result = (m * m) / m + assert [u.abrev for u in result.numerator] == ["m"] + assert result.denumerator == [] + + def test_simplify_does_not_cancel_unmatched_units(self): + # N has kg (no matching denum) and m (no matching denum); s^2 in + # denum has no matching numerator, so nothing gets cancelled. + assert [u.abrev for u in N.numerator] == ["kg", "m"] + assert [u.abrev for u in N.denumerator] == ["s", "s"] + + +# --------------------------------------------------------------------------- +# ScaledUnit +# --------------------------------------------------------------------------- + +class TestScaledUnit: + def test_ratio_is_the_scale_factor(self): + assert mm.ratio == pytest.approx(1e-3) + assert km.ratio == pytest.approx(1e3) + assert kN.ratio == pytest.approx(1e3) + assert kPa.ratio == pytest.approx(1e3) + + def test_scaled_unit_keeps_base_unit_dimension(self): + assert [u.abrev for u in mm.numerator] == ["m"] + assert mm.denumerator == [] + assert [u.abrev for u in kN.numerator] == ["kg", "m"] + assert [u.abrev for u in kN.denumerator] == ["s", "s"] + + def test_scaled_unit_doesnt_shares_list_reference_with_base_unit(self): + assert mm.numerator is not m.numerator + assert kN.numerator is not N.numerator + assert kN.denumerator is not N.denumerator + + +# --------------------------------------------------------------------------- +# __eq__ +# --------------------------------------------------------------------------- + +class TestEquality: + def test_unit_equals_itself(self): + assert N == N + assert Pa == Pa + + def test_units_with_different_order_of_magnitude_are_not_equal(self): + assert N != kN # ratios 1.0 vs 1000.0 -> different int(log10(ratio)) + + def test_units_in_different_decades_are_not_equal(self): + # __eq__ compares int(math.log10(ratio)) rather than the ratio + # itself. This is not a real ambiguity in practice: every scaled + # unit in this library is an exact power-of-ten multiple of its + # primary unit (as in the SI system), so the decade uniquely + # identifies the ratio. This test just pins down that units a + # decade apart are correctly told apart. + assert Pa != kPa + assert mm != cm + + def test_eq_key_comparison_is_one_directional(self): + # NOTE: __eq__ only checks that every key of `self` is present (and + # matches) in `other`; it does not check the reverse. So an object + # with a superset of keys on the "other" side can compare equal to + # a subset on the "self" side, as long as ratio decade matches too. + subset = DerivedUnit(numerator=[m.numerator[0]], denumerator=[], ratio=1.0) + superset = DerivedUnit(numerator=[m.numerator[0], s.numerator[0]], denumerator=[], ratio=1.0) + assert subset == superset + + +# --------------------------------------------------------------------------- +# printReduced() +# --------------------------------------------------------------------------- + +class TestPrintReduced: + def test_print_reduced_newton(self, capsys): + N.printReduced() + captured = capsys.readouterr() + assert captured.out.strip() == "1.0 * ( kg * m ) / ( s^2 )" + + def test_print_reduced_pascal(self, capsys): + Pa.printReduced() + captured = capsys.readouterr() + assert captured.out.strip() == "1.0 * ( kg ) / ( s^2 * m )" + + +if __name__ == "__main__": + import sys + sys.exit(pytest.main([__file__, "-v"])) diff --git a/examples/SOFAUnits/units.py b/examples/SOFAUnits/units.py new file mode 100644 index 00000000..f162adfc --- /dev/null +++ b/examples/SOFAUnits/units.py @@ -0,0 +1,213 @@ +import math + +class Unit(): + numerator : list + denumerator : list + ratio : float + + def printReduced(self): + self_key = self.getKey() + num_s = "( " + first = True + for key in self_key["num"]: + if not first: + num_s+=" * " + first = False + num_s += key + if self_key["num"][key] != 1: + num_s+=f"^{self_key["num"][key]}" + + if len(num_s) != 2: + num_s += " ) " + else: + num_s = "1" + + + denum_s = "/ ( " + first = True + for key in self_key["denum"]: + if not first: + denum_s+=" * " + first = False + denum_s += key + if self_key["denum"][key] != 1: + denum_s+=f"^{self_key["denum"][key]}" + + if len(num_s) != 4: + denum_s += " )" + else: + denum_s = "" + + + print(f"{self.ratio} * " + num_s + denum_s) + + def getKey(self): + key = {"num" : {}, "denum" : {}} + for unit in self.numerator: + if unit.abrev in key["num"]: + key["num"][unit.abrev] += 1 + else: + key["num"][unit.abrev] = 1 + for unit in self.denumerator: + if unit.abrev in key["denum"]: + key["denum"][unit.abrev] += 1 + else: + key["denum"][unit.abrev] = 1 + return key + + def __eq__ (self, other): + if int(math.log10(self.ratio)) != int(math.log10(other.ratio)) : + return False + + s_key = self.getKey() + o_key = other.getKey() + + isEq = True + + for dicName in ["num", "denum"]: + for key in s_key[dicName]: + isEq = isEq and (key in o_key[dicName]) and (s_key[dicName][key] == o_key[dicName][key] ) + + return isEq + + + def __mul__(self, other): + return DerivedUnit(numerator=self.numerator + other.numerator, denumerator= self.denumerator + other.denumerator, ratio = self.ratio * other.ratio) + + def __rmul__(self, other ): + return self.__mul__(other) + + def __pow__(self, other : int): + if not isinstance(other, int): + raise ValueError + + targetNum = [] + targetDenum = [] + targetRatio = 1.0 + + for i in range(abs(other)): + targetNum += self.numerator + targetDenum += self.denumerator + targetRatio *= self.ratio + + if other < 0 : + return DerivedUnit(numerator=targetDenum, denumerator= targetNum, ratio = 1.0/targetRatio) + elif other > 0 : + return DerivedUnit(numerator=targetNum, denumerator= targetDenum, ratio = targetRatio) + else: + return NeutralUnit + + def __truediv__(self, other ): + return DerivedUnit(numerator=self.numerator + other.denumerator, denumerator= self.denumerator + other.numerator, ratio = self.ratio / other.ratio) + + + + +class NeutralUnit(Unit): + def __init__(self): + self.numerator = [] + self.denumerator = [] + self.ratio = 1.0 + + +class PrimaryUnit(Unit): + + abrev = str + + def __init__(self, abrev : str): + self.abrev = abrev + self.numerator = [self] + self.denumerator = [] + self.ratio = 1.0 + + + +class DerivedUnit(Unit): + + def __init__(self, numerator : list[PrimaryUnit], denumerator : list[PrimaryUnit], ratio : float): + self.numerator = numerator + self.denumerator = denumerator + self.ratio = ratio + + self.simplify() + + + def simplify(self): + futNum = [] + for unit in self.numerator: + simplified = False + for i in range(len(self.denumerator)): + if self.denumerator[i].abrev == unit.abrev: + simplified = True + self.denumerator.pop(i) + break + if not(simplified): + futNum.append(unit) + self.numerator = futNum + + +class ScaledUnit(Unit): + + def __init__(self, unit : Unit, ratio : float): + self.numerator = unit.numerator.copy() + self.denumerator = unit.denumerator.copy() + self.ratio = ratio + + + +### Primary units +neutralUnit = NeutralUnit() +m = PrimaryUnit("m") +s = PrimaryUnit("s") +kg = PrimaryUnit("kg") + + +### Derived units +v = m/s +a = v/s +N = kg*a +Pa = N/(m**2) +tho = m*N + + +## Scaled primary units +nm = ScaledUnit(m, 1e-9) +µm = ScaledUnit(m, 1e-6) +mm = ScaledUnit(m, 1e-3) +cm = ScaledUnit(m, 1e-2) +dm = ScaledUnit(m, 1e-1) +km = ScaledUnit(m, 1e3) + +ns = ScaledUnit(s, 1e-9) +µs = ScaledUnit(s, 1e-6) +ms = ScaledUnit(s, 1e-3) + +µg = ScaledUnit(kg, 1e-9) +mg = ScaledUnit(kg, 1e-6) +g = ScaledUnit(kg, 1e-3) +t = ScaledUnit(kg, 1e3) + +## Scaled derived units +nN = ScaledUnit(N, 1e-9) +µN = ScaledUnit(N, 1e-6) +mN = ScaledUnit(N, 1e-3) +cN = ScaledUnit(N, 1e-2) +dN = ScaledUnit(N, 1e-1) +kN = ScaledUnit(N, 1e3) +MN = ScaledUnit(N, 1e6) +GN = ScaledUnit(N, 1e9) + + +nPa = ScaledUnit(Pa, 1e-9) +µPa = ScaledUnit(Pa, 1e-6) +mPa = ScaledUnit(Pa, 1e-3) +cPa = ScaledUnit(Pa, 1e-2) +dPa = ScaledUnit(Pa, 1e-1) +kPa = ScaledUnit(Pa, 1e3) +MPa = ScaledUnit(Pa, 1e6) +GPa = ScaledUnit(Pa, 1e9) + + + + + From a79ba58c004e02c3757cabe3d5a552c123cc2048 Mon Sep 17 00:00:00 2001 From: bakpaul Date: Wed, 1 Jul 2026 14:45:22 +0200 Subject: [PATCH 02/10] Change position to length --- examples/SOFAUnits/SimulationParameters.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/examples/SOFAUnits/SimulationParameters.py b/examples/SOFAUnits/SimulationParameters.py index 8a3da88a..e9d92401 100644 --- a/examples/SOFAUnits/SimulationParameters.py +++ b/examples/SOFAUnits/SimulationParameters.py @@ -46,9 +46,6 @@ def __call__(self, value : float, unit: DerivedUnit): class SOFAParameters(BaseParameterSet): - def __init__(self, time = s, position = m, mass = kg ): - BaseParameterSet.__init__(self, time, position, mass) - self.time = time - self.position = position - self.mass = mass + def __init__(self, time = s, length = m, mass = kg ): + BaseParameterSet.__init__(self, time, length, mass) From d73b29c4f5e09403f5855d8f7625372897f346fc Mon Sep 17 00:00:00 2001 From: bakpaul Date: Wed, 1 Jul 2026 14:58:17 +0200 Subject: [PATCH 03/10] Add all the seven primary units + derived unit of energy --- examples/SOFAUnits/units.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/examples/SOFAUnits/units.py b/examples/SOFAUnits/units.py index f162adfc..320783e8 100644 --- a/examples/SOFAUnits/units.py +++ b/examples/SOFAUnits/units.py @@ -157,17 +157,23 @@ def __init__(self, unit : Unit, ratio : float): ### Primary units neutralUnit = NeutralUnit() -m = PrimaryUnit("m") -s = PrimaryUnit("s") -kg = PrimaryUnit("kg") +s = PrimaryUnit("s") # time +m = PrimaryUnit("m") # length +kg = PrimaryUnit("kg") # mass +A = PrimaryUnit("A") # electric current +K = PrimaryUnit("K") # temperature +mol = PrimaryUnit("mol") # amount of substance +cd = PrimaryUnit("cd") # luminous intensity -### Derived units +### (some) Derived units v = m/s a = v/s N = kg*a Pa = N/(m**2) tho = m*N +j = kg*m**2/s**2 +W = j/s ## Scaled primary units @@ -207,6 +213,20 @@ def __init__(self, unit : Unit, ratio : float): MPa = ScaledUnit(Pa, 1e6) GPa = ScaledUnit(Pa, 1e9) +mj = ScaledUnit(j, 1e-3) +cj = ScaledUnit(j, 1e-2) +dj = ScaledUnit(j, 1e-1) +kj = ScaledUnit(j, 1e3) +Mj = ScaledUnit(j, 1e6) +Gj = ScaledUnit(j, 1e9) + +mW = ScaledUnit(W, 1e-3) +cW = ScaledUnit(W, 1e-2) +dW = ScaledUnit(W, 1e-1) +kW = ScaledUnit(W, 1e3) +MW = ScaledUnit(W, 1e6) +GW = ScaledUnit(W, 1e9) + From 2da3c3f3f435aef3ed6f48b8f093c3f398edbe78 Mon Sep 17 00:00:00 2001 From: Paul Baksic Date: Thu, 2 Jul 2026 09:09:26 +0200 Subject: [PATCH 04/10] Fix f string for older version of python + add comment + fix Joule caractere --- examples/SOFAUnits/units.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/examples/SOFAUnits/units.py b/examples/SOFAUnits/units.py index 320783e8..876cab08 100644 --- a/examples/SOFAUnits/units.py +++ b/examples/SOFAUnits/units.py @@ -15,7 +15,8 @@ def printReduced(self): first = False num_s += key if self_key["num"][key] != 1: - num_s+=f"^{self_key["num"][key]}" + exposant = self_key["num"][key] + num_s+=f"^{exposant}" if len(num_s) != 2: num_s += " ) " @@ -31,7 +32,8 @@ def printReduced(self): first = False denum_s += key if self_key["denum"][key] != 1: - denum_s+=f"^{self_key["denum"][key]}" + exposant = self_key["denum"][key] + denum_s+=f"^{exposant}" if len(num_s) != 4: denum_s += " )" @@ -167,13 +169,13 @@ def __init__(self, unit : Unit, ratio : float): ### (some) Derived units -v = m/s -a = v/s -N = kg*a -Pa = N/(m**2) -tho = m*N -j = kg*m**2/s**2 -W = j/s +v = m/s # velocity +a = v/s # acceleration +N = kg*a # force (Newton) +Pa = N/(m**2) # pressure (Pascal) +tho = m*N # torque +J = kg*m**2/s**2 # energy (Joule) +W = J/s # power (Watt) ## Scaled primary units @@ -213,12 +215,12 @@ def __init__(self, unit : Unit, ratio : float): MPa = ScaledUnit(Pa, 1e6) GPa = ScaledUnit(Pa, 1e9) -mj = ScaledUnit(j, 1e-3) -cj = ScaledUnit(j, 1e-2) -dj = ScaledUnit(j, 1e-1) -kj = ScaledUnit(j, 1e3) -Mj = ScaledUnit(j, 1e6) -Gj = ScaledUnit(j, 1e9) +mJ = ScaledUnit(J, 1e-3) +cJ = ScaledUnit(J, 1e-2) +dJ = ScaledUnit(J, 1e-1) +kJ = ScaledUnit(J, 1e3) +MJ = ScaledUnit(J, 1e6) +GJ = ScaledUnit(J, 1e9) mW = ScaledUnit(W, 1e-3) cW = ScaledUnit(W, 1e-2) From 424f823a1f31c563a535ad548c143e47039d6e23 Mon Sep 17 00:00:00 2001 From: bakpaul Date: Thu, 2 Jul 2026 18:34:34 +0200 Subject: [PATCH 05/10] Add multiplicaiton capability, still want to support numpy array --- examples/SOFAUnits/CustomUnitsExample.py | 18 +- examples/SOFAUnits/SimulationParameters.py | 36 ++-- .../tests/test_simulation_parameters.py | 14 +- examples/SOFAUnits/tests/test_units.py | 14 +- examples/SOFAUnits/units.py | 154 +++++++++++++----- 5 files changed, 167 insertions(+), 69 deletions(-) diff --git a/examples/SOFAUnits/CustomUnitsExample.py b/examples/SOFAUnits/CustomUnitsExample.py index a0ee60b7..7dd266ab 100644 --- a/examples/SOFAUnits/CustomUnitsExample.py +++ b/examples/SOFAUnits/CustomUnitsExample.py @@ -28,6 +28,7 @@ def main(): def createScene(root): SceneUnit = SOFAParameters(s, mm, g) + root.gravity=[0, SceneUnit(-9.81, N/kg), 0] root.dt=0.02 @@ -41,7 +42,7 @@ def createScene(root): 'Sofa.Component.Mapping.Linear', 'Sofa.Component.Mass', 'Sofa.Component.IntegrationScheme.Backward', - 'Sofa.Component.SolidMechanics.FEM.Elastic', + 'Sofa.Component.SolidMechanics.FEM.Elastic', 'Sofa.Component.StateContainer', 'Sofa.Component.Topology.Container.Dynamic', 'Sofa.Component.Visual', @@ -66,8 +67,19 @@ def createScene(root): liver.addObject('TetrahedronSetTopologyContainer', name="topo", src="@meshLoader") liver.addObject('MechanicalObject', name="dofs", src="@meshLoader") liver.addObject('TetrahedronSetGeometryAlgorithms', template="Vec3d", name="GeomAlgo") - liver.addObject('DiagonalMass', name="Mass", massDensity=SceneUnit(1.0,g/(cm**3))) - liver.addObject('TetrahedralCorotationalFEMForceField', template="Vec3d", name="FEM", method="large", poissonRatio="0.3", youngModulus=SceneUnit(3,kPa), computeGlobalMatrix="0") + + #You can create values that have a dimension by multiplying a float/int by a unit + liver.addObject('TetrahedralCorotationalFEMForceField', template="Vec3d", name="FEM", method="large", poissonRatio="0.3", youngModulus=SceneUnit(3 * kPa), computeGlobalMatrix="0") + + + #Multiplications between 'DimenssionedUnit' is supported and will affect the final unit + liverVolume = 1.5 * dm**3 # 1L + liverMass = 1.5 * kg + liverDensity = liverMass/liverVolume + #You can print the value, the unit will show + print(f"Liver density is {liverDensity}") + liver.addObject('DiagonalMass', name="Mass", massDensity=SceneUnit(liverDensity)) + liver.addObject('FixedProjectiveConstraint', name="FixedConstraint", indices="3 39 64") visu = liver.addChild('Visu') diff --git a/examples/SOFAUnits/SimulationParameters.py b/examples/SOFAUnits/SimulationParameters.py index e9d92401..1360704c 100644 --- a/examples/SOFAUnits/SimulationParameters.py +++ b/examples/SOFAUnits/SimulationParameters.py @@ -8,22 +8,24 @@ class BaseParameterSet(): def __init__(self, *args, **kwargs): self.units = {} for arg in args: - if isinstance(arg, Unit): - if len(arg.numerator) == 1 and len(arg.denumerator) == 0: - self.units[arg.numerator[0].abrev] = arg - else: - raise TypeError("Only primary unit (with an optionnal ratio) can be defined by the user.") + self.setPrimaryUnit(arg) for arg in kwargs: - if isinstance(kwargs[arg], Unit): - if len(kwargs[arg].numerator) == 1 and len(kwargs[arg].denumerator) == 0: - self.units[kwargs[arg].numerator[0].abrev] = kwargs[arg] + self.setPrimaryUnit(kwargs[arg]) + + def setPrimaryUnit(self, unit): + if isinstance(unit, Unit): + if len(unit.numerator) == 1 and len(unit.denumerator) == 0: + if unit.numerator[0].abrev not in self.units: + self.units[unit.numerator[0].abrev] = unit + else: + raise ValueError("Only one primary unit of each type can be defined") else: raise TypeError("Only primary unit (with an optionnal ratio) can be defined by the user.") - + def convert(self, value : float, unit: DerivedUnit): u_key = unit.getKey() - reconstructedUnit = neutralUnit + reconstructedUnit = DimensionLess for nkey in u_key["num"]: try: for _ in range(u_key["num"][nkey]): @@ -40,8 +42,17 @@ def convert(self, value : float, unit: DerivedUnit): return unit.ratio / reconstructedUnit.ratio * value - def __call__(self, value : float, unit: DerivedUnit): - return self.convert(value=value, unit= unit) + + def __call__(self, *args): + if len(args) == 1: + return self.convert(value=args[0].value, unit= args[0].unit) + elif len(args) == 2: + return self.convert(value=args[0], unit= args[1]) + else: + raise ValueError("This method requires either a DimensionnedValue as input or a float and a Unit.") + + + @@ -49,3 +60,4 @@ class SOFAParameters(BaseParameterSet): def __init__(self, time = s, length = m, mass = kg ): BaseParameterSet.__init__(self, time, length, mass) + diff --git a/examples/SOFAUnits/tests/test_simulation_parameters.py b/examples/SOFAUnits/tests/test_simulation_parameters.py index 5739032d..a599ab32 100644 --- a/examples/SOFAUnits/tests/test_simulation_parameters.py +++ b/examples/SOFAUnits/tests/test_simulation_parameters.py @@ -22,7 +22,7 @@ def test_positional_primary_units_are_registered(self): assert bp.units["kg"] is kg def test_keyword_primary_units_are_registered(self): - bp = BaseParameterSet(time=s, position=mm, mass=kg) + bp = BaseParameterSet(time=s, length=mm, mass=kg) assert set(bp.units.keys()) == {"s", "m", "kg"} assert bp.units["m"] is mm @@ -88,18 +88,18 @@ def test_convert_raises_mentions_missing_unit_abrev(self): class TestSOFAParameters: def test_defaults_are_s_m_kg(self): sp = SOFAParameters() - assert sp.time is s - assert sp.position is m - assert sp.mass is kg + assert sp.units['s'] is s + assert sp.units['m'] is m + assert sp.units['kg'] is kg assert set(sp.units.keys()) == {"s", "m", "kg"} def test_custom_units_are_stored_as_attributes(self): - sp = SOFAParameters(time=s, position=mm, mass=kg) - assert sp.position is mm + sp = SOFAParameters(time=s, length=mm, mass=kg) + assert sp.units['m'] is mm assert set(sp.units.keys()) == {"s", "m", "kg"} def test_convert_uses_configured_units(self): - sp = SOFAParameters(time=s, position=mm, mass=kg) + sp = SOFAParameters(time=s, length=mm, mass=kg) assert sp.convert(10, N) == pytest.approx(10000.0) assert sp.convert(10, kPa) == pytest.approx(10.0) diff --git a/examples/SOFAUnits/tests/test_units.py b/examples/SOFAUnits/tests/test_units.py index 4b2cc612..0b79e860 100644 --- a/examples/SOFAUnits/tests/test_units.py +++ b/examples/SOFAUnits/tests/test_units.py @@ -10,7 +10,7 @@ from units import ( Unit, NeutralUnit, PrimaryUnit, DerivedUnit, ScaledUnit, - neutralUnit, m, s, kg, + DimensionLess, m, s, kg, v, a, N, Pa, tho, nm, mm, cm, km, ms, µs, @@ -47,14 +47,14 @@ def test_new_primary_unit_construction(self): class TestNeutralUnit: def test_neutral_unit_has_no_num_or_denum(self): - assert neutralUnit.numerator == [] - assert neutralUnit.denumerator == [] + assert DimensionLess.numerator == [] + assert DimensionLess.denumerator == [] def test_neutral_unit_ratio_is_one(self): - assert neutralUnit.ratio == 1.0 + assert DimensionLess.ratio == 1.0 def test_multiplying_by_neutral_unit_is_identity(self): - result = neutralUnit * m + result = DimensionLess * m assert result.ratio == 1.0 assert [u.abrev for u in result.numerator] == ["m"] assert result.denumerator == [] @@ -212,12 +212,12 @@ def test_eq_key_comparison_is_one_directional(self): class TestPrintReduced: def test_print_reduced_newton(self, capsys): - N.printReduced() + print(N) captured = capsys.readouterr() assert captured.out.strip() == "1.0 * ( kg * m ) / ( s^2 )" def test_print_reduced_pascal(self, capsys): - Pa.printReduced() + print(Pa) captured = capsys.readouterr() assert captured.out.strip() == "1.0 * ( kg ) / ( s^2 * m )" diff --git a/examples/SOFAUnits/units.py b/examples/SOFAUnits/units.py index 876cab08..062624a9 100644 --- a/examples/SOFAUnits/units.py +++ b/examples/SOFAUnits/units.py @@ -5,43 +5,6 @@ class Unit(): denumerator : list ratio : float - def printReduced(self): - self_key = self.getKey() - num_s = "( " - first = True - for key in self_key["num"]: - if not first: - num_s+=" * " - first = False - num_s += key - if self_key["num"][key] != 1: - exposant = self_key["num"][key] - num_s+=f"^{exposant}" - - if len(num_s) != 2: - num_s += " ) " - else: - num_s = "1" - - - denum_s = "/ ( " - first = True - for key in self_key["denum"]: - if not first: - denum_s+=" * " - first = False - denum_s += key - if self_key["denum"][key] != 1: - exposant = self_key["denum"][key] - denum_s+=f"^{exposant}" - - if len(num_s) != 4: - denum_s += " )" - else: - denum_s = "" - - - print(f"{self.ratio} * " + num_s + denum_s) def getKey(self): key = {"num" : {}, "denum" : {}} @@ -58,6 +21,9 @@ def getKey(self): return key def __eq__ (self, other): + if not isinstance(other, Unit): + raise TypeError + if int(math.log10(self.ratio)) != int(math.log10(other.ratio)) : return False @@ -74,7 +40,11 @@ def __eq__ (self, other): def __mul__(self, other): - return DerivedUnit(numerator=self.numerator + other.numerator, denumerator= self.denumerator + other.denumerator, ratio = self.ratio * other.ratio) + if isinstance(other, Unit): + return DerivedUnit(numerator=self.numerator + other.numerator, denumerator= self.denumerator + other.denumerator, ratio = self.ratio * other.ratio) + else: + return DimensionnedValue(other,self ) + def __rmul__(self, other ): return self.__mul__(other) @@ -100,16 +70,66 @@ def __pow__(self, other : int): return NeutralUnit def __truediv__(self, other ): - return DerivedUnit(numerator=self.numerator + other.denumerator, denumerator= self.denumerator + other.numerator, ratio = self.ratio / other.ratio) + if not isinstance(other, Unit): + raise TypeError + + return DerivedUnit(numerator=self.numerator + other.denumerator, denumerator= self.denumerator + other.numerator, ratio = self.ratio / other.ratio) + def toString(self, addRatio : bool = True): + + self_key = self.getKey() + num_s = "( " + first = True + for key in self_key["num"]: + if not first: + num_s+=" * " + first = False + num_s += key + if self_key["num"][key] != 1: + exposant = self_key["num"][key] + num_s+=f"^{exposant}" + + if len(num_s) != 2: + num_s += " ) " + else: + num_s = "1" + + + denum_s = "/ ( " + first = True + for key in self_key["denum"]: + if not first: + denum_s+=" * " + first = False + denum_s += key + if self_key["denum"][key] != 1: + exposant = self_key["denum"][key] + denum_s+=f"^{exposant}" + + + if len(num_s) != 4: + denum_s += " )" + else: + denum_s = "" + + + if addRatio: + return f"{self.ratio} * " + num_s + denum_s + else: + return num_s + denum_s + def __str__(self): + return self.toString() class NeutralUnit(Unit): def __init__(self): self.numerator = [] self.denumerator = [] self.ratio = 1.0 + + def __str__(self): + return "1" class PrimaryUnit(Unit): @@ -156,9 +176,63 @@ def __init__(self, unit : Unit, ratio : float): self.ratio = ratio +class DimensionnedValue(): + + value : float + unit : Unit + + def __init__(self, value : float, unit : Unit): + self.value = value + self.unit = unit + + def __eq__ (self, other): + if not isinstance(other, DimensionnedValue): + raise TypeError("Dimensionned values can only be compared to other dimensionned values") + + no_ratio_s_unit = self.unit + no_ratio_s_unit.ratio = 1.0 + no_ratio_o_unit = other + no_ratio_o_unit.ratio = 1.0 + + if not no_ratio_s_unit == no_ratio_o_unit: + raise TypeError("Only values that share the same units can be compared") + + return self.value * self.unit.ration == other.value * other.unit.ratio + + + def __mul__(self, other): + if isinstance(other, DimensionnedValue): + return DimensionnedValue(self.value * other.value,self.unit * other.unit) + elif isinstance(other, Unit) : + return DimensionnedValue(self.value ,self.unit * other ) + else : + return DimensionnedValue(self.value * other.value,self.unit ) + + def __rmul__(self, other ): + return self.__mul__(other) + + def __pow__(self, other : int): + if not isinstance(other, int): + raise ValueError + + return DimensionnedValue(self.value ** other , self.unit**other ) + + def __truediv__(self, other ): + if isinstance(other, DimensionnedValue): + return DimensionnedValue(self.value / other.value,self.unit / other.unit) + elif isinstance(other, Unit) : + return DimensionnedValue(self.value ,self.unit / other ) + else: + return DimensionnedValue(self.value / other.value,self.unit ) + + def __str__(self): + return f"{self.value * self.unit.ratio} * " + self.unit.toString(False) + + + ### Primary units -neutralUnit = NeutralUnit() +DimensionLess = NeutralUnit() s = PrimaryUnit("s") # time m = PrimaryUnit("m") # length kg = PrimaryUnit("kg") # mass From 15266f66c965bebc7d1de92212661bb0406f47f7 Mon Sep 17 00:00:00 2001 From: bakpaul Date: Fri, 3 Jul 2026 13:10:01 +0200 Subject: [PATCH 06/10] Enable the use of numpy arrays and lists + some fixes + tests --- examples/SOFAUnits/CustomUnitsExample.py | 16 +- examples/SOFAUnits/SimulationParameters.py | 28 +- .../tests/test_simulation_parameters.py | 115 +++++++ examples/SOFAUnits/tests/test_units.py | 302 +++++++++++++++++- examples/SOFAUnits/units.py | 114 +++---- 5 files changed, 494 insertions(+), 81 deletions(-) diff --git a/examples/SOFAUnits/CustomUnitsExample.py b/examples/SOFAUnits/CustomUnitsExample.py index 7dd266ab..11ca65e9 100644 --- a/examples/SOFAUnits/CustomUnitsExample.py +++ b/examples/SOFAUnits/CustomUnitsExample.py @@ -3,6 +3,7 @@ from units import * from SimulationParameters import SOFAParameters +import numpy as np def main(): # Required import for python @@ -28,7 +29,8 @@ def main(): def createScene(root): SceneUnit = SOFAParameters(s, mm, g) - + # You now convert any value of any unit to the one expected by SOFa without knowing it. + # Here we know that the gravity constant is 9.81 in SI unit, we let SofaUnit convert it to the custom unit system root.gravity=[0, SceneUnit(-9.81, N/kg), 0] root.dt=0.02 @@ -68,19 +70,23 @@ def createScene(root): liver.addObject('MechanicalObject', name="dofs", src="@meshLoader") liver.addObject('TetrahedronSetGeometryAlgorithms', template="Vec3d", name="GeomAlgo") - #You can create values that have a dimension by multiplying a float/int by a unit + # You can create values that have a dimension by multiplying a float/int by a unit liver.addObject('TetrahedralCorotationalFEMForceField', template="Vec3d", name="FEM", method="large", poissonRatio="0.3", youngModulus=SceneUnit(3 * kPa), computeGlobalMatrix="0") - #Multiplications between 'DimenssionedUnit' is supported and will affect the final unit + # Multiplications between 'DimenssionedUnit' is supported and will affect the final unit liverVolume = 1.5 * dm**3 # 1L liverMass = 1.5 * kg liverDensity = liverMass/liverVolume - #You can print the value, the unit will show + # You can print the value, the unit will show print(f"Liver density is {liverDensity}") liver.addObject('DiagonalMass', name="Mass", massDensity=SceneUnit(liverDensity)) - liver.addObject('FixedProjectiveConstraint', name="FixedConstraint", indices="3 39 64") + # The library is also compatible with numpy array + # This would also work np.array([10, 1, 5 ]) * N/m + # or classical list (but with lists, the list multiplication will fail, you need to specify the unit for each member) + stiffness = np.array([10 * N/m, 1 * N/m, 5 * N/m]) + liver.addObject('RestShapeSpringsForceField', name="WeakConstraint", points="3 39 64", stiffness=SceneUnit(stiffness)) visu = liver.addChild('Visu') visu.addObject('OglModel', name="VisualModel", src="@../../LiverSurface") diff --git a/examples/SOFAUnits/SimulationParameters.py b/examples/SOFAUnits/SimulationParameters.py index 1360704c..835ae597 100644 --- a/examples/SOFAUnits/SimulationParameters.py +++ b/examples/SOFAUnits/SimulationParameters.py @@ -1,5 +1,6 @@ from units import * +import numpy as np class BaseParameterSet(): @@ -45,9 +46,32 @@ def convert(self, value : float, unit: DerivedUnit): def __call__(self, *args): if len(args) == 1: - return self.convert(value=args[0].value, unit= args[0].unit) + if isinstance(args[0], np.ndarray): + convertedArray = np.empty(args[0].shape, dtype=np.float32) + for i in range(convertedArray.size): + convertedArray.flat[i] = self.convert(value=args[0].flat[i].value, unit= args[0].flat[i].unit) + return convertedArray + elif isinstance(args[0], list): + retList = [None] * len(args[0]) + for i in range(len(retList)): + retList[i] = self.__call__(args[0][i]) + return retList + else: + return self.convert(value=args[0].value, unit= args[0].unit) elif len(args) == 2: - return self.convert(value=args[0], unit= args[1]) + if isinstance(args[0], np.ndarray): + convertedArray = np.empty(args[0].shape, dtype=np.float32) + for i in range(convertedArray.size): + convertedArray.flat[i] = self.convert(value=args[0].flat[i], unit = args[1]) + return convertedArray + elif isinstance(args[0], list): + retList = [None] * len(args[0]) + for i in range(len(retList)): + retList[i] = self.__call__(args[0][i], args[1]) + return retList + + else: + return self.convert(value=args[0], unit= args[1]) else: raise ValueError("This method requires either a DimensionnedValue as input or a float and a Unit.") diff --git a/examples/SOFAUnits/tests/test_simulation_parameters.py b/examples/SOFAUnits/tests/test_simulation_parameters.py index a599ab32..009f2c6f 100644 --- a/examples/SOFAUnits/tests/test_simulation_parameters.py +++ b/examples/SOFAUnits/tests/test_simulation_parameters.py @@ -136,6 +136,121 @@ def test_convert_when_scaled_dimension_only_has_exponent_one(self): assert bp.convert(10, kPa) == pytest.approx(10.0) +# =========================================================================== +# ADDITIONAL COVERAGE (added 2026-07-03) +# =========================================================================== + +import numpy as np +from units import g, v, DimensionLess + + +class TestDuplicateAndMixedRegistration: + def test_duplicate_primary_unit_raises_value_error(self): + with pytest.raises(ValueError): + BaseParameterSet(s, s) + + def test_scaled_and_plain_of_same_dimension_raises(self): + # mm and m share the abbreviation "m" -> second one must be refused + with pytest.raises(ValueError): + BaseParameterSet(m, mm) + + def test_scaled_unit_is_accepted_as_primary(self): + bp = BaseParameterSet(mm) + assert bp.units["m"] is mm + + +class TestConvertMoreCases: + def test_convert_scaled_target_unit_uses_its_ratio(self): + bp = BaseParameterSet(s, m, kg) + assert bp.convert(1, kN) == pytest.approx(1000.0) + + def test_convert_scaled_target_against_matching_scaled_base(self): + bp = BaseParameterSet(s, mm, kg) + assert bp.convert(1, mm) == pytest.approx(1.0) + + def test_convert_with_scaled_mass_unit(self): + # base mass = gram: 1 sim force unit = 1 g*m/s^2 = 1e-3 N + bp = BaseParameterSet(s, m, g) + assert bp.convert(1, N) == pytest.approx(1e3) + + def test_convert_dimensionless_is_identity(self): + bp = BaseParameterSet(s, m, kg) + assert bp.convert(5, DimensionLess) == pytest.approx(5.0) + + def test_convert_missing_denominator_unit_raises(self): + bp = BaseParameterSet(m, kg) # no 's' defined, v = m/s needs it + with pytest.raises(RuntimeError, match="s"): + bp.convert(1, v) + + def test_convert_zero_value(self): + bp = BaseParameterSet(s, mm, kg) + assert bp.convert(0, N) == pytest.approx(0.0) + + def test_convert_negative_value(self): + bp = BaseParameterSet(s, mm, kg) + assert bp.convert(-1, N) == pytest.approx(-1000.0) + + +class TestCallDispatch: + """__call__ accepts (DimensionnedValue) or (float, Unit), scalars, + lists and numpy arrays.""" + + def setup_method(self): + self.bp = BaseParameterSet(s, mm, kg) # 1 N -> 1000 sim units + + def test_call_with_dimensionned_value(self): + assert self.bp(10 * N) == pytest.approx(10000.0) + + def test_call_with_float_and_unit(self): + assert self.bp(10, N) == pytest.approx(10000.0) + + def test_call_with_list_of_floats_and_unit(self): + assert self.bp([1.0, 2.0], N) == pytest.approx([1000.0, 2000.0]) + + def test_call_with_nested_list(self): + assert self.bp([[1.0], [2.0]], N) == [[pytest.approx(1000.0)], [pytest.approx(2000.0)]] + + def test_call_with_ndarray_and_unit(self): + out = self.bp(np.array([1.0, 2.0]), N) + assert isinstance(out, np.ndarray) + assert list(out) == pytest.approx([1000.0, 2000.0]) + + def test_call_with_ndarray_of_dimensionned_values(self): + arr = np.array([1 * N, 2 * N], dtype=object) + out = self.bp(arr) + assert list(out) == pytest.approx([1000.0, 2000.0]) + + def test_call_with_list_of_dimensionned_values(self): + assert self.bp([1 * N, 2 * N]) == pytest.approx([1000.0, 2000.0]) + + def test_call_output_array_is_float32(self): + # NOTE/quirk: converted arrays are allocated as float32, so large + # magnitudes silently lose precision (~7 significant digits). + out = self.bp(np.array([1.0]), N) + assert out.dtype == np.float32 + + def test_call_with_no_args_raises_value_error(self): + with pytest.raises(ValueError): + self.bp() + + def test_call_with_three_args_raises_value_error(self): + with pytest.raises(ValueError): + self.bp(1.0, N, N) + + +class TestSOFAParametersMore: + def test_positional_override(self): + sp = SOFAParameters(ms, mm) + assert sp.units["s"] is ms + assert sp.units["m"] is mm + assert sp.units["kg"] is kg + + def test_mixed_scaled_units_conversion(self): + # time in ms, length in mm: 1 sim force = kg*mm/ms^2 = 1000 N + sp = SOFAParameters(time=ms, length=mm) + assert sp.convert(1, N) == pytest.approx(1e-3) + + if __name__ == "__main__": import sys sys.exit(pytest.main([__file__, "-v"])) diff --git a/examples/SOFAUnits/tests/test_units.py b/examples/SOFAUnits/tests/test_units.py index 0b79e860..9f568030 100644 --- a/examples/SOFAUnits/tests/test_units.py +++ b/examples/SOFAUnits/tests/test_units.py @@ -1,15 +1,17 @@ """ Unit tests for units.py -These tests describe the CURRENT behavior of the module, including a couple -of quirks (documented in comments) that were not changed, per request. +These tests describe the CURRENT behavior of the module (updated 2026-07-03 +for the fixed version of units.py). All previously pinned bugs are fixed; +remaining design quirks (ScaledUnit ratio semantics, simplify() list +mutation, float32 arrays in SimulationParameters) are documented in comments. Run with: pytest test_units.py -v """ import math import pytest from units import ( - Unit, NeutralUnit, PrimaryUnit, DerivedUnit, ScaledUnit, + Unit, NeutralUnit, PrimaryUnit, DerivedUnit, ScaledUnit, DimensionnedValue, DimensionLess, m, s, kg, v, a, N, Pa, tho, nm, mm, cm, km, @@ -196,14 +198,12 @@ def test_units_in_different_decades_are_not_equal(self): assert Pa != kPa assert mm != cm - def test_eq_key_comparison_is_one_directional(self): - # NOTE: __eq__ only checks that every key of `self` is present (and - # matches) in `other`; it does not check the reverse. So an object - # with a superset of keys on the "other" side can compare equal to - # a subset on the "self" side, as long as ratio decade matches too. + def test_eq_key_comparison_is_symmetric(self): + # FIXED: __eq__ now compares getKey() dicts, which is symmetric, so + # a subset of dimensions no longer compares equal to a superset. subset = DerivedUnit(numerator=[m.numerator[0]], denumerator=[], ratio=1.0) superset = DerivedUnit(numerator=[m.numerator[0], s.numerator[0]], denumerator=[], ratio=1.0) - assert subset == superset + assert subset != superset # --------------------------------------------------------------------------- @@ -222,6 +222,290 @@ def test_print_reduced_pascal(self, capsys): assert captured.out.strip() == "1.0 * ( kg ) / ( s^2 * m )" + + +# =========================================================================== +# ADDITIONAL COVERAGE (added 2026-07-03) +# Tests below extend coverage to __pow__, __truediv__ edge cases, toString, +# ScaledUnit composition, DimensionnedValue arithmetic, and pin down several +# genuine bugs (marked BUG) so they can't regress silently while unfixed. +# =========================================================================== + +class TestPower: + def test_positive_power(self): + area = m ** 2 + assert area.getKey() == {"num": {"m": 2}, "denum": {}} + assert area.ratio == pytest.approx(1.0) + + def test_positive_power_of_scaled_unit_compounds_ratio(self): + sq_mm = mm ** 2 + assert sq_mm.getKey() == {"num": {"m": 2}, "denum": {}} + assert sq_mm.ratio == pytest.approx(1e-6) + + def test_negative_power_inverts_unit(self): + per_s2 = s ** -2 + assert per_s2.getKey() == {"num": {}, "denum": {"s": 2}} + assert per_s2.ratio == pytest.approx(1.0) + + def test_negative_power_inverts_ratio(self): + per_km = km ** -1 + assert per_km.ratio == pytest.approx(1e-3) + + def test_power_of_derived_unit(self): + n2 = N ** 2 + assert n2.getKey() == {"num": {"kg": 2, "m": 2}, "denum": {"s": 4}} + + def test_non_int_power_raises_value_error(self): + with pytest.raises(ValueError): + m ** 2.0 + with pytest.raises(ValueError): + m ** "2" + + def test_power_zero_returns_neutral_unit_instance(self): + # FIXED: exponent 0 now returns a NeutralUnit *instance*. + result = m ** 0 + assert isinstance(result, NeutralUnit) + assert result.getKey() == {"num": {}, "denum": {}} + assert result.ratio == pytest.approx(1.0) + + +class TestTrueDivEdgeCases: + def test_divide_by_non_unit_raises_standard_type_error(self): + # FIXED: __truediv__ returns NotImplemented for non-Units, so Python + # itself raises the standard "unsupported operand" TypeError after + # both sides decline. + with pytest.raises(TypeError, match="unsupported operand"): + m / 2 + + def test_division_fully_cancels_identical_units(self): + r = (m / s) / (m / s) + assert r.getKey() == {"num": {}, "denum": {}} + assert r.ratio == pytest.approx(1.0) + + def test_division_by_scaled_unit_divides_ratio(self): + r = m / mm + assert r.getKey() == {"num": {}, "denum": {}} + assert r.ratio == pytest.approx(1e3) + + +class TestEqualityEdgeCases: + def test_eq_with_non_unit_returns_false(self): + # FIXED: __eq__ returns NotImplemented for non-Units, so Python + # falls back to identity comparison and `==` evaluates to False + # instead of raising. + assert (m == 5) is False + assert (m != 5) is True + + def test_dimensionless_no_longer_equals_everything(self): + # FIXED: symmetric dict comparison of getKey() means an empty key + # dict only matches another empty key dict. + assert (DimensionLess == m) is False + assert (DimensionLess == N) is False + assert DimensionLess == NeutralUnit() + + +class TestUnitHash: + def test_equal_units_hash_equal(self): + assert hash(N) == hash(kg * m / s**2) + assert hash(Pa) == hash(N / m**2) + + def test_hash_distinguishes_ratio_decades(self): + assert hash(N) != hash(kN) + + def test_hash_is_order_independent(self): + assert hash(m * s) == hash(s * m) + + def test_units_usable_in_sets_and_dicts(self): + assert len({N, kg * m / s**2}) == 1 + d = {m: "length", s: "time"} + assert d[m] == "length" + + +class TestToString: + def test_str_of_unit_with_num_and_denum(self): + assert str(v) == "1.0 * ( m ) / ( s )" + + def test_to_string_without_ratio(self): + assert N.toString(addRatio=False) == "( kg * m ) / ( s^2 )" + + def test_str_of_pure_denominator_unit(self): + per_s = NeutralUnit() / s + assert str(per_s) == "1.0 * 1/ ( s )" + + def test_str_of_unit_with_empty_denominator_has_no_dangling_parens(self): + # FIXED: an empty denominator is now omitted entirely (note the + # trailing space after the numerator group is kept). + assert str(m) == "1.0 * ( m ) " + + +class TestScaledUnitComposition: + def test_scaling_a_scaled_unit_replaces_ratio_instead_of_composing(self): + # NOTE/quirk: ScaledUnit takes `ratio` as an absolute ratio to the + # primary unit, not a factor applied on top of the base unit's own + # ratio. ScaledUnit(km, 2.0) therefore has ratio 2.0, not 2000.0. + twice = ScaledUnit(km, 2.0) + assert twice.ratio == pytest.approx(2.0) + + def test_scaled_unit_of_derived_unit_keeps_dimension(self): + u = ScaledUnit(Pa, 42.0) + assert u.getKey() == Pa.getKey() + assert u.ratio == pytest.approx(42.0) + + +class TestSimplifyMutation: + def test_constructor_mutates_callers_denominator_list(self): + # BUG/quirk: DerivedUnit.simplify() pops from the very list object + # the caller passed in, so the caller's list is emptied as a side + # effect. The operators (*, /, **) always build fresh lists so they + # are unaffected, but direct construction is not safe. + num, den = [m], [m] + DerivedUnit(numerator=num, denumerator=den, ratio=1.0) + assert den == [] # caller's list was mutated in place + + +# --------------------------------------------------------------------------- +# DimensionnedValue +# --------------------------------------------------------------------------- + +class TestDimensionnedValueConstruction: + def test_unit_times_scalar_builds_value(self): + dv = m * 5 + assert isinstance(dv, DimensionnedValue) + assert dv.value == 5 + assert dv.unit is m + + def test_scalar_times_unit_builds_value(self): + dv = 5 * m + assert isinstance(dv, DimensionnedValue) + assert dv.value == 5 + + def test_str_applies_ratio(self): + dv = 5 * km + assert str(dv) == "5000.0 * ( m ) " + + +class TestDimensionnedValueArithmetic: + def test_value_times_value_multiplies_values_and_units(self): + force = (3 * kg) * (2 * a) + assert force.value == 6 + assert force.unit.getKey() == N.getKey() + + def test_value_times_unit_extends_unit(self): + dv = (3 * m) * s + assert dv.value == 3 + assert dv.unit.getKey() == {"num": {"m": 1, "s": 1}, "denum": {}} + + def test_value_divided_by_value(self): + speed = (10 * m) / (2 * s) + assert speed.value == pytest.approx(5.0) + assert speed.unit.getKey() == v.getKey() + + def test_value_divided_by_unit(self): + dv = (10 * m) / s + assert dv.value == 10 + assert dv.unit.getKey() == v.getKey() + + def test_value_power(self): + sq = (3 * m) ** 2 + assert sq.value == 9 + assert sq.unit.getKey() == {"num": {"m": 2}, "denum": {}} + + def test_value_power_non_int_raises(self): + with pytest.raises(ValueError): + (3 * m) ** 0.5 + + def test_value_times_plain_scalar(self): + # FIXED: scalar branch now multiplies by `other` directly. + dv = (5 * m) * 2 + assert dv.value == 10 + assert dv.unit is m + + def test_value_divided_by_plain_scalar(self): + # FIXED: same fix in __truediv__'s scalar branch. + dv = (6 * m) / 2 + assert dv.value == pytest.approx(3.0) + assert dv.unit is m + + +class TestRightDivision: + def test_scalar_divided_by_value_inverts_unit(self): + dv = 2 / (5 * m) + assert dv.value == pytest.approx(0.4) + assert dv.unit.getKey() == {"num": {}, "denum": {"m": 1}} + + def test_value_divided_by_value_still_uses_normal_path(self): + speed = (10 * m) / (5 * s) + assert speed.value == pytest.approx(2.0) + assert speed.unit.getKey() == v.getKey() + + def test_unit_divided_by_value_delegates_to_rtruediv(self): + # FIXED: Unit.__truediv__ now returns NotImplemented for non-Units, + # so Python falls back to DimensionnedValue.__rtruediv__. + dv = m / (5 * s) + assert dv.value == pytest.approx(0.2) + assert dv.unit.getKey() == v.getKey() + + +class TestDimensionnedValueEquality: + # FIXED: __eq__ now compares getKey() dicts symmetrically, uses + # other.unit (not other), no longer mutates any unit's ratio, and + # compares normalized magnitudes with math.isclose. + + def test_equal_values_compare_equal(self): + assert (5 * m) == (5 * m) + + def test_cross_scale_comparison(self): + # 1000 mm == 1 m; isclose absorbs the 1000 * 1e-3 float wobble. + assert (1000 * mm) == (1 * m) + assert (1 * kN) == (1000 * N) + + def test_unequal_magnitudes_compare_unequal(self): + assert ((5 * m) == (6 * m)) is False + + def test_mismatched_dimensions_raise_type_error(self): + with pytest.raises(TypeError, match="share the same units"): + (5 * m) == (5 * s) + + def test_comparison_with_non_value_raises_type_error(self): + with pytest.raises(TypeError, match="compared"): + (5 * m) == 5 + + def test_eq_no_longer_mutates_unit_ratio(self): + # Regression guard for the old reference-instead-of-copy bug that + # permanently reset shared unit ratios to 1.0. + local_mm = ScaledUnit(m, 1e-3) + (1 * local_mm) == (2 * local_mm) + assert local_mm.ratio == pytest.approx(1e-3) + (1000 * mm) == (1 * m) + assert mm.ratio == pytest.approx(1e-3) + assert m.ratio == pytest.approx(1.0) + + +class TestDimensionnedValueHash: + def test_equal_values_hash_equal(self): + assert hash(1 * m) == hash(1 * m) + assert hash(1 * m) == hash(1.0 * m) + + def test_cross_scale_values_hash_equal(self): + # Exact for upward powers of ten (2 * 1e3 is exactly 2000.0); the + # round(..., 9) in __hash__ absorbs small downward-scale wobble. + assert hash(2 * km) == hash(2000.0 * m) + assert hash(1000 * mm) == hash(1 * m) + + def test_values_usable_in_sets(self): + assert len({1 * m, 1.0 * m}) == 1 + + def test_hash_isclose_caveat(self): + # NOTE: __eq__ uses math.isclose (relative tolerance) while __hash__ + # uses round(..., 9) (fixed decimal buckets). These can disagree for + # values that are isclose but straddle a rounding boundary, or for + # large magnitudes where 1e-9 absolute rounding is coarser than the + # relative tolerance. Acceptable for this library's power-of-ten + # ratios, but don't rely on set/dict deduplication for values that + # are only *approximately* equal. + assert True + + if __name__ == "__main__": import sys sys.exit(pytest.main([__file__, "-v"])) diff --git a/examples/SOFAUnits/units.py b/examples/SOFAUnits/units.py index 062624a9..1033bc15 100644 --- a/examples/SOFAUnits/units.py +++ b/examples/SOFAUnits/units.py @@ -22,21 +22,12 @@ def getKey(self): def __eq__ (self, other): if not isinstance(other, Unit): - raise TypeError + return NotImplemented if int(math.log10(self.ratio)) != int(math.log10(other.ratio)) : return False - s_key = self.getKey() - o_key = other.getKey() - - isEq = True - - for dicName in ["num", "denum"]: - for key in s_key[dicName]: - isEq = isEq and (key in o_key[dicName]) and (s_key[dicName][key] == o_key[dicName][key] ) - - return isEq + return self.getKey() == other.getKey() def __mul__(self, other): @@ -67,60 +58,43 @@ def __pow__(self, other : int): elif other > 0 : return DerivedUnit(numerator=targetNum, denumerator= targetDenum, ratio = targetRatio) else: - return NeutralUnit + return NeutralUnit() def __truediv__(self, other ): if not isinstance(other, Unit): - raise TypeError + return NotImplemented return DerivedUnit(numerator=self.numerator + other.denumerator, denumerator= self.denumerator + other.numerator, ratio = self.ratio / other.ratio) def toString(self, addRatio : bool = True): - self_key = self.getKey() - num_s = "( " - first = True - for key in self_key["num"]: - if not first: - num_s+=" * " - first = False - num_s += key - if self_key["num"][key] != 1: - exposant = self_key["num"][key] - num_s+=f"^{exposant}" - - if len(num_s) != 2: - num_s += " ) " - else: - num_s = "1" - - denum_s = "/ ( " - first = True - for key in self_key["denum"]: - if not first: - denum_s+=" * " - first = False - denum_s += key - if self_key["denum"][key] != 1: - exposant = self_key["denum"][key] - denum_s+=f"^{exposant}" - - - if len(num_s) != 4: - denum_s += " )" - else: - denum_s = "" + def side(units: dict) -> str: + return " * ".join( + k if exp == 1 else f"{k}^{exp}" + for k, exp in units.items() + ) + num = side(self_key["num"]) + denum = side(self_key["denum"]) - if addRatio: - return f"{self.ratio} * " + num_s + denum_s - else: - return num_s + denum_s + num_s = f"( {num} ) " if num else "1" + denum_s = f"/ ( {denum} )" if denum else "" + + prefix = f"{self.ratio} * " if addRatio else "" + return prefix + num_s + denum_s def __str__(self): return self.toString() + + def __hash__(self): + key = self.getKey() + return hash(( + frozenset(key["num"].items()), + frozenset(key["denum"].items()), + int(math.log10(self.ratio)), + )) class NeutralUnit(Unit): def __init__(self): @@ -188,25 +162,21 @@ def __init__(self, value : float, unit : Unit): def __eq__ (self, other): if not isinstance(other, DimensionnedValue): raise TypeError("Dimensionned values can only be compared to other dimensionned values") - - no_ratio_s_unit = self.unit - no_ratio_s_unit.ratio = 1.0 - no_ratio_o_unit = other - no_ratio_o_unit.ratio = 1.0 + - if not no_ratio_s_unit == no_ratio_o_unit: + if self.unit.getKey() != other.unit.getKey(): raise TypeError("Only values that share the same units can be compared") - return self.value * self.unit.ration == other.value * other.unit.ratio + return math.isclose(self.value * self.unit.ratio, other.value * other.unit.ratio) def __mul__(self, other): if isinstance(other, DimensionnedValue): return DimensionnedValue(self.value * other.value,self.unit * other.unit) elif isinstance(other, Unit) : - return DimensionnedValue(self.value ,self.unit * other ) + return DimensionnedValue(self.value ,self.unit * other) else : - return DimensionnedValue(self.value * other.value,self.unit ) + return DimensionnedValue(self.value * other,self.unit) def __rmul__(self, other ): return self.__mul__(other) @@ -215,20 +185,34 @@ def __pow__(self, other : int): if not isinstance(other, int): raise ValueError - return DimensionnedValue(self.value ** other , self.unit**other ) + return DimensionnedValue(self.value ** other, self.unit**other) - def __truediv__(self, other ): + def __truediv__(self, other): if isinstance(other, DimensionnedValue): - return DimensionnedValue(self.value / other.value,self.unit / other.unit) + return DimensionnedValue(self.value / other.value, self.unit / other.unit) elif isinstance(other, Unit) : - return DimensionnedValue(self.value ,self.unit / other ) + return DimensionnedValue(self.value, self.unit / other) else: - return DimensionnedValue(self.value / other.value,self.unit ) + return DimensionnedValue(self.value / other, self.unit) + + def __rtruediv__(self, other): + if isinstance(other, DimensionnedValue): + return DimensionnedValue(other.value / self.value, other.unit / self.unit) + elif isinstance(other, Unit): + return DimensionnedValue(1.0 / self.value, other / self.unit) + else: + return DimensionnedValue(other / self.value, self.unit ** -1) def __str__(self): return f"{self.value * self.unit.ratio} * " + self.unit.toString(False) - + def __hash__(self): + key = self.unit.getKey() + return hash(( + frozenset(key["num"].items()), + frozenset(key["denum"].items()), + round(self.value * self.unit.ratio, 9) # normalized magnitude + )) ### Primary units From 74c04103bca76c49372dd9d02b25aa21b3a68b6b Mon Sep 17 00:00:00 2001 From: bakpaul Date: Fri, 3 Jul 2026 16:01:45 +0200 Subject: [PATCH 07/10] Make SOFA.Units a new submodule of Sofa --- .../Sofa/package/Units}/SimulationParameters.py | 2 +- .../units.py => bindings/Sofa/package/Units/Types.py | 0 bindings/Sofa/package/Units/__init__.py | 1 + .../Sofa/package/Units}/tests/conftest.py | 0 .../package/Units}/tests/test_simulation_parameters.py | 0 .../Sofa/package/Units}/tests/test_units.py | 0 examples/BaseViewer.ini | 8 ++++++++ .../CustomUnitsExample.py => SofaUnitsExample.py} | 8 ++++---- 8 files changed, 14 insertions(+), 5 deletions(-) rename {examples/SOFAUnits => bindings/Sofa/package/Units}/SimulationParameters.py (99%) rename examples/SOFAUnits/units.py => bindings/Sofa/package/Units/Types.py (100%) create mode 100644 bindings/Sofa/package/Units/__init__.py rename {examples/SOFAUnits => bindings/Sofa/package/Units}/tests/conftest.py (100%) rename {examples/SOFAUnits => bindings/Sofa/package/Units}/tests/test_simulation_parameters.py (100%) rename {examples/SOFAUnits => bindings/Sofa/package/Units}/tests/test_units.py (100%) create mode 100644 examples/BaseViewer.ini rename examples/{SOFAUnits/CustomUnitsExample.py => SofaUnitsExample.py} (94%) diff --git a/examples/SOFAUnits/SimulationParameters.py b/bindings/Sofa/package/Units/SimulationParameters.py similarity index 99% rename from examples/SOFAUnits/SimulationParameters.py rename to bindings/Sofa/package/Units/SimulationParameters.py index 835ae597..435fc0b2 100644 --- a/examples/SOFAUnits/SimulationParameters.py +++ b/bindings/Sofa/package/Units/SimulationParameters.py @@ -1,5 +1,5 @@ -from units import * +from Sofa.Units.Types import * import numpy as np class BaseParameterSet(): diff --git a/examples/SOFAUnits/units.py b/bindings/Sofa/package/Units/Types.py similarity index 100% rename from examples/SOFAUnits/units.py rename to bindings/Sofa/package/Units/Types.py diff --git a/bindings/Sofa/package/Units/__init__.py b/bindings/Sofa/package/Units/__init__.py new file mode 100644 index 00000000..dee1924d --- /dev/null +++ b/bindings/Sofa/package/Units/__init__.py @@ -0,0 +1 @@ +__all__=["Types", "SimulationParameters"] diff --git a/examples/SOFAUnits/tests/conftest.py b/bindings/Sofa/package/Units/tests/conftest.py similarity index 100% rename from examples/SOFAUnits/tests/conftest.py rename to bindings/Sofa/package/Units/tests/conftest.py diff --git a/examples/SOFAUnits/tests/test_simulation_parameters.py b/bindings/Sofa/package/Units/tests/test_simulation_parameters.py similarity index 100% rename from examples/SOFAUnits/tests/test_simulation_parameters.py rename to bindings/Sofa/package/Units/tests/test_simulation_parameters.py diff --git a/examples/SOFAUnits/tests/test_units.py b/bindings/Sofa/package/Units/tests/test_units.py similarity index 100% rename from examples/SOFAUnits/tests/test_units.py rename to bindings/Sofa/package/Units/tests/test_units.py diff --git a/examples/BaseViewer.ini b/examples/BaseViewer.ini new file mode 100644 index 00000000..c93beb9b --- /dev/null +++ b/examples/BaseViewer.ini @@ -0,0 +1,8 @@ +EnableSelectionDraw=true +ShowSelectedNodeBoundingBox=true +ShowSelectedObjectBoundingBox=true +ShowSelectedObjectPositions=false +ShowSelectedObjectSurfaces=false +ShowSelectedObjectVolumes=false +ShowSelectedObjectIndices=false +VisualScaling=1 diff --git a/examples/SOFAUnits/CustomUnitsExample.py b/examples/SofaUnitsExample.py similarity index 94% rename from examples/SOFAUnits/CustomUnitsExample.py rename to examples/SofaUnitsExample.py index 11ca65e9..5841bc17 100644 --- a/examples/SOFAUnits/CustomUnitsExample.py +++ b/examples/SofaUnitsExample.py @@ -1,8 +1,8 @@ # Choose in your script to activate or not the GUI USE_GUI = True -from units import * -from SimulationParameters import SOFAParameters +from Sofa.Units.Types import * +from Sofa.Units.SimulationParameters import SOFAParameters import numpy as np def main(): @@ -43,7 +43,7 @@ def createScene(root): 'Sofa.Component.LinearSolver.Iterative', 'Sofa.Component.Mapping.Linear', 'Sofa.Component.Mass', - 'Sofa.Component.IntegrationScheme.Backward', + 'Sofa.Component.ODESolver.Backward', 'Sofa.Component.SolidMechanics.FEM.Elastic', 'Sofa.Component.StateContainer', 'Sofa.Component.Topology.Container.Dynamic', @@ -63,7 +63,7 @@ def createScene(root): root.addObject('MeshOBJLoader', name="LiverSurface", filename="mesh/liver-smooth.obj") liver = root.addChild('Liver') - liver.addObject('EulerImplicitIntegrationScheme', name="cg_odesolver", rayleighStiffness="0.1", rayleighMass="0.1") + liver.addObject('EulerImplicitSolver', name="cg_odesolver", rayleighStiffness="0.1", rayleighMass="0.1") liver.addObject('CGLinearSolver', name="linear_solver", iterations="25", tolerance="1e-09", threshold="1e-09") liver.addObject('MeshGmshLoader', name="meshLoader", filename="mesh/liver.msh") liver.addObject('TetrahedronSetTopologyContainer', name="topo", src="@meshLoader") From d28d2be190214698da33da19c648cb579c1468f3 Mon Sep 17 00:00:00 2001 From: Paul Baksic <30337881+bakpaul@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:37:54 +0200 Subject: [PATCH 08/10] Delete examples/BaseViewer.ini --- examples/BaseViewer.ini | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 examples/BaseViewer.ini diff --git a/examples/BaseViewer.ini b/examples/BaseViewer.ini deleted file mode 100644 index c93beb9b..00000000 --- a/examples/BaseViewer.ini +++ /dev/null @@ -1,8 +0,0 @@ -EnableSelectionDraw=true -ShowSelectedNodeBoundingBox=true -ShowSelectedObjectBoundingBox=true -ShowSelectedObjectPositions=false -ShowSelectedObjectSurfaces=false -ShowSelectedObjectVolumes=false -ShowSelectedObjectIndices=false -VisualScaling=1 From 50b6d741437d2ed719abf57b4d326ed627059509 Mon Sep 17 00:00:00 2001 From: bakpaul Date: Fri, 3 Jul 2026 16:58:43 +0200 Subject: [PATCH 09/10] Refactor the names to better match whats inside --- .../Sofa/package/Units/{Types.py => Core.py} | 77 ------------------ bindings/Sofa/package/Units/Definitions.py | 79 +++++++++++++++++++ .../package/Units/SimulationParameters.py | 4 +- bindings/Sofa/package/Units/__init__.py | 2 +- .../Units/tests/test_simulation_parameters.py | 4 +- .../Sofa/package/Units/tests/test_units.py | 7 +- examples/SofaUnitsExample.py | 2 +- 7 files changed, 90 insertions(+), 85 deletions(-) rename bindings/Sofa/package/Units/{Types.py => Core.py} (78%) create mode 100644 bindings/Sofa/package/Units/Definitions.py diff --git a/bindings/Sofa/package/Units/Types.py b/bindings/Sofa/package/Units/Core.py similarity index 78% rename from bindings/Sofa/package/Units/Types.py rename to bindings/Sofa/package/Units/Core.py index 1033bc15..468aaea3 100644 --- a/bindings/Sofa/package/Units/Types.py +++ b/bindings/Sofa/package/Units/Core.py @@ -214,80 +214,3 @@ def __hash__(self): round(self.value * self.unit.ratio, 9) # normalized magnitude )) - -### Primary units -DimensionLess = NeutralUnit() -s = PrimaryUnit("s") # time -m = PrimaryUnit("m") # length -kg = PrimaryUnit("kg") # mass -A = PrimaryUnit("A") # electric current -K = PrimaryUnit("K") # temperature -mol = PrimaryUnit("mol") # amount of substance -cd = PrimaryUnit("cd") # luminous intensity - - -### (some) Derived units -v = m/s # velocity -a = v/s # acceleration -N = kg*a # force (Newton) -Pa = N/(m**2) # pressure (Pascal) -tho = m*N # torque -J = kg*m**2/s**2 # energy (Joule) -W = J/s # power (Watt) - - -## Scaled primary units -nm = ScaledUnit(m, 1e-9) -µm = ScaledUnit(m, 1e-6) -mm = ScaledUnit(m, 1e-3) -cm = ScaledUnit(m, 1e-2) -dm = ScaledUnit(m, 1e-1) -km = ScaledUnit(m, 1e3) - -ns = ScaledUnit(s, 1e-9) -µs = ScaledUnit(s, 1e-6) -ms = ScaledUnit(s, 1e-3) - -µg = ScaledUnit(kg, 1e-9) -mg = ScaledUnit(kg, 1e-6) -g = ScaledUnit(kg, 1e-3) -t = ScaledUnit(kg, 1e3) - -## Scaled derived units -nN = ScaledUnit(N, 1e-9) -µN = ScaledUnit(N, 1e-6) -mN = ScaledUnit(N, 1e-3) -cN = ScaledUnit(N, 1e-2) -dN = ScaledUnit(N, 1e-1) -kN = ScaledUnit(N, 1e3) -MN = ScaledUnit(N, 1e6) -GN = ScaledUnit(N, 1e9) - - -nPa = ScaledUnit(Pa, 1e-9) -µPa = ScaledUnit(Pa, 1e-6) -mPa = ScaledUnit(Pa, 1e-3) -cPa = ScaledUnit(Pa, 1e-2) -dPa = ScaledUnit(Pa, 1e-1) -kPa = ScaledUnit(Pa, 1e3) -MPa = ScaledUnit(Pa, 1e6) -GPa = ScaledUnit(Pa, 1e9) - -mJ = ScaledUnit(J, 1e-3) -cJ = ScaledUnit(J, 1e-2) -dJ = ScaledUnit(J, 1e-1) -kJ = ScaledUnit(J, 1e3) -MJ = ScaledUnit(J, 1e6) -GJ = ScaledUnit(J, 1e9) - -mW = ScaledUnit(W, 1e-3) -cW = ScaledUnit(W, 1e-2) -dW = ScaledUnit(W, 1e-1) -kW = ScaledUnit(W, 1e3) -MW = ScaledUnit(W, 1e6) -GW = ScaledUnit(W, 1e9) - - - - - diff --git a/bindings/Sofa/package/Units/Definitions.py b/bindings/Sofa/package/Units/Definitions.py new file mode 100644 index 00000000..576c51e1 --- /dev/null +++ b/bindings/Sofa/package/Units/Definitions.py @@ -0,0 +1,79 @@ +from .Core import * + + +### Primary units +DimensionLess = NeutralUnit() +s = PrimaryUnit("s") # time +m = PrimaryUnit("m") # length +kg = PrimaryUnit("kg") # mass +A = PrimaryUnit("A") # electric current +K = PrimaryUnit("K") # temperature +mol = PrimaryUnit("mol") # amount of substance +cd = PrimaryUnit("cd") # luminous intensity + + +### (some) Derived units +v = m/s # velocity +a = v/s # acceleration +N = kg*a # force (Newton) +Pa = N/(m**2) # pressure (Pascal) +tho = m*N # torque +J = kg*m**2/s**2 # energy (Joule) +W = J/s # power (Watt) + + +## Scaled primary units +nm = ScaledUnit(m, 1e-9) +µm = ScaledUnit(m, 1e-6) +mm = ScaledUnit(m, 1e-3) +cm = ScaledUnit(m, 1e-2) +dm = ScaledUnit(m, 1e-1) +km = ScaledUnit(m, 1e3) + +ns = ScaledUnit(s, 1e-9) +µs = ScaledUnit(s, 1e-6) +ms = ScaledUnit(s, 1e-3) + +µg = ScaledUnit(kg, 1e-9) +mg = ScaledUnit(kg, 1e-6) +g = ScaledUnit(kg, 1e-3) +t = ScaledUnit(kg, 1e3) + +## Scaled derived units +nN = ScaledUnit(N, 1e-9) +µN = ScaledUnit(N, 1e-6) +mN = ScaledUnit(N, 1e-3) +cN = ScaledUnit(N, 1e-2) +dN = ScaledUnit(N, 1e-1) +kN = ScaledUnit(N, 1e3) +MN = ScaledUnit(N, 1e6) +GN = ScaledUnit(N, 1e9) + + +nPa = ScaledUnit(Pa, 1e-9) +µPa = ScaledUnit(Pa, 1e-6) +mPa = ScaledUnit(Pa, 1e-3) +cPa = ScaledUnit(Pa, 1e-2) +dPa = ScaledUnit(Pa, 1e-1) +kPa = ScaledUnit(Pa, 1e3) +MPa = ScaledUnit(Pa, 1e6) +GPa = ScaledUnit(Pa, 1e9) + +mJ = ScaledUnit(J, 1e-3) +cJ = ScaledUnit(J, 1e-2) +dJ = ScaledUnit(J, 1e-1) +kJ = ScaledUnit(J, 1e3) +MJ = ScaledUnit(J, 1e6) +GJ = ScaledUnit(J, 1e9) + +mW = ScaledUnit(W, 1e-3) +cW = ScaledUnit(W, 1e-2) +dW = ScaledUnit(W, 1e-1) +kW = ScaledUnit(W, 1e3) +MW = ScaledUnit(W, 1e6) +GW = ScaledUnit(W, 1e9) + + + + + diff --git a/bindings/Sofa/package/Units/SimulationParameters.py b/bindings/Sofa/package/Units/SimulationParameters.py index 435fc0b2..a3d2dd19 100644 --- a/bindings/Sofa/package/Units/SimulationParameters.py +++ b/bindings/Sofa/package/Units/SimulationParameters.py @@ -1,5 +1,5 @@ - -from Sofa.Units.Types import * +from .Core import * +from .Definitions import DimensionLess, s, m, kg import numpy as np class BaseParameterSet(): diff --git a/bindings/Sofa/package/Units/__init__.py b/bindings/Sofa/package/Units/__init__.py index dee1924d..7147241d 100644 --- a/bindings/Sofa/package/Units/__init__.py +++ b/bindings/Sofa/package/Units/__init__.py @@ -1 +1 @@ -__all__=["Types", "SimulationParameters"] +__all__=["Core", "Definitions", "SimulationParameters"] diff --git a/bindings/Sofa/package/Units/tests/test_simulation_parameters.py b/bindings/Sofa/package/Units/tests/test_simulation_parameters.py index 009f2c6f..33f4163b 100644 --- a/bindings/Sofa/package/Units/tests/test_simulation_parameters.py +++ b/bindings/Sofa/package/Units/tests/test_simulation_parameters.py @@ -5,7 +5,7 @@ """ import pytest -from units import s, m, mm, ms, kg, N, Pa, kPa, kN, tho +from Definitions import s, m, mm, ms, kg, N, Pa, kPa, kN, tho from SimulationParameters import BaseParameterSet, SOFAParameters @@ -141,7 +141,7 @@ def test_convert_when_scaled_dimension_only_has_exponent_one(self): # =========================================================================== import numpy as np -from units import g, v, DimensionLess +from Definitions import g, v, DimensionLess class TestDuplicateAndMixedRegistration: diff --git a/bindings/Sofa/package/Units/tests/test_units.py b/bindings/Sofa/package/Units/tests/test_units.py index 9f568030..28c3a153 100644 --- a/bindings/Sofa/package/Units/tests/test_units.py +++ b/bindings/Sofa/package/Units/tests/test_units.py @@ -10,8 +10,7 @@ import math import pytest -from units import ( - Unit, NeutralUnit, PrimaryUnit, DerivedUnit, ScaledUnit, DimensionnedValue, +from Definitions import ( DimensionLess, m, s, kg, v, a, N, Pa, tho, nm, mm, cm, km, @@ -21,6 +20,10 @@ kPa, MPa, ) +from Core import ( + Unit, NeutralUnit, PrimaryUnit, DerivedUnit, ScaledUnit, DimensionnedValue +) + # --------------------------------------------------------------------------- # PrimaryUnit / NeutralUnit diff --git a/examples/SofaUnitsExample.py b/examples/SofaUnitsExample.py index 5841bc17..7c5afa5d 100644 --- a/examples/SofaUnitsExample.py +++ b/examples/SofaUnitsExample.py @@ -1,7 +1,7 @@ # Choose in your script to activate or not the GUI USE_GUI = True -from Sofa.Units.Types import * +from Sofa.Units.Definitions import s, m, mm, dm, N, g, kg, kPa from Sofa.Units.SimulationParameters import SOFAParameters import numpy as np From 9f7968afb8b3e9743cbb5eaa0b132552ddd12b2a Mon Sep 17 00:00:00 2001 From: Paul Baksic <30337881+bakpaul@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:54:52 +0200 Subject: [PATCH 10/10] Update comments --- examples/SofaUnitsExample.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/SofaUnitsExample.py b/examples/SofaUnitsExample.py index 7c5afa5d..296a8516 100644 --- a/examples/SofaUnitsExample.py +++ b/examples/SofaUnitsExample.py @@ -27,10 +27,11 @@ def main(): def createScene(root): + # We know the scene units are second for time, mm for length (because the file we import is in mm) and g for mass SceneUnit = SOFAParameters(s, mm, g) - # You now convert any value of any unit to the one expected by SOFa without knowing it. - # Here we know that the gravity constant is 9.81 in SI unit, we let SofaUnit convert it to the custom unit system + # You can now convert any value of any unit to the one expected by SOFA without knowing it. + # Here we know that the gravity constant is 9.81 in SI unit (a.k.a. N/kg), we let SofaUnit convert it to the custom unit system that we defined root.gravity=[0, SceneUnit(-9.81, N/kg), 0] root.dt=0.02