web-dev-qa-db-fra.com

Comment formater un nombre à virgule flottante avec exactement 2 chiffres significatifs en bash?

Je veux imprimer le nombre à virgule flottante avec exactement deux chiffres significatifs dans bash (peut-être en utilisant un outil commun comme awk, bc, dc, Perl etc.).

Exemples:

  • 76543 doit être imprimé comme 76000
  • 0,0076543 doit être imprimé comme 0,0076

Dans les deux cas, les chiffres significatifs sont 7 et 6. J'ai lu quelques réponses pour des problèmes similaires comme:

Comment arrondir des nombres à virgule flottante dans le shell?

Bash limitant la précision des variables à virgule flottante

mais les réponses se concentrent sur la limitation du nombre de décimales (par exemple. bc commande avec scale=2 ou printf commande avec %.2f) au lieu de chiffres significatifs.

Existe-t-il un moyen simple de formater le nombre avec exactement 2 chiffres significatifs ou dois-je écrire ma propre fonction?

18
tafit3

Cette réponse à la première question liée a la ligne presque jetable à la fin:

Voir également %g pour arrondir à un nombre spécifié de chiffres significatifs.

Vous pouvez donc simplement écrire

printf "%.2g" "$n"

(mais voir la section ci-dessous sur le séparateur décimal et les paramètres régionaux, et notez que les non-Bash printf n'ont pas besoin de supporter %f et %g).

Exemples:

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

Bien sûr, vous avez maintenant une représentation des exposants de mantisse plutôt que la décimale pure, vous voudrez donc reconvertir:

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

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

Mettre tout cela ensemble et l'envelopper dans une fonction:

# 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
}

(Remarque - cette fonction est écrite dans un shell portable (POSIX), mais suppose que printf gère les conversions en virgule flottante. Bash a un printf intégré qui le fait, donc ça va ici, et l'implémentation GNU fonctionne également, donc la plupart des systèmes GNU/Linux peuvent utiliser Dash en toute sécurité).

Cas de test

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

Résultats de test

.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

Une note sur le séparateur décimal et les paramètres régionaux

Tout ce qui précède suppose que le caractère radical (également connu sous le nom de séparateur décimal) est ., comme dans la plupart des environnements linguistiques anglais. D'autres paramètres régionaux utilisent , à la place, et certains shells ont un printf intégré qui respecte les paramètres régionaux. Dans ces shells, vous devrez peut-être définir LC_NUMERIC=C pour forcer l'utilisation de . comme caractère radical, ou écrivez /usr/bin/printf pour empêcher l'utilisation de la version intégrée. Cette dernière est compliquée par le fait que (au moins certaines versions) semblent toujours analyser les arguments en utilisant ., mais imprimez en utilisant les paramètres régionaux actuels.

14
Toby Speight

TL; DR

Copiez et utilisez simplement la fonction sigf dans la section A reasonably good "significant numbers" function:. Il est écrit (comme tout le code dans cette réponse) pour fonctionner avec tiret .

Cela donnera l'approximation printf à partie entière de N avec $sig chiffres.

À propos du séparateur décimal.

Le premier problème à résoudre avec printf est l'effet et l'utilisation de la "marque décimale", qui aux États-Unis est un point, et en DE est une virgule (par exemple). C'est un problème car ce qui fonctionne pour certains paramètres régionaux (ou Shell) échouera avec certains autres paramètres régionaux. Exemple:

$ 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

Une solution courante (et incorrecte) consiste à définir LC_ALL=C pour la commande printf. Mais cela définit la marque décimale à un point décimal fixe. Pour les paramètres régionaux où une virgule (ou autre) est le caractère couramment utilisé qui pose problème.

La solution consiste à découvrir à l'intérieur du script du shell qui l'exécute quel est le séparateur décimal local. C'est assez simple:

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

Suppression des zéros:

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

Cette valeur est utilisée pour modifier le fichier avec la liste des tests:

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

Cela rend les exécutions sur n'importe quel shell ou paramètres régionaux automatiquement valides.


Quelques notions de base.

Il devrait être intuitif de couper le nombre à formater au format %.*e ou même %.*g de printf. La principale différence entre l'utilisation de %.*e ou %.*g est la façon dont ils comptent les chiffres. L'une utilise le nombre total, l'autre a besoin du nombre moins 1:

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

Cela a bien fonctionné pour 4 chiffres significatifs.

Une fois que le nombre de chiffres a été coupé du nombre, nous avons besoin d'une étape supplémentaire pour formater les nombres avec des exposants différents de 0 (comme c'était le cas ci-dessus).

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

Cela fonctionne correctement. Le nombre de la partie entière (à gauche du séparateur décimal) n'est que la valeur de l'exposant ($ exp). Le nombre de décimales nécessaires est le nombre de chiffres significatifs ($ sig) moins le nombre de chiffres déjà utilisés sur la partie gauche du séparateur décimal:

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

Comme la partie intégrante du format f n'a pas de limite, il n'est en fait pas nécessaire de la déclarer explicitement et ce code (plus simple) fonctionne:

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

Premier essai.

Une première fonction qui pourrait le faire de manière plus automatisée:

# 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.
}

Cette première tentative fonctionne avec de nombreux nombres mais échouera avec les nombres pour lesquels le nombre de chiffres disponibles est inférieur au nombre significatif demandé et l'exposant est inférieur à -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  

Il ajoutera de nombreux zéros inutiles.

Deuxième procès.

Pour résoudre ce problème, nous devons nettoyer N de l'exposant et tous les zéros à la fin. Ensuite, nous pouvons obtenir la longueur effective des chiffres disponibles et travailler avec cela:

# 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.
}

Cependant, cela utilise des mathématiques en virgule flottante, et "rien n'est simple en virgule flottante": Pourquoi mes chiffres ne s'additionnent-ils pas?

Mais rien en "virgule flottante" n'est simple.

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

Toutefois:

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

Pourquoi?:

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

Et, aussi, la commande printf est une commande intégrée de nombreux shells.
Ce que printf imprime peut changer avec le 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

Une fonction "nombres significatifs" raisonnablement bonne:

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"
}

Et les résultats sont:

$ 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
4
user79743

Si vous avez déjà le nombre sous forme de chaîne, c'est-à-dire "3456" ou "0,003756", vous ne pouvez le faire qu'en utilisant la manipulation de chaînes. Ce qui suit est sur le dessus de ma tête, et n'est pas minutieusement testé, et utilise sed, mais considérez:

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"
}

Où, fondamentalement, vous supprimez et enregistrez tout "-0,000" au début, puis utilisez une opération de sous-chaîne simple sur le reste. Une mise en garde à propos de ce qui précède est que les multiples 0 en tête ne sont pas supprimés. Je vais laisser cela comme un exercice.

0
John Allsup