Search
Programming Style 1

Programming Style 1

This book is intended to teach 3rd year Civil Engineering students about the design of structural steel components.

It is not intended to teach Python programming, though it is hoped that eventally they will be able to make small modifications to the notebooks and see the effect.

This document is an attempt to show alternative Python programming styles, and to compare them.

The base documents from which the students are working will almost always use single character identifiers to refer to various physical quantities. Therefore there will be a lot of overloading. For example, t is always a thickness, but context will tell what thickness it is referring to at any particular instant.

I think it important that the Python code mimic this as closely as is practical. But the important part is that the Python not get in the way of understanding the engineering.

Problem Characteristics

Problems are mostly to compute strengths of specialized physical objects made of structural steel.
These are the general characteristics:

  • The most simple problems will have 5 to 10 parameters (usually single numbers specifying a dimension of a part, or a material characteristic, or a force, or an option, etc.). The most complex problems may have up to 30 or 40 or so parameters.
  • In the most complex problems, these parameters may be spread over 5 or 10 different physical parts and a couple of different materials. Multiple parts could have dimensions that are typically referred to using the same name, for example thickness, and denoted using the same symbol, t.
  • Calculations involve fairly small amounts of logic to calculate each specific quantity. Usually that logic is easily expressible in only a few lines of Python.
  • Simple problems may have 5 sets of these calculations; complex problems may have 30 or 40.
  • The notebook structure is ideal for this, with one calculation set per cell and explanatory material between.
  • This is all to support what we call "design" - determining the proper sizes of an object for structural safety. In all but the simplest cases, this is a trial and error process: guess at a set of parameters, calculate a strength; if not adequate, change something and do it all over.
  • Mostly, the calculations are specified by very specific and precise rules and formulae that cover many common cases. In our case, the document is the CSA S16-14 Design of Steel Structures, supplemented with lecture material and other sets of information.

Below we will perform one typical set of calculations to determine the tension strength with respect to a complete fracture of the cross-section (there are other failure modes). In the vocabulary of the profession, we are determining the 'Factored Tension Resistance' of a bolted tension member

We will show a number of different ways of structuring the Python to do this, and discuss the relative merits of each.

My favorite is currently Alternative 4b - using the 'with' statement.



Typical Data Definition

I think we can agree that we will not use simple global variables to refer to various physical quantities. In a more complex problem than shown here, we would have t1, t2, t3 or t_angle, t_gusset, t_plate, etc. to refer to the various thicknesses. It becomes unwieldy and makes the code less re-usable.

So the first step is to use normal Python objects, and use attribute values to store the data. These are essentially just a way of providing multiple namespaces (i.e., associating a particular value of t with the correct object).

class Part:
    def __init__(self,doc,**kw):
        self.__doc__ = doc
        self.__dict__.update(kw)
Steel = Part( "Material for Angles",
            grade = "CSA G40.21 350W",
            Fy = 350,      # yield strength, MPa = N/mm^2
            Fu = 450,      # ultimate strength, MPa
            )

AngleB7 = Part( "Brace B-7",
            size = "L152x102x16",
            d = 152,        # width of longest leg, mm
            b = 102,        # width of shortest leg
            t = 15.9,       # thickness
            A = 3780,       # gross cross-sectional area, mm^2
            hd = 24,        # bolt hole diameter allowance
            nbolts = 4,     # number of bolts in direction of load
            )
phi = 0.9        # CSA S16-14 13.1
phiu = 0.75

Alternative 1:

We just refer to attributes of the objects using the normal Python dot notation. Nothing special.

An = AngleB7.A - AngleB7.hd*AngleB7.t  # net x-sect area is gross area minus allowance for one hole
if AngleB7.nbolts >= 4:         # CSA S16-14 12.3.3.2 b)
    Ane = 0.8*An                #                        i)
else:
    Ane = 0.6*An                #                        ii)
Tr = phiu*Ane*Steel.Fu
Tr * 1E-3    # convert to kN
917.5680000000001

Alternative 1u:

Essentially the same as the code above, but use the pint units module to associate units with the quantities. This makes it more readable and explicit for students and safer (the unit conversions, if required, are explicit) Compare this with the implicit conversion requiring a comment, above.

import pint                  # setup to use the module for computing with units
ureg = pint.UnitRegistry()
mm = ureg['mm']
kN = ureg['kN']
MPa = ureg['MPa']
ureg.default_format = '~P'
Steel = Part( "Material for Angles",
            grade = "CSA G40.21 350W",
            Fy = 350*MPa,      # yield strength
            Fu = 450*MPa,      # ultimate strength
            )

AngleB7 = Part( "Brace B-7",
            size = "L152x102x16",
            d = 152*mm,        # width of longest leg
            b = 102*mm,        # width of shortest leg
            t = 15.9*mm,       # thickness
            A = 3780*mm*mm,    # gross cross-sectional area
            hd = 24*mm,        # bolt hole diameter allowance
            nbolts = 4,        # number of bolts in direction of load
            )
An = AngleB7.A - AngleB7.hd*AngleB7.t  # net x-sect area is gross area minus allowance for one hole
if AngleB7.nbolts >= 4:         # CSA S16-14 12.3.3.2 b)
    Ane = 0.8*An                #                        i)
else:
    Ane = 0.6*An                #                        ii)
Tr = phiu*Ane*Steel.Fu          # CSA S16-14 13.2 a) iii)
Tr.to(kN)
\[917.5680000000001\ kN\]

Pros:

  • bog-standard Python, no additional libraries/concepts required. Students should be able to understand all the Python bits after their first course.
  • small number of global variables (2, in this case), with smaller chance of them being inadvertently stepped on in the notebook.
  • very explicit. there is no doubt what part the t belongs to in the first line.

Cons:

  • harder to read with all those extra (and usually extraneous) identifiers in the expressions.
    As expressions get more complex, this gets worse.
  • detracts from re-useability of the code. If this is copied to serve another part, it will have to be edited. Defining functions to do the calculations is problematic as well - see Alternative xx.

Alternative 2a:

Extract the required attribute values into global variables at the start of each cell.

Ag = AngleB7.A
hd = AngleB7.hd
t = AngleB7.t
n = AngleB7.nbolts
Fu = Steel.Fu

An = Ag - hd*t                # net x-sect area is gross area minus allowance for one hole
if n >= 4:                    # CSA S16-14 12.3.3.2 b)
    Ane = 0.8*An
else:
    Ane = 0.6*An
Tr = phiu*Ane*Fu              # CSA S16-14 13.2 a) iii)
Tr.to(kN)
\[917.5680000000001\ kN\]

Pros:

  • pretty well the same as the above Alternative 1, plus
  • the actual computation expressions are much more similar to the actual expressions in S16. For example, the computation of $T_r$ is specified as '$T_r = \phi_u A_{ne} F_u$' in S16 and expressed in the code as Tr = phiu*Ane*Fu. The mapping from one to the other is pretty straightforward. The code is much easier to read than in Alternative 1.

Cons:

  • adds quite a few lines of code to each cell - most of it not conceptually very useful (though the mapping from attribute to variable is useful knowledge for the reader).
  • this is a biggy -- we have introduced and rely on global variables, even though it is intended that the scope of most of these (Fu for example) extend only over the one cell.
  • Global variables are easy to forget. If we do a computation later that requires a different t from a different part and if we forget to extract it properly, the wrong value will be silently used. This is deadly.

Alternative 2b:

We can arrange some simple functionality so that the extraction of attributes to global variables can be done more compactly.

def get(names,obj):
    return [getattr(obj,name.strip()) for name in names.split(',')]
Ag,hd,t,n = get('A,hd,t,nbolts',AngleB7)
Fu = Steel.Fu

An = Ag - hd*t                # net x-sect area is gross minus allowance for one hole
if n >= 4:                    # CSA S16-14 12.3.3.2 b)
    Ane = 0.8*An
else:
    Ane = 0.6*An
Tr = phiu*Ane*Fu              # CSA S16-14 13.2 a) iii)
Tr.to(kN)
\[917.5680000000001\ kN\]

Pros:

  • pretty well the same as above, plus
  • fairly compact and explicit extraction of the values needed for this cell.
  • not a lot of conceptual overhead. It is pretty easy to learn what the 'get' function does, and how multiple values are assigned to multiple variables, so students should be able to work past this with little trouble.
  • variables can be renamed to better suit the problem (eg 'Ag' vs 'A' and 'n' vs 'nbolts').

Cons:

  • As above. Global variables are evil when they shouldn't be global.

Alternative 2c

Here we make the get function a bit more complex in order to provide a sort of 'inheritance'. We can now provide a list of one or more parts, and these are effectively searched to provide the attribute values.

def get(names,*objs):
    _d = {}
    for obj in reversed(objs):   # reverse order makes leftmost highest priority
        _d.update(obj.__dict__)
    return [_d[name.strip()] for name in names.split(',')]

The above implementation effectively has the search for each attribute proceeding from left to right. If an attribute is found in more than one object, the leftmost object is used. I don't know if this is correct.

Ag,hd,t,n,Fu = get('A,hd,t,nbolts,Fu',AngleB7,Steel)

An = Ag - hd*t                # net x-sect area is gross area minus allowance for one hole
if n >= 4:                    # CSA S16-14 12.3.3.2 b)
    Ane = 0.8*An
else:
    Ane = 0.6*An
Tr = phiu*Ane*Fu              # CSA S16-14 13.2 a) iii)
Tr.to(kN)
\[917.5680000000001\ kN\]

Pros:

  • much the same as the other Alternative 2s, above, plus:
  • ability to get some inheritance. For example, Steel is to give the properties of all angles, except Fu could be transparently over-ridden by AngleB7 - a sort of inheritance.

Cons:

  • pretty much the same as Alternative 2a and 2b.

Alternative 4a

Make a context manager that will be executed by the Python with statement. The context manager will inject all attribute values into the global namespace at the beginning of the block, and undo all those changes at the end of the block. Could even make them produce warnings if values are over-ridden (for example, if t was aleady defined before the beginning of the block) (we don't currently do that).

class Set_and_Clean:
    def __init__(self,dct,parts):
        self.dct = dct
        self.parts = parts
        self._reset()
    def _reset(self):
        self.oldvars = {}
        self.newvars = []
    def __enter__(self):
        self._reset()
        ns = get_ipython().user_ns
        for k,v in self.dct.items():
            if k in ns:
                self.oldvars[k] = ns[k]
            else:
                self.newvars.append(k)
            ns[k] = v
    def __exit__(self,*args):
        ns = get_ipython().user_ns
        for k,v in self.oldvars.items():
            ns[k] = v
        for k in self.newvars:
            del ns[k]
            self._reset()
        return False

def setvars(*parts):
    d = {}
    for p in parts:
        d.update(p.__dict__)
    return Set_and_Clean(d,parts)
A = hd = t = nbolts = Fu = None   # demo that these will get set properly and unset

In the following, the setvars() function returns a context manager that injects all attribute values as global variables at the beginning of the block, and removes them and restores previous values at the end.

with setvars(Steel,AngleB7):
    An = A - hd*t             # net x-sect area is gross area minus allowance for one hole
    if nbolts >= 4:           # CSA S16-14 12.3.3.2 b)
        Ane = 0.8*An
    else:
        Ane = 0.6*An
    Tr = phiu*Ane*Fu          # CSA S16-14 13.2 a) iii)
Tr.to(kN) 
\[917.5680000000001\ kN\]
A,hd,t,nbolts,Fu    # they were restored back to their original values
(None, None, None, None, None)

Pros:

  • by far the most compact and least textual overhead.
  • scope of some variables is limited. The value of t used in this block disappears at the end of the block. This may be HUGELY good. (Although new variables like Ane are still permanently injected into the global namespace).
  • as the objects in the with statement are processed left-to-right, we get an automatic inheritance mechanism (Angle87 over-rides the same named attributes from Steel). This can also be a bad thing, of course. And in this implementation, the rightmost object 'wins' which is a different precedence from class inheritance. Though we could change setvars() to give leftmost objects precence, just like class inheritance.

Cons:

  • fair bit of conceptual overhead to understanding this. It is probably 'advanced' Python. Students will probably not see this in a first course taught to engineers.
  • cannot rename the variables - must use the attribute names defined in the object (this may actually be a good thing).
  • source of values is not explicit. For example, where does t come from? From reading just this, you cannot tell whether it is Steel.t or AngleB7.t
  • BIG! With var names being implicit, its far too easy to get the wrong values. I had:

      with LapPlates,Bolts:
          wn = W - nperline*ha
          Ane = An = wn*T
          Tr = phiu*Ane*Fu          # S16-14: 13.2 a) iii)
          REC(Tr,'Lap Plates, Net Fracture','W,T,ha,wn,phiu,Ane,Fu')
    
    

    and Fu was taken from Bolts, whereas it should have been taken from LapPlates. I guess we need to make the variables explicit for each one.

Alternative 4b

In Python, it is usually thought that Explicit is better than implicit. Lets change the implementation of setvars to take an explicit list of symbols. We'll pull out all the stops and allow explicit renaming and simple expression evaluation. And also fix the name conflict to use the leftmost.

def setvars2(names,*parts):
    _d = {}
    dct = {}
    for p in reversed(parts):
        _d.update(p.__dict__)
    ns = get_ipython().user_ns
    for name in [x.strip() for x in names.split(',')]:
        if name:
            if '=' in name:
                name,rhs = [x.strip() for x in name.split('=',1)]
                val = eval(rhs,ns,_d)
            else:
                val = _d[name]            
            dct[name] = val
    return Set_and_Clean(dct,parts)
with setvars2('t,hd,Fu,n=nbolts,Ag=A',Steel,AngleB7):
    An = Ag - hd*t         # net x-sect area is gross area minus allowance for one hole
    if n >= 4:           # CSA S16-14 12.3.3.2 b)
        Ane = 0.8*An
    else:
        Ane = 0.6*An
    Tr = phiu*Ane*Fu          # CSA S16-14 13.2 a) iii)
Tr.to(kN) 
\[917.5680000000001\ kN\]

Pros:

  • about the same as above, plus
  • variables extracted and set are now explicit at the top of each block, with chance for some error checking

Cons:

  • tiny bit more complexity / conceptual overhead. But I think outweighed by clarity and explictness.

Can even have multiple calls to setvars() so you know exactly where each variable comes from. This will change the precedence order when the same attribute is defined in more than object, but at least it is explicit.

with setvars2('Fu',Steel),setvars2('t,hd,n=nbolts,Ag=A,N=2',AngleB7):
    An = Ag - hd*t         # net x-sect area is gross area minus allowance for one hole
    if n >= 4:           # CSA S16-14 12.3.3.2 b)
        Ane = 0.8*An
    else:
        Ane = 0.6*An
    Tr = phiu*Ane*Fu * N         # CSA S16-14 13.2 a) iii)
Tr.to(kN)
\[1835.1360000000002\ kN\]

Alternative 5

Have all computations done in a function, where the parameter list names the local values. Then you can write a little utility that automatically extracts the values from the objects:

import inspect

def call(fn,*objs,**kw):
    _d = {}
    for o in reversed(objs):
        _d.update(o.__dict__)
    argspec = inspect.getfullargspec(fn)
    args = [(arg,kw.get(arg,arg)) for arg in argspec.args]
    dct = {}
    for argname,attrname in args:
        if attrname in _d:
            dct[argname] = _d[attrname]
    return fn(**dct)
def fun1(Ag,hd,t,n,Fu=350):
    An = Ag - hd*t       # net x-sect area is gross minus allowance for one hole
    if n >= 4:           # CSA S16-14 12.3.3.2 b)
        Ane = 0.8*An
    else:
        Ane = 0.6*An
    Tr = phiu*Ane*Fu     # CSA S16-14 13.2 a) iii)
    return Tr.to(kN)

call(fun1, Steel, AngleB7, n='nbolts', Ag='A')   # or something like this
\[917.5680000000001\ kN\]

Pros:

  • explicitly limited scope of variables, including newly created ones like Ane.
  • can nicely provide default values
  • reasonably easy to remap attribute names ('nbolts' to 'n', for eg).

Cons:

  • Not expecially simple - adds a bit of conceptual overhead - perhaps more so than some of the above.
  • difficult to tell where values come from. Does 'Fu' come from Steel or AngleB7?
  • I don't like it.

Random notes

  1. The problem is in mapping general concepts, like $t$ and thickness, to symbols used in the code, without introducing too much baggage.
  2. we are really trying to get cell-level scope for certain variables.
  3. or Cell-level namespaces.
  4. I wonder if this could be down with 'magics'?
  5. the context manager could interact with the DesignNotes object - maybe make it a method. Could automatically capture before and after variable values, the 'design var' value, note any changed variables, including the design var, etc... Have option to trace variables in addition to those set. print a trace, etc - just like RECORD ... I LIKE THIS!!!!!

    notes = DesignNotes('Tr',trace=True,title='Big Hairy Brace')
     SET = notes.setvars
     ...
     with SET('x,y',Foo,Bar,...,title='Bearing Resistance',extra='z'):
         Br = x+y+z
         Tr = Br
    

    In fact, maybe the design object itself is the context maanager - no need for additional class. In fact, maybe this SET method could be used outside a with with slightly reduced functionality.

    Could also have a list of symbols that are not initialized at beginning but are removed at end ('Ane' for example). Maybe this is the same list as 'extra'?

    Also need an option NOT to log anything (sometimes want nested WITH statements so they dont get too long).