7
\$\begingroup\$

I've written these two command-line tools to be used to facilitate forestry surveys.

For a little background, the idea here is that often a landowner needs to know precisely how much wood is coming out of his dirt. Not only that, but he has to account for the fact that you can't turn a cylinder (trees) into rectangular prisms (boards) without a bit of loss.

So what the landowner does is he pays my client to come down and run around the woods with a bit of measuring tape, taking the circumferences of trees. He then apparently works out the radius in his head (I hope that's what he does, otherwise everything he's ever done is completely wrong) and shouts it into a radio along with the species and how tall he believes the tree is (in 16-foot logs). Sometimes, such as in the case of a tree that has two trunks, he'll call out more than one of the same sort of tree at one time.

The computer operator - that's me - then types a three-part statement into the computer containing a short arbitrary string that identifies the tree species ("WO" for White Oak, "ASH" for Ash, "YGG" for Yggdrasil, etc...), the diameter, and the length in 16-foot logs or halves thereof. If the forester called in more than one of the same kind of tree, I can also add a number on the end that tells the program how many of the specified tree I want to add.

This is all saved to disk. Once we're back at home base, we can take the data file the tallying program produced and feed it into the analysis program, which computes (using a formula derived from the original table the client used to do this process by hand, itself derived from arcane magics) the usable board footage of each variety of tree and lists the total board footage, total number of trees, board footage by species, board footage by species and size (one log that the sawmill can produce two boards from is worth more than two boards the 'mill can produce one from), etc. etc.

The client was very (indeed, quite irrationally) concerned about the reliability of the program, so I decided the best way to store the data was to keep it in memory for as short a time as possible, and instead save the tree data to disk every time a new tree was entered. That way, if there's a program crash or power failure or the computer's thermal safety goes off, nothing is lost (with the cost of a slight increase in the risk that the power will go out while the machine is writing to the disk, thus blanking/trashing the file). Eventually I might switch to a journaling system or Python's sqlite implementation, but at design-time I decided that I'd be better off with my own simple system, with the option of converting later on if it proves to be a good idea.

I've never really showed my code to anyone before, certainly never anyone who knew what they were doing, so I'm excited to see what my comrades think of my first "professional" bit of work!

This is the tallying program that actually takes in the tree data:

'''
Created on Nov 2, 2013

@author: Schilcote
'''

def load_trees():
    trees=dict()
    try:
        with open("trees.txt","r") as treefil:
            while True:
                lin=treefil.readline()
                if lin=="":
                    break
                species, count = lin.split()
                trees[species]=int(count)
    except FileNotFoundError:
        return dict()
    return trees

def save_trees(trees):
    with open("trees.txt","w") as treefil:
        for thetree in trees:
            treefil.write(thetree+" "+str(trees[thetree])+"\n")
    return

def main():

    print("Autotally V0")
    print("Ready")

    while True:
        cmd=input(">").upper()
        cmdtup=list(cmd.split())

        print (cmdtup)
        if cmdtup[0]=="LIST":
            print(load_trees())
        else:       
            trees=load_trees()

            if len(cmdtup) >= 3:
                cmdtup.append(1)

            key=cmdtup[0]+"_"+cmdtup[1]+"_"+cmdtup[2]
            try:
                trees[key]=int(trees[key])+int(cmdtup[3])
            except KeyError:
                trees[key]=int(cmdtup[3])



            try:
                print("Inserted "+str(cmdtup[3])+" "+str(cmdtup[0])+" of diameter "+str(cmdtup[1])+" with "+str(cmdtup[2])+" logs.")
            except IndexError:
                print("Inserted "+str(cmdtup[0])+" of diameter "+str(cmdtup[1])+" with "+str(cmdtup[2])+" logs.")
            save_trees(trees)


if __name__ == '__main__':
    main()

And this is the analysis side of it:

'''
Created on May 31, 2014

@author: Schilcote
'''
import collections
import pprint

def scribner(diameter,length):
    "Convert length and diameter to board-feet via the Scribner method. WARNING: Only valid for measurements at top of tree. Do not use."
    a = (0.0494 * diameter * diameter * length) 
    b = (0.124 * diameter * length) 
    c = (0.269 * length)
    return a-b-c

def regressive_scribner(d,l):
    "Convert length (in feet) and diameter (in inches) to board-feet via an equation derived from the Scribner tables. Accurate to around 10 board feet."
    v = 0.0942919095863512*d**2 + 0.0231348479474668*l*d**2 - 16.494587251523 - 0.119077488412871*l - 0.00210681861605682*d*l**2
    return v

def tree_to_dl(tree):
    "Takes a string tree representation and turns it into a tuple of (species, diameter, logs)"
    species, diameter, logs = tree.split("_")
    diameter=int(diameter)
    logs=float(logs)
    return species, diameter, logs

from treetally import load_trees

if __name__ == '__main__':
    trees=load_trees()

    totaltrees=sum(trees.values())
    print("# of trees:",totaltrees)

    #We want all these dicts to start out with values of zero for every type of tree;
    #so we set their default_factory to a lambda that simply returns 0
    retzero=lambda: 0
    species_counts=collections.defaultdict(retzero)
    spec_diam_counts=collections.defaultdict(retzero)
    species_bf=collections.defaultdict(retzero)
    spec_diam_bf=collections.defaultdict(retzero)

    for tree, count in trees.items():
        spec, diam, logs = tree_to_dl(tree)
        species_counts[spec]+=count
        spec_diam_counts[(spec,diam)]+=count

        bf=scribner(diam,int(logs*16)) #Scribner wants feet; 16 feet to a log
        species_bf[spec]+=int(bf)*count
        spec_diam_bf[(spec, diam)]+=int(bf)*count

    totalbf=int(sum(species_bf.values()))
    print("Total board feet:",totalbf)
    print("Avg. BF per tree: ",(totalbf//totaltrees))

    print("Board feet by species:")
    pprint.pprint(species_bf)

    print("Board feet by species & diameter:")
    pprint.pprint(spec_diam_bf)

    print("Counts by species:")
    pprint.pprint(species_counts)

    print("Counts by species and diameter:")
    pprint.pprint(spec_diam_counts)

A few notes on the criticisms I anticipate:

  1. Yes, it's not compliant with PEP8. I don't find PEP8 to be particularly pleasant to write or read, however, so pleh. This code isn't intended to be worked on by anyone but myself, so I just did what I find the most readable.
  2. I probably shouldn't leave disused functions just lying around. The failed scribner() was the product of hours of research, however, so I decided I ought to keep it around in the conceivable case I worked out a more elegant method than my regression-based formula.
  3. I realize that hardcoding the filename is unpythonic and generally unwise, but the impetus here was to get something the client could look at out the door as fast as possible, and as far as I know Python doesn't have that one-line command to invoke the Windows file selector that Blitz Plus has.

Thoughts?

\$\endgroup\$
10
\$\begingroup\$
def load_trees():
    trees=dict()

You only use this variable in the case that you successfully opened the file. I'd move it after the open.

    try:
        with open("trees.txt","r") as treefil:

I recommend not shortening names like this. Call it tree_file it not really any serious amount of more typing, but its way easier to see what it is.

            while True:
                lin=treefil.readline()
                if lin=="":
                    break

The line can be replaced by for lin in treefile:. It'll give you each line in the file one at a time.

                species, count = lin.split()
                trees[species]=int(count)

What if the file is messed up and count isn't a number here? You may very well simply not care.

    except FileNotFoundError:
        return dict()
    return trees

I recommend moving return trees into the try: block.

def save_trees(trees):
    with open("trees.txt","w") as treefil:
        for thetree in trees:
            treefil.write(thetree+" "+str(trees[thetree])+"\n")
    return

This line does nothing.

def main():

    print("Autotally V0")
    print("Ready")

    while True:
        cmd=input(">").upper()
        cmdtup=list(cmd.split())

        print (cmdtup)

You note that you aren't following PEP8. That's okay. But you should probably be consistent. Here you've got an extra space after print, but not for any other function.

        if cmdtup[0]=="LIST":
            print(load_trees())

The output of printing a python dict would seem rather programmer friendly, not use friendly. I can't imagine that the output of this statement is very useful.

        else:       
            trees=load_trees()

            if len(cmdtup) >= 3:
                cmdtup.append(1)

Why >=? If the user types 5 parameters, you want to add a sixth? It seems to me that you really want is ==. That is if the user only passes three parameters, add a fourth.

            key=cmdtup[0]+"_"+cmdtup[1]+"_"+cmdtup[2]

For keys in internal data structures, its probably best to use a tuple, not a string. It's just less awkward to work with.

            try:
                trees[key]=int(trees[key])+int(cmdtup[3])
            except KeyError:
                trees[key]=int(cmdtup[3])

Why don't you use a defaultdict here?

What happens when the user passes 2 or 4 parameters? Your program will either die or do the wrong thing.

            try:
                print("Inserted "+str(cmdtup[3])+" "+str(cmdtup[0])+" of diameter "+str(cmdtup[1])+" with "+str(cmdtup[2])+" logs.")
            except IndexError:
                print("Inserted "+str(cmdtup[0])+" of diameter "+str(cmdtup[1])+" with "+str(cmdtup[2])+" logs.")

So, cmd_tup already contains strings, there is no need to pass it to str again. In what situations would this throw an IndexError?

            save_trees(trees)


if __name__ == '__main__':
    main()

def scribner(diameter,length):
    "Convert length and diameter to board-feet via the Scribner method. WARNING: Only valid for measurements at top of tree. Do not use."
    a = (0.0494 * diameter * diameter * length) 
    b = (0.124 * diameter * length) 
    c = (0.269 * length)
    return a-b-c

def regressive_scribner(d,l):
    "Convert length (in feet) and diameter (in inches) to board-feet via an equation derived from the Scribner tables. Accurate to around 10 board feet."
    v = 0.0942919095863512*d**2 + 0.0231348479474668*l*d**2 - 16.494587251523 - 0.119077488412871*l - 0.00210681861605682*d*l**2
    return v

def tree_to_dl(tree):
    "Takes a string tree representation and turns it into a tuple of (species, diameter, logs)"
    species, diameter, logs = tree.split("_")
    diameter=int(diameter)
    logs=float(logs)
    return species, diameter, logs

from treetally import load_trees

if __name__ == '__main__':
    trees=load_trees()

    totaltrees=sum(trees.values())
    print("# of trees:",totaltrees)

    #We want all these dicts to start out with values of zero for every type of tree;
    #so we set their default_factory to a lambda that simply returns 0
    retzero=lambda: 0

Actually you can use int for this purpose. int() returns 0. Furthermore, collections.Counter() is actually even better.

    species_counts=collections.defaultdict(retzero)
    spec_diam_counts=collections.defaultdict(retzero)
    species_bf=collections.defaultdict(retzero)
    spec_diam_bf=collections.defaultdict(retzero)

    for tree, count in trees.items():
        spec, diam, logs = tree_to_dl(tree)
        species_counts[spec]+=count
        spec_diam_counts[(spec,diam)]+=count

        bf=scribner(diam,int(logs*16)) #Scribner wants feet; 16 feet to a log
        species_bf[spec]+=int(bf)*count
        spec_diam_bf[(spec, diam)]+=int(bf)*count

    totalbf=int(sum(species_bf.values()))
    print("Total board feet:",totalbf)
    print("Avg. BF per tree: ",(totalbf//totaltrees))

Why round for the average?

    print("Board feet by species:")
    pprint.pprint(species_bf)

    print("Board feet by species & diameter:")
    pprint.pprint(spec_diam_bf)

    print("Counts by species:")
    pprint.pprint(species_counts)

    print("Counts by species and diameter:")
    pprint.pprint(spec_diam_counts)

General thoughts:

  1. Storing your data in a one-off format is not generally the best idea. Instead, I'd suggest using the csv or json modules to write the data out in a standard format. This should reduce the amount of code you have to write, and make it easier to work with other data. For example, you can easily import csv into a spreadsheet.

  2. It's a little odd to make this a command line tool rather then a GUI or web tool. Of course, that's more work, so maybe ts not worthwhile.

  3. Your code was pretty straightforward to follow. That's good.

\$\endgroup\$
  • \$\begingroup\$ Used the string keys because I wanted to have the identifiers be uniform across the tally-er, processor, and the files they used to communicate. You're probably right that I should have used CSV to start with, but hindsight is 20-20, especially when it comes to programming. If the file is corrupt, the file is corrupt; trying to extract useful data from a damaged file would be a bit of a waste of effort; either way, you're gonna have to do the whole job over again. I int-divided the average mainly because no-one talks in fractions of a board foot, and the program isn't that accurate anyway. \$\endgroup\$ – Schilcote May 31 '14 at 20:47
  • \$\begingroup\$ The rest of the suggestions are great, and I've implemented them just now. \$\endgroup\$ – Schilcote May 31 '14 at 20:48

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.