Source code for loadtracer.joist_load_conversion

"""
Conversion of joist point loads into distributed loads.

A joist array from papermodels (e.g. "J1.0") is analysed as one member per joist
("J1.0-0", "J1.0-1", ...), so the members that support the array receive one
point load per joist. That is the most accurate representation, but it is hard
to compare against hand calculations, which use a line load (or an occupancy
over a trib width). This module replaces the point loads of each joist array on
a member with an equivalent distributed load.

For each run of joists and each load case, the joist reactions P_i are divided
by the width that each joist carries (half the distance to its neighbours) to
get line load intensities w_i. The profile of the intensities along the run
decides the shape of the distributed load:

- "uniform": a uniform load (UDL)
- "increasing" / "decreasing": a trapezoidal load fitted through the intensities
- "variable": a UDL, with a JoistLoadVariabilityWarning when the largest
    intensity is 'variability_ratio' (default 2) or more times the smallest

In every case the total of the distributed load equals the total of the joist
reactions it replaces.
"""

from __future__ import annotations

import math
import statistics
import warnings
from collections import defaultdict
from typing import Optional

from .errors import LoadTracerWarning

UNIFORM = "uniform"
INCREASING = "increasing"
DECREASING = "decreasing"
VARIABLE = "variable"


[docs] class JoistLoadVariabilityWarning(LoadTracerWarning): """ The joist reactions of an array vary so much that the uniform load that replaces them may not be representative. """
[docs] class JoistTribWidthWarning(LoadTracerWarning): """ The trib width view of a converted joist load is approximate (the joists carry more than one occupancy or loads other than occupancy loads). """
[docs] def split_joist_tag(tag: str) -> Optional[tuple[str, int]]: """ Returns (parent, index) for the tag of a joist in a joist array (e.g. "J1.0-3" -> ("J1.0", 3)) or None if 'tag' is not a joist tag. """ parent, sep, index = tag.rpartition("-") if sep and parent and index.isdigit(): return parent, int(index) return None
[docs] def group_joist_point_loads( point_loads: list[dict], gap_factor: float = 1.5, min_joists: int = 2 ) -> list[list[dict]]: """ Returns runs of point loads from joist arrays. Each run is a list of point loads from the same joist array (and in the same direction), sorted by location, with no gap between neighbours of more than 'gap_factor' times the median joist spacing of the array. Runs with fewer than 'min_joists' point loads are not returned. Joists at the same location are left as point loads by convert_joist_point_loads. Only point loads that have been expanded (their source reactions were found) are included. Point loads from members that are not joists are ignored. """ by_array = defaultdict(list) for point_load in point_loads: joist = split_joist_tag(point_load.get("transfer_source") or "") if joist is None or "expanded" not in point_load: continue by_array[(joist[0], point_load.get("direction", "gravity"))].append(point_load) runs = [] for loads in by_array.values(): loads.sort(key=lambda load: load["location"]) spacings = [b["location"] - a["location"] for a, b in zip(loads, loads[1:])] max_gap = gap_factor * statistics.median(spacings) if spacings else 0.0 run = loads[:1] for spacing, load in zip(spacings, loads[1:]): if max_gap > 0 and spacing > max_gap: runs.append(run) run = [] run.append(load) runs.append(run) return [run for run in runs if len(run) >= min_joists]
[docs] def trib_widths(locations: list[float]) -> list[float]: """ Returns the width carried by each joist at 'locations' (sorted): half of the distance to each neighbour. The widths add up to the length of the run. """ midpoints = [(a + b) / 2 for a, b in zip(locations, locations[1:])] bounds = [locations[0]] + midpoints + [locations[-1]] return [b - a for a, b in zip(bounds, bounds[1:])]
[docs] def classify_profile(intensities: list[float], rel_tol: float = 0.10) -> str: """ Returns one of "uniform", "increasing", "decreasing" or "variable" for the line load 'intensities' along a joist run (in order of location). 'rel_tol' is relative to the load: the intensities are divided by their mean magnitude before they are compared, so the result does not depend on the units or the size of the load. - "uniform": the normalized intensities vary by no more than 'rel_tol' - "increasing": no step down of more than 'rel_tol' and a rise of more than 'rel_tol' from the first to the last - "decreasing": the mirror of "increasing" - "variable": anything else, including loads that change sign """ scale = statistics.fmean(abs(w) for w in intensities) if math.isclose(scale, 0.0, abs_tol=1e-12): return UNIFORM if any(w > 0 for w in intensities) and any(w < 0 for w in intensities): return VARIABLE normalized = [abs(w) / scale for w in intensities] if max(normalized) - min(normalized) <= rel_tol: return UNIFORM steps = [b - a for a, b in zip(normalized, normalized[1:])] rise = normalized[-1] - normalized[0] if all(step >= -rel_tol for step in steps) and rise > rel_tol: return INCREASING if all(step <= rel_tol for step in steps) and -rise > rel_tol: return DECREASING return VARIABLE
[docs] def max_min_ratio(intensities: list[float]) -> float: """ Returns the ratio of the largest to the smallest intensity magnitude (infinity when the smallest is zero, 1.0 when all are zero). """ magnitudes = [abs(w) for w in intensities] if max(magnitudes) == 0.0: return 1.0 if min(magnitudes) == 0.0: return math.inf return max(magnitudes) / min(magnitudes)
[docs] def fit_trapezoid( locations: list[float], intensities: list[float], total: float ) -> tuple[float, float]: """ Returns the (start, end) intensities of a trapezoidal load over locations[0] to locations[-1] whose resultant is 'total'. The slope comes from a least squares line through the intensities; both ends are then scaled so that the resultant is exactly 'total'. An end that would have the opposite sign of 'total' is set to zero. """ length = locations[-1] - locations[0] x_mean = statistics.fmean(locations) w_mean = statistics.fmean(intensities) sxx = sum((x - x_mean) ** 2 for x in locations) slope = ( sum((x - x_mean) * (w - w_mean) for x, w in zip(locations, intensities)) / sxx ) w_start = w_mean + slope * (locations[0] - x_mean) w_end = w_mean + slope * (locations[-1] - x_mean) resultant = (w_start + w_end) / 2 * length if math.isclose(resultant, 0.0, abs_tol=1e-12): return total / length, total / length factor = total / resultant w_start, w_end = w_start * factor, w_end * factor if w_start * total < 0: w_start, w_end = 0.0, 2 * total / length elif w_end * total < 0: w_start, w_end = 2 * total / length, 0.0 return w_start, w_end
[docs] def convert_joist_point_loads( pm: dict, joist_pms: Optional[dict] = None, occupancies: Optional[dict] = None, rel_tol: float = 0.10, variability_ratio: float = 2.0, min_joists: int = 2, ) -> dict: """ Returns 'pm' (an expanded papermodel) with the point loads from each run of joists (see group_joist_point_loads) replaced by one distributed load. 'pm' is mutated. The replaced point loads are kept in pm["loads"]["converted_point_loads"]. The distributed load has the usual keys of a transfer load (its 'transfer_source' is the joist array) and: "converted_from": the tags of the joists "trib_view": {case: {...}} the load as an occupancy over a trib width (see trib_view) Each of its "expanded" components also has: "profile": the profile of the joist reactions (see classify_profile) "point_load_total": the total of the joist reactions it replaces "max_min_ratio": the ratio of the largest to the smallest intensity 'joist_pms': The expanded papermodels of the joists, keyed by tag. Used for the trib width view. 'occupancies': The occupancies of the load rundown. Used for the trib width view. 'rel_tol': The relative tolerance for classify_profile 'variability_ratio': A "variable" profile whose max/min intensity ratio is this or more raises a JoistLoadVariabilityWarning """ tag = pm["element_attributes"]["tag"] runs = group_joist_point_loads(pm["loads"]["point_loads"], min_joists=min_joists) converted_ids = set() new_dist_loads = [] for run in runs: if min(trib_widths([load["location"] for load in run])) <= 0: # Joists at the same location: there is no width to spread them over continue dist_load = _convert_run( run, tag, joist_pms or {}, occupancies or {}, rel_tol, variability_ratio ) new_dist_loads.append(dist_load) converted_ids.update(id(load) for load in run) if converted_ids: point_loads = pm["loads"]["point_loads"] pm["loads"]["converted_point_loads"] = [ load for load in point_loads if id(load) in converted_ids ] pm["loads"]["point_loads"] = [ load for load in point_loads if id(load) not in converted_ids ] pm["loads"]["distributed_loads"].extend(new_dist_loads) return pm
def _convert_run( run: list[dict], tag: str, joist_pms: dict, occupancies: dict, rel_tol: float, variability_ratio: float, ) -> dict: locations = [load["location"] for load in run] widths = trib_widths(locations) joist_tags = [load["transfer_source"] for load in run] parent = split_joist_tag(joist_tags[0])[0] magnitudes = defaultdict(lambda: [0.0] * len(run)) for idx, load in enumerate(run): for component in load["expanded"]: magnitudes[(component["case"], component["dir"])][idx] += component["magnitude"] components = [] for (case, direction), loads in magnitudes.items(): intensities = [p / w for p, w in zip(loads, widths)] total = sum(loads) profile = classify_profile(intensities, rel_tol) ratio = max_min_ratio(intensities) if profile in (INCREASING, DECREASING): w_start, w_end = fit_trapezoid(locations, intensities, total) else: w_start = w_end = total / (locations[-1] - locations[0]) if profile == VARIABLE and ratio >= variability_ratio: warnings.warn( f"Member {tag}: the reactions of joist array {parent} ({case}) vary by " f"{ratio:.1f}:1 along {locations[0]} to {locations[-1]}; they were " "converted to a uniform load. Review the joist loads.", JoistLoadVariabilityWarning, stacklevel=3, ) components.append( { "start_magnitude": w_start, "end_magnitude": w_end, "start_loc": locations[0], "end_loc": locations[-1], "case": case, "dir": direction, "profile": profile, "point_load_total": total, "max_min_ratio": ratio, } ) indexes = {load.get("transfer_reaction_index") for load in run} return { "transfer_source": parent, "transfer_reaction_index": indexes.pop() if len(indexes) == 1 else None, "transfer_source_start_extent": [], "transfer_source_end_extent": [], "occupancy": "", "load_components": {}, "applied_area": 0.0, "start_loc": locations[0], "start_magnitude": 1.0, "end_loc": locations[-1], "end_magnitude": 1.0, "direction": run[0].get("direction", "gravity"), "converted_from": joist_tags, "trib_view": trib_view(components, joist_tags, joist_pms, occupancies, tag, parent), "expanded": components, }
[docs] def trib_view( components: list[dict], joist_tags: list[str], joist_pms: dict, occupancies: dict, tag: str, parent: str, ) -> dict: """ Returns the converted load 'components' as an occupancy pressure over a trib width, keyed by load case: {case: {"occupancy", "pressure", "trib_width_start", "trib_width_end", "approximate"}} The occupancy is the one with the most applied area on the joists. The trib widths are the line load divided by the pressure of that occupancy. They are exact when the joists carry only that occupancy; otherwise "approximate" is True (and a JoistTribWidthWarning is raised). """ area_by_occupancy = defaultdict(float) has_other_loads = False for joist_tag in joist_tags: joist_pm = joist_pms.get(joist_tag) if joist_pm is None: continue for load in joist_pm["loads"]["distributed_loads"] + joist_pm["loads"]["point_loads"]: if load.get("occupancy"): area_by_occupancy[load["occupancy"]] += load.get("applied_area", 0.0) elif load.get("transfer_source") and load.get("expanded"): # A load transferred onto the joists (e.g. from a post) has_other_loads = True if not area_by_occupancy: return {} occupancy = max(area_by_occupancy, key=area_by_occupancy.get) approximate = len(area_by_occupancy) > 1 or has_other_loads if approximate: carried = sorted(area_by_occupancy) + (["transfer loads"] if has_other_loads else []) warnings.warn( f"Member {tag}: joist array {parent} carries {', '.join(carried)}; its " f"trib widths are expressed with {occupancy!r} and are approximate.", JoistTribWidthWarning, stacklevel=4, ) view = {} for component in components: pressure = occupancies.get(occupancy, {}).get(component["case"]) if not pressure: continue view[component["case"]] = { "occupancy": occupancy, "pressure": pressure, "trib_width_start": abs(component["start_magnitude"]) / pressure, "trib_width_end": abs(component["end_magnitude"]) / pressure, "approximate": approximate, } return view