diff --git a/.gitignore b/.gitignore index 97407b2..607dea6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .idea/* tests/report* tests/asdf* +tests/optim* tests/__pychache__/* trussme/__pycache__/* diff --git a/tests/test_optimization.py b/tests/test_optimization.py new file mode 100644 index 0000000..c30fd34 --- /dev/null +++ b/tests/test_optimization.py @@ -0,0 +1,124 @@ +import unittest +import os + +import scipy.optimize +import numpy + +import trussme + + +class TestCustomStuff(unittest.TestCase): + def test_setup(self): + truss_from_commands = trussme.Truss() + truss_from_commands.add_pinned_joint([0.0, 0.0, 0.0]) + truss_from_commands.add_free_joint([1.0, 0.0, 0.0]) + truss_from_commands.add_free_joint([2.0, 0.0, 0.0]) + truss_from_commands.add_free_joint([3.0, 0.0, 0.0]) + truss_from_commands.add_free_joint([4.0, 0.0, 0.0]) + truss_from_commands.add_pinned_joint([5.0, 0.0, 0.0]) + + truss_from_commands.add_free_joint([0.5, 1.0, 0.0]) + truss_from_commands.add_free_joint([1.5, 1.0, 0.0]) + truss_from_commands.add_free_joint([2.5, 1.0, 0.0]) + truss_from_commands.add_free_joint([3.5, 1.0, 0.0]) + truss_from_commands.add_free_joint([4.5, 1.0, 0.0]) + + truss_from_commands.add_out_of_plane_support("z") + + truss_from_commands.joints[8].loads[1] = -20000 + + truss_from_commands.add_member(0, 1) + truss_from_commands.add_member(1, 2) + truss_from_commands.add_member(2, 3) + truss_from_commands.add_member(3, 4) + truss_from_commands.add_member(4, 5) + + truss_from_commands.add_member(6, 7) + truss_from_commands.add_member(7, 8) + truss_from_commands.add_member(8, 9) + truss_from_commands.add_member(9, 10) + + truss_from_commands.add_member(0, 6) + truss_from_commands.add_member(6, 1) + truss_from_commands.add_member(1, 7) + truss_from_commands.add_member(7, 2) + truss_from_commands.add_member(2, 8) + truss_from_commands.add_member(8, 3) + truss_from_commands.add_member(3, 9) + truss_from_commands.add_member(9, 4) + truss_from_commands.add_member(4, 10) + truss_from_commands.add_member(10, 5) + + goals = trussme.Goals() + + x0, obj, con, gen = trussme.make_optimization_functions( + truss_from_commands, goals + ) + + self.assertEqual( + trussme.report_to_str(gen(x0), goals), + trussme.report_to_str(truss_from_commands, goals), + ) + + def test_joint_optimization(self): + truss_from_commands = trussme.Truss() + truss_from_commands.add_pinned_joint([0.0, 0.0, 0.0]) + truss_from_commands.add_free_joint([1.0, 0.0, 0.0]) + truss_from_commands.add_free_joint([2.0, 0.0, 0.0]) + truss_from_commands.add_free_joint([3.0, 0.0, 0.0]) + truss_from_commands.add_free_joint([4.0, 0.0, 0.0]) + truss_from_commands.add_pinned_joint([5.0, 0.0, 0.0]) + + truss_from_commands.add_free_joint([0.5, 1.0, 0.0]) + truss_from_commands.add_free_joint([1.5, 1.0, 0.0]) + truss_from_commands.add_free_joint([2.5, 1.0, 0.0]) + truss_from_commands.add_free_joint([3.5, 1.0, 0.0]) + truss_from_commands.add_free_joint([4.5, 1.0, 0.0]) + + truss_from_commands.add_out_of_plane_support("z") + + truss_from_commands.joints[8].loads[1] = -20000 + + truss_from_commands.add_member(0, 1) + truss_from_commands.add_member(1, 2) + truss_from_commands.add_member(2, 3) + truss_from_commands.add_member(3, 4) + truss_from_commands.add_member(4, 5) + + truss_from_commands.add_member(6, 7) + truss_from_commands.add_member(7, 8) + truss_from_commands.add_member(8, 9) + truss_from_commands.add_member(9, 10) + + truss_from_commands.add_member(0, 6) + truss_from_commands.add_member(6, 1) + truss_from_commands.add_member(1, 7) + truss_from_commands.add_member(7, 2) + truss_from_commands.add_member(2, 8) + truss_from_commands.add_member(8, 3) + truss_from_commands.add_member(3, 9) + truss_from_commands.add_member(9, 4) + truss_from_commands.add_member(4, 10) + truss_from_commands.add_member(10, 5) + + goals = trussme.Goals() + + x0, obj, con, gen = trussme.make_optimization_functions( + truss_from_commands, goals, joint_coordinates=True, shape_parameters=False + ) + + results = scipy.optimize.minimize( + lambda x, *args: obj(x), + x0, + scipy.optimize.NonlinearConstraint( + con, -numpy.inf, 0.0, keep_feasible=True + ), + method="trust-constr", + options={"verbose": 2, "maxiter": 50}, + ) + + result_truss = gen(results.x) + result_truss.analyze() + trussme.report_to_md( + os.path.join(os.path.dirname(__file__), "optim.md"), result_truss, goals + ) diff --git a/trussme/__init__.py b/trussme/__init__.py index b84f3fe..5814b8d 100644 --- a/trussme/__init__.py +++ b/trussme/__init__.py @@ -41,3 +41,4 @@ ) from trussme.truss import Truss, read_trs, read_json, Goals from trussme.report import report_to_str, report_to_md, print_report +from trussme.optimize import make_truss_generator_function, make_optimization_functions diff --git a/trussme/optimize.py b/trussme/optimize.py new file mode 100644 index 0000000..cc54d54 --- /dev/null +++ b/trussme/optimize.py @@ -0,0 +1,163 @@ +from typing import Callable +import io + +import numpy + +from trussme import Truss, Goals, read_json + + +def make_x0( + truss: Truss, + joint_coordinates: bool = True, + shape_parameters: bool = True, +) -> list[float]: + """ + Returns a vector that encodes the current truss design + + Parameters + ---------- + truss: Truss + The truss to configure. + joint_coordinates: bool, default=True + Whether to include joint location parameters. + shape_parameters: bool, default=True + Whether to include shape parameters. + + Returns + ------- + list[float] + A starting vector that encodes the current truss design + """ + + planar_direction: str = truss.is_planar() + x0: list[float] = [] + + configured_truss = read_json(truss.to_json()) + + if joint_coordinates: + for i in range(len(configured_truss.joints)): + if ( + numpy.sum(configured_truss.joints[i].translation_restricted) + == (0 if planar_direction == "none" else 1) + and numpy.sum(configured_truss.joints[i].loads) == 0 + ): + if planar_direction != "x": + x0.append(configured_truss.joints[i].coordinates[0]) + if planar_direction != "y": + x0.append(configured_truss.joints[i].coordinates[1]) + if planar_direction != "z": + x0.append(configured_truss.joints[i].coordinates[2]) + + return x0 + + +def make_truss_generator_function( + truss: Truss, + joint_coordinates: bool = True, + shape_parameters: bool = True, +) -> Callable[[list[float]], Truss]: + """ + Returns a function that takes a list of floats and returns a truss. + + Parameters + ---------- + truss: Truss + The truss to configure. + joint_coordinates: bool, default=True + Whether to include joint location parameters. + shape_parameters: bool, default=True + Whether to include shape parameters. + + Returns + ------- + Callable[[list[float]] + A tuple containing a vector that describes the input truss and a function that takes a list of floats and returns a truss. + """ + + planar_direction: str = truss.is_planar() + + def truss_generator(x: list[float]) -> Truss: + configured_truss = read_json(truss.to_json()) + idx = 0 + + if joint_coordinates: + for i in range(len(configured_truss.joints)): + if ( + numpy.sum(configured_truss.joints[i].translation_restricted) + == (0 if planar_direction == "none" else 1) + and numpy.sum(configured_truss.joints[i].loads) == 0 + ): + if planar_direction != "x": + configured_truss.joints[i].coordinates[0] = x[idx] + idx += 1 + if planar_direction != "y": + configured_truss.joints[i].coordinates[1] = x[idx] + idx += 1 + if planar_direction != "z": + configured_truss.joints[i].coordinates[2] = x[idx] + idx += 1 + + return configured_truss + + return truss_generator + + +def make_optimization_functions( + truss: Truss, + goals: Goals, + joint_coordinates: bool = True, + shape_parameters: bool = True, +) -> tuple[ + list[float], + Callable[[list[float]], float], + Callable[[list[float]], list[float]], + Callable[[list[float]], Truss], +]: + """ + Creates functions for use in optimization, including a starting vector, objective function, a constraint function, and a truss generator function. + + Parameters + ---------- + truss: Truss + The truss to use as a starting configuration + joint_coordinates: bool, default=True + Whether to include joint location parameters. + shape_parameters: bool, default=True + Whether to include shape parameters. + + Returns + ------- + tuple[ + list[float], + Callable[[list[float]], float], + Callable[[list[float]], list[float]], + Callable[[list[float]], Truss], + ] + A tuple containing the starting vector, objective function, constraint function, and truss generator function. + """ + + x0 = make_x0(truss, joint_coordinates, shape_parameters) + + truss_generator = make_truss_generator_function( + truss, joint_coordinates, shape_parameters + ) + + def objective_function(x: list[float]) -> float: + truss = truss_generator(x) + return truss.mass + + def inequality_constraints(x: list[float]) -> list[float]: + truss = truss_generator(x) + truss.analyze() + return [ + goals.minimum_fos_buckling - truss.fos_buckling, + goals.minimum_fos_yielding - truss.fos_yielding, + truss.deflection - goals.maximum_deflection, + ] + + return ( + x0, + objective_function, + inequality_constraints, + truss_generator, + ) diff --git a/trussme/truss.py b/trussme/truss.py index 4ce10d3..01353e0 100644 --- a/trussme/truss.py +++ b/trussme/truss.py @@ -1,5 +1,5 @@ import dataclasses -from typing import Literal +from typing import Literal, Union import json import numpy @@ -123,6 +123,33 @@ def limit_state(self) -> Literal["buckling", "yielding"]: else: return "yielding" + def is_planar(self) -> Literal["x", "y", "z", "none"]: + """ + Check if the truss is planar + + Returns + ------- + Literal["x", "y", "z", "none"] + The axis along which the truss is planar, or None if it is not planar + """ + + restriction = numpy.prod( + numpy.array([joint.translation_restricted for joint in self.joints]), + axis=0, + ) + + # Check if the truss is planar + if (restriction == [False, False, False]).all(): + return "none" + elif (restriction == [True, False, False]).all(): + return "x" + elif (restriction == [False, True, False]).all(): + return "y" + elif (restriction == [False, False, True]).all(): + return "z" + else: + return "none" + def add_pinned_joint(self, coordinates: list[float]) -> int: """Add a pinned joint to the truss at the given coordinates @@ -397,18 +424,18 @@ def analyze(self): for i in range(self.number_of_members): self.members[i].force = forces[i] - def to_json(self, file_name: str) -> None: + def to_json(self, file_name: Union[None, str] = None) -> Union[str, None]: """ Saves the truss to a JSON file Parameters ---------- - file_name: str - The filename to use for the JSON file + file_name: Union[None, str] + The filename to use for the JSON file. If None, the json is returned as a string Returns ------- - None + Union[str, None] """ class JointEncoder(json.JSONEncoder): @@ -447,8 +474,11 @@ def default(self, obj): "members": json.loads(members), } - with open(file_name, "w") as f: - json.dump(combined, f, indent=4) + if file_name is None: + return json.dumps(combined) + else: + with open(file_name, "w") as f: + json.dump(combined, f, indent=4) def to_trs(self, file_name: str) -> None: """ @@ -606,15 +636,18 @@ def read_json(file_name: str) -> Truss: Parameters ---------- file_name: str - The name of the JSON file to be read + The name of the JSON file to be read, or a valid JSON string Returns ------- Truss The object loaded from the JSON file """ - with open(file_name, "r") as file: - json_truss = json.load(file) + try: + json_truss = json.loads(file_name) + except ValueError: + with open(file_name, "r") as file: + json_truss = json.load(file) truss = Truss() current_material_library: list[Material] = json_truss["materials"]