diff --git a/src/loadbearing_wall/__init__.py b/src/loadbearing_wall/__init__.py index c036b09..9a85613 100644 --- a/src/loadbearing_wall/__init__.py +++ b/src/loadbearing_wall/__init__.py @@ -1,5 +1,5 @@ """ -A package that models a generic load-bearing wall for the purpose of determining +A package that models a generic load-bearing wall for the purpose of determining reactions and load-transfer. The wall model is parameterizable and can represent any material @@ -8,4 +8,4 @@ __version__ = "0.1.0" from loadbearing_wall.wall_model import LinearWallModel -from loadbearing_wall import * \ No newline at end of file +from loadbearing_wall import * diff --git a/src/loadbearing_wall/geom_ops.py b/src/loadbearing_wall/geom_ops.py index cebc687..923ecca 100644 --- a/src/loadbearing_wall/geom_ops.py +++ b/src/loadbearing_wall/geom_ops.py @@ -1,6 +1,7 @@ import math from typing import Optional + def apply_spread_angle( wall_height: float, wall_length: float, @@ -12,7 +13,7 @@ def apply_spread_angle( ) -> dict: """ Returns a dictionary representing the load described by - w0, w1, x0, x1. If only w0 and x0 are provided, the + w0, w1, x0, x1. If only w0 and x0 are provided, the load is assumed to be a point load. The total spread cannot be longer than the wall length. @@ -37,14 +38,19 @@ def apply_spread_angle( projected_w1 = w1 * ratio else: projected_w1 = w0 * ratio - return (round_to_close_integer(projected_w0), round_to_close_integer(projected_w1), round_to_close_integer(projected_x0), round_to_close_integer(projected_x1)) + return ( + round_to_close_integer(projected_w0), + round_to_close_integer(projected_w1), + round_to_close_integer(projected_x0), + round_to_close_integer(projected_x1), + ) -def round_to_close_integer(x: float, eps = 1e-7) -> float | int: +def round_to_close_integer(x: float, eps=1e-7) -> float | int: """ Rounds to the nearest int if it is REALLY close """ if abs(abs(round(x)) - abs(x)) < eps: return round(x) else: - return x \ No newline at end of file + return x diff --git a/src/loadbearing_wall/linear_reactions.py b/src/loadbearing_wall/linear_reactions.py index b871abc..b1e1df3 100644 --- a/src/loadbearing_wall/linear_reactions.py +++ b/src/loadbearing_wall/linear_reactions.py @@ -3,6 +3,7 @@ from load_distribution import Singularity, singularities_to_polygon from .geom_ops import round_to_close_integer as rtci + @dataclass class LinearReaction: w0: float @@ -12,7 +13,7 @@ class LinearReaction: def point_in_reaction(self, x: float): return self.x0 <= x <= self.x1 - + def points_enclose_reaction(self, xa: float, xb: float) -> bool: """ Returns True if xa <= self.x0 <= self.x1 <= xb @@ -26,12 +27,14 @@ def extract_reaction(self, xa: float, xb: float) -> "LinearReaction": """ m = (self.w1 - self.w0) / (self.x1 - self.x0) y0 = self.w0 - if not any([ - self.point_in_reaction(xa), - self.point_in_reaction(xb), - self.points_enclose_reaction(xa, xb), - ]): - return LinearReaction(0.0, 0.0, self.x0, self.x1) + if not any( + [ + self.point_in_reaction(xa), + self.point_in_reaction(xb), + self.points_enclose_reaction(xa, xb), + ] + ): + return LinearReaction(0.0, 0.0, self.x0, self.x1) if not self.point_in_reaction(xa): xi = self.x0 yi = self.w0 @@ -54,6 +57,7 @@ class LinearReactionString: """ A class to manage a collection of LinearReactions """ + linear_reactions: dict[str, dict[str, list[LinearReaction]]] magnitude_start_key: str magnitude_end_key: str @@ -62,7 +66,7 @@ class LinearReactionString: @classmethod def from_projected_loads( - cls, + cls, projected_loads: dict[str, dict[str, list[dict]]], magnitude_start_key: str, magnitude_end_key: str, @@ -86,11 +90,14 @@ def from_projected_loads( applied_load.get(x1), ) - linear_reaction_components[load_dir][load_case].append(linear_reaction) + linear_reaction_components[load_dir][load_case].append( + linear_reaction + ) return cls(linear_reaction_components, w0, w1, x0, x1) - - def extract_reaction_string(self, xa: float, xb: float, case: str, dir: str) -> Optional[list[LinearReaction]]: + def extract_reaction_string( + self, xa: float, xb: float, case: str, dir: str + ) -> Optional[list[LinearReaction]]: """ Returns a LinearReactionString representing the linear reactions that exist between 'xa' and 'xb' extracted from self. @@ -111,14 +118,10 @@ def extract_reaction_string(self, xa: float, xb: float, case: str, dir: str) -> self.location_start_key, self.location_end_key, ) - - + def consolidate_reactions( - self, - flatten: bool, - dir_key: str = "dir", - case_key: str = "case" - ): + self, flatten: bool, dir_key: str = "dir", case_key: str = "case" + ): """ Collects distributed loads from the top of a wall run and converts them into a LinearReactionString which can sum and @@ -156,23 +159,25 @@ def consolidate_reactions( singularity_functions = [] for lr in linear_reactions: if lr.w1 is None and lr.x1 is None: - point_load = {w0: lr.w0, x0: lr.x0, dir_key: load_dir, case_key: load_case} - flattened_reaction_components.append( - point_load - ) - reaction_components[load_dir][load_case].append( - point_load - ) + point_load = { + w0: lr.w0, + x0: lr.x0, + dir_key: load_dir, + case_key: load_case, + } + flattened_reaction_components.append(point_load) + reaction_components[load_dir][load_case].append(point_load) else: m = (lr.w1 - lr.w0) / (lr.x1 - lr.x0) y0 = lr.w0 - singularity_function = Singularity(x0=lr.x0, y0=y0, x1=lr.x1, m=m, precision=6) + singularity_function = Singularity( + x0=lr.x0, y0=y0, x1=lr.x1, m=m, precision=6 + ) singularity_functions.append(singularity_function) - if not singularity_functions: continue + if not singularity_functions: + continue linear_reactions = singularity_xy_to_distributed_loads( - singularities_to_polygon( - singularity_functions, xy=True - ), + singularities_to_polygon(singularity_functions, xy=True), magnitude_start_key=w0, magnitude_end_key=w1, location_start_key=x0, @@ -189,9 +194,11 @@ def consolidate_reactions( if flatten: return flattened_reaction_components return reaction_components - - -def filter_repeated_y_values(xy_vals: list[list[float], list[float]]) -> list[list[float, float]]: + + +def filter_repeated_y_values( + xy_vals: list[list[float], list[float]], +) -> list[list[float, float]]: """ Returns xy_vals but with any "repeating" data points removed and returns a list of coordinates, list[list[float, float]] @@ -211,7 +218,7 @@ def filter_repeated_y_values(xy_vals: list[list[float], list[float]]) -> list[li filtered.append([x, y]) prev_y = y return filtered - + def singularity_xy_to_distributed_loads( xy_vals: list[list[float], list[float]], @@ -225,7 +232,7 @@ def singularity_xy_to_distributed_loads( dir_key: str = "dir", ) -> list[dict]: """ - Returns dicts representing distributed + Returns dicts representing distributed """ w0 = magnitude_start_key w1 = magnitude_end_key @@ -234,16 +241,24 @@ def singularity_xy_to_distributed_loads( dist_loads = [] prev_x = None for idx, (x, y) in enumerate(zip(*xy_vals)): - if idx == 0: continue + if idx == 0: + continue if prev_x is None: prev_x = x prev_y = y elif x - prev_x > 1e-3: - dist_load = {w0: float(rtci(prev_y)), w1: float(rtci(y)), x0: float(rtci(prev_x)), x1: float(rtci(x)), case_key: case, dir_key: dir} + dist_load = { + w0: float(rtci(prev_y)), + w1: float(rtci(y)), + x0: float(rtci(prev_x)), + x1: float(rtci(x)), + case_key: case, + dir_key: dir, + } dist_loads.append(dist_load) prev_x = x prev_y = y else: prev_x = x prev_y = y - return dist_loads \ No newline at end of file + return dist_loads diff --git a/src/loadbearing_wall/point_reactions.py b/src/loadbearing_wall/point_reactions.py index b78d5bb..86e243f 100644 --- a/src/loadbearing_wall/point_reactions.py +++ b/src/loadbearing_wall/point_reactions.py @@ -1,34 +1,27 @@ from dataclasses import dataclass + @dataclass class PointReactionCollection: _reaction_components: dict[str, dict[str, list[float]]] - def extract_reactions(self, case: str, dir: str) -> float: """ Returns a float representing the total sum of loads for the given 'dir' (direction) and 'case' (load case). """ return sum(self._reaction_components.get(case, {}).get(dir, [])) - @classmethod def from_point_loads(cls, point_loads: dict[str, dict[str, str | float]]): - """ - - """ + """ """ reaction_components = {} for load_case in point_loads.items(): reaction_components.setdefault(load_case, {}) for load_dir, pt_loads in load_case.items(): reaction_components[load_case].setdefault(load_dir, []) for point_load in pt_loads: - reaction_components[load_dir][load_case].append(point_load['magnitude']) + reaction_components[load_dir][load_case].append( + point_load["magnitude"] + ) return cls(reaction_components) - - - - - - diff --git a/src/loadbearing_wall/wall_model.py b/src/loadbearing_wall/wall_model.py index d6c2763..69318c8 100644 --- a/src/loadbearing_wall/wall_model.py +++ b/src/loadbearing_wall/wall_model.py @@ -4,15 +4,19 @@ from . import linear_reactions as lr from . import point_reactions as pr from . import geom_ops as geom - + @dataclass class LinearWallModel: height: float length: float vertical_spread_angle: float = 0.0 - distributed_loads: dict = field(default_factory=dict) # Check no side-effects for multiple instances - point_loads: dict = field(default_factory=dict) # Check no side-effects for multiple instances + distributed_loads: dict = field( + default_factory=dict + ) # Check no side-effects for multiple instances + point_loads: dict = field( + default_factory=dict + ) # Check no side-effects for multiple instances gravity_dir: str = "z" inplane_dir: str = "x" out_of_plane_dir: str = "y" @@ -52,15 +56,16 @@ class LinearWallModel: 'location_end_key': The key that will be used internally and in reaction results for the end location """ + def add_dist_load( - self, - magnitude_start: float, - magnitude_end: float, - location_start: float, - location_end: float, - case: str, - dir: str - ) -> None: + self, + magnitude_start: float, + magnitude_end: float, + location_start: float, + location_end: float, + case: str, + dir: str, + ) -> None: """ Adds a distributed load to the model @@ -76,20 +81,18 @@ def add_dist_load( self.distributed_loads.setdefault(dir, {}) self.distributed_loads[dir].setdefault(case, []) - self.distributed_loads[dir][case].append({ - self.magnitude_start_key: magnitude_start, - self.magnitude_end_key: magnitude_end, - self.location_start_key: location_start, - self.location_end_key: location_end, - }) + self.distributed_loads[dir][case].append( + { + self.magnitude_start_key: magnitude_start, + self.magnitude_end_key: magnitude_end, + self.location_start_key: location_start, + self.location_end_key: location_end, + } + ) def add_point_load( - self, - magnitude: float, - location: float, - case: str, - dir: str - ) -> None: + self, magnitude: float, location: float, case: str, dir: str + ) -> None: """ Adds a point load to the model @@ -102,11 +105,13 @@ def add_point_load( self.distributed_loads.setdefault(dir, {}) self.distributed_loads[dir].setdefault(case, []) - self.distributed_loads[dir][case].append({ - self.magnitude_start_key: magnitude, - self.location_start_key: location, - }) - + self.distributed_loads[dir][case].append( + { + self.magnitude_start_key: magnitude, + self.location_start_key: location, + } + ) + def spread_loads(self) -> None: """ Populates self._projected_loads with the loads projected from the distributed @@ -123,81 +128,81 @@ def spread_loads(self) -> None: for load_dir, load_cases in self.distributed_loads.items(): proj.setdefault(load_dir, {}) should_apply_spread_angle = ( - ( - load_dir == self.gravity_dir - and self.apply_spread_angle_gravity - and self.vertical_spread_angle != 0.0 - ) - or - ( - load_dir == self.inplane_dir - and self.apply_spread_angle_inplane - and self.vertical_spread_angle != 0.0 - ) + load_dir == self.gravity_dir + and self.apply_spread_angle_gravity + and self.vertical_spread_angle != 0.0 + ) or ( + load_dir == self.inplane_dir + and self.apply_spread_angle_inplane + and self.vertical_spread_angle != 0.0 ) for load_case, dist_loads in load_cases.items(): proj[load_dir].setdefault(load_case, []) for dist_load in dist_loads: if should_apply_spread_angle: projected_load = geom.apply_spread_angle( - self.height, - self.length, + self.height, + self.length, self.vertical_spread_angle, dist_load[w0], dist_load[x0], dist_load.get(w1), dist_load.get(x1), ) - proj[load_dir][load_case].append({ - w0: projected_load[0], - w1: projected_load[1], - x0: projected_load[2], - x1: projected_load[3] - }) + proj[load_dir][load_case].append( + { + w0: projected_load[0], + w1: projected_load[1], + x0: projected_load[2], + x1: projected_load[3], + } + ) else: proj[load_dir][load_case].append(dist_load) for load_dir, load_cases in self.point_loads.items(): proj.setdefault(load_dir, {}) should_apply_spread_angle = ( - ( - load_dir == self.gravity_dir - and self.apply_spread_angle_gravity - and self.vertical_spread_angle != 0.0 - ) - or - ( - load_dir == self.inplane_dir - and self.apply_spread_angle_inplane - and self.vertical_spread_angle != 0.0 - ) + load_dir == self.gravity_dir + and self.apply_spread_angle_gravity + and self.vertical_spread_angle != 0.0 + ) or ( + load_dir == self.inplane_dir + and self.apply_spread_angle_inplane + and self.vertical_spread_angle != 0.0 ) for load_case, point_loads in load_cases.items(): proj[load_dir].setdefault(load_case, []) for point_load in point_loads: if should_apply_spread_angle: projected_load = geom.apply_spread_angle( - self.height, - self.length, + self.height, + self.length, self.vertical_spread_angle, point_load[w0], point_load[x0], point_load.get(w1), point_load.get(x1), ) - proj[load_dir][load_case].append({ - w0: projected_load[0], - w1: projected_load[1], - x0: projected_load[2], - x1: projected_load[3] - }) + proj[load_dir][load_case].append( + { + w0: projected_load[0], + w1: projected_load[1], + x0: projected_load[2], + x1: projected_load[3], + } + ) else: proj[load_dir][load_case].append(point_load) - - self._projected_loads = proj + self._projected_loads = proj - def get_reactions(self, flattened: bool = False, direction_key: str = "dir", case_key: str = "case"): + def get_reactions( + self, + flattened: bool = False, + direction_key: str = "dir", + case_key: str = "case", + ): self.spread_loads() lrs = lr.LinearReactionString.from_projected_loads( self._projected_loads, @@ -206,7 +211,6 @@ def get_reactions(self, flattened: bool = False, direction_key: str = "dir", cas self.location_start_key, self.location_end_key, ) - return lrs.consolidate_reactions(flatten=flattened, dir_key=direction_key, case_key=case_key) - - - + return lrs.consolidate_reactions( + flatten=flattened, dir_key=direction_key, case_key=case_key + ) diff --git a/tests/test_geom.py b/tests/test_geom.py index 63bfb8d..eccb346 100644 --- a/tests/test_geom.py +++ b/tests/test_geom.py @@ -2,14 +2,10 @@ def test_apply_spread_angle(): - ret = apply_spread_angle( - 4, - 3, - spread_angle=10, - w0=10, - x0=1, - w1=10, - x1=2 - + ret = apply_spread_angle(4, 3, spread_angle=10, w0=10, x0=1, w1=10, x1=2) + assert ret == ( + 4.148317542163208, + 4.148317542163208, + 0.2946920771661401, + 2.70530792283386, ) - assert ret == (4.148317542163208, 4.148317542163208, 0.2946920771661401, 2.70530792283386) diff --git a/tests/test_wall_model.py b/tests/test_wall_model.py index 7372309..8adbd23 100644 --- a/tests/test_wall_model.py +++ b/tests/test_wall_model.py @@ -1,48 +1,42 @@ from loadbearing_wall.wall_model import LinearWallModel from pytest import fixture + @fixture def WM0(): return LinearWallModel( height=2.0, length=4.0, - vertical_spread_angle=0.0, # deg - distributed_loads = { + vertical_spread_angle=0.0, # deg + distributed_loads={ "Fz": { - "D": [ - {"w1": 10.0, "w2": 10.0, "x1": 1.0, "x2": 3.0} - ], - "L": [ - {"w1": 15.0, "w2": 15.0, "x1": 0.0, "x2": 2.0} - ] + "D": [{"w1": 10.0, "w2": 10.0, "x1": 1.0, "x2": 3.0}], + "L": [{"w1": 15.0, "w2": 15.0, "x1": 0.0, "x2": 2.0}], } }, - point_loads = { + point_loads={ "Fz": { "D": [ {"w1": 100.0, "x1": 0.5}, ], "L": [ {"w1": 100.0, "x1": 0.5}, - ] + ], }, - "Fx": { - "W": [ - {"w1": 2000, "x1": 0.0} - ] - } + "Fx": {"W": [{"w1": 2000, "x1": 0.0}]}, }, - gravity_dir = "Fz", - inplane_dir = "Fx", + gravity_dir="Fz", + inplane_dir="Fx", magnitude_start_key="w1", magnitude_end_key="w2", location_start_key="x1", location_end_key="x2", ) + @fixture def WM1(WM0): - WM0.vertical_spread_angle=45 + WM0.vertical_spread_angle = 45 return WM0 @@ -50,25 +44,55 @@ def test_wall_model_runs(WM0, WM1): assert WM0.get_reactions() assert WM1.get_reactions() + def test_no_spread(WM0): rxn = WM0.get_reactions(flattened=False) - assert rxn['Fz']['D'] == [ - {"dir": "Fz", "case": "D", "w1": 100.0, "x1": 0.5}, - {"dir": "Fz", "case": "D", "w1": 10.0, "w2": 10.0, "x1": 1.0, "x2": 3.0}, - ] - assert rxn['Fz']['L'] == [ - {"dir": "Fz", "case": "L", "w1": 100.0, "x1": 0.5}, - {"dir": "Fz", "case": "L", "w1": 15.0, "w2": 15.0, "x1": 0.0, "x2": 2.0}, - ] - + assert rxn["Fz"]["D"] == [ + {"dir": "Fz", "case": "D", "w1": 100.0, "x1": 0.5}, + {"dir": "Fz", "case": "D", "w1": 10.0, "w2": 10.0, "x1": 1.0, "x2": 3.0}, + ] + assert rxn["Fz"]["L"] == [ + {"dir": "Fz", "case": "L", "w1": 100.0, "x1": 0.5}, + {"dir": "Fz", "case": "L", "w1": 15.0, "w2": 15.0, "x1": 0.0, "x2": 2.0}, + ] + + def test_45_spread(WM1): rxn = WM1.get_reactions() - assert rxn['Fz']['D'] == [ - {"dir": "Fz", "case": "D", "w1": 45.0, "w2": 45.0, "x1": 0.0, "x2": 2.499999999999}, - {"dir": "Fz", "case": "D", "w1": 5.0, "w2": 5.0, "x1": 2.500000000001, "x2": 4.0}, - ] - - assert rxn['Fz']['L'] == [ - {"dir": "Fz", "case": "L", "w1": 47.5, "w2": 47.5, "x1": 0.0, "x2": 2.499999999999}, - {"dir": "Fz", "case": "L", "w1": 7.5, "w2": 7.5, "x1": 2.500000000001, "x2": 4.0}, - ] \ No newline at end of file + assert rxn["Fz"]["D"] == [ + { + "dir": "Fz", + "case": "D", + "w1": 45.0, + "w2": 45.0, + "x1": 0.0, + "x2": 2.499999999999, + }, + { + "dir": "Fz", + "case": "D", + "w1": 5.0, + "w2": 5.0, + "x1": 2.500000000001, + "x2": 4.0, + }, + ] + + assert rxn["Fz"]["L"] == [ + { + "dir": "Fz", + "case": "L", + "w1": 47.5, + "w2": 47.5, + "x1": 0.0, + "x2": 2.499999999999, + }, + { + "dir": "Fz", + "case": "L", + "w1": 7.5, + "w2": 7.5, + "x1": 2.500000000001, + "x2": 4.0, + }, + ]