#!/usr/bin/env python # -*- coding: utf-8 -*- # *************************************************************************** # * Copyright (C) 2013, Paul Lutus * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU General Public License as published by * # * the Free Software Foundation; either version 2 of the License, or * # * (at your option) any later version. * # * * # * This program is distributed in the hope that it will be useful, * # * but WITHOUT ANY WARRANTY; without even the implied warranty of * # * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * # * GNU General Public License for more details. * # * * # * You should have received a copy of the GNU General Public License * # * along with this program; if not, write to the * # * Free Software Foundation, Inc., * # * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * # *************************************************************************** import re,sys,os,subprocess,optparse from math import * PROGRAM_VERSION = '1.4' # a simple Cartesian data pair class class Cart: def __init__(self,x,y): self.x = float(x) self.y = float(y) def __repr__(self): return '{%f,%f}' % (self.x,self.y) # a class that creates an HTML or PDF page class OutputFormatter: css_style_block = """ """ xml_tab_str = ' ' def wrap_tag(self,tag,data,mod = ''): if(len(mod) > 0): mod = ' ' + mod return '<%s%s>\n%s\n\n' % (tag,mod,data,tag) def beautify_xhtml(self,data,otab = 0): tab = otab xml = [] data = re.sub('\n+','\n',data) for record in data.split('\n'): record = record.strip() outc = len(re.findall('',record)) inc = len(re.findall('<\w',record)) net = inc - outc tab += min(net,0) xml.append((self.xml_tab_str * tab) + record) tab += max(net,0) if(tab != otab): sys.stderr.write('Error: tag mismatch: %d\n' % tab) return '\n'.join(xml) + '\n' def encode_xhtml(self,title,extra,array): table = '' row_num = 0 for record in array: row = '' for field in record: field = field.strip() # strip quotes field = re.sub('"','',field) # replace blank field with   field = re.sub('^$',' ',field) row += self.wrap_tag(('td','th')[row_num == 0],field) row_num += 1 mod = ('class="cell%d"' % (row_num % 2),'')[row_num == 0] table+= self.wrap_tag('tr',row,mod) table = self.wrap_tag('table',table, 'cellpadding="0" cellspacing="0" border="0"') footer = 'Created using ' footer += self.wrap_tag('a','TankProfiler','href=\"http://arachnoid.com/TankProfiler\"') footer += ' — copyright © 2012, P. Lutus' if(extra): extra = re.sub('\n','
\n',extra) table = self.wrap_tag('p',extra) + table if(title): table = self.wrap_tag('h3',title) + table table += self.wrap_tag('p',footer) page = self.wrap_tag('div',table,'align="center"') page = self.wrap_tag('body',page) head = '' if(title): head += self.wrap_tag('title',title) page = self.wrap_tag('head',head + self.css_style_block) + page page = self.wrap_tag('html',page) page = self.beautify_xhtml(page) return page # must have 'wkhtmlpdf' PDF formatter installed: # http://code.google.com/p/wkhtmltopdf/ def encode_pdf(self,html_title,extra,array): try: out = self.encode_xhtml(html_title,extra,array) p = subprocess.Popen( ['wkhtmltopdf','-q','--footer-center', '[page]/[toPage]', '-', '-'] ,stdin=subprocess.PIPE ,stdout=subprocess.PIPE ,stderr=subprocess.PIPE) stdoutdata, stderrdata = p.communicate(out) return stdoutdata except Exception as e: sys.stderr.write('Error: %s (possibly no pdf converter present)\n' % e) return '' class TankProfiler: # read file/stream containing tank profile # in a robust, tolerant way def read_profile(self,path): profile = [] if(path == '-'): data = sys.stdin.read() else: with open(path,'r') as f: data = f.read() n = 0 a = None b = 0 # strip comment lines data = re.sub('(?sm)^#.*?$','',data) # split on non-numeric chars array = re.split('(?is)[^\d\.e+-]+',data) for s in array: try: b = float(s) if(a != None and n % 2 != 0): profile.append(Cart(a,b)) a = b n += 1 except: None return profile # circle area partitioned by y # -r <= y <= r def circle_sec(self,y,r): if(r == 0): return 0 else: if(y > r): return pi * r * r elif(y < -r): return 0 else: y /= r return (y * sqrt(1-y*y) + acos(-y)) * r * r # full tank volume, closed-form # treat each section as a cylinder if a.y == b.y # or a conical frustum if not def full_volume(self): a = False s = 0 for b in self.profile: # if paired and nonzero length if(a and a.x != b.x): length = b.x - a.x # if a cylindrical section if(a.y == b.y): s += pi * a.y*a.y * length else: # conical frustum s += pi * length * (a.y * a.y + a.y * b.y + b.y * b.y) / 3.0 a = b return s # partial frustum volume partitioned by y def partitioned_frustum(self,r_a,r_b,h,y): return ((3*h**2*r_a**2*y + (r_a**2 - 2*r_a*r_b + r_b**2)*y**3 - 3*(h*r_a**2 - h*r_a*r_b)*y**2)*pi/h**2) / 3.0 # frustum surface area # from http://mathworld.wolfram.com/ConicalFrustum.html # this area doesn't include the top and bottom disc areas # but it does manage the extreme cases of no height (disk) # and same radii (cylinder) def frustum_area(self,ra,rb,h): return pi * (ra+rb) * sqrt((ra-rb)**2 + h**2) # tank surface area including possible unclosed initial and final radii def tank_surface_area(self): a = False area = 0 for b in self.profile: if(a): area += self.frustum_area(b.y,a.y,b.x-a.x) else: # add initial nonzero radius area += pi * b.y**2 a = b # add final nonzero radius area += pi * b.y**2 return area # numerical integral horizontal volume partitioned by y def num_vol_horiz(self,y): a = False s = 0 for b in self.profile: # if paired and nonzero length if(a and a.x != b.x): length = b.x - a.x # if a cylindrical section if(a.y == b.y): s += self.circle_sec(y,a.y) * length # else model numerically else: ss = 0 step = (b.y - a.y) / self.intsteps r = a.y + step * 0.5 for i in range(int(self.intsteps)): ss += self.circle_sec(y,r) r += step s += ss * length / self.intsteps a = b return s # numerical integral vertical volume partitioned by y def num_vol_vert(self,y): a = False s = 0 for b in self.profile: # only include volumes below y level if(a and a.x != b.x and (b.x < y or a.x < y)): span = ((y >= a.x and y <= b.x) or (y >= b.x and y <= a.x)) if(span): if(b.x < a.x): height = b.x - y else: height = y - a.x else: height = b.x - a.x # if a cylindrical section if(a.y == b.y): s += pi * a.y * a.y * height # else model as conical frustum else: if(span): s += self.partitioned_frustum(a.y,b.y,b.x - a.x,height) else: s += pi * height * (a.y * a.y + a.y * b.y + b.y * b.y) / 3 a = b return s # root finder for y from volume argument def num_y(self,v,f): epsilon = 1e-8 y = 0 dy = self.ymax while(abs(dy) > epsilon): dv = f(y) dy *= 0.5 y += (dy,-dy)[dv>v] return y def emit_prog(self): if(self.show_prog): sys.stderr.write('.') sys.stderr.flush() def print_format(self,a,b,m): return ('%.*f,%.*f,%.*f' % (self.decimals,a,self.decimals,b,self.decimals,b*100/m)) def to_array(self,s): self.data_table.append(s.split(',')) def height_to_volume(self,y,ff): v = ff(y) * self.vol_mult return self.print_format(y+self.soffset, v / self.conv+self.voffset, self.fv * self.vol_mult+self.voffset) def height_table(self,ff): fs = self.height_to_volume(self.ymax,ff) y = self.ymin while(y <= self.ymax): self.emit_prog() s = self.height_to_volume(y,ff) self.to_array(s) y += self.step if(fs != s): self.to_array(fs) def volume_to_height(self,v,ff): y = self.num_y(v * self.conv,ff) return self.print_format( v * self.vol_mult+self.voffset, y+self.soffset, self.ymax+self.soffset) def volume_table(self,ff): fs = self.volume_to_height(self.fv,ff) s = 0 v = 0 while(v <= self.fv): self.emit_prog() s = self.volume_to_height(v,ff) self.to_array(s) v += self.step if(fs != s): self.to_array(fs) def fill_plot(self,plt,px,py): plt.plot(py,px,'-',color='blue') plt.fill(py,px,color='#f0f0f0') if(self.plot_points): plt.plot(py,px,'o',color='red') # must have matplotlib installed def show_save_graph(self): import matplotlib.pyplot as plt # set 1:1 aspect ratio plt.figure().add_subplot(111).set_aspect(1) px = [] py = [] yl = 1e30 yh = -1e30 xl = 1e30 xh = -1e30 # draw positive-coordinate side for p in self.profile: xl = min(xl,p.x) xh = max(xh,p.x) yl = min(yl,-p.y) yh = max(yh,p.y) px.append(p.x) py.append(p.y) # draw opposite side for p in self.profile[::-1]: px.append(p.x) py.append(-p.y) # close the figure px.append(p.x) py.append(p.y) border = (xh-xl) / 10 if(self.vmode): plt.ylim(xl - border,xh + border) plt.xlim(yl - border,yh + border) plt.plot([0,0],[xl-border,xh+border],'-',color='green') self.fill_plot(plt,px,py) else: plt.xlim(xl - border,xh + border) plt.ylim(yl - border + self.ymax,yh + border + self.ymax) plt.plot([xl-border,xh+border],[self.ymax,self.ymax],'-',color='green') py = [y + self.ymax for y in py] self.fill_plot(plt,py,px) plt.grid(True) plt.savefig('tank.png') plt.show() def output(self): if(self.show_prog): sys.stderr.write('\n') out = '' if(self.pdf_mode): out = OutputFormatter().encode_pdf(self.html_title,self.extra,self.data_table) elif(self.html_mode): out = OutputFormatter().encode_xhtml(self.html_title,self.extra,self.data_table) else: if(self.extra): out = self.extra for line in self.data_table: out += ','.join(line) + '\n' if(self.ofile): with open(self.ofile,'w') as f: f.write(out) else: sys.stdout.write(out) def version(self): print(' %s version %s' % (self.__class__.__name__,PROGRAM_VERSION)) print(' %s is © 2012, P. Lutus' % self.__class__.__name__) print(' For full documentation and latest version, visit') print(' http://arachnoid.com/%s' % self.__class__.__name__) def __init__(self): # force help if no args provided if(len(sys.argv) < 2): sys.argv.append('-h') # a default example tank with radius 30 units, # a central, 100-unit cylindrical section, # and two end frusta with height 30 units self.profile = (Cart(0,10),Cart(30,30),Cart(130,30),Cart(160, 10)) self.vmode = False self.revmode = False self.show_prog = True self.intsteps = 1000.0 self.step = 1.0 self.vol_mult = 1.0 self.soffset = 0.0 self.voffset = 0.0 self.conv = 1.0 self.decimals = 4 self.ofile = False self.html_mode = False self.html_title = False self.pdf_mode = False self.plot_points = False self.arg = False self.extra = False self.wall = 0 self.data_table = [] self.parser = optparse.OptionParser() self.parser.add_option("-a", "--arg", dest="arg", help="use argument to compute a single height/volume result instead of a table") self.parser.add_option("-c", "--conv", dest="conv", help=u"volume conversion factor (cm³ to liters = 1000, in³ to gallons = 231), default %.4f" % self.conv) self.parser.add_option("-d", "--decimals", dest="dec", help="result decimal places, default %d" % self.decimals) self.parser.add_option("-D", "--draw", action="store_true",dest="draw", help="draw an image of the tank (and save 'tank.png')") self.parser.add_option("-f", "--ifile", dest="ifile", help="input file path containing tank profile (x,y value pairs) ('-' = stdin)") self.parser.add_option("-F", "--ofile", dest="ofile", help="output file path (default: stdout)") self.parser.add_option("-H", "--html", action="store_true",dest="html", help="use HTML output format (default: CSV)") self.parser.add_option("-i", "--intsteps", dest="intsteps", help="integration steps (only used in horizontal mode), default %.0f" % self.intsteps) self.parser.add_option("-m", "--mult", dest="mult", help="volume multiplier for oval tanks, default %.4f" % self.vol_mult) self.parser.add_option("-n", "--noprog", action="store_true",dest="noprog", help="don't print progress chars") self.parser.add_option("-P", "--pdf", action="store_true",dest="pdf", help="use PDF output format (default: CSV)") self.parser.add_option("-p", "--plot", action="store_true",dest="plot", help="plot coordinate points in graphic image") self.parser.add_option("-r", "--rev", action="store_true",dest="rev", help="create volume -> height table (default height -> volume) -- slower.") self.parser.add_option("-s", "--step", dest="step", help="table increment size, default %.4f" % self.step) self.parser.add_option("-S", "--soff", dest="soffset", help="sensor offset value, default %.4f" % self.soffset) self.parser.add_option("-t", "--title", dest="title", help="HTML/pdf page title (default none)") self.parser.add_option("-u", "--upright", action="store_true",dest="upright", help="upright (vertical) tank mode (x and y reversed)") self.parser.add_option("-v", "--version", action="store_true",dest="version", help="program version and additional information") self.parser.add_option("-V", "--voff", dest="voffset", help="volume offset value, default %.4f" % self.voffset) self.parser.add_option("-w", "--wall", dest="wall", help="tank wall thickness, default %.4f" % self.wall) self.parser.add_option("-x", "--extra", action="store_true",dest="extra", help="compute full volume, surface area, surface volume (if wall thickness provided)") (options, args) = self.parser.parse_args() if(options.upright): self.vmode = True if(options.arg): self.arg = float(options.arg) if(options.rev): self.revmode = True if(options.ifile): self.profile = self.read_profile(options.ifile) if(options.ofile): self.ofile = options.ofile if(options.html): self.html_mode = True if(options.pdf): self.pdf_mode = True if(options.plot): self.plot_points = True if(options.title): self.html_title = options.title if(options.intsteps): self.intsteps = float(options.intsteps) if(options.step): self.step = float(options.step) if(options.mult): self.vol_mult = float(options.mult) if(options.dec): self.decimals = int(options.dec) if(options.soffset): self.soffset = float(options.soffset) if(options.voffset): self.voffset = float(options.voffset) if(options.conv): self.conv = float(options.conv) if(options.wall): self.wall = float(options.wall) if(options.noprog): self.show_prog = False # get analytical full volume self.fv = self.full_volume() / self.conv # find y min and max in profile self.ymax = -1e30 self.ymin = 1e30 self.xmin = 1e30 if(self.vmode): ff = self.num_vol_vert for p in self.profile: self.ymax = max(self.ymax,p.x) self.ymin = min(self.ymin,p.x) # set bottom/left tank coordinate to zero for p in self.profile: p.x -= self.ymin self.ymax -= self.ymin self.ymin = 0 else: ff = self.num_vol_horiz for p in self.profile: self.xmin = min(self.xmin,p.x) self.ymax = max(self.ymax,p.y) self.ymin = min(self.ymin,-p.y) self.soffset += self.ymax # reset left boundary to zero for p in self.profile: p.x -= self.xmin if(options.version): self.version() quit() if(options.extra): self.extra = 'Tank interior volume: %.*f units³\n' % (self.decimals,self.fv * self.vol_mult) sa = self.tank_surface_area() self.extra += 'Tank surface area: %.*f units²\n' % (self.decimals,sa * self.vol_mult) if(self.wall != 0): self.extra += 'Tank surface volume: %.*f units³\n' % (self.decimals,sa * self.vol_mult * self.wall) if(options.draw): self.show_save_graph() else: if(self.fv < 0): if(not self.extra): self.extra = '' self.extra += 'Error: volume < 0\n' else: if(self.revmode): self.to_array('Volume,Height,%') else: self.to_array('Height,Volume,%') if(self.arg): # single argument if(self.revmode): s = self.volume_to_height(self.arg,ff) else: s = self.height_to_volume(self.arg-self.soffset,ff) self.to_array(s) else: # generate table if(self.revmode): self.volume_table(ff) else: self.height_table(ff) self.output() TankProfiler()