Source code for planesections.diagram.diagram

# -*- coding: utf-8 -*-
"""
Created on Sat Mar  5 20:57:19 2022

@author: Christian

"""

import numpy as np
from planesections import diagramUnits
from planesections.builder import EleLoadDist, EleLoadLinear

from .components import basic as basic
from matplotlib.pyplot import Figure, Axes

"""
Not used properly right now, but ideally this is defined only in one place.
"""
fixities  = {'free':[0,0,0], 'roller': [0,1,0], 
             'pinned':[1,1,0], 'fixed':[1,1,1]}

class EleLoadBox:
    """
    Assume that y is bounded by zero, or crosses boundaries
    
    The internal datum is where the datum lies between y1 and y2 as a ratio.
    
    By default assumes that the distribution is a constant oin the positive 
    side.
    
    Parameters
    ----------
    x : tuple[float]
        A tuple containing [x1, x2] for the box.
    y : tuple[float]
        A tuple containing [y1, y2] for the box. Y is always ordered where
        y1 < y2.
    fint : tuple[float], optional
        A tuple contiaining [fint1, fint2], where each value ranges from 0 to 1.
        The default is None.

    Returns
    -------
    None.

    """    
    def __init__(self, x:tuple[float], y:tuple[float], fint:tuple[float]=None,
                 intDatum:float=None):

        self.x = x
        self.y = y    

        self.x.sort()
        self.y.sort()
        
        if fint == None:
            fint = [1, 1]

        self.fint = fint
        self.fout = [self._interpolate(fint[0]), self._interpolate(fint[1])]
        
        # If the internal datum is manually set
        if intDatum:
            self.intDatum = intDatum
            self.datum = self._interpolate(intDatum)
            
            sign1 = np.sign(self.fout[0])
            sign2 = np.sign(self.fout[1])
            if sign1 == sign2 >= 0:
                self.changedDirection = False
            else:
                self.changedDirection = True
                
        # If there is no internal datum, this is the typical case.
        else:    
            self._initInternalDatum()
    
    def setDatum(self, datum):
        dy = datum - self.datum
        self.y = [self.y[0] + dy, self.y[1] + dy]
        self.datum = datum
        
        fint = self.fint
        self.fout = [self._interpolate(fint[0]), self._interpolate(fint[1])]
    
    def shiftDatum(self, dy):
        self.y = [self.y[0] + dy, self.y[1] + dy]
        self.datum = self.datum + dy
        
        fint = self.fint
        self.fout = [self._interpolate(fint[0]), self._interpolate(fint[1])]

    def getInternalDatum(self):
        return self.datum
    
    def _interpolate(self, fint):
        return (self.y[1] - self.y[0])*fint + self.y[0]
    
    def _initInternalDatum(self):
        """
        Sets the internal datum, making assumptions about the shape of the 
        system. Notably:
            - the box is "placed" next to the x axis
        """
        sign1 = np.sign(self.fout[0])
        sign2 = np.sign(self.fout[1])
        
        self.datum = 0
        if sign1 >= 0 and sign2 >= 0:
            self.changedDirection = False
            self.intDatum = 0
        
        elif sign1 <= 0 and sign2 <= 0:
            self.changedDirection = False
            self.intDatum = 1
        else:
            self.changedDirection = True
            dy = self.y[0] - self.y[1]
            self.intDatum =  self.y[0] / dy
    
    @property
    def isConstant(self):
        return self.fint[0] == self.fint[1]
        
def _checkInRange(xrange1, xrange2):
    """
    Checks if either point of xrange1 is in xrange2
    """
    if  (xrange2[0] <= xrange1[1]) and (xrange1[0] <= xrange2[1]):
        return True
    else:
        return False

def checkBoxesForOverlap(box1:EleLoadBox, box2:EleLoadBox):
    """
    Checks if box1 overlaps with box2

    Parameters
    ----------
    box1 : EleLoadBox
        The first Box.
    box2 : EleLoadBox
        The second Box.

    Returns
    -------
    None.

    """
    
    if _checkInRange(box1.x, box2.x) and _checkInRange(box1.y, box2.y):
        return True  
    else:
        return False

class Boxstacker:
    
    def __init__(self, boxes:list[EleLoadBox]):
        """
        A class that can be used to stack a series of element boxes.

        Parameters
        ----------
        boxes : list[EleLoadBox]
            The list of boxes to stack.

        Returns
        -------
        None.

        """
        self.boxes = boxes

    def setStackedDatums(self):
        """
        Gives the forces an order, and finds where to put them porportionally.
        Longer forces will go on the bottom, while shorter forces are
        placed on top of them.
        """
        
        boxes = self.boxes
        Nforces = len(boxes)
        lengths = [None]*Nforces
        xcoords = np.array([box.x for box in boxes])
        ycoords = np.array([box.y for box in boxes]) # [bottom, top]
        
        # Get the lengths, the start with the longest and go to shortest
        lengths = xcoords[:,1] - xcoords[:,0]
        sortedInds = np.argsort(lengths)[::-1]

        # the current x and y points being plotted.    
        posStackx = []
        posStackTop = []
        negStackx = []
        negStackTop = []
        
        # start at the widest items and plot them first
        for ind in sortedInds:
            box = boxes[ind]
            
            # Datum is where we point towards!
            y = ycoords[ind]
            x = xcoords[ind]
                       
            # Case 1: Constantly distributed, use dy
            if box.isConstant:
                dy = box.fout[0]
                
                if 0 < dy:
                    self._addToStack(box, dy, x, posStackx, posStackTop)
                else:
                    self._addToStack(box, dy, x, negStackx, negStackTop)                
                
            # Case 2: linearly distributed, no sign change, use max values
            elif not box.changedDirection:
                # If a value is greater than zero, stack on pos side.
                if 0 < max(y):
                    dy = max(y)
                else:
                    dy = min(y)               
            
                if 0 < dy:
                    self._addToStack(box, dy, x, posStackx, posStackTop)
                else:
                    self._addToStack(box, dy, x, negStackx, negStackTop)
            
            # Case 3: Linearly distributed through zero, we work with fout
            # print(box.changedDirection)
            elif box.changedDirection:
                inPos, _ = self._checkInStack(x, posStackx)
                inNeg, _ = self._checkInStack(x, negStackx)
                
                # If 
                dyPos = max(box.fout)
                dyNeg = min(box.fout)
                
                # Case 3i: 
                # If there is no stacks, add it to the bottom of both stacks
                if (not inPos) and (not inNeg):
                    self._addToStack(box, dyPos, x, posStackx, posStackTop)
                    self._addToStack(box, dyNeg, x, negStackx, negStackTop)
                
                # Case 3ii: 
                # If there is a positive stack add it to the top of that stack
                elif inPos:
                    dy = dyPos - dyNeg
                    dDatum =  -dyNeg

                    self._addToStack(box, dy, x, posStackx, posStackTop, dDatum)
                # Case iii:
                # If there is only negative, shift above the x axis
                elif inNeg:
                    # box.shiftDatum(-box.datum)
                    dDatum =  -dyNeg
                    dy =  -dyNeg
                    self._addToStack(box, dy, x, posStackx, posStackTop, dDatum) 
                        
        return boxes
     
    def _checkIfInRange(self, xtest, x1,x2):
        if (x1 < xtest) and (xtest < x2):
            return True
        return False

    def _addToStack(self, box, dy, xcoords, stackx, stacktops, dDatum = 0):
        y0 = self._getStackTop(xcoords, stackx, stacktops)
        box.shiftDatum(y0 + dDatum)
        stackx.append(xcoords)           
        stacktops.append((y0 + dy))

    def _checkInStack(self,   xCurrent     :list[float, float], 
                              stackRanges  :list[list[float, float]]):
        """
        Checks all the stacks to seee if the current range is within the stack.
        
        """
        
        # Check all the stacks to see if the current stack is 
        inStack = False
        Nloads = len(stackRanges)
        for ii in range(Nloads):
            localInd = Nloads - 1 - ii
            x1, x2  = stackRanges[localInd]
            if self._checkIfInRange(xCurrent[0], x1, x2): # left side
                return  True, localInd
            if self._checkIfInRange(xCurrent[1], x1, x2): # right side
                return  True, localInd
            
            # Stack boxes that are directly on top of eachother.
            # We need both to be true so boxes side by side do not stack
            if (xCurrent[0] == x1) and (xCurrent[1] == x2): # right side
                return  True, localInd
        return inStack, None         
                
    def _getStackTop(self,   xCurrent     :list[float, float], 
                             stackRanges  :list[list[float, float]], 
                             currentY     :list[list[float, float]]):
        """
        Look at all of the current forces on the side in question.
        
        Starting at the top of the force stack, check each force to see if
        it intersects with any other forces.
        """
        
        inStack, localInd = self._checkInStack(xCurrent, stackRanges)
        if inStack == True:
            return currentY[localInd]       
        
        return 0

def _getSigns(forces):
    """
    Safely gets the signs in a way that won't result in dividing by zero.

    Returns
    -------
    None.

    """
    tmpForces = np.copy(forces)
    inds = np.where(tmpForces == 0)
    tmpForces[inds] = 1 
    signs = forces / np.abs(tmpForces)
    return signs   
    
def _setForceVectorLengthEle(boxes:list[EleLoadBox], vectScale = 1):
    """
    Gets the force vector length in terms of the drawing units.
    Force vectors will have a static component that doesn't change,
    and a dynamic component that adapts to the magnitude of forces.
    
    The output plotting forces are in the direction they act.
    
    The element load plotting does not work with non-vertical loads.

    """
    fscale0 = 0.4
    fstatic0 = 0.3
    
    forces = np.array([box.y for box in boxes])
    boxesOut = [None]*len(boxes)
    
    Fmax = np.max(np.abs(forces))

    # Get the sign of the maximum force. Ignores loads with sign changes
    signs = _getSigns(forces)
    
    # Find all force that are zero. These should remain zero
    Inds0 = np.where(np.abs(forces) == 0)
    
    # Plot the static portion, and the scale port of the force
    fscale = fscale0*abs(forces) / Fmax
    fstatic = fstatic0*np.ones_like(forces)
    fstatic[Inds0[0], Inds0[1]] = 0 # don't move the bottom of the plot!
    fplot =  ((fscale + fstatic) * signs)*vectScale
    
    for ii in range(len(boxes)):
        boxOld = boxes[ii]
        
        dy = fplot[ii][1] - fplot[ii][0]
        # We scale fout appropriately and calcualte a new fint
        fout_fscale  = fscale0*(np.array(boxOld.fout) / Fmax)
        signs        = _getSigns(fout_fscale)
        fout_plot    =  ((abs(fout_fscale) + fstatic0) * signs)*vectScale
        fint         = (fout_plot - fplot[ii][0]) / dy
        boxesOut[ii] = EleLoadBox(boxOld.x, fplot[ii], list(fint))
    
    return boxesOut       

[docs] class BeamPlotter2D: def __init__(self, beam, figsize = 8, units = 'environment'): """ Used to make a diagram of the beam. Only certain fixities are supported for plotting, including free, roller (support only in y), pinned (support in x and y), and fixed (support in x/y/rotation). Only certain forces are supported for plotting - for distrubuted forces only the y component of the beam can be plotted. Mixed Forces, those that contain a combination of Px/Py/Moment will not be plotted Note, the diagram has been rescaled so it's length in the digram isn't it's analysis lenght. This is to make consistent plotting easier across a number of beam sizes, however, the matplotlib objects in the plot have different lengths than the actual beam. The class used as a interface between the high level beam abstraction and lower level rules plotting. unitHandler: str, or dict Represents the . the env tag. """ self.beam = beam self.figsize = figsize if units == 'environment': self.unitHandler = diagramUnits.activeEnv else: self.unitHandler = diagramUnits.getEnvironment(units) L = beam.getLength() xscale = L / self.figsize self.xscale = xscale baseSpacing = self.beam.getLength() / self.xscale self.plotter:basic.BasicDiagramPlotter = basic.BasicDiagramPlotter(L=L) self.plotter.setEleLoadLineSpacing(baseSpacing) xlims = beam.getxLims() self.xmin = xlims[0] self.xmax = xlims[1] self.xlimsPlot = [(xlims[0] - L/20) / xscale, (xlims[1] + L/20) / xscale] self.ylimsPlot = [-L/10 / xscale, L/10 / xscale] self.plottedNodeIDs = []
[docs] def plot(self, plotLabel = False, labelForce = True, plotForceValue = True, **kwargs): """ Plots the beam, supports, point forces, element forces, and labels. Note that forces have a "static" and "adaptive" portion of their length . This means that arrows can't have values length less than a certain length, preventing very small arrows from being plotted that look silly. However, this also means that the ratio between different arrows won't be exactly the ratio between their force magnitude. Only the vertical components of distributed forces are plotted. """ args = (self.figsize, self.xlimsPlot, self.ylimsPlot) self.fig, self.ax = self.plotter._initPlot(*args) self.plotSupports() pfplot, efplot = None, None if self.beam.pointLoads: pfplot = self.plotPointForces() if self.beam.pointLoads and plotLabel: self.plotPointForceLables(pfplot, labelForce, plotForceValue) if self.beam.eleLoads: efplot, xcoords = self.plotEleForces() if self.beam.eleLoads and plotLabel: self.plotDistForceLables(efplot, xcoords, labelForce, plotForceValue) if plotLabel: self.plotLabels() self.plotBeam() if (not (pfplot is None)) or (not (efplot is None)): self._adjustPlot(pfplot, efplot)
def _adjustPlot(self, pfplot, efplot): if (pfplot is None): pfplot = (0) if (efplot is None): efplot = (0) fmax = max(np.max(pfplot), np.max(efplot)) fmin = min(np.min(pfplot), np.min(efplot)) if fmin < self.ylimsPlot[0]: self.ylimsPlot[0] = fmin if self.ylimsPlot[1] < fmax: self.ylimsPlot[1] = fmax self.ax.set_ylim(self.ylimsPlot)
[docs] def plotBeam(self): """ Plots the base beam element. """ xlims = self.beam.getxLims() xy0 = [xlims[0] / self.xscale, 0] xy1 = [xlims[1] / self.xscale, 0] self.plotter.plotBeam(self.ax, xy0, xy1)
[docs] def plotSupports(self): """ Finds the type of and plots each supports """ for node in self.beam.nodes: fixityType = node.getFixityType() x = node.getPosition() xy = [x / self.xscale, 0] """ The direction assignment assumes the shape of the system. """ kwargs = {} if fixityType == 'fixed' and x == self.xmin: kwargs = {'isLeft':True} if fixityType == 'fixed' and not x == self.xmin: kwargs = {'isLeft':False} self.plotter.plotSupport(self.ax, xy, fixityType, kwargs)
def _addLabelToPlotted(self, nodeID): self.plottedNodeIDs.append(nodeID) def _checkIfLabelPlotted(self, nodeID): check = nodeID in self.plottedNodeIDs return check
[docs] def plotLabels(self): """ Adds all labels to the plot. Labels are offset from the point in the x and y. """ for node in self.beam.nodes: label = node.label x = node.getPosition() if label and (self._checkIfLabelPlotted(node.ID) != True): xy = [x / self.xscale, 0] self.plotter.plotLabel(self.ax, xy, label) self._addLabelToPlotted(node.ID)
def _getValueText(self, diagramType, forceValue): unit = self.unitHandler[diagramType].unit scale = self.unitHandler[diagramType].scale Ndecimal = self.unitHandler[diagramType].Ndecimal # Round force forceValue *= scale if Ndecimal == 0: forceValue = round(forceValue) else: forceValue = round(forceValue*10**Ndecimal) / 10**Ndecimal return forceValue, unit
[docs] def plotPointForceLables(self, fplot, labelForce, plotForceValue): """ Adds all labels to the plot. Labels are offset from the point in the x and y. """ inds = range(len(self.beam.pointLoads)) for ii, force in zip(inds, self.beam.pointLoads): Px, Py, Mx = fplot[ii] isMoment = False if Mx != 0: isMoment = True Py = -0.15 diagramType = 'moment' fText = force.P[2] else: # shift the force down so it fits in the diagram! Py += 0.15 diagramType = 'force' fText = np.sum(force.P[:2]**2)**0.5 # get the label from the node - it's store there and not on the force. labelBase = force.label # labelBase = self.beam.nodes[force.nodeID - 1].label label = '' if labelBase and labelForce and isMoment: label += f'$M_{{{labelBase}}}$' # Tripple brackets needed to make the whole thing subscript elif labelBase and labelForce and (not isMoment): label += f'$P_{{{labelBase}}}$' else: pass if labelBase and plotForceValue and labelForce: valueText, unit = self._getValueText(diagramType, fText) label += ' = ' + str(valueText) + "" + unit x = force.getPosition() xy = [x / self.xscale, -Py] if label and self._checkIfLabelPlotted(force.nodeID) != True: self.plotter.plotLabel(self.ax, xy, label) self._addLabelToPlotted(force.nodeID)
[docs] def plotDistForceLables(self, fplot, xcoords, labelForce, plotForceValue): """ Adds all labels to the plot. Labels are offset from the point in the x and y. """ diagramType = 'distForce' inds = range(len(self.beam.eleLoads)) for ii, force in zip(inds, self.beam.eleLoads): qx, qy = fplot[ii] fText = force.P[1] labelBase = force.label label = '' if labelBase and labelForce: label += f'$q_{{{labelBase}}}$' if labelBase and plotForceValue and labelForce: valueText, unit = self._getValueText(diagramType, fText) label += ' = ' + str(valueText) + "" + unit x1, x2 = xcoords[ii] aMid = (x1 + x2 ) / 2 xy = [aMid, -qy] # note, aMid has already been scaled self.plotter.plotLabel(self.ax, xy, label)
def _getForceVectorLengthPoint(self, forces, vectScale = 1): """ Gets the force vector length in terms of the drawing units. Force vectors will have a static component that doesn't change, and a dynamic component that adapts to the magnitude of forces. The output plotting forces are in the direction they act. """ fscale0 = 0.4 fstatic0 = 0.3 # Normalize forces forces = np.array(forces) signs = np.sign(forces) # The maximum force in each direction Fmax = np.max(np.abs(forces), 0) # Avoid dividing by zero later Inds = np.where(np.abs(Fmax) == 0) Fmax[Inds[0]] = 1 # Find all force that are zero. These should remain zero Inds0 = np.where(np.abs(forces) == 0) # Plot the static portion, and the scale port of the force fscale = fscale0*abs(forces) / Fmax fstatic = fstatic0*np.ones_like(forces) fstatic[Inds0[0],Inds0[1]] = 0 fplot = (fscale + fstatic)*signs return fplot*vectScale
[docs] def plotPointForces(self): """ Plots all point forces. Forces have a static portion to their length and dynamic portion. This means that arrows can't have length less than a certain value. This prevents small from being plotted that look silly. """ forces = [] xcoords = [] for force in self.beam.pointLoads: forces.append(force.P) xcoords.append(force.x / self.xscale) fplot = self._getForceVectorLengthPoint(forces) NLoads = len(forces) for ii in range(NLoads): Px, Py, Mx = fplot[ii] x = xcoords[ii] if (Px == 0 and Py ==0): # if it's a moment, plot it as a moment if Mx < 0: postive = True else: postive = False self.plotter.plotPointMoment(self.ax, (x,0), postive) else: self.plotter.plotPointForce(self.ax, (x - Px, -Py), (Px, Py)) return fplot
def _plotEleForce(self, box:EleLoadBox): Py = box.fout if (Py[0] == 0) and (Py[1] == 0): print("WARNING: Plotted load has no vertical component.") if box.isConstant: self.plotter.plotElementDistributedForce(self.ax, box) else: self.plotter.plotElementLinearForce(self.ax, box)
[docs] def normalizeData(self, data): return (data - np.min(data)) / (np.max(data) - np.min(data))
def _getLinFint(self, Ptmp): """ Contains the logic for finding the signs of Fint """ # fintTemp = list(self.normalizeData(Ptmp)) # If both are on the positive side if 0 < np.sign(Ptmp[0]) and 0 < np.sign(Ptmp[1]): if Ptmp[0] < Ptmp[1]: fintTemp = [Ptmp[0]/Ptmp[1], 1] elif Ptmp[0] == Ptmp[1]: # If equal the load acts like a constant load fintTemp = [1, 1] else: fintTemp = [1, Ptmp[1]/Ptmp[0]] Ptmp = [0, max(Ptmp)] # If both are on the negative side side elif np.sign(Ptmp[0]) < 0 and np.sign(Ptmp[1]) < 0 : if Ptmp[0] < Ptmp[1]: fintTemp = [0, Ptmp[1]/Ptmp[0]] elif Ptmp[0] == Ptmp[1]: # If equal the load acts like a constant load fintTemp = [0, 0] else: fintTemp = [1-Ptmp[0]/Ptmp[1], 0] Ptmp = [min(Ptmp), 0] # If the inputs change sign, just use the normalized value. else: fintTemp = list(self.normalizeData(Ptmp)) return Ptmp, fintTemp def _getEleForceBoxes(self): """ Handles all the logic of generating stacked object positions. """ eleBoxes = [] for load in self.beam.eleLoads: xDiagram = [load.x1 / self.xscale, load.x2 / self.xscale] if isinstance(load, EleLoadDist): # Constant Load # Adapt the load so it's a 2D vector Ptmp = [0, -load.P[1]] #!!! The sign is flipped to properly stack if -load.P[1] < 0: #!!! The sign is flipped to properly stack fintTemp = [0, 0] # start at the bottom if negative else: fintTemp = [1, 1] # start at the top if negative eleBoxes.append(EleLoadBox(xDiagram, Ptmp, fintTemp)) # Arbitary Distributed Load between two points elif isinstance(load, EleLoadLinear): Ptmp = -load.P[1] #!!! The sign is flipped to properly stack Ptmp, fintTemp = self._getLinFint(Ptmp) eleBoxes.append(EleLoadBox(xDiagram, Ptmp, fintTemp)) eleBoxes = _setForceVectorLengthEle(eleBoxes, vectScale = 0.4) stacker = Boxstacker(eleBoxes) eleBoxes = stacker.setStackedDatums() return eleBoxes
[docs] def plotEleForces(self): """ Plots all distributed forces. Only vertical forces can be plotted. If a horizontal component is supplied to the force, it is not included in the plot. """ eleBoxes = self._getEleForceBoxes() for box in eleBoxes: self._plotEleForce(box) fplot = [box.y for box in eleBoxes] xcoords = [box.x for box in eleBoxes] return fplot, xcoords
[docs] def plotBeamDiagram(beam, plotLabel = True, labelForce = False, plotForceValue = False, units = 'environment') -> (Figure, Axes): """ Creates a diagram of the created beam. Only certain fixities are supported for plotting, including free, roller (support only in y), pinned (support in x and y), and fixed (support in x/y/rotation). Only certain forces are supported for plotting - for distrubuted forces only the y component of the beam can be plotted. Note, the diagram has been rescaled so the beam has lenght scaled to the maximum beam size of 8. This is to make consistent plotting easier across a number of beam sizes, however, the matplotlib objects in the plot have different value than the actual beam. The resulting diagram is a matplotlib figure that can be further manipulated. Parameters ---------- beam : PlaneSections beam The Beam Object to be plotted. plotLabel : bool, optional A toggle that turns on or off plotting user defined labels along the beam. The default is True. labelForce : bool, optional A toggle that turns on or off if forces get labeled. If toggled to true, forces will be given a label instead of the beam location. The default is True. plotForceValue : bool, optional A toggle that turns on or off plotting the force values with the label. If set to true, the magnitude of the force will be plotted in the digram units. The default is False. units : str, optional A string that specified how the diagram units will be managed in the. The default value 'environment' causes the plot to read from the active environment. see :py:class:`planesections.DiagramUnitEnvironmentHandler` If not set to environment, it can have value: *'metric', 'metric_kNm', 'metric_Nm', 'imperial_ftkip', 'imperial_ftlb'* The unit handler for the plot. This The default is 'environment'. Returns ------- fig : matplotlib fig ax : matplotlib ax """ diagram = BeamPlotter2D(beam, units = units) diagram.plot(plotLabel, labelForce, plotForceValue) return diagram.fig, diagram.ax