Monday, December 11, 2017

#1 2015-08-11 11:21:39 pm

bmose
Member
From:: Massachusetts
Registered: 2006-01-03
Posts: 219

Rounding numbers with arbitrary precision

This handler utilizes the shell calculator bc to round a number or numerical expression in any of five different ways, mimicking Applescript's rounding commands but without Applescript's bit precision limit of 2^52 - 1 (approximately 15 or 16 digits).  It returns the rounded number as well as its text string representation in decimal and exponential forms.

The input argument is a record of the form {theNumber:xxx, roundingMethod:xxx, nDecimalPlaces:xxx}, where:

    theNumber may be any integer, real number, or text string whose content is a valid number or bc calculator numerical expression

    roundingMethod is one of the following text strings:
        "school"  (the default value if omitted from the input argument)
            - Applescript's "rounding as taught in school"
            - Rounds the fractional portion away from zero if ≥ 0.5 and toward zero if < 0.5
        "nearest"
            - Applescript's "rounding to nearest"
            - Rounds the fractional portion away from zero if > 0.5, or = 0.5 and the preceding digit is odd
            - Rounds the fractional portion toward zero if < 0.5, or = 0.5 and the preceding digit is even
        "truncate"
            - Applescript's "rounding toward zero"
            - Rounds the fractional portion toward zero (i.e., truncates the fractional portion)
        "down"
            - Applescript's "rounding down"
            - Rounds a non-zero fractional portion toward minus infinity
        "up"
            - Applescript's "rounding up"
            - Rounds a non-zero fractional portion toward plus infinity

    nDecimalPlaces is an integer ≥ 0 indicating the number of decimal places to which the number is rounded
        - The default value is 0 if omitted from the input argument, which rounds to an integer value
   
The value returned by the handler is a record of the form {roundedNumber:xxx, decimalForm:xxx, exponentialForm:xxx}, where:

    roundedNumber is an integer or real number, or the text string "too large" if the value exceeds Applescript's maximum allowed value of 1.79769313486231 * 10^308
        - A number exceeding Applescript's bit precision limit of 2^52 - 1 = 4.50359962737 * 10^15 (approximately 15 or 16 digits) will likely be an inexact value

    decimalForm is a text string representation of the exact value of the rounded number in decimal form with no limits on the number's size or precision

    exponentialForm is a text string representation of the exact value of the rounded number in exponential form with no limits on the number's size or precision

The returned values will be set to null if the input number or expression does not resolve to a valid number.

If more than 1000 decimal places of precision is needed, increase the value of maximumScale in the assignment statement "set maximumScale to 1000"   :-)
   
Examples:
    tell roundNumber({theNumber:0.12344, roundingMethod:"up", nDecimalPlaces:4}) to return {its roundedNumber, its decimalForm, its exponentialForm}
        --> {0.1235, "0.1235", "1.235E-1"}
    tell roundNumber({theNumber:1.5, roundingMethod:"nearest", nDecimalPlaces:0}) to return {its roundedNumber, its decimalForm, its exponentialForm}
        --> {2, "2", "2.0E+0"}
    tell roundNumber({theNumber:2.5, roundingMethod:"nearest", nDecimalPlaces:0}) to return {its roundedNumber, its decimalForm, its exponentialForm}
        --> {2, "2", "2.0E+0"}
    tell roundNumber({theNumber:3.5, roundingMethod:"nearest", nDecimalPlaces:0}) to return {its roundedNumber, its decimalForm, its exponentialForm}
        --> {4, "4", "4.0E+0"}
    tell roundNumber({theNumber:4.5, roundingMethod:"nearest", nDecimalPlaces:0}) to return {its roundedNumber, its decimalForm, its exponentialForm}
        --> {4, "4", "4.0E+0"}
    tell roundNumber({theNumber:"1.000000000000000000005", roundingMethod:"down", nDecimalPlaces:20}) to return {its roundedNumber, its decimalForm, its exponentialForm}
        --> {1, "1", "1.0E+0"}
    tell roundNumber({theNumber:"1.000000000000000000005", roundingMethod:"up", nDecimalPlaces:20}) to return {its roundedNumber, its decimalForm, its exponentialForm}
        --> {1, "1.00000000000000000001", "1.00000000000000000001E+0"}  too many digits for Applescript to represent exactly!!
    tell roundNumber({theNumber:"1.000000000000000000005", roundingMethod:"school", nDecimalPlaces:20}) to return {its roundedNumber, its decimalForm, its exponentialForm}
        --> {1, "1.00000000000000000001", "1.00000000000000000001E+0"}  too many digits for Applescript to represent exactly!!
    tell roundNumber({theNumber:"1 + 1", roundingMethod:"school", nDecimalPlaces:0}) to return {its roundedNumber, its decimalForm, its exponentialForm}
        --> {2, "2", "2.0E+0"}
    tell roundNumber({theNumber:"2e-901+3e-901", roundingMethod:"school", nDecimalPlaces:900}) to return {its roundedNumber, its decimalForm, its exponentialForm}
        --> {"too large", "0.[...899 0's...]1", "1.0E-900"}
    tell roundNumber({theNumber:"2e-901+3e-901", roundingMethod:"down", nDecimalPlaces:900}) to return {its roundedNumber, its decimalForm, its exponentialForm}
        --> {0, "0", "0.0E+0"}
    tell roundNumber({theNumber:"1e900+1e-900", roundingMethod:"school", nDecimalPlaces:900}) to return {its roundedNumber, its decimalForm, its exponentialForm}
        --> {"too large", "1[...900 0's...].[...899 0's...]1", "1.[...1799 0's...]1E+900"}

Applescript:

on roundNumber(inputArgument)
   -- Constant
   set maximumScale to 1000
   -- Process the input arguments and set default values for missing properties
   tell (inputArgument & {roundingMethod:"school", nDecimalPlaces:0}) to set {theNumber, roundingMethod, nDecimalPlaces} to {its theNumber, its roundingMethod, its nDecimalPlaces}
   if theNumber's class = text then
       set theNumberAsText to theNumber
   else if theNumber's class is in {integer, real} then
       try
           -- Generates an error message containing a text representation of the number with greater bit precision than a simple coercion to text
           -- E.g.: 2^45 as text -> "3.518437208883E+13"; || of 2^45 method -> "3.5184372088832E+13", which is exactly correct
           || of theNumber
       on error errorMessage
           set {o1, o2} to {6 + (offset of "|| of " in errorMessage), -2}
           set theNumberAsText to errorMessage's text o1 thru o2
       end try
   else
       error "The input number must be an integer, real number, or text string."
   end if
   set nDecimalPlaces to nDecimalPlaces as integer
   if nDecimalPlaces < 0 then set nDecimalPlaces to 0
   -- Execute a shell script that utilizes the bc calculator to round the input number or expression into decimal form with arbitrary bit precision and then output the decimal form's components
   set bcCalculatorOutput to do shell script "
       ## Get the input number or expression, and convert any terms in E exponential format to bc calculator exponential format (e.g., '1.2E+3' -> '1.2*10^3')

       input_expression=$(sed -E 's/([0-9]+)[eE]([+]?([0-9]+)|([-][0-9]+))/\\1*10^\\3\\4/g' <<<"
& theNumberAsText's quoted form & ")

       ## Create an error test variable that is set to '1/0' if the input number or expression contains undeclared variable names, or to the empty string if it does not
       ## This step gets around bc's behavior of assigning a default value of 0 to undeclared variables rather than flagging them as invalid

       error_test=$(sed -En '
           H
           ${
           x
           s/[^a-z_]*(break|continue|else|for|halt|if|length|quit|read|return|scale|sqrt|while)[^a-z_]*//g
           s/[^a-z_]*(a|c|e|j|l|s)[(]([^)]*)[)]/\\2/g
           /[a-z_]/!s/.*//p
           /[a-z_]/s/.*/1\\/0/p
           }
       ' <<<\"$input_expression\")

       ## Run the bc calculator script

       bc -l <<<\"

           ## Run the error test which, if positive, causes bc to send an error message to standard error and thus flags the input number as invalid

           $error_test

           ## Get the input number at full scale

           max_scale="
& maximumScale & "
           scale=max_scale
           input_number=$input_expression

           ## Get the nDecimalPlaces and roundingMethod input argument values

           n_decimal_places="
& nDecimalPlaces & "
           rounding_method="
& (offset of roundingMethod in "school¦nearest¦truncate¦down¦up") & "

           ## Extract the sign of the input number, then transform the number to its absolute value with the decimal point shifted nDecimalPlaces to the right

           number_sign=1-2*(input_number<0)
           shifted_number=number_sign*(10^n_decimal_places)*input_number

           ## Round the input number according to the method specified by the input argument by adding 0, 0.5, or 1 as appropriate to the positive-valued shifted number

           if (rounding_method==1) { ## school
               shifted_number=shifted_number+0.5
           } else if (rounding_method==8) { ## nearest
               scale=0
               final_digit=(shifted_number%10)/1
               final_digit_is_odd_number=final_digit%2
               fractional_part_after_final_digit=shifted_number-(shifted_number/1)
               scale=max_scale
               if ((fractional_part_after_final_digit>0.5) || ((fractional_part_after_final_digit==0.5) && (final_digit_is_odd_number==1))) shifted_number=shifted_number+0.5
           } else if (rounding_method==16) { ## truncate
               ## do nothing
           } else if (rounding_method==25) { ## down
               if (number_sign==1) {
                   ## do nothing
               } else if (number_sign==-1) {
                   scale=0
                   fractional_part=shifted_number-(shifted_number/1)
                   if (fractional_part>0) shifted_number=shifted_number+1
               }
           } else if (rounding_method==30) { ## up
               if (number_sign==1) {
                   scale=0
                   fractional_part=shifted_number-(shifted_number/1)
                   if (fractional_part>0) shifted_number=shifted_number+1
               } else if (number_sign==-1) {
                   ## do nothing
               }
           }

           ## Remove the fractional part of the shifted number, shift the decimal point back to its original position (nDecimalPlaces to the left), and restore the number sign

           scale=0
           shifted_number=shifted_number/1
           scale=n_decimal_places
           rounded_number=number_sign*shifted_number/(10^n_decimal_places)

           ## Print the rounded number in decimal form

           print rounded_number

           ## Capture any error message (2>&1), and remove any reverse slashes or linefeeds from bc's output of the rounded number

       \" 2>&1 | tr -d '\\\\\\n' | sed -E '

           ## Output the following components of the rounded number on separate lines of output:
           ## Number sign
           ## First nonzero digit of the whole number part
           ## Remaining digits of the whole number part
           ## Leading zeros of the fractional part
           ## First nonzero digit of the fractional part
           ## Remaining digits of the fractional part
           ## Alternatively, if the input number or expression is invalid (i.e., the bc output contains an error message), flag it as such by outputting the letter 'I' followed by five empty lines

           /[^+-.0-9]/s/.*/I/
           /^[I+-]/!s/^/+/
           /[.]/!s/$/./
           s/([+-])0*[.]/\\1./
           s/0+$//
           s/([+]|([I-]))([1-9]?)([0-9]*)[.](0*)([1-9]?)([0-9]*)/\\2\\"
& linefeed & "\\3\\" & linefeed & "\\4\\" & linefeed & "\\5\\" & linefeed & "\\6\\" & linefeed & "\\7/'
       "

   -- Construct the text representation of the rounded number in decimal and exponential forms from the components parts
   -- Also, coerce the decimal form into an Applescript integer or real number, or assign the value "too large" if the number exceeds Applescript's maximum allowed value of 1.79769313486231 * 10^308
   -- If the number is invalid, set the results to the null value instead
   set {numberSign, wholeNumberFirstNonzeroDigit, wholeNumberRemainingDigits, fractionLeadingZeros, fractionFirstNonzeroDigit, fractionRemainingDigits} to bcCalculatorOutput's paragraphs
   set {roundedNumber, decimalForm, exponentialForm} to {null, null, null}
   if numberSign ≠ "I" then -- i.e., if the item is a valid number
       set decimalVersion to wholeNumberFirstNonzeroDigit & wholeNumberRemainingDigits & "." & fractionLeadingZeros & fractionFirstNonzeroDigit & fractionRemainingDigits
       tell decimalVersion to if it starts with "." then set decimalVersion to "0" & it
       tell decimalVersion to if it ends with "." then set decimalVersion to text 1 thru -2
       tell (numberSign & decimalVersion)
           try
               set roundedNumber to it as number
           on error
               set roundedNumber to "too large" -- if the number exceeds Applescript's maximum allowed value of 1.79769313486231 * 10^308
           end try
           set decimalForm to it
       end tell
       if wholeNumberFirstNonzeroDigit = "" then
           set {theMantissa, theExponent} to {fractionFirstNonzeroDigit & "." & fractionRemainingDigits, (-1 * ((fractionFirstNonzeroDigit's length) + (fractionLeadingZeros's length))) as text}
       else
           set {theMantissa, theExponent} to {wholeNumberFirstNonzeroDigit & "." & wholeNumberRemainingDigits & fractionLeadingZeros & fractionFirstNonzeroDigit & fractionRemainingDigits, wholeNumberRemainingDigits's length as text}
       end if
       tell theMantissa to if it starts with "." then set theMantissa to "0" & it
       tell theMantissa to if it ends with "." then set theMantissa to it & "0"
       tell theExponent to if it does not start with "-" then set theExponent to "+" & it
       set exponentialForm to numberSign & theMantissa & "E" & theExponent
   end if
   -- Return the rounded number in three different forms: Applescript number, text representation in decimal form, and text representation in exponential form
   return {roundedNumber:roundedNumber, decimalForm:decimalForm, exponentialForm:exponentialForm}
end roundNumber

Last edited by bmose (2015-08-12 12:47:19 am)


Filed under: Arbitrary, Rounding, precision, bc

Offline

 

#2 2015-08-12 01:38:40 am

Shane Stanley
Member
From:: Australia
Registered: 2002-12-07
Posts: 5197

Re: Rounding numbers with arbitrary precision

Nice. If you wanted to set your range a little lower -- coping with numbers with a mantissa of up to 38 digits and an exponent from -128 to 127 -- You can do something similar using AppleScriptObjC (under Mavericks or later):

Applescript:

use AppleScript version "2.3.1"
use scripting additions
use framework "Foundation"

on roundNumber(inputArgument)
   tell (inputArgument & {roundingMethod:"school", nDecimalPlaces:0}) to set {theNumber, roundingMethod, nDecimalPlaces} to {its theNumber, its roundingMethod, its nDecimalPlaces}
   if roundingMethod = "school" then
       set roundingMethod to (current application's NSRoundPlain)
   else if roundingMethod = "up" then
       set roundingMethod to (current application's NSRoundUp)
   else if roundingMethod = "down" then
       set roundingMethod to (current application's NSRoundDown)
   else if roundingMethod = "nearest" then
       set roundingMethod to (current application's NSRoundBankers)
   end if
   -- make decimal number
   if theNumber's class = text then
       set theNSDecimalNumber to current application's NSDecimalNumber's decimalNumberWithString:theNumber
   else
       set theNSDecimalNumber to current application's NSDecimalNumber's numberWithDouble:theNumber
   end if
   -- round it
   set theHandler to current application's NSDecimalNumberHandler's decimalNumberHandlerWithRoundingMode:roundingMethod scale:nDecimalPlaces raiseOnExactness:false raiseOnOverflow:true raiseOnUnderflow:true raiseOnDivideByZero:true
   set theRoundedNumber to theNSDecimalNumber's decimalNumberByRoundingAccordingToBehavior:theHandler
   -- get AS number
   try
       set roundedNumber to theRoundedNumber's doubleValue()
   on error
       set roundedNumber to "too large"
   end try
   -- get formatted strings
   set theFormatter to current application's NSNumberFormatter's alloc()'s init()
   theFormatter's setNumberStyle:(current application's NSNumberFormatterDecimalStyle)
   theFormatter's setMinimumFractionDigits:nDecimalPlaces
   theFormatter's setMaximumFractionDigits:nDecimalPlaces
   theFormatter's setHasThousandSeparators:false
   set decimalForm to (theFormatter's stringFromNumber:theRoundedNumber) as text
   theFormatter's setNumberStyle:(current application's NSNumberFormatterScientificStyle)
   theFormatter's setFormat:"0.#E+0;0.#E-0"
   set exponentialForm to (theFormatter's stringFromNumber:roundedNumber) as text
   return {roundedNumber:roundedNumber, decimalForm:decimalForm, exponentialForm:exponentialForm}
end roundNumber

It might be useful if you're processing a lot of numbers, because it's considerably faster.


Shane Stanley <sstanley@myriad-com.com.au>
www.macosxautomation.com/applescript/apps/

Offline

 

#3 2015-08-12 06:48:00 am

DJ Bazzie Wazzie
Member
From:: the Netherlands
Registered: 2004-10-20
Posts: 2726
Website

Re: Rounding numbers with arbitrary precision

Very useful script Bmose.

Small note, the AppleScript rounding value is always "too large" because there are more countries using a decimal comma instead of a decimal point. Coercing string containing an decimal point will return into an error or will be handled as an thousand separator. One way to solve that is using using the run script command. Replacing the tell (numberSign & decimalVersion) block with the code below and it will work no matter what kind of number formatter your system  uses.

Applescript:

try
   set roundedNumber to run script numberSign & decimalVersion
on error
   set roundedNumber to "too large" -- if the number exceeds Applescript's maximum allowed value of 1.79769313486231 * 10^308
end try
set decimalForm to numberSign & decimalVersion

Last edited by DJ Bazzie Wazzie (2015-08-12 06:50:14 am)

Offline

 

#4 2015-08-12 09:37:03 am

bmose
Member
From:: Massachusetts
Registered: 2006-01-03
Posts: 219

Re: Rounding numbers with arbitrary precision

Thank you for the nice comments.

Shane, your script is a concise, fast, and elegant ASOC solution, as usual. It would be particularly useful when crunching large numbers of values within the bit precision limits you describe. My handler's fortes are that it can handle arbitrary number sizes and precisions and can take as input numerical expressions of arbitrary complexity (exponentiation, trigonometric terms, grouping with parentheses, ...) as long as they are valid bc calculator expressions. The tradeoff is that it runs about 16 to 20 times slower than your ASOC solution in my initial tests. On my aging 2012 Powerbook, after reconfiguring the handler to accept a list of input numbers which are processed in a repeat loop inside the handler, it takes about 0.04 + (0.007 x #input numbers) seconds, or about 3/4 of a second for 100 numbers.

DJ, this is my first exposure to locale issues in number formatting. I appreciate your "run script" solution. I was wondering if there might be a faster alternative.  One idea that comes to mind is to get the separator character with

Applescript:

set decimalChar to (1 as real as text)'s text 2

then replace any literal periods in my handler with the decimalChar variable. What are your thoughts about this approach? Also, can you please tell me if the shell script part of the handler, i.e., the code inside

Applescript:

set bcCalculatorOutput to do shell script "..."

would also need to be adjusted for locale, or are shell scripts somehow magically locale-indifferent? Thanks again for your helpful comments.

Last edited by bmose (2015-08-12 09:37:46 am)

Offline

 

#5 2015-08-12 10:09:27 am

DJ Bazzie Wazzie
Member
From:: the Netherlands
Registered: 2004-10-20
Posts: 2726
Website

Re: Rounding numbers with arbitrary precision

bmose wrote:

DJ, this is my first exposure to locale issues in number formatting. I appreciate your "run script" solution. I was wondering if there might be a faster alternative.  One idea that comes to mind is to get the separator character with

Applescript:

set decimalChar to (1 as real as text)'s text 2

then replace any literal periods in my handler with the decimalChar variable. What are your thoughts about this approach?


I think it's an better approach. It requires more lines of code but would run definitely faster.

bmose wrote:

Also, can you please tell me if the shell script part of the handler, i.e., the code inside

Applescript:

set bcCalculatorOutput to do shell script "..."

would also need to be adjusted for locale, or are shell scripts somehow magically locale-indifferent? Thanks again for your helpful comments.


On my machine bc handles and returns only decimal points no matter what locale is set in the shell itself (tried it with different locale settings). I think it's safe to say you don't have to worry about that.

Offline

 

#6 2015-08-12 10:24:01 am

bmose
Member
From:: Massachusetts
Registered: 2006-01-03
Posts: 219

Re: Rounding numbers with arbitrary precision

Thanks on both counts. I breathed a sigh of relief with your news about not having to change the shell script code. I will post the changes when I get free time later.

Offline

 

#7 2015-08-12 07:01:29 pm

Shane Stanley
Member
From:: Australia
Registered: 2002-12-07
Posts: 5197

Re: Rounding numbers with arbitrary precision

bmose wrote:

The tradeoff is that it runs about 16 to 20 times slower than your ASOC solution in my initial tests.


That matches what I saw. If performance is an issue, you could always add a check to your handler, and pass values that are within the lesser range to the other handler.


Shane Stanley <sstanley@myriad-com.com.au>
www.macosxautomation.com/applescript/apps/

Offline

 

#8 2015-08-12 09:35:36 pm

bmose
Member
From:: Massachusetts
Registered: 2006-01-03
Posts: 219

Re: Rounding numbers with arbitrary precision

The hybrid approach is an interesting idea.  It would certainly be worth considering under the right circumstances and if the need for speed were paramount.

Last edited by bmose (2015-08-12 09:35:51 pm)

Offline

 

#9 2015-08-15 08:25:50 am

bmose
Member
From:: Massachusetts
Registered: 2006-01-03
Posts: 219

Re: Rounding numbers with arbitrary precision

Several improvements were made:

(1) The input argument theNumber can now be a list of numbers and/or bc calculator numerical expressions. In this case, the returned properties will be lists of corresponding values, rather than the values themselves.
(2) Locale differences in number formatting (i.e., decimal separator character) should now be handled properly.
(3) Informative error messages are now returned for input items that failed to round properly.
(4) The sed algorithm for detecting undeclared bc calculator variable names has been refined.
(5) User-defined variables, which must be of the form lowercase x followed by one or more digits, may now be included in bc calculator numerical expressions.
(6) The input argument nDecimalPlaces can now be a negative number, which allows rounding at any digit to the left of the decimal point, specifically the (|nDecimalPlaces|+1)'th digit to the left of the decimal point

The following example demonstrates the input of a list of items and purposely invalid entries to demonstrate list entry and the error message feature:

Applescript:

set numberList to {1, 2.2, "3.3", {4, 5, 6}, "xx + 7", "8/0", "9+("}

tell roundNumber({theNumber:numberList, roundingMethod:"school", nDecimalPlaces:1})
   its roundedNumber --> {1, 2.2, 3.3, null, null, null, null}
   its decimalForm --> {"1", "2.2", "3.3", null, null, null, null}
   its exponentialForm --> {"1.0E+0", "2.2E+0", "3.3E+0", null, null, null, null}
   its errorMessage --> {null, null, null, "Error: The input item is not an integer, real number, or text string", "Error: The input item contains one or more undeclared variables", "Runtime error (func=(main), adr=176): Divide by zero", "(standard_in) 20: parse error(standard_in) 1: parse error"}
end tell

The following examples demonstrate a bc numerical expression including user-defined variables. The result is trivial, reducing to the number e rounded to 49 decimal places, but it shows how an arbitrarily complex expression could be entered:

Applescript:

set bcExpression to "
x1=3
x2=0
while (x2<4) {
   x2++
}
x3=sqrt(x1^(1+1) + x2^(e(0) + 10^0))
print x3 - 5 + e(1)"


tell roundNumber({theNumber:bcExpression, roundingMethod:"down", nDecimalPlaces:49})
   its roundedNumber --> 2.718281828459 (too large for Applescript to represent exactly)
   its decimalForm --> "2.7182818284590452353602874713526624977572470936999"
   its exponentialForm --> "2.7182818284590452353602874713526624977572470936999E+0"
   its errorMessage --> null
end tell

tell roundNumber({theNumber:bcExpression, roundingMethod:"up", nDecimalPlaces:49})
   its roundedNumber --> 2.718281828459 (too large for Applescript to represent exactly)
   its decimalForm --> "2.7182818284590452353602874713526624977572470937"
   its exponentialForm --> "2.7182818284590452353602874713526624977572470937E+0"
   its errorMessage --> null
end tell

The following example demonstrates setting nDecimalPlaces to a negative number to round at the (|nDecimalPlaces|+1)'th (3rd in the current example) digit to the left of the decimal point:

Applescript:

tell roundNumber({theNumber:"123456.789", roundingMethod:"school", nDecimalPlaces:-2})
   its roundedNumber --> 123500
   its decimalForm --> "123500"
   its exponentialForm --> "1.23500E+5"

Applescript:

(*
USAGE NOTES:

This handler utilizes the shell calculator bc to round a number or bc calculator numerical expression, or a list of such numbers or expressions, in any of five different ways, mimicking Applescript's rounding commands but without Applescript's bit precision limit of 2^52 - 1 (approximately 15 or 16 digits). It returns the rounded number or numbers as well as their text string representation in decimal and exponential forms.

The input argument is a record of the form {theNumber:xxx, roundingMethod:xxx, nDecimalPlaces:xxx}, where:

   theNumber may be any integer, real number, or text string whose content is a valid number or bc calculator numerical expression, or a list of such numbers and text strings
   
       NOTES:
           - For bc calculator numerical expressions, user-defined variable names must begin with lowercase x followed by one or more digits; any others will be flagged as invalid
           - This restriction allows the handler to detect erroneous undeclared variables, which the bc calculator would otherwise initialize to zero rather than flag as invalid

   roundingMethod is one of the following text strings:
       "school" (the default value if omitted from the input argument)
           - Applescript's "rounding as taught in school"
           - Rounds the fractional portion away from zero if ≥ 0.5 and toward zero if < 0.5
       "nearest"
           - Applescript's "rounding to nearest"
           - Rounds the fractional portion away from zero if > 0.5, or = 0.5 and the preceding digit is odd
           - Rounds the fractional portion toward zero if < 0.5, or = 0.5 and the preceding digit is even
       "truncate"
           - Applescript's "rounding toward zero"
           - Rounds the fractional portion toward zero (i.e., truncates the fractional portion)
       "down"
           - Applescript's "rounding down"
           - Rounds a non-zero fractional portion toward minus infinity
       "up"
           - Applescript's "rounding up"
           - Rounds a non-zero fractional portion toward plus infinity

   nDecimalPlaces is an integer indicating the number of decimal places to which the number is rounded
       - A positive value rounds to that many decimal places; a negative value rounds at the (|nDecimalPlaces|+1)'th digit to the left of the decimal point
       - The default value is 0 if omitted from the input argument, which results in rounding to an integer value
       - A real value will be coerced to an integer
   
The value returned by the handler is a record of the form {roundedNumber:xxx, decimalForm:xxx, exponentialForm:xxx, errorMessage:xxx}, where:

   roundedNumber is an integer or real number, or the text string "too large" if the value exceeds Applescript's maximum allowed value of 1.79769313486231 * 10^308
       - A number exceeding Applescript's bit precision limit of 2^52 - 1 = 4.50359962737 * 10^15 (approximately 15 or 16 digits) will likely be an inexact value

   decimalForm is a text string representation of the exact value of the rounded number in decimal form with no limits on the number's size or precision

   exponentialForm is a text string representation of the exact value of the rounded number in exponential form with no limits on the number's size or precision
   
   errorMessage is the bc calculator error message if an error occurred during rounding of the input number or expression
   
   NOTES:
       - The property values will be returned as lists of values if the input argument theNumber is a list of numbers and expressions
       - roundedNumber, decimalForm, and exponentialForm will be set to the null value for any input number or expression that failed to round due to an error
       - errorMessage will be set to the null value for any number that rounded without an error

Locale differences in number formatting (comma vs period decimal separator) should be handled properly.

If more than 1000 decimal places of precision is needed, increase the value of maximumScale in the assignment statement "set maximumScale to 1000" :-)
*)


on roundNumber(inputArgument)
   -- Set the number of decimal places of precision for the intermediary calculations
   set maximumScale to 1000
   -- Process the input arguments and set default values for missing properties
   tell (inputArgument & {roundingMethod:"school", nDecimalPlaces:0}) to set {theNumber, roundingMethod, nDecimalPlaces} to {its theNumber, its roundingMethod, its nDecimalPlaces}
   tell theNumber to if its class ≠ list then set theNumber to {it}
   if roundingMethod is not in {"school", "nearest", "truncate", "down", "up"} then error "The input argument roundingMethod must be one of the following text strings: \"school\", \"nearest\", \"truncate\", \"down\", \"up\""
   tell nDecimalPlaces
       if its class is not in {integer, real} then error "The input argument nDecimalPlaces must be an integer or real number."
       set nDecimalPlaces to it as integer
   end tell
   -- Transform the input numbers and expressions into lines of text, and flag as invalid any item that is not an integer, real number, or text string
   set tid to AppleScript's text item delimiters
   try
       set AppleScript's text item delimiters to ";"
       set numbersAsTextStrings to {}
       repeat with currNumber in theNumber
           set currNumber to currNumber's contents
           if currNumber's class = text then
               -- Transform a multi-line expression into a single line of semicolon-delimited text
               set end of numbersAsTextStrings to currNumber's paragraphs as text
           else if currNumber's class is in {integer, real} then
               try
                   -- Generate an error message containing a text representation of the number with greater bit precision than a simple coercion to text
                   -- E.g.: 2^45 as text -> "3.518437208883E+13"; || of 2^45 method -> "3.5184372088832E+13", which is exactly correct
                   || of currNumber
               on error errorMessage
                   set {o1, o2} to {6 + (offset of "|| of " in errorMessage), -2}
                   set end of numbersAsTextStrings to errorMessage's text o1 thru o2
               end try
           else
               set end of numbersAsTextStrings to "invalid class"
           end if
       end repeat
       -- Transform the list of input numbers into lines of text
       set AppleScript's text item delimiters to linefeed
       set numbersAsLinesOfText to numbersAsTextStrings as text
   end try
   set AppleScript's text item delimiters to tid
   -- For each input number or expression, output the decimal components of the rounded number on six separate lines of text
   set decimalComponents to paragraphs of (do shell script "
   
       ## Cycle through the input numbers and expressions
   
       while read input_item
       do
   
           ## Validate and format the current input number or bc numerical expression
   
           input_expression=$(sed -E '

               ## If the input item was flagged as being of an invalid class, mark the expression as invalid by setting its value to an invalid class text string, then skip to the end to output the expression

               /^invalid class$/s/(.)/\\1/
               t
   
               ## Convert any terms in E exponential format to bc calculator exponential format (e.g., 1.2E+3 -> 1.2*10^3), then store the expression in the hold space
   
               s/([0-9]+)[eE]([+]?([0-9]+)|([-][0-9]+))/\\1*10^\\3\\4/g
               h
   
               ## Recursively replace all bc reserved words, including function names and special variable names (i.e., names beginning with a lowercase x followed by one or more digits), with a unique token string (¦§§¦)
   
               :again
               s/auto|break|continue|define|else|for|halt|ibase|if|last|length|limits|obase|print|quit|read|return|scale|sqrt|warranty|while|x[0-9]+/¦§§¦/g
               s/(a|c|e|j|l|s)([(][^)]*[)])/¦§§¦\\2/g
               t again
   
               ## If the expression contains any bc reserved words abutting one another, mark the expression as invalid by setting its value to an undeclared variable text string, then skip to the end to output the expression
   
               /¦§§¦¦§§¦/s/^.+$/undeclared variable/
               t
   
               ## Replace all tokens with spaces
   
               s/¦§§¦/ /g
   
               ## If the expression contains any remaining alphabetic characters, treat them as undeclared variables, mark the expressioon as invalid by setting its value to an undeclared variable text string, then skip to the end to output the expression
               ## Prior to doing this, reset the t command so that it can recognize if a substitution takes place in the s command
   
               t reset
               :reset
               /[a-zA-Z]/s/^.+$/undeclared variable/
               t
   
               ## If the expression does not have undeclared variable names, get the uncorrupted version of the expression from the hold space, trim leading and trailing spaces, and output the expression
   
               g
               s/^[[:space:]]+//
               s/[[:space:]]+$//
   
           ' <<<\"$input_item\")

           ## If the input expression was flagged as being an invalid input item or having undeclared variables, set the invalid flag to a unique positive integer and input_expression to the empty string
           ## Otherwise, set the invalid flag to 0
           
           if [[ $input_expression = 'invalid class' ]]; then
               invalid_flag=1
           elif [[ $input_expression = 'undeclared variable' ]]; then
               invalid_flag=2
           else
               invalid_flag=0
           fi
           [[ invalid_flag -gt 0 ]] && input_expression=''

           ## Run the bc calculator script
   
           bc -l <<<\"
               if ($invalid_flag==1) {
                   
                   ## If the input item is of an invalid class, flag it as such by printing an error message to stdout and end any further processing of the input expression
                   
                   print \\\"
Error: The input item is not an integer, real number, or text string.\\\"

               } else if ($invalid_flag==2) {
                   
                   ## If the input expression has undeclared variables, flag it as such by printing an error message to stdout and end any further processing of the input expression
                   
                   print \\\"
Error: The input item contains one or more undeclared variables.\\\"

               } else {
   
                   ## Get the value of the input expression at maximum precision (with enclosing curly braces so that multiple statements are executed as a group), print the result to stdout (which happens automatically in the input_expression statement), and save the result to the input_value variable for subsequent rounding
       
                   max_scale="
& maximumScale & "
                   scale=max_scale
                   {$input_expression}
                   input_value=last
       
                   ## Get the nDecimalPlaces and roundingMethod input argument values
       
                   n_decimal_places="
& nDecimalPlaces & "
                   rounding_method="
& (offset of roundingMethod in "school¦nearest¦truncate¦down¦up") & "
       
                   ## Extract the sign of the input number, then transform the number to its absolute value with the decimal point shifted nDecimalPlaces to the right (to the left if nDecimalPlaces < 0)
       
                   number_sign=1-2*(input_value<0)
                   if (n_decimal_places>=0) {
                       shifted_number=number_sign*input_value*(10^n_decimal_places)
                   } else {
                       ### This formulation gets around bc's inability to handle negative exponents
                       shifted_number=number_sign*input_value/(10^-n_decimal_places)
                   }
                   ## Round the input number according to the method specified by the input argument by adding 0, 0.5, or 1 as appropriate to the positive-valued shifted number
       
                   if (rounding_method==1) { ## i.e., if rounding_method==school
                       shifted_number=shifted_number+0.5
                   } else if (rounding_method==8) { ## i.e., if rounding_method==nearest
                       scale=0
                       final_digit=(shifted_number%10)/1
                       final_digit_is_odd_number=final_digit%2
                       fractional_part_after_final_digit=shifted_number-(shifted_number/1)
                       scale=max_scale
                       if ((fractional_part_after_final_digit>0.5) || ((fractional_part_after_final_digit==0.5) && (final_digit_is_odd_number==1))) shifted_number=shifted_number+0.5
                   } else if (rounding_method==16) { ## i.e., if rounding_method==truncate
                       ## do nothing
                   } else if (rounding_method==25) { ## i.e., if rounding_method==down
                       if (number_sign==1) {
                           ## do nothing
                       } else if (number_sign==-1) {
                           scale=0
                           fractional_part=shifted_number-(shifted_number/1)
                           if (fractional_part>0) shifted_number=shifted_number+1
                       }
                   } else if (rounding_method==30) { ## i.e., if rounding_method==up
                       if (number_sign==1) {
                           scale=0
                           fractional_part=shifted_number-(shifted_number/1)
                           if (fractional_part>0) shifted_number=shifted_number+1
                       } else if (number_sign==-1) {
                           ## do nothing
                       }
                   }
       
                   ## Remove the fractional part of the shifted number, shift the decimal point back to its original position nDecimalPlaces to the left (to the right if nDecimalPlaces < 0), and restore the number sign
       
                   scale=0
                   shifted_number=shifted_number/1
                   scale=n_decimal_places
                   if (n_decimal_places>=0) {
                       rounded_number=number_sign*shifted_number/(10^n_decimal_places)
                   } else {
                       ### This formulation gets around bc's inability to handle negative exponents
                       rounded_number=number_sign*shifted_number*(10^-n_decimal_places)
                   }
       
                   ## Print a unique separator token string followed by the the rounded number in decimal form to stdout
                   ## The token string allows the rounded value to be distinguished from the previously printed input expression value and any error messages
       
                   print \\\"
¦§§¦\\\", rounded_number
               }
   
               ## Capture any error messages (2>&1), and remove any reverse slashes or linefeeds from the rounded number returned by bc
   
           \" 2>&1 | tr -d '\\\\\\n' | sed -E '
   
               ## If the input number or expression is invalid (i.e., the output from the bc calculator has an error message), branch to the end of the script and output the letter I followed by the error message on one line followed by five empty lines
               
               /[eE]rror/s/^([^¦]+)(¦§§¦.*)?$/I\\1\\"
& linefeed & "\\" & linefeed & "\\" & linefeed & "\\" & linefeed & "\\" & linefeed & "/
               t
   
               ## If the input number or expression is valid, remove all text up to and including the separator token, leaving only the rounded number, then print the following components of the rounded number on six separate lines
               ## Number sign
               ## First nonzero digit of the whole number part
               ## Remaining digits of the whole number part
               ## Leading zeros of the fractional part
               ## First nonzero digit of the fractional part
               ## Remaining digits of the fractional part
   
               s/^.*¦§§¦//
               /^[+-]/!s/^/+/
               /[.]/!s/$/./
               s/([+-])0*[.]/\\1./
               s/0+$//
               s/([+]|([-]))([1-9]?)([0-9]*)[.](0*)([1-9]?)([0-9]*)/\\2\\"
& linefeed & "\\3\\" & linefeed & "\\4\\" & linefeed & "\\5\\" & linefeed & "\\6\\" & linefeed & "\\7/
           '
       done <<<"
& numbersAsLinesOfText's quoted form)
   -- Construct the text representation of the rounded number or numbers in decimal and exponential forms from the components parts
   -- Also, coerce the decimal form into an Applescript integer or real number (or assign the value "too large" if the number exceeds Applescript's maximum allowed value of 1.79769313486231 * 10^308)
   -- If a number or expression is invalid, set its rounded results to the null value, and output the error message encountered
   set decimalChar to (1 as real as text)'s text 2 -- takes into account locale differences in number formatting
   set {roundedNumber, decimalForm, exponentialForm, errorMessage} to {{}, {}, {}, {}}
   tell decimalComponents
       repeat with i from 0 to (length - 6) by 6
           set {numberSign, wholeNumberFirstNonzeroDigit, wholeNumberRemainingDigits, fractionLeadingZeros, fractionFirstNonzeroDigit, fractionRemainingDigits} to items (i + 1) thru (i + 6)
           if numberSign starts with "I" then -- if the item is invalid
               set {end of roundedNumber, end of decimalForm, end of exponentialForm, end of errorMessage} to {null, null, null, numberSign's text 2 thru -1}
           else -- if the item is valid
               set decimalVersion to wholeNumberFirstNonzeroDigit & wholeNumberRemainingDigits & decimalChar & fractionLeadingZeros & fractionFirstNonzeroDigit & fractionRemainingDigits
               tell decimalVersion to if it starts with decimalChar then set decimalVersion to "0" & it
               tell decimalVersion to if it ends with decimalChar then set decimalVersion to text 1 thru -2
               tell (numberSign & decimalVersion)
                   try
                       set end of roundedNumber to it as number
                   on error
                       set end of roundedNumber to "too large" -- if the number exceeds Applescript's maximum allowed value of 1.79769313486231 * 10^308
                   end try
                   set end of decimalForm to it
               end tell
               if wholeNumberFirstNonzeroDigit = "" then
                   set {theMantissa, theExponent} to {fractionFirstNonzeroDigit & decimalChar & fractionRemainingDigits, (-1 * ((fractionFirstNonzeroDigit's length) + (fractionLeadingZeros's length))) as text}
               else
                   set {theMantissa, theExponent} to {wholeNumberFirstNonzeroDigit & decimalChar & wholeNumberRemainingDigits & fractionLeadingZeros & fractionFirstNonzeroDigit & fractionRemainingDigits, wholeNumberRemainingDigits's length as text}
               end if
               tell theMantissa to if it starts with decimalChar then set theMantissa to "0" & it
               tell theMantissa to if it ends with decimalChar then set theMantissa to it & "0"
               tell theExponent to if it does not start with "-" then set theExponent to "+" & it
               set end of exponentialForm to numberSign & theMantissa & "E" & theExponent
               set end of errorMessage to null
           end if
       end repeat
   end tell
   -- If there is only one input item, convert the output lists to their item values instead
   if roundedNumber's length = 1 then set {roundedNumber, decimalForm, exponentialForm, errorMessage} to {roundedNumber's first item, decimalForm's first item, exponentialForm's first item, errorMessage's first item}
   -- Return the rounded number or numbers in three different forms: Applescript number, text representation in decimal form, and text representation in exponential form, along with any error messages for input items that failed to round properly
   return {roundedNumber:roundedNumber, decimalForm:decimalForm, exponentialForm:exponentialForm, errorMessage:errorMessage}
end roundNumber

Edit note: This entry was modified since originally submitted to allow negative nDecimalPlaces values.
Edit note 7-Oct-2015: The substitution command that detects undeclared bc calculator variable names was altered to be more robust in detecting undeclared variable names (/[a-z][a-z0-9_]*/s/^.+$/undeclared variable/ changed to /[a-zA-Z]/s/^.+$/undeclared variable/).

Last edited by bmose (2015-10-07 08:19:51 am)

Offline

 

Board footer

Powered by FluxBB

RSS (new topics) RSS (active topics)