"""
Validation and normalization of papermodel dicts.
Two passes are applied to every member during a load rundown:
1. validate_papermodel: checks the shape of the papermodel as output by
papermodels.LoadedElement.model() (before any loads are expanded) and
normalizes the values that papermodels is known to produce loosely
(unsorted supports, list-valued point load locations on columns,
distributed loads without extents).
2. conform_loads_to_member: after the loads have been expanded into their
load case components, keeps every load component on the member so that
the analysis model can be built. Load that falls outside of the member
is moved to the nearest member end as a point load so that the total
load on the member is unchanged.
Both return the list of issues they found so that the designer can review them.
"""
from copy import deepcopy
from typing import Optional
from .errors import InvalidPapermodelError
ORIENTATIONS = ("horizontal", "vertical")
REACTION_TYPES = ("point", "linear")
DEFAULT_DIRECTIONS = frozenset({"gravity", "inplane", "out_of_plane"})
[docs]
def validate_papermodel(
pm: dict,
tol: float = 1e-3,
directions: frozenset[str] = DEFAULT_DIRECTIONS,
) -> tuple[dict, list[str]]:
"""
Returns a normalized copy of 'pm' and a list of issues found.
Raises InvalidPapermodelError if 'pm' cannot be used.
'pm': A papermodel dict for a single member
'tol': Supports off the member are moved onto the nearest member end (and
reported as an issue when they are more than 'tol' off). Supports within
'tol' of each other are flagged as coincident.
'directions': The load directions that are recognized (the keys of the
direction mappings on the LoadTracer).
"""
match pm:
case {
"element_attributes": {"tag": str(tag)} as attrs,
"element_geometry": dict(geometry),
"loads": {"point_loads": list(), "distributed_loads": list()},
}:
pass
case {"element_attributes": {"tag": tag}}:
raise InvalidPapermodelError(
"The papermodel must have 'element_attributes', 'element_geometry' and "
"'loads' (with 'point_loads' and 'distributed_loads' lists).",
tag,
)
case _:
raise InvalidPapermodelError(
"The papermodel has no 'element_attributes' with a 'tag'."
)
match attrs:
case {
"orientation": "horizontal" | "vertical" as orientation,
"reaction_type": "point" | "linear" as reaction_type,
}:
pass
case _:
raise InvalidPapermodelError(
f"Unsupported orientation/reaction type: "
f"{attrs.get('orientation')!r}/{attrs.get('reaction_type')!r}. "
f"Expected one of {ORIENTATIONS} and one of {REACTION_TYPES}.",
tag,
)
pm = deepcopy(pm)
issues = []
is_column = orientation == "vertical" and reaction_type == "point"
length = None
if not is_column:
match attrs.get("length"):
case int() | float() as length if length > 0:
length = float(length)
case other:
raise InvalidPapermodelError(
f"A {orientation} member with a {reaction_type} reaction needs a "
f"positive 'length', not {other!r}.",
tag,
)
if orientation == "horizontal":
pm["element_geometry"]["supports"] = _normalize_supports(
geometry.get("supports", []), length, tol, tag, issues
)
for point_load in pm["loads"]["point_loads"]:
_check_direction(point_load, directions, tag)
match point_load:
case {"location": int() | float()}:
pass
case {"location": list()} if is_column:
# papermodels gives columns a list of the transfer locations of
# the elements above. A concentric column does not use them.
point_load["location"] = 0.0
case {"location": [int() | float() as location]}:
point_load["location"] = location
case {"location": list() as locations}:
raise InvalidPapermodelError(
f"The point load from {point_load.get('transfer_source')!r} has "
f"{len(locations)} locations; expected one.",
tag,
)
case _:
raise InvalidPapermodelError(
f"A point load has no numeric 'location': {point_load!r}", tag
)
for dist_load in pm["loads"]["distributed_loads"]:
_check_direction(dist_load, directions, tag)
match dist_load:
case {"start_loc": int() | float(), "end_loc": int() | float()}:
pass
case {"start_loc": [], "end_loc": []} if length is not None:
# papermodels could not calculate the extents of a linear
# correspondent (e.g. a wall on a wall); apply it over the full length
dist_load["start_loc"] = 0.0
dist_load["end_loc"] = length
issues.append(
f"The distributed load from {dist_load.get('transfer_source')!r} "
"has no extents; it was applied over the full length."
)
case _:
raise InvalidPapermodelError(
"A distributed load has no numeric 'start_loc'/'end_loc': "
f"{dist_load!r}",
tag,
)
return pm, issues
def _check_direction(load: dict, directions: frozenset[str], tag: str) -> None:
match load.get("direction", "gravity"):
case str(direction) if direction in directions:
pass
case direction:
raise InvalidPapermodelError(
f"Unknown load direction {direction!r}. Known directions: "
f"{sorted(directions)}",
tag,
)
def _normalize_supports(
supports: list, length: float, tol: float, tag: str, issues: list[str]
) -> list[dict]:
"""
Returns 'supports' sorted by location with locations near the member ends
moved onto the ends and coincident supports flagged with "coincident".
"""
acc = []
for support in supports:
match support:
case {"location": int() | float() as location}:
pass
case _:
raise InvalidPapermodelError(
f"A support has no numeric 'location': {support!r}", tag
)
clamped = min(max(float(location), 0.0), length)
if abs(clamped - location) > tol:
issues.append(
f"The support at {location} is off the member (length {length}); "
f"it was moved to {clamped}."
)
acc.append(dict(support, location=clamped))
acc.sort(key=lambda s: s["location"])
# A coincident support is kept (so that reaction indexes still line up with
# papermodels' transfer_reaction_index) but flagged: it is not modelled and
# the reaction at that location is reported on the first support.
for prev, support in zip(acc, acc[1:]):
if support["location"] - prev["location"] <= tol:
support["coincident"] = True
issues.append(
f"Supports at {prev['location']} and {support['location']} coincide; "
"the reaction is reported on the first of them."
)
return acc
def _clip_dist_component(
component: dict, length: float
) -> tuple[Optional[dict], list[tuple[dict, float]]]:
"""
Returns the part of the distributed load 'component' that lies on
[0, length] (or None) and a list of (point_component, overhang_length)
for the parts that lie off of it, as resultants at the nearest member end.
"""
x1, x2 = component["start_loc"], component["end_loc"]
w1, w2 = component["start_magnitude"], component["end_magnitude"]
if x2 - x1 <= 0.0:
return None, []
def w(x: float) -> float:
return w1 + (w2 - w1) * (x - x1) / (x2 - x1)
def resultant(a: float, b: float) -> float:
return (w(a) + w(b)) / 2 * (b - a)
outside = []
for a, b, end in ((x1, min(x2, 0.0), 0.0), (max(x1, length), x2, length)):
if b > a:
point_component = {
"magnitude": resultant(a, b),
"location": end,
"case": component["case"],
"dir": component["dir"],
}
outside.append((point_component, b - a))
a, b = max(x1, 0.0), min(x2, length)
inside = None
if b > a:
inside = dict(
component,
start_loc=a,
end_loc=b,
start_magnitude=w(a),
end_magnitude=w(b),
)
return inside, outside