¿Cómo formatear el número de coma flotante con exactamente 2 dígitos significativos en bash?

17

Quiero imprimir el número de coma flotante con exactamente dos dígitos significativos en bash (tal vez usando una herramienta común como awk, bc, dc, perl, etc.).

Ejemplos:

  • 76543 debe imprimirse como 76000
  • 0.0076543 debe imprimirse como 0.0076

En ambos casos, los dígitos significativos son 7 y 6. He leído algunas respuestas para problemas similares como:

¿Cómo redondear números de coma flotante en shell?

Bash que limita la precisión de las variables de coma flotante

pero las respuestas se centran en limitar el número de decimales (por ejemplo, bccomando con scale=2o printfcomando con %.2f) en lugar de dígitos significativos.

¿Hay una manera fácil de formatear el número con exactamente 2 dígitos significativos o tengo que escribir mi propia función?

tafit3
fuente

Respuestas:

13

Esta respuesta a la primera pregunta vinculada tiene la línea casi desechable al final:

Consulte también %gpara redondear a un número específico de dígitos significativos.

Entonces puedes simplemente escribir

printf "%.2g" "$n"

(pero consulte la siguiente sección sobre separador decimal y configuración regional, y tenga en cuenta que no es printfnecesario que Bash no sea compatible %fy %g).

Ejemplos:

$ printf "%.2g\n" 76543 0.0076543
7.7e+04
0.0077

Por supuesto, ahora tiene una representación de exponente de mantisa en lugar de un decimal puro, por lo que querrá convertir de nuevo:

$ printf "%0.f\n" 7.7e+06
7700000

$ printf "%0.7f\n" 7.7e-06
0.0000077

Poniendo todo esto junto, y envolviéndolo en una función:

# Function round(precision, number)
round() {
    n=$(printf "%.${1}g" "$2")
    if [ "$n" != "${n#*e}" ]
    then
        f="${n##*e-}"
        test "$n" = "$f" && f= || f=$(( ${f#0}+$1-1 ))
        printf "%0.${f}f" "$n"
    else
        printf "%s" "$n"
    fi
}

(Nota: esta función está escrita en un shell portátil (POSIX), pero se supone que printfmaneja las conversiones de punto flotante. Bash tiene una función incorporada printfque sí, así que estás bien aquí, y la implementación de GNU también funciona, por lo que la mayoría de GNU / Los sistemas Linux pueden usar Dash de forma segura).

Casos de prueba

radix=$(printf %.1f 0)
for i in $(seq 12 | sed -e 's/.*/dc -e "12k 1.234 10 & 6 -^*p"/e' -e "y/_._/$radix/")
do
    echo $i "->" $(round 2 $i)
done

Resultados de la prueba

.000012340000 -> 0.000012
.000123400000 -> 0.00012
.001234000000 -> 0.0012
.012340000000 -> 0.012
.123400000000 -> 0.12
1.234 -> 1.2
12.340 -> 12
123.400 -> 120
1234.000 -> 1200
12340.000 -> 12000
123400.000 -> 120000
1234000.000 -> 1200000

Una nota sobre separador decimal y locale

Todo el trabajo anterior supone que el carácter radix (también conocido como separador decimal) es ., como en la mayoría de las configuraciones regionales inglesas. En su ,lugar, se utilizan otras configuraciones regionales , y algunas shells tienen una printfconfiguración regional que respeta la configuración regional. En estos shells, es posible que deba configurar LC_NUMERIC=Cpara forzar el uso de .como carácter de raíz o escribir /usr/bin/printfpara evitar el uso de la versión incorporada. Esto último se complica por el hecho de que (al menos algunas versiones) parecen analizar siempre los argumentos usando ., pero imprimen usando la configuración regional actual.

Toby Speight
fuente
@ Stéphane Chazelas, ¿por qué cambiaste mi shebang de shell POSIX cuidadosamente probado a Bash después de que eliminé el bashism? Su comentario menciona %f/ %g, pero ese es el printfargumento, y uno no necesita un POSIX printfpara tener un shell POSIX. Creo que deberías haber comentado en lugar de editar allí.
Toby Speight
printf %gno se puede usar en un script POSIX. Es cierto que se trata de la printfutilidad, pero esa utilidad está integrada en la mayoría de los depósitos. El OP etiquetado como bash, por lo que usar bash shebang es una manera fácil de obtener una printf que admita% g. De lo contrario, necesitaría agregar un asumiendo que su printf (o el printf incorporado de su shif printfestá incorporado allí) admite el no estándar (pero bastante común) %g...
Stéphane Chazelas
dash's tiene un incorporado printf(que soporta %g). En los sistemas GNU, mkshes probable que sea el único shell en estos días que no tendrá una función integrada printf.
Stéphane Chazelas
Gracias por sus mejoras. He editado para eliminar el shebang (ya que la pregunta está etiquetada bash) y relegar parte de esto a las notas. ¿Parece correcto ahora?
Toby Speight
1
Lamentablemente, esto no imprime el número correcto de dígitos si los dígitos finales son ceros. Por ejemplo, printf "%.3g\n" 0.400da 0.4 no 0.400
phiresky
4

TL; DR

Simplemente copie y use la función sigfen la sección A reasonably good "significant numbers" function:. Está escrito (como todo el código en esta respuesta) para trabajar con el guión .

Le dará la printfaproximación a la parte entera de N con $sigdígitos.

Sobre el separador decimal.

El primer problema a resolver con printf es el efecto y el uso de la "marca decimal", que en EE. UU. Es un punto, y en DE es una coma (por ejemplo). Es un problema porque lo que funciona para alguna configuración regional (o shell) fallará con otra configuración regional. Ejemplo:

$ dash -c 'printf "%2.3f\n" 12.3045'
12.305
$  ksh -c 'printf "%2.3f\n" 12.3045'
ksh: printf: 12.3045: arithmetic syntax error
ksh: printf: 12.3045: arithmetic syntax error
ksh: printf: warning: invalid argument of type f
12,000
$ ksh -c 'printf "%2.2f\n" 12,3045'
12,304

Una solución común (e incorrecta) es establecer LC_ALL=Cel comando printf. Pero eso establece la marca decimal en un punto decimal fijo. Para entornos locales donde una coma (u otra) es el carácter utilizado comúnmente que es un problema.

La solución es descubrir dentro del script para el shell que lo ejecuta cuál es el separador decimal de la configuración regional. Eso es bastante simple:

$ printf '%1.1f' 0
0,0                            # for a comma locale (or shell).

Eliminar ceros:

$ dec="$(IFS=0; printf '%s' $(printf '%.1f'))"; echo "$dec"
,                              # for a comma locale (or shell).

Ese valor se usa para cambiar el archivo con la lista de pruebas:

sed -i 's/[,.]/'"$dec"'/g' infile

Eso hace que las ejecuciones en cualquier shell o configuración regional sean automáticamente válidas.


Algunos conceptos básicos

Debería ser intuitivo cortar el número que se formateará con el formato %.*eo incluso con %.*gprintf. La principal diferencia entre usar %.*eo %.*ges cómo cuentan los dígitos. Uno usa el conteo completo, el otro necesita el conteo menos 1:

$ printf '%.*e  %.*g' $((4-1)) 1,23456e0 4 1,23456e0
1,235e+00  1,235

Eso funcionó bien durante 4 dígitos significativos.

Después de que el número de dígitos se ha cortado del número, necesitamos un paso adicional para formatear números con exponentes diferentes a 0 (como se indicó anteriormente).

$ N=$(printf '%.*e' $((4-1)) 1,23456e3); echo "$N"
1,235e+03
$ printf '%4.0f' "$N"
1235

Esto funciona correctamente El recuento de la parte entera (a la izquierda de la marca decimal) es solo el valor del exponente ($ exp). El recuento de decimales necesarios es la cantidad de dígitos significativos ($ sig) menos la cantidad de dígitos ya utilizados en la parte izquierda del separador decimal:

a=$((exp<0?0:exp))                      ### count of integer characters.
b=$((exp<sig?sig-exp:0))                ### count of decimal characters.
printf '%*.*f' "$a" "$b" "$N"

Como la parte integral del fformato no tiene límite, de hecho no hay necesidad de declararlo explícitamente y este código (más simple) funciona:

a=$((exp<sig?sig-exp:0))                ### count of decimal characters.
printf '%0.*f' "$a" "$N"

Primer intento.

Una primera función que podría hacer esto de una manera más automatizada:

# Function significant (number, precision)
sig1(){
    sig=$(($2>0?$2:1))                      ### significant digits (>0)
    N=$(printf "%0.*e" "$(($sig-1))" "$1")  ### N in sci (cut to $sig digits).
    exp=$(echo "${N##*[eE+]}+1"|bc)         ### get the exponent.
    a="$((exp<sig?sig-exp:0))"              ### calc number of decimals.
    printf "%0.*f" "$a" "$N"                ### re-format number.
}

Este primer intento funciona con muchos números, pero fallará con números para los cuales la cantidad de dígitos disponibles es menor que el recuento significativo solicitado y el exponente es menor que -4:

   Number       sig                       Result        Correct?
   123456789 --> 4<                       123500000 >--| yes
       23455 --> 4<                           23460 >--| yes
       23465 --> 4<                           23460 >--| yes
      1,2e-5 --> 6<                    0,0000120000 >--| no
     1,2e-15 -->15< 0,00000000000000120000000000000 >--| no
          12 --> 6<                         12,0000 >--| no  

Agregará muchos ceros que no son necesarios.

Segundo juicio

Para resolver eso, necesitamos limpiar N del exponente y los ceros finales. Entonces podemos obtener la longitud efectiva de dígitos disponibles y trabajar con eso:

# Function significant (number, precision)
sig2(){ local sig N exp n len a
    sig=$(($2>0?$2:1))                      ### significant digits (>0)
    N=$(printf "%+0.*e" "$(($sig-1))" "$1") ### N in sci (cut to $sig digits).
    exp=$(echo "${N##*[eE+]}+1"|bc)         ### get the exponent.
    n=${N%%[Ee]*}                           ### remove sign (first character).
    n=${n%"${n##*[!0]}"}                    ### remove all trailing zeros
    len=$(( ${#n}-2 ))                      ### len of N (less sign and dec).
    len=$((len<sig?len:sig))                ### select the minimum.
    a="$((exp<len?len-exp:0))"              ### use $len to count decimals.
    printf "%0.*f" "$a" "$N"                ### re-format the number.
}

Sin embargo, eso está usando matemática de coma flotante, y "nada es simple en coma flotante": ¿Por qué no se suman mis números?

Pero nada en "coma flotante" es simple.

printf "%.2g  " 76500,00001 76500
7,7e+04  7,6e+04

Sin embargo:

 printf "%.2g  " 75500,00001 75500
 7,6e+04  7,6e+04

¿Por qué?:

printf "%.32g\n" 76500,00001e30 76500e30
7,6500000010000000001207515928855e+34
7,6499999999999999997831226199114e+34

Y, además, el comando printfestá integrado por muchos proyectiles.
Qué printfimpresiones pueden cambiar con el shell:

$ dash -c 'printf "%.*f" 4 123456e+25'
1234560000000000020450486779904.0000
$  ksh -c 'printf "%.*f" 4 123456e+25'
1234559999999999999886313162278,3840

$  dash ./script.sh
   123456789 --> 4<                       123500000 >--| yes
       23455 --> 4<                           23460 >--| yes
       23465 --> 4<                           23460 >--| yes
      1.2e-5 --> 6<                        0.000012 >--| yes
     1.2e-15 -->15<              0.0000000000000012 >--| yes
          12 --> 6<                              12 >--| yes
  123456e+25 --> 4< 1234999999999999958410892148736 >--| no

Una función razonablemente buena de "números significativos":

dec=$(IFS=0; printf '%s' $(printf '%.1f'))   ### What is the decimal separator?.
sed -i 's/[,.]/'"$dec"'/g' infile

zeros(){ # create an string of $1 zeros (for $1 positive or zero).
         printf '%.*d' $(( $1>0?$1:0 )) 0
       }

# Function significant (number, precision)
sigf(){ local sig sci exp N sgn len z1 z2 b c
    sig=$(($2>0?$2:1))                      ### significant digits (>0)
    N=$(printf '%+e\n' $1)                  ### use scientific format.
    exp=$(echo "${N##*[eE+]}+1"|bc)         ### find ceiling{log(N)}.
    N=${N%%[eE]*}                           ### cut after `e` or `E`.
    sgn=${N%%"${N#-}"}                      ### keep the sign (if any).
    N=${N#[+-]}                             ### remove the sign
    N=${N%[!0-9]*}${N#??}                   ### remove the $dec
    N=${N#"${N%%[!0]*}"}                    ### remove all leading zeros
    N=${N%"${N##*[!0]}"}                    ### remove all trailing zeros
    len=$((${#N}<sig?${#N}:sig))            ### count of selected characters.
    N=$(printf '%0.*s' "$len" "$N")         ### use the first $len characters.

    result="$N"

    # add the decimal separator or lead zeros or trail zeros.
    if   [ "$exp" -gt 0 ] && [ "$exp" -lt "$len" ]; then
            b=$(printf '%0.*s' "$exp" "$result")
            c=${result#"$b"}
            result="$b$dec$c"
    elif [ "$exp" -le 0 ]; then
            # fill front with leading zeros ($exp length).
            z1="$(zeros "$((-exp))")"
            result="0$dec$z1$result"
    elif [ "$exp" -ge "$len" ]; then
            # fill back with trailing zeros.
            z2=$(zeros "$((exp-len))")
            result="$result$z2"
    fi
    # place the sign back.
    printf '%s' "$sgn$result"
}

Y los resultados son:

$ dash ./script.sh
       123456789 --> 4<                       123400000 >--| yes
           23455 --> 4<                           23450 >--| yes
           23465 --> 4<                           23460 >--| yes
          1.2e-5 --> 6<                        0.000012 >--| yes
         1.2e-15 -->15<              0.0000000000000012 >--| yes
              12 --> 6<                              12 >--| yes
      123456e+25 --> 4< 1234000000000000000000000000000 >--| yes
      123456e-25 --> 4<       0.00000000000000000001234 >--| yes
 -12345.61234e-3 --> 4<                          -12.34 >--| yes
 -1.234561234e-3 --> 4<                       -0.001234 >--| yes
           76543 --> 2<                           76000 >--| yes
          -76543 --> 2<                          -76000 >--| yes
          123456 --> 4<                          123400 >--| yes
           12345 --> 4<                           12340 >--| yes
            1234 --> 4<                            1234 >--| yes
           123.4 --> 4<                           123.4 >--| yes
       12.345678 --> 4<                           12.34 >--| yes
      1.23456789 --> 4<                           1.234 >--| yes
    0.1234555646 --> 4<                          0.1234 >--| yes
       0.0076543 --> 2<                          0.0076 >--| yes
   .000000123400 --> 2<                      0.00000012 >--| yes
   .000001234000 --> 2<                       0.0000012 >--| yes
   .000012340000 --> 2<                        0.000012 >--| yes
   .000123400000 --> 2<                         0.00012 >--| yes
   .001234000000 --> 2<                          0.0012 >--| yes
   .012340000000 --> 2<                           0.012 >--| yes
   .123400000000 --> 2<                            0.12 >--| yes
           1.234 --> 2<                             1.2 >--| yes
          12.340 --> 2<                              12 >--| yes
         123.400 --> 2<                             120 >--| yes
        1234.000 --> 2<                            1200 >--| yes
       12340.000 --> 2<                           12000 >--| yes
      123400.000 --> 2<                          120000 >--| yes

fuente
0

Si ya tiene el número como una cadena, es decir, como "3456" o "0.003756", entonces podría hacerlo solo utilizando la manipulación de la cadena. Lo siguiente está fuera de mi cabeza, y no se probó a fondo, y usa sed, pero considere:

f() {
    local A="$1"
    local B="$(echo "$A" | sed -E "s/^-?0?\.?0*//")"
    local C="$(eval echo "${A%$B}")"
    if ((${#B} > 2)); then
        D="${B:0:2}"
    else
        D="$B"
    fi
    echo "$C$D"
}

Donde básicamente te quitas y guardas cualquier cosa "-0,000" al principio, luego usas una operación de subcadena simple en el resto. Una advertencia sobre lo anterior es que no se eliminan múltiples ceros iniciales. Lo dejaré como ejercicio.

John Allsup
fuente
1
Más que un ejercicio: no rellena el entero con ceros, ni tiene en cuenta el punto decimal incrustado. Pero sí, es factible usar este enfoque (aunque lograrlo puede estar más allá de las habilidades de OP).
Thomas Dickey