Package: calcfu
CalCFUTable of ContentsOverviewGetting StartedPlateClassFieldsValidationClass VariablesClass PropertiesCalCFUClassFieldsClass PropertiesMethodscalculatecalc_no_dil_validcalc_mult_dil_validbank_roundOverviewThese Python scripts calculate CFU counts for plating methods outlined in the NCIMS 2400 using two custom classes.Platefor storing plate characteristics.CalCFUfor the calculator logic.While the calculation can be performed easily in most cases,
this script allows for bulk-automated calculations where any dilution and number of plates can be used.The code below outlines the entire process and references the NCIMS 2400s.NCIMS 2400a: SPC - Pour Plate (Oct 2013)NCIMS 2400a-4: SPC - Petrifilm (Nov 2017)Getting StartedpipinstallcalcfuPlatePlates are set up via thePlatedataclass.fromcalcfuimportPlate# 1 PAC plate with a 10^-2 dilution of a weighed sample yielding a total count of 234.plates_1=Plate(plate_type="PAC",count=234,dilution=-2,weighed=True,num_plts=1)# 1 PAC plate with a 10^-3 dilution of a weighed sample yielding a total count of 53.plates_2=Plate(plate_type="PAC",count=53,dilution=-3,weighed=True,num_plts=1)FieldsEach instance of the dataclass is created with five arguments which are set as fields.Arguments:plate_type[str]Plate type.count[int]Raw plate counts.dilution[str]Dilution used to plate.weighed[bool]Sample was weighed or not.num_plts[int]Number of plates for each dilution.By default, this is set to 1.@dataclass(frozen=True,order=True)classPlate(CalcConfig):plate_type:strcount:intdilution:intweighed:boolnum_plts:int=1Class VariablesWhen an instance of thePlateorCalCFUclass is created, it inherits from theCalcConfigclass which stores
all necessary configuration variables for the calculator.PLATE_RANGES[dict]Acceptable counts for each plate type.SPC - NCIMS 2400a.16.eCPC - NCIMS 2400a.17.ePAC/RAC - NCIMS 2400a-4.16.ePCC/HSCC - NCIMS 2400a-4.17.eWEIGHED_UNITS[dict]Units for if weighed or not.VALID_DILUTIONS[tuple]Acceptable dilutions for each plate type.INPUT_VALIDATORS[dict]A dictionary of anonymous functions used to validate input arguments.@dataclass(frozen=True,order=True)classPlate(CalcConfig):...@dataclass(frozen=True,order=True)classCalCFU(CalcConfig):...classCalcConfig:# Logging/File Path Variables...VALID_DILUTIONS=(0,-1,-2,-3,-4)PLATE_RANGES={"SPC":(25,250),"PAC":(25,250),"RAC":(25,250),"CPC":(1,154),"HSCC":(1,154),"PCC":(1,154),"YM":(),"RYM":()}WEIGHED_UNITS={True:" / g",False:" / mL"}INPUT_VALIDATORS={# count must be an integer and greater than 0"plate_type":lambdaplate_type:plate_typeinCalcConfig.PLATE_RANGES,"count":lambdacount:isinstance(count,int)andcount>0,# dilution must be in valid dilutions"dilution":lambdadilution:dilutioninCalcConfig.VALID_DILUTIONS,"weighed":lambdaweighed:isinstance(weighed,bool),# num_plts must be an integer and greater than 0"num_plts":lambdanum_plts:isinstance(num_plts,int)andnum_plts>0,# plates must all be an instance of the Plate dataclass and must be all the same plate_type"plates":lambdaplates,plt_cls:all(isinstance(plate,plt_cls)andplate.plate_type==plates[0].plate_typeforplateinplates),"all_weighed":lambdaplates:all(plates[0].weighed==plate.weighedforplateinplates)}Field ValidationArguments/fields are validated via a__post_init__method where each key is checked
against conditions inself.INPUT_VALIDATORS# post init dunder method for validationdef__post_init__(self):forkey,valueinasdict(self).items():assertself.INPUT_VALIDATORS[key](value),\"Invalid value. Check calc_config.py."PropertiesProperties are also defined to allow for read-only calculation of attributes from the input arguments.@propertydefcnt_range(self):# self.cnt_range[0] is min, self.cnt_range[1] is maxreturnself.PLATE_RANGES.get(self.plate_type,None)@propertydefin_between(self):ifself.cnt_range[0]<=self.count<=self.cnt_range[1]:returnTrueelse:returnFalse@propertydefsign(self):if0<=self.count<self.cnt_range[0]:return"<"elifself.count>self.cnt_range[1]:return">"else:return""@propertydef_bounds_abs_diff(self):# Dict of bounds and their abs difference between the number of colonies.return{bound:abs(self.count-bound)forboundinself.cnt_range}@propertydefhbound_abs_diff(self):returnabs(self.count-self.cnt_range[1])@propertydefclosest_bound(self):# return closest bound based on min abs diff between count and boundreturnmin(self._bounds_abs_diff,key=self._bounds_abs_diff.get)cnt_range[tuple: 2]Countable colony numbers for a plate type.in_between[bool]Ifcountwithin countable range.sign[str]Sign for reported count if allcountvalues are outside the acceptable range.Used inCalCFU._calc_no_dil_valid._bounds_abs_diff[dict]Absolute differences ofcountand low and highcnt_ranges.hbound_abs_diff[int]Absolute difference ofcountand high ofcnt_range.closest_bound[int]Closest count incnt_rangetocount.Based on minimum absolute difference betweencountandcnt_ranges.
The smaller the difference, the closer thecountis to a bound.CalCFUThe calculator is contained in theCalCFUdataclass.
Using the previously createdPlateinstances, aCalCFUinstance is created.fromcalcfuimportCalCFU# Setup calculator with two PAC plates that contain a weighed sample.calc=CalCFU(plates=[plates_1,plates_2])FieldsEach instance of CountCalculator is initialized with a list of the plates to be calculated:Arguments:plates[list]Contains Plate instances.Validated via__post_init__method.plate_ids[list]OptionalContains list of plate IDs.Used to identify incorrect plates in error message.@dataclass(frozen=True,order=True)classCalCFU(CalcConfig):plates:Listplate_ids:Optional[List]=NoneProperties@propertydefvalid_plates(self):return[plateforplateinself.platesifplate.in_between]@propertydefreported_units(self):# grab first plate and use plate type. should be all the samereturnf"{self.plates[0].plate_type}{self.WEIGHED_UNITS.get(self.plates[0].weighed)}"valid_plates[list]Plates that have acceptable counts for their plate type.reported_units[str]Units based on plate type and if weighed.Estimated letter added inself.calculate()MethodsTwo methods are available for use with the CountCalculator instance:calculatebank_roundcalculate(self)This method is the "meat-and-potatoes" of the script.
It calculates the reported/adjusted count based on the plates given.Optional arguments:round_to[int]Digit to round to. Default is 1.Relative to leftmost digit (0).Python is 0 indexed.ex. Round to 1: 2(5),666ex. Round to 3: 25,6(6)6report_count[bool]Option to return reported count or unrounded, unlabeled adjusted count.First, each plate is checked to see if its count is in between the accepted count range.
Based on the number of valid plates, a different method is used to calculate the result.defcalculate(self,round_to=2,report_count=True):valid_plates=self.valid_plates# assign empty str to sign var. will be default unless no plate validsign=""# track if estimated i.e. no plate is valid.estimated=Falseiflen(valid_plates)==0:sign,adj_count=self._calc_no_dil_valid()estimated=Trueeliflen(valid_plates)==1:# only one plate is valid so multiple by reciprocal of dil.valid_plate=valid_plates[0]adj_count=valid_plate.count*(10**abs(valid_plate.dilution))else:adj_count=self._calc_multi_dil_valid()ifreport_count:units=f"{(''ifnotestimatedelse'e')}{self.reported_units}"# add sign, thousands separator, and unitsreturnf"{sign}{'{:,}'.format(self.bank_round(adj_count,round_to))}{units}"else:returnadj_count_calc_no_dil_valid(self, report_count)This function runs whenno plates have valid counts.Arguments:report_count[bool]Same ascalculate.Procedure:Reduce theself.platesdown to one plate by checking adjacent plates'hbound_abs_diff.The plate with the smallest difference is closest to the highest acceptable count bound.NCIMS 2400a.16.h|NCIMS 2400a-4.17.hEx. |267 - 250| = 17 and |275 - 250| = 2517 < 25 so 267 is closer to 250 than 275.Set throwaway variablevaluetocountorclosest_boundbased on if reported count needed.Finally, returnsignand multiply the closest bound by the reciprocal of the dilution.NCIMS 2400a.16.l|NCIMS 2400a-4.17.hdef_calc_no_dil_valid(self,report_count):# Use reduce to reduce plates to a single plate:#   plate with the lowest absolute difference between the hbound and valueclosest_to_hbound=reduce(lambdap1,p2:min(p1,p2,key=lambdax:x.hbound_abs_diff),self.plates)# if reporting, use closest bound; otherwise, use count.value=closest_to_hbound.closest_boundifreport_countelseclosest_to_hbound.countreturnclosest_to_hbound.sign,value*(10**abs(closest_to_hbound.dilution))_calc_multi_dil_valid(self)This method runs ifmultiple plates have valid counts.Variables:main_dil[int]The lowest dilution of thevalid_plates.dil_weights[list]The weights each dilution/plate contributes to the total count.div_factor[int]The sum ofdil_weights. Part of the denominator of the weighted averaged.Procedure:First, sum counts from all valid plates (plates_1andplates_2).1If all plates are the same dilution, setdiv_factorto the total number of valid plates.Each plate has equal weight indiv_factor.NCIMS 2400a.16.l.1|NCIMS 2400a-4.17.eOtherwise, we will take a weighted average taking into account how each dilution contributes to thediv_factor.2Each dilution will have aweightof how much it contributes to the total count (via thediv_factor)If the plate dilution is themain_dil, set the dilution's weight to 1.This value is themain_dil's weight towards the total count.The least diluted plate contributes the largest number of colonies
to the overall count. It will always be 1 and serves to normalize the effect of the other dilutions.NCIMS 2400a.16.l.1|NCIMS 2400a-4.17.eIf it is not, subtract the absolute value ofmain_dilby the absolute value ofplate.dilution.By raising 10 to the power ofabs_diff_dil,the plate dilution's weight - relative tomain_dil- is calculated.Each dilution weight is then multiplied by the number of plates used for that dilution.The sum of all dilution weights indil_weightsis the division weight,div_factor.Dividing thetotalby the product ofdiv_factorandmain_dilyields the adjusted count.3Figure 1. Sum of counts from all valid plates (Step 2)Figure 2. Weighted average formula. (Step 3)Figure 3. Adjusted count formula. (Step 7)def_calc_multi_dil_valid(self):valid_plates=self.valid_platestotal=sum(plate.countforplateinvalid_plates)main_dil=max(plate.dilutionforplateinvalid_plates)# If all plates have the same dilution.ifall(plate.dilution==valid_plates[0].dilutionforplateinvalid_plates):# each plates is equally weighed because all the same dildiv_factor=sum(1*plate.num_pltsforplateinvalid_plates)else:dil_weights=[]forplateinvalid_plates:ifplate.dilution==main_dil:dil_weights.append(1*plate.num_plts)else:# calculate dil weight relative to main_dilabs_diff_dil=abs(main_dil)-abs(plate.dilution)dil_weights.append((10**abs_diff_dil)*plate.num_plts)div_factor=sum(dil_weights)returnint(total/(div_factor*(10**main_dil)))Once a value is returned...bank_round(value, place_from_left)This method rounds values using banker's rounding.
String manipulation was used rather than working with floats toavoid rounding errors.Arguments:value[int]Value to be rounded.place_from_left[int]Digit to round to.Leftmost digit is 1 (NOT 0).Seecalculate()for examples.Variables:value_len[int]Len ofstring value.str_abbr_value[str]Abbreviated value as string.Sliced to only allow 1 digit before rounded digit.Python rounding behavior changes based on digits after.Built-in Functions - round()NCIMS 2400a.19.c.1.a-b|NCIMS 2400a-4.19.d.1.a-bstr_padded_value[str]Zero-padded value as string.adj_value[int]Abbreviated, padded value as integer.adj_place_from_leftAdjusted index for base pythonround(). Needs to be ndigits from decimal point.Ex. round(2(1)5., -1) -> 220final_val[int]Rounded value.@staticmethoddefbank_round(value,place_from_left):ifisinstance(value,int)andisinstance(place_from_left,int):# Length of unrounded value.value_len=len(str(value))# remove digits that would alter rounding only allowing 1 digit before desired placestr_abbr_value=str(value)[0:place_from_left+1]# pad with 0's equal to number of removed digitsstr_padded_value=str_abbr_value+("0"*(value_len-len(str_abbr_value)))adj_value=int(str_padded_value)# reindex place_from_left for round function.# place_from_left = 2 for 2(5)553. to round, needs to be -3 so subtract length by place and multiply by -1.adj_place_from_left=-1*(value_len-place_from_left)final_val=round(adj_value,adj_place_from_left)returnfinal_valelse:raiseValueError("Invalid value or place (Not an integer).")Example:result=bank_round(value=24553,place_from_left=2)Find the length of the value as a string.value_len=5Abbreviate value as string.str_abbr_value="245"Pad value as string out to original length.str_padded_value="24500"Convert padded value back to a number.adj_value=24500Reindexplace_from_leftfor theroundfunction.adj_place_from_left=-3Roundadj_valuewithplace_from_left, the new index.result=24000ReferencesNCIMS 2400a: SPC - Pour Plate (Oct 2013)NCIMS 2400a-4: SPC - Petrifilm (Nov 2017)Built-in Functions - round()Floating Point Arithmetic: Issues and Limitations