Source code for pycba.bridge

"""
PyCBA - Continuous Beam Analysis - Bridge Crossing Module
"""
from __future__ import annotations  # https://bit.ly/3KYiL2o
from typing import Optional, Union, Dict, List
import numpy as np
import matplotlib.pyplot as plt
from .analysis import BeamAnalysis
from .results import Envelopes, BeamResults
from .vehicle import Vehicle
from .load import add_LM


def _vertical_support_xs(beam) -> np.ndarray:
    """Global x-coordinates of the vertical supports (fixed or spring)."""
    node_x = np.cumsum(np.insert(np.asarray(beam.mbr_lengths, dtype=float), 0, 0.0))
    restraints = np.asarray(beam.restraints)
    return np.array(
        [x0 for i, x0 in enumerate(node_x) if restraints[2 * i] != 0], dtype=float
    )


[docs] def resolve_shear_points( beam, points: Optional[Union[float, List[float], np.ndarray]] = None, dv: Optional[float] = None, ) -> (Dict[int, np.ndarray], np.ndarray): """ Resolve requested "shear point" sections to per-member local coordinates. Shear points are sections at which the shear is recovered exactly (on both sides), for example the face of a support or the critical-shear section a distance ``dv`` from a support. They are specified in global beam coordinates and are fixed for the analysis, so the evaluation grid stays consistent across a moving-load traverse. Parameters ---------- beam : Beam The :class:`pycba.beam.Beam` to resolve against. points : float or array-like, optional Global coordinate(s), measured from the left end of the beam, at which shear points are required. dv : float, optional The critical-shear distance (e.g. the MCFT effective shear depth :math:`d_v`). Adds a shear point at this distance from every vertical support, on each valid (on-deck) side: two sections at an interior support, one at an end support. Returns ------- sp : Dict[int, np.ndarray] Mapping of 0-based member index to a sorted vector of member-local coordinates, suitable for ``BeamResults(shear_points=...)``. points_global : np.ndarray The sorted vector of global section coordinates actually placed. """ globals_ = [] if points is not None: globals_.extend(float(p) for p in np.atleast_1d(points)) if dv is not None: for x0 in _vertical_support_xs(beam): globals_.extend([x0 - dv, x0 + dv]) # Keep only on-deck interior sections, de-duplicated and sorted. points_global = np.array(sorted({g for g in globals_ if 0.0 < g < beam.length})) sp: Dict[int, list] = {} for g in points_global: ispan, pos_in_span = beam.get_local_span_coords(g) if ispan != -1: sp.setdefault(ispan, []).append(pos_in_span) return {k: np.array(sorted(v)) for k, v in sp.items()}, points_global
[docs] class BridgeAnalysis: """ Performs a bridge crossing analysis for a defined vehicle. The vehicle is moved from the zero global x-coordinate of the beam until it has left the beam at the far end. Any loads already defined on the `BeamAnalysis` object are retained and superimposed in each vehicle position analysis. """ def __init__( self, ba: Optional[BeamAnalysis] = None, veh: Optional[Vehicle] = None ): """ Can instantiate with nothing and later add or define the objects, or with instantiate with pre-defined bridge and vehicle objects. Any loads already defined on the `BeamAnalysis` object are retained in each vehicle position analysis. Parameters ---------- ba : Optional[BeamAnalysis], optional A :class:`pycba.analysis.BeamAnalysis` object. The default is None. veh : Optional[Vehicle], optional A :class:`pycba.bridge.Vehicle` object. The default is None. Returns ------- None. """ self.ba = ba self.veh = veh self.vResults = [] self.pos = [] # Global coordinates of any shear-point sections placed in the last # analysis (populated by run_vehicle/static_vehicle when requested). self.shear_points_x = np.array([]) self.static_LM = [] if self.ba: self.static_LM = self.ba.beam.loads
[docs] def add_bridge( self, L: np.ndarray, EI: Union[float, np.ndarray], R: np.ndarray, eletype: Optional[np.ndarray] = None, GAv: Optional[Union[float, np.ndarray]] = None, ): """ Create and add a beam to a bridge analysis Parameters ---------- L : np.ndarray A vector of span lengths. EI : Union[float, np.ndarray] A vector of member flexural rigidities. R : np.ndarray A vector describing the support conditions at each member end. eletype : Optional[np.ndarray] A vector of the member types. Defaults to a fixed-fixed element. GAv : Optional[Union[float, np.ndarray]] Transverse shear rigidity ``G·A_v`` of each span. When given, the bridge is analysed with shear-deformable Timoshenko elements; the default (``None``) uses Euler–Bernoulli elements. Returns ------- ba : BeamAnalysis A :class:`pycba.analysis.BeamAnalysis` object. """ self.ba = BeamAnalysis(L=L, EI=EI, R=R, eletype=eletype, GAv=GAv) self.static_LM = self.ba.beam.loads return self.ba
[docs] def set_bridge(self, ba: BeamAnalysis): """ Set the bridge for the bridge analysis. Any loads already defined on the `BeamAnalysis` object are retained in each vehicle position analysis. Parameters ---------- ba : BeamAnalysis A :class:`pycba.analysis.BeamAnalysis` object. Returns ------- None. """ self.ba = ba self.static_LM = self.ba.beam.loads
[docs] def add_vehicle(self, axle_spacings: np.ndarray, axle_weights: np.ndarray): """ Create and add the vehicle to the analysis Parameters ---------- axle_spacings : np.ndarray A vector of axle spacings of length one fewer than the length of the vector of axle weights axle_weights : np.ndarray A vector of axle weights, length one greater than the length of the axle spacings vector. Returns ------- veh : Vehicle A :class:`pycba.bridge.Vehicle` object. """ self.veh = Vehicle(axle_spacings, axle_weights) return self.veh
[docs] def set_vehicle(self, veh: Vehicle): """ Set the vehicle for the bridge analysis. Parameters ---------- veh : Vehicle A :class:`pycba.bridge.Vehicle` object. Returns ------- None. """ self.veh = veh
[docs] def static_vehicle( self, pos: float, plotflag: bool = False, shear_points: Optional[Union[float, List[float], np.ndarray, Dict]] = None, dv: Optional[float] = None, ) -> BeamResults: """ Performs a single analysis for the vehicle, static at a given position Parameters ---------- pos : float The location of the front axle of the vehicle in global beam coordinates. plotflag : bool, optional Whether or not to plot the results. The default is False. shear_points : float, array-like or dict, optional Section(s) at which the shear is recovered exactly on both sides. Either global coordinate(s) (resolved via :func:`resolve_shear_points`) or a pre-resolved ``{member_index: local_coords}`` mapping. dv : float, optional Critical-shear distance: add a shear point this distance from every vertical support, on each valid side (see :func:`resolve_shear_points`). Raises ------ ValueError If a static beam analysis does not succeed, usually due to a beam configuration error. Returns ------- ba: BeamResults The `pycba.Beamresults` object containing the analysis results. """ self._check_objects() self._apply_shear_points(shear_points, dv) try: out = self._single_analysis(pos) if out != 0: raise ValueError("Bridge analysis did not succeed") finally: self.ba.shear_points = None if plotflag: self.plot_static(pos) return self.ba.beam_results
def _apply_shear_points(self, shear_points, dv): """ Resolve and arm shear-point sections on the underlying beam analysis. Sets ``self.ba.shear_points`` (consumed by each ``analyze()`` call) and records the placed global section coordinates in ``self.shear_points_x`` for :meth:`critical_values`. A pre-resolved ``dict`` is used as-is; any other spec is resolved with :func:`resolve_shear_points`. """ if isinstance(shear_points, dict): sp = shear_points self.shear_points_x = np.array([]) elif shear_points is None and dv is None: sp = None self.shear_points_x = np.array([]) else: sp, self.shear_points_x = resolve_shear_points( self.ba.beam, shear_points, dv ) self.ba.shear_points = sp if sp else None def _axle_LM(self, pos: float) -> List[list]: """ Build the load-matrix rows for the vehicle axles with the front axle at ``pos``. Only axles currently on the deck are included. Parameters ---------- pos : float The location of the front axle in global beam coordinates. Returns ------- List[list] Point-load (type 2) rows for the on-deck axles. """ axle_positions = pos - self.veh.axle_coords rows = [] for iaxle in range(self.veh.NoAxles): load = self.veh.axw[iaxle] ispan, pos_in_span = self.ba.beam.get_local_span_coords( axle_positions[iaxle] ) if ispan != -1: rows.append([ispan + 1, 2, load, pos_in_span, 0]) return rows def _interval_udl_LM(self, xa: float, xb: float, w: float) -> List[list]: """ Convert a global UDL interval ``[xa, xb]`` of intensity ``w`` into per-span partial-UDL (type 3) load-matrix rows, splitting the interval at the span boundaries. Portions off the deck are ignored. Parameters ---------- xa, xb : float Start and end of the loaded interval in global coordinates. w : float The UDL intensity. Returns ------- List[list] Partial-UDL rows ``[span, 3, w, a, c]`` for each loaded span portion. """ beam = self.ba.beam node_x = np.cumsum(np.insert(np.asarray(beam.mbr_lengths, dtype=float), 0, 0.0)) rows = [] for i in range(beam.no_spans): lo = max(xa, node_x[i]) hi = min(xb, node_x[i + 1]) if hi - lo > 1e-9: rows.append([i + 1, 3, w, lo - node_x[i], hi - lo]) return rows def _single_analysis(self, pos: float) -> int: """ Internal function for efficiency in run_vehicle - assumes Bridge and Vehicle are already defined/checked in UI functions Parameters ---------- pos : float The location of the front axle of the vehicle in global beam coordinates. Returns ------- int 0 if the analysis succeeds. """ # Superimpose the axle loads on any pre-existing loads on the beam LM = add_LM(self.static_LM, self._axle_LM(pos)) self.ba.set_loads(LM) return self.ba.analyze()
[docs] def run_vehicle( self, step: float, plot_env: bool = False, plot_all: bool = False, pos_start: Optional[float] = None, pos_end: Optional[float] = None, shear_points: Optional[Union[float, List[float], np.ndarray, Dict]] = None, dv: Optional[float] = None, ) -> Envelopes: """ Runs the vehicle over the bridge performing a static analysis at each point Parameters ---------- step : float The distance increment to move the vehicle. plot_env : bool, optional Whether or not to plot the results envelope. The default is False. plot_all : bool, optional Whether or not to plot the results for each position as an animation. The default is False. pos_start : Optional[float], optional The starting position of the front axle. Defaults to 0 (front axle at the left end of the beam). pos_end : Optional[float], optional The ending position of the front axle. Defaults to beam length plus vehicle length (front axle past the right end of the beam). shear_points : float, array-like or dict, optional Section(s) at which the shear is recovered exactly on both sides for every vehicle position - e.g. support faces or a distance ``d_v`` from a support, for bridge shear assessment. Either global coordinate(s) (resolved via :func:`resolve_shear_points`) or a pre-resolved ``{member_index: local_coords}`` mapping. The resulting sections are reported by :meth:`critical_values`. dv : float, optional Critical-shear distance: add a shear point this distance from every vertical support, on each valid side (see :func:`resolve_shear_points`). Raises ------ ValueError If a static beam analysis does not succeed, usually due to a beam configuration error. Returns ------- Envelopes The load effect envelopes for the traverse; a `pycba.Envelopes` object. """ self._check_objects() self.pos = [] self.vResults = [] self._apply_shear_points(shear_points, dv) if pos_start is None: pos_start = 0.0 if pos_end is None: pos_end = self.ba.beam.length + self.veh.L npts = round((pos_end - pos_start) / step) + 1 if plot_all: fig, axs = plt.subplots(2, 1, sharex=True, figsize=(10, 6)) try: for i in range(npts): # load position pos = pos_start + i * step self.pos.append(pos) out = self._single_analysis(pos) if out != 0: raise ValueError("Bridge analysis did not succeed at {pos=}") if plot_all: self.plot_static(pos, axs) plt.pause(0.01) self.vResults.append(self.ba.beam_results) finally: self.ba.shear_points = None env = Envelopes(self.vResults) if plot_env: self.plot_envelopes(env) return env
@staticmethod def _validate_clearances(clearances): """ Validate a lane-UDL ``(back, front)`` clearance pair. Returns ``(back, front)`` as floats, or ``(0.0, 0.0)`` when ``clearances`` is ``None`` (no clear zone). Raises ``ValueError`` for anything that is not a finite, non-negative pair. """ if clearances is None: return 0.0, 0.0 try: back, front = clearances except (TypeError, ValueError): raise ValueError( "clearances must be a (back, front) tuple of two distances, or None" ) back, front = float(back), float(front) if not (np.isfinite(back) and np.isfinite(front)) or back < 0.0 or front < 0.0: raise ValueError("clearances (back, front) must be finite and non-negative") return back, front
[docs] def run_load_model( self, step: float, w_lane: float, clearances: Optional[tuple] = None, plot_env: bool = False, pos_start: Optional[float] = None, pos_end: Optional[float] = None, shear_points: Optional[Union[float, List[float], np.ndarray, Dict]] = None, dv: Optional[float] = None, ) -> Envelopes: """ Run a moving load model: the vehicle together with an accompanying lane UDL, enveloped over the traverse. At each vehicle position the axles are placed as point loads (as in :meth:`run_vehicle`) and a uniform lane UDL of intensity ``w_lane`` is applied along the deck while the vehicle sweeps across it. By default the UDL is continuous, running *directly beneath the vehicle* - as for the AS5100 M1600, where the 6 kN/m lane UDL accompanies the truck with no break. Load models that interrupt the lane UDL around the vehicle specify a clear zone via ``clearances`` (see below). The lane UDL is applied over the full deck (outside any clear zone). For a continuous beam, loading only the same-sign influence-line regions can be more adverse for a given effect; influence-line patterning of the lane UDL is a planned refinement. To pattern manually, combine separate envelopes with :meth:`Envelopes.sum` / :meth:`Envelopes.augment`. Parameters ---------- step : float The distance increment to move the vehicle. w_lane : float The lane UDL intensity (same sign convention as a beam UDL). clearances : (back, front) tuple, optional The clear zone over which the lane UDL is removed around the vehicle, given as ``(back, front)`` distances in metres: ``back`` is measured rearward from the *rear* axle and ``front`` forward from the *front* axle, and the wheelbase between the axles is always part of the cleared zone. Both values must be finite and non-negative. **Beware:** ``None`` and ``(0.0, 0.0)`` are *not* the same. ``None`` (the default) means *no* clear zone - the lane UDL is continuous and runs directly beneath the vehicle (the AS5100 M1600 case). ``(0.0, 0.0)`` clears exactly the wheelbase (the UDL is removed from under the vehicle) with no headway either side. For a single-point vehicle (``vehicle.L == 0``) the wheelbase has zero length, so ``(0.0, 0.0)`` then coincides with ``None``. plot_env : bool, optional Whether to plot the resulting envelope. The default is False. pos_start, pos_end : float, optional The traverse range of the front axle (see :meth:`run_vehicle`). shear_points : float, array-like or dict, optional Sections for exact both-sided shear recovery (see :meth:`run_vehicle`). dv : float, optional Critical-shear distance shear points at every vehicle position (see :meth:`run_vehicle`). Raises ------ ValueError If ``clearances`` is not a finite, non-negative ``(back, front)`` pair, or if a static beam analysis does not succeed. Returns ------- Envelopes The load-effect envelopes for the traverse. """ back, front = self._validate_clearances(clearances) self._check_objects() self.pos = [] self.vResults = [] self._apply_shear_points(shear_points, dv) length = self.ba.beam.length if pos_start is None: pos_start = 0.0 if pos_end is None: pos_end = length + self.veh.L npts = round((pos_end - pos_start) / step) + 1 try: for i in range(npts): pos = pos_start + i * step self.pos.append(pos) # Lane UDL along the deck. With no clear zone the UDL is # continuous (runs under the vehicle). Otherwise it is removed # from `back` behind the rear axle to `front` ahead of the # front axle (front axle at `pos`, rear axle at `pos - veh.L`). if clearances is None: udl_rows = self._interval_udl_LM(0.0, length, w_lane) else: excl_lo = (pos - self.veh.L) - back excl_hi = pos + front udl_rows = [] if excl_lo > 0.0: udl_rows += self._interval_udl_LM( 0.0, min(excl_lo, length), w_lane ) if excl_hi < length: udl_rows += self._interval_udl_LM( max(excl_hi, 0.0), length, w_lane ) LM = add_LM(self.static_LM, self._axle_LM(pos) + udl_rows) self.ba.set_loads(LM) out = self.ba.analyze() if out != 0: raise ValueError(f"Bridge analysis did not succeed at {pos=}") self.vResults.append(self.ba.beam_results) finally: self.ba.shear_points = None env = Envelopes(self.vResults) if plot_env: self.plot_envelopes(env) return env
[docs] def critical_values( self, env: Envelopes ) -> Dict[str, Dict[str, Union[float, np.ndarray]]]: """ From the envelopes output, returns the extreme values, their locations, and the position of the vehicle for each in a dictionary of dictionaries. Each moment entry (``Mmax``, ``Mmin``) also contains a ``"Vco"`` key with the coincident shear at the critical location. Each shear entry (``Vmax``, ``Vmin``) contains a ``"Mco"`` key with the coincident moment. Parameters ---------- env : Envelopes An `pycba.Envelopes` object containing the results of a moving load analysis. Raises ------ ValueError If the supplied envelope is inconsistent with the current `pycba.bridge.BridgeAnalysis` object. Returns ------- crit_values : Dict[str, Dict[str, Union[float, np.ndarray]]] A dictionary of dictionaries containing the critical values (i.e. extremes) of each of the load effects, both maximum and minimum, along with coincident values of the other effect. """ crit_values = {} indx = {} Mmax = env.Mmax.max() Mmin = env.Mmin.min() Vmax = env.Vmax.max() Vmin = env.Vmin.min() # Find the indices of the critical vehicle positions indx["Mmax"] = [ i for i, res in enumerate(self.vResults) if np.isclose(res.results.M.max(), Mmax) ] indx["Mmin"] = [ i for i, res in enumerate(self.vResults) if np.isclose(res.results.M.min(), Mmin) ] indx["Vmax"] = [ i for i, res in enumerate(self.vResults) if np.isclose(res.results.V.max(), Vmax) ] indx["Vmin"] = [ i for i, res in enumerate(self.vResults) if np.isclose(res.results.V.min(), Vmin) ] # Now check for any errors if [] in indx.values(): raise ValueError("Envelope not from the current bridge analysis") # Good to proceed crit_values["Mmax"] = { "val": Mmax, "at": env.x[env.Mmax.argmax()], "pos": [self.pos[i] for i in indx["Mmax"]], "Vco": env.Vco_Mmax[env.Mmax.argmax()], } crit_values["Mmin"] = { "val": Mmin, "at": env.x[env.Mmin.argmin()], "pos": [self.pos[i] for i in indx["Mmin"]], "Vco": env.Vco_Mmin[env.Mmin.argmin()], } crit_values["Vmax"] = { "val": Vmax, "at": env.x[env.Vmax.argmax()], "pos": [self.pos[i] for i in indx["Vmax"]], "Mco": env.Mco_Vmax[env.Vmax.argmax()], } crit_values["Vmin"] = { "val": Vmin, "at": env.x[env.Vmin.argmin()], "pos": [self.pos[i] for i in indx["Vmin"]], "Mco": env.Mco_Vmin[env.Vmin.argmin()], } crit_values["nsup"] = env.nsup for i in range(env.nsup): crit_values[f"Rmax{i}"] = { "val": env.Rmax[i, :].max(), "pos": self.pos[env.Rmax[i, :].argmax()], } crit_values[f"Rmin{i}"] = { "val": env.Rmin[i, :].min(), "pos": self.pos[env.Rmin[i, :].argmin()], } # Report the corrected critical shears at any requested shear points. # Each section was sampled by a station pair straddling it, so the # left- and right-hand shear limits are read from the stations either # side of the section coordinate. Each section is tagged with the # vertical support it is nearest to and the side it lies on, so the end # supports (at x = 0 and x = L) are identifiable in the results. if len(self.shear_points_x) > 0: support_xs = _vertical_support_xs(self.ba.beam) sp_block = {} for xg in self.shear_points_x: left = np.nonzero(env.x < xg)[0] right = np.nonzero(env.x > xg)[0] if len(left) == 0 or len(right) == 0: continue il = left[np.argmax(env.x[left])] ir = right[np.argmin(env.x[right])] entry = { "Vmax": max(env.Vmax[il], env.Vmax[ir]), "Vmin": min(env.Vmin[il], env.Vmin[ir]), "Vmax_left": env.Vmax[il], "Vmax_right": env.Vmax[ir], "Vmin_left": env.Vmin[il], "Vmin_right": env.Vmin[ir], } if len(support_xs) > 0: isup = int(np.argmin(np.abs(support_xs - xg))) sx = support_xs[isup] entry["support"] = float(sx) entry["side"] = "left" if xg < sx else "right" sp_block[float(xg)] = entry crit_values["shear_points"] = sp_block return crit_values
[docs] def envelopes_ratios( self, trial_env: Envelopes, ref_env: Envelopes ) -> Dict[str, np.ndarray]: """ Returns the ratios of two sets of envelopes considering zero values and reactions. Note that ratios are only meaningful for any one location on the beam, and so reaction envelopes ratios reduce to a scalar value. Ratios are absolute, and zeroed if within 1e-3 absolute tolerance of zero. Parameters ---------- trial_env : Envelopes The numerator `pycba.Envelopes` object, usually from the vehicle seeking access to the bridge. ref_env : Envelopes The denominator `pycba.Envelopes` object, usually from the reference or benchamrk of acceptable load effects on the bridge. Can be from a single notional vehicle, or a suite of such vehicles. Raises ------ ValueError The envelopes need to be from the same bridge analysis object. Returns ------- Dict[str,np.ndarray] A dictionary of ratios between the two envelopes, considering the maximum and minimum of each load effect. """ if ref_env.npts != trial_env.npts or ref_env.nsup != trial_env.nsup: raise ValueError("Ratios can only be found for compatible envelopes") def get_ratio(a, b): """ Zeroes infinities when a ref load effect is zero. b is reference vector """ return np.abs( np.divide( a, b, out=np.zeros_like(a), where=~np.isclose(b, np.zeros_like(b), atol=1e-3, rtol=0.0), ) ) env_ratios = {} env_ratios["x"] = ref_env.x env_ratios["Mmax"] = get_ratio(trial_env.Mmax, ref_env.Mmax) env_ratios["Mmin"] = get_ratio(trial_env.Mmin, ref_env.Mmin) env_ratios["Vmax"] = get_ratio(trial_env.Vmax, ref_env.Vmax) env_ratios["Vmin"] = get_ratio(trial_env.Vmin, ref_env.Vmin) env_ratios["nsup"] = ref_env.nsup maxvals = get_ratio(trial_env.Rmaxval, ref_env.Rmaxval) minvals = get_ratio(trial_env.Rminval, ref_env.Rminval) for i in range(ref_env.nsup): env_ratios[f"Rmax{i}"] = maxvals[i] env_ratios[f"Rmin{i}"] = minvals[i] return env_ratios
[docs] def plot_static(self, pos: float, axs: Optional[plt.Axes] = None, units=None): """ Draw the bridge with the vehicle at a given position, above the instantaneous bending-moment and shear-force diagrams. The top panel shows the structural schematic - the deck with its real support symbols and any permanent loads - with the vehicle drawn as a small truck on the deck at ``pos`` (a wheel at each axle and a downward load arrow, labelled with its weight, for every axle that is currently on the bridge). The two panels below show the bending moment and shear force for that single ("stationary") position; faint vertical lines mark the axle positions so the load effects can be related to the loads that cause them. Parameters ---------- pos : float The position of the front axle of the vehicle in global bridge coordinates. axs : array_like of matplotlib.axes.Axes, optional Axes to draw into. If ``None`` (default) a new three-panel figure is created and the analysis for ``pos`` is run first. When axes are supplied the current results are used as-is: pass three axes (schematic + moment + shear) or two (moment + shear only). units : str or pycba.units.UnitSystem, optional Display unit system for the labels (see :func:`pycba.set_units`). Returns ------- tuple(matplotlib.figure.Figure, numpy.ndarray) or None The figure and its axes when a new figure is created, otherwise ``None``. """ from .units import resolve us = resolve(units) own_fig = axs is None fig = None if own_fig: self._check_objects() self._single_analysis(pos) fig, axs = plt.subplots( 3, 1, sharex=True, figsize=(10, 8), gridspec_kw={"height_ratios": [1.2, 1.0, 1.0]}, ) res = self.ba.beam_results.results L = self.ba.beam.length if len(axs) >= 3: # schematic + load effects self._draw_deck_and_vehicle(axs[0], pos, us) m_ax, v_ax = axs[1], axs[2] else: # load effects only (e.g. the run_vehicle animation) m_ax, v_ax = axs[0], axs[1] # Faint markers linking on-deck axle positions to the diagrams below axle_x = pos - self.veh.axle_coords on_deck = axle_x[(axle_x >= 0.0) & (axle_x <= L)] m_ax.plot([0, L], [0, 0], "k", lw=2) m_ax.plot(res.x, -res.M, "r") for x in on_deck: m_ax.axvline(x, color="0.7", ls=":", lw=0.8, zorder=0) m_ax.grid() m_ax.set_ylabel(us.moment_axis) v_ax.plot([0, L], [0, 0], "k", lw=2) v_ax.plot(res.x, res.V, "r") for x in on_deck: v_ax.axvline(x, color="0.7", ls=":", lw=0.8, zorder=0) v_ax.grid() v_ax.set_ylabel(us.shear_axis) v_ax.set_xlabel(us.distance_axis) if own_fig: return fig, axs
def _draw_deck_and_vehicle(self, ax, pos: float, us=None): """ Draw the deck schematic (supports + permanent loads) with the vehicle rendered as a small truck at ``pos`` on the given axes. """ from matplotlib.patches import Rectangle, Circle from .render import BeamPlotter from .units import resolve us = resolve(us) L = self.ba.beam.length u = 0.05 * L # symbol unit, matching the schematic renderer # Bare deck with real supports (and any permanent loads), stretched to # fill the panel so it stays x-aligned with the diagrams below. BeamPlotter(self.ba.beam, self.static_LM).render_mpl( ax=ax, dimensions=False, labels=True, load_values=bool(self.static_LM), equal_aspect=False, units=us, ) ax.set_xlabel("") axle_x = pos - self.veh.axle_coords w = self.veh.axw x_lo, x_hi = float(np.min(axle_x)), float(np.max(axle_x)) rw = 0.30 * u # wheel radius body_y0 = 2.0 * rw body_h = 1.1 * u body_top = body_y0 + body_h pad = 0.6 * u # body overhang past the outer axles # Vehicle body, with a small cab on the leading (travel +x) end ax.add_patch( Rectangle( (x_lo - pad, body_y0), (x_hi - x_lo) + 2 * pad, body_h, fc="0.82", ec="0.35", lw=1.2, alpha=0.9, zorder=6, ) ) cab_w = 0.8 * u ax.add_patch( Rectangle( (x_hi + pad - cab_w, body_top), cab_w, 0.5 * u, fc="0.82", ec="0.35", lw=1.2, alpha=0.9, zorder=6, ) ) # Wheels at every axle (a hub highlight for a touch of polish) for x in axle_x: ax.add_patch( Circle( (x, rw), rw, fc="0.15", ec="k", lw=1.0, zorder=7, gid="vehicle_wheel", ) ) ax.add_patch(Circle((x, rw), 0.32 * rw, fc="0.7", ec="k", lw=0.6, zorder=8)) # Downward load arrow for each on-deck axle (off-deck axles apply none) arr_top = body_top + 1.0 * u on_mask = (axle_x >= 0.0) & (axle_x <= L) for x in axle_x[on_mask]: ax.annotate( "", xy=(x, 0.03 * u), xytext=(x, arr_top), arrowprops=dict(arrowstyle="-|>", color="r", lw=1.6), zorder=9, ) # Travel-direction arrow ahead of the leading axle ax.annotate( "", xy=(x_hi + pad + 1.6 * u, body_top + 0.25 * u), xytext=(x_hi + pad + 0.4 * u, body_top + 0.25 * u), arrowprops=dict(arrowstyle="-|>", color="0.4", lw=1.5), ) # Fit the glyph and keep the deck aligned in x with the diagrams below ax.set_ylim(-1.9 * u, arr_top + 1.5 * u) ax.set_xlim( min(-0.06 * L, x_lo - 0.8 * u), max(1.06 * L, x_hi + pad + 2.4 * u), ) ax.set_ylabel("") ax.set_yticks([]) # Axle weights are summarised once in a caption (per-axle labels would # collide for closely-spaced axle groups); the arrows show positions. ax.text( 0.012, 0.98, "Vehicle: " + self._vehicle_spec(us), transform=ax.transAxes, ha="left", va="top", fontsize=8.5, color="0.15", bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="0.7", alpha=0.9), zorder=10, ) def _vehicle_spec(self, us=None) -> str: """A compact axle-group summary, e.g. ``3x150 + 2x120 + 40 kN``.""" from .units import resolve us = resolve(us) w = self.veh.axw parts = [] i = 0 while i < len(w): j = i while j + 1 < len(w) and np.isclose(w[j + 1], w[i]): j += 1 n = j - i + 1 parts.append(f"{n}×{w[i]:g}" if n > 1 else f"{w[i]:g}") i = j + 1 fu = f" {us.force}" if us.force else "" return " + ".join(parts) + f"{fu} (ΣW = {self.veh.W:g}{fu})"
[docs] def animate( self, step: float, save: Optional[str] = None, fps: int = 12, pos_start: Optional[float] = None, pos_end: Optional[float] = None, dpi: int = 110, units=None, ): """ Animate the vehicle crossing the bridge. Steps the vehicle across the bridge and animates three stacked, x-aligned panels: the deck with the moving truck on top, then the instantaneous bending-moment and shear-force diagrams. As the vehicle advances, the running envelope of each load effect is shaded behind the instantaneous diagram and grows to the full traverse envelope - so the envelope can be seen being *built up* position by position. All positions are analysed up front (this is the slow part); playback and saving then just redraw the stored results. Parameters ---------- step : float Distance increment to move the vehicle between frames. save : str, optional If given, write the animation to this path. A ``.gif`` is written with Pillow; any other extension (e.g. ``.mp4``) uses ffmpeg. fps : int Frames per second for playback / the saved file. pos_start, pos_end : float, optional First and last front-axle positions (defaults: ``0`` to ``beam length + vehicle length``, i.e. a full on-and-off crossing). dpi : int Resolution used when ``save`` is given. units : str or pycba.units.UnitSystem, optional Display unit system for the labels (see :func:`pycba.set_units`). Returns ------- matplotlib.animation.FuncAnimation The animation. In a notebook, display it with ``HTML(anim.to_jshtml())`` (or ``anim.to_html5_video()``); the ``FuncAnimation`` must be kept referenced while it plays. """ from matplotlib.animation import FuncAnimation from .units import resolve us = resolve(units) # Analyse every position once, then animate from the stored results. self.run_vehicle(step, pos_start=pos_start, pos_end=pos_end) positions = list(self.pos) x = self.vResults[0].results.x # Plot bending moment as -M (sagging below the axis), matching plot_static bmd = np.array([-r.results.M for r in self.vResults]) sfd = np.array([r.results.V for r in self.vResults]) # Running (cumulative) envelope up to each frame emax_b = np.maximum.accumulate(bmd, axis=0) emin_b = np.minimum.accumulate(bmd, axis=0) emax_s = np.maximum.accumulate(sfd, axis=0) emin_s = np.minimum.accumulate(sfd, axis=0) L = self.ba.beam.length def _lims(lo_arr, hi_arr): lo, hi = float(lo_arr.min()), float(hi_arr.max()) span = hi - lo if hi > lo else (abs(hi) + 1.0) return lo - 0.08 * span, hi + 0.08 * span bmd_lo, bmd_hi = _lims(emin_b[-1], emax_b[-1]) sfd_lo, sfd_hi = _lims(emin_s[-1], emax_s[-1]) deck_xlim = (-0.06 * L, 1.06 * L) fig, axs = plt.subplots( 3, 1, figsize=(10, 8), gridspec_kw={"height_ratios": [1.2, 1.0, 1.0]}, ) def _draw_effect(ax, y, emin, emax, ylo, yhi, ylabel, xlabel=False): ax.plot([0, L], [0, 0], "k", lw=2) ax.fill_between(x, emin, emax, color="0.82", alpha=0.8, lw=0) ax.plot(x, emax, color="0.55", lw=0.7) ax.plot(x, emin, color="0.55", lw=0.7) ax.plot(x, y, "r", lw=1.6) ax.set_xlim(*deck_xlim) ax.set_ylim(ylo, yhi) ax.grid(True) ax.set_ylabel(ylabel) if xlabel: ax.set_xlabel(us.distance_axis) def update(i): for ax in axs: ax.cla() # Deck + moving truck (fixed deck view; the truck clips in/out) self._draw_deck_and_vehicle(axs[0], positions[i], us) axs[0].set_xlim(*deck_xlim) lu = f" {us.length}" if us.length else "" axs[0].set_title(f"Front axle at x = {positions[i]:.1f}{lu}", fontsize=10) _draw_effect( axs[1], bmd[i], emin_b[i], emax_b[i], bmd_lo, bmd_hi, us.moment_axis, ) _draw_effect( axs[2], sfd[i], emin_s[i], emax_s[i], sfd_lo, sfd_hi, us.shear_axis, xlabel=True, ) return axs anim = FuncAnimation( fig, update, frames=len(positions), interval=1000.0 / fps, blit=False ) if save is not None: writer = "pillow" if str(save).lower().endswith(".gif") else "ffmpeg" anim.save(str(save), writer=writer, fps=fps, dpi=dpi) return anim
[docs] def plot_envelopes(self, env: Envelopes, units=None): """ Plots the envelopes of load effects from a vehicle traverse analysis Parameters ---------- env : Envelopes An `pycba.Envelopes` object containing the results of a moving load analysis. units : str or pycba.units.UnitSystem, optional Display unit system for the labels (see :func:`pycba.set_units`). Returns ------- None """ from .units import resolve us = resolve(units) L = self.ba.beam.length x = env.x nreactions = env.nsup fig = plt.figure(constrained_layout=True, figsize=(10, 4)) subfigs = fig.subfigures(1, 2, wspace=0.07) # Shear and moment in left panel subfigs[0].suptitle("Stress Resultants") axsLeft = subfigs[0].subplots(2, 1, sharex=True) ax = axsLeft[0] ax.plot([0, L], [0, 0], "k", lw=2) ax.plot(x, env.Mmax, "r") ax.plot(x, env.Mmin, "b") ax.invert_yaxis() ax.grid() ax.set_ylabel(us.moment_axis) ax = axsLeft[1] ax.plot([0, L], [0, 0], "k", lw=2) ax.plot(x, env.Vmax, "r") ax.plot(x, env.Vmin, "b") ax.grid() ax.set_ylabel(us.shear_axis) ax.set_xlabel(us.distance_axis) # Reactions in right panel subfigs[1].suptitle("Support Reactions") # Reactions may be force or moment; show both unit labels. react_u = f" ({us.force}/{us.moment})" if us.force and us.moment else "" # Check if consistent envelope if len(self.pos) == env.Rmax.shape[1]: axsRight = np.atleast_1d(subfigs[1].subplots(nreactions, 1, sharex=True)) pos = np.asarray(self.pos) for i, ax in enumerate(axsRight): ax.plot([0, L], [0, 0], "k", lw=2) ax.plot(pos, env.Rmax[i, :], "r") ax.plot(pos, env.Rmin[i, :], "b") # Mark and label the governing (extreme) reaction values so the # critical magnitude is readable directly off the plot. self._mark_reaction_extreme(ax, pos, env.Rmax[i, :], "r", np.argmax) self._mark_reaction_extreme(ax, pos, env.Rmin[i, :], "b", np.argmin) ax.grid() ax.set_ylabel(f"Reaction {i+1}{react_u}") axsRight[-1].set_xlabel(us.length_axis("Position of Front Axle")) else: # Otherwise envelope of envelopes axsRight = subfigs[1].subplots(2, 1, sharex=True) for i, (ax, le, col) in enumerate( zip(axsRight, ["max", "min"], ["r", "b"]) ): r = eval(f"env.R{le}val") # kinda yuk! bars = ax.bar(np.arange(env.nsup), r, color=col) # Label each bar with its governing reaction value. ax.bar_label(bars, fmt="%.3g", fontsize=8, padding=2) ax.set_xticks(np.arange(env.nsup)) ax.set_xticklabels([f"R{i+1}" for i in range(env.nsup)]) ax.set_ylabel(f"Reactions [{le}]{react_u}") ax.grid() axsRight[1].set_xlabel("Reaction ID")
@staticmethod def _mark_reaction_extreme(ax, pos, series, color, arg_fn): """ Mark and annotate the extreme of a reaction time-history on ``ax``. ``arg_fn`` is :func:`numpy.argmax` (governing maximum, ``r`` line) or :func:`numpy.argmin` (governing minimum, ``b`` line); a marker is drawn at the extreme and labelled with its value. """ series = np.asarray(series) if series.size == 0: return idx = int(arg_fn(series)) xv, yv = pos[idx], series[idx] ax.plot([xv], [yv], color=color, marker="o", ms=5, zorder=5) above = arg_fn is np.argmax ax.annotate( f"{yv:.3g}", (xv, yv), textcoords="offset points", xytext=(0, 5 if above else -5), ha="center", va="bottom" if above else "top", fontsize=8, color=color, fontweight="bold", )
[docs] def plot_ratios(self, env_ratios: Dict[str, np.ndarray], units=None): """ Plots the output of :meth:`pycba.bridge.BridgeAnalysis.envelopes_ratios`. Parameters ---------- env_ratios : Dict[str,np.ndarray] The dictionary of envelopes ratios. units : str or pycba.units.UnitSystem, optional Display unit system for the distance axis (the ratios themselves are dimensionless). See :func:`pycba.set_units`. Raises ------ ValueError Inconsistency in the dictionary entries. Returns ------- None. """ from .units import resolve us = resolve(units) # Set of keys we want to confirm are present check_keys = set( ["x", "Mmax", "Mmin", "Vmax", "Vmin", "nsup", "Rmax0", "Rmin0"] ) if not check_keys.issubset(env_ratios.keys()): raise ValueError( "Dictionary argument does not contain sufficient ratios information" ) x = env_ratios["x"] L = x[-1] nsup = env_ratios["nsup"] fig = plt.figure(constrained_layout=True, figsize=(10, 4)) subfigs = fig.subfigures(1, 2, wspace=0.07) # Shear and moment in left panel subfigs[0].suptitle("Stress Resultants") axsLeft = subfigs[0].subplots(2, 1, sharex=True) ax = axsLeft[0] ax.plot([0, L], [0, 0], "k", lw=2) ax.plot(x, env_ratios["Mmax"], "r") ax.plot(x, env_ratios["Mmin"], "b") ax.grid() ax.set_ylabel("Bending Moment Ratio") ax = axsLeft[1] ax.plot([0, L], [0, 0], "k", lw=2) ax.plot(x, env_ratios["Vmax"], "r") ax.plot(x, env_ratios["Vmin"], "b") ax.grid() ax.set_ylabel("Shear Force Ratio") ax.set_xlabel(us.distance_axis) # Reactions in right panel subfigs[1].suptitle("Support Reactions") axsRight = subfigs[1].subplots(2, 1, sharex=True) for i, (ax, le, col) in enumerate(zip(axsRight, ["max", "min"], ["r", "b"])): r = np.array([env_ratios[f"R{le}{i}"] for i in range(nsup)]) ax.bar(np.arange(nsup), r, color=col) ax.set_xticks(np.arange(nsup)) ax.set_xticklabels([f"R{i+1}" for i in range(nsup)]) ax.set_ylabel(f"Reaction Ratio [{le}]") ax.grid() axsRight[1].set_xlabel("Reaction ID")
def _check_objects(self): """ Check that suitable objects are defined before an analysis is run. Parameters ---------- None Returns ------- None """ if not self.ba: raise ValueError("A bridge must be defined in advance") if not self.veh: raise ValueError("A vehicle must be defined in advance")