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

17

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, bccommande avec scale=2ou printfcommande 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?

tafit3
la source

Réponses:

13

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

Voir aussi %gpour 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 printfn'ont pas besoin de prendre en charge %fet %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 qu'elle printfgère les conversions à virgule flottante. Bash a une fonction intégrée printfqui le fait, donc ça va ici, et l'implémentation GNU fonctionne également, donc la plupart des GNU / Les systèmes 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 le travail ci-dessus suppose que le caractère radix (é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 intégré printfqui respecte les paramètres régionaux. Dans ces shells, vous devrez peut-être définir LC_NUMERIC=Cpour forcer l'utilisation de .comme caractère Radix, ou écrire /usr/bin/printfpour 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 imprimer en utilisant les paramètres régionaux actuels.

Toby Speight
la source
@ Stéphane Chazelas, pourquoi avez-vous changé mon shebang de coquille POSIX soigneusement testé en Bash après avoir retiré le bashisme? Votre commentaire mentionne %f/ %g, mais c'est l' printfargument, et on n'a pas besoin d'un POSIX printfpour avoir un shell POSIX. Je pense que vous auriez dû commenter au lieu de le modifier.
Toby Speight
printf %gne peut pas être utilisé dans un script POSIX. Il est vrai que cela printfdépend de l' utilitaire, mais cet utilitaire est intégré dans la plupart des shells. L'OP étiqueté comme bash, donc utiliser un shebang bash est un moyen facile d'obtenir un printf qui prend en charge% g. Sinon, vous devrez ajouter un en supposant que votre printf (ou le printf intégré à votre shif y printfest intégré) supporte le non standard (mais assez courant) %g...
Stéphane Chazelas
dasha un intégré printf(qui prend en charge %g). Sur les systèmes GNU, mkshc'est probablement le seul shell de nos jours qui n'aura pas de fonction intégrée printf.
Stéphane Chazelas
Merci pour vos améliorations - j'ai édité pour supprimer simplement le shebang (puisque la question est balisée bash) et reléguer une partie de cela aux notes - cela semble-t-il correct maintenant?
Toby Speight
1
Malheureusement, cela n'imprime pas le nombre correct de chiffres si les derniers chiffres sont des zéros. Par exemple, printf "%.3g\n" 0.400donne 0,4 et non 0,400
phiresky
4

TL; DR

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

Il donnera l' printfapproximation de la partie entière de N avec des $sigchiffres.

À 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=Cla 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 locale automatiquement valides.


Quelques notions de base.

Il devrait être intuitif de couper le nombre à formater avec le format %.*eou même %.*gprintf. La principale différence entre l'utilisation%.*e ou la %.*gfaç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 dans 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 fformat n'a pas de limite, il n'est en fait pas nécessaire de le 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

Pourtant:

 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, également, la commande printfest une fonction intégrée de nombreux obus.
Quelles printfimpressions peuvent changer avec la coque:

$ 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

la source
0

Si vous avez déjà le numéro 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.

John Allsup
la source
1
Plus qu'un exercice: il ne remplit pas l'entier avec des zéros, ni ne rend compte du point décimal incorporé. Mais oui, il est possible d'utiliser cette approche (même si cela peut dépasser les compétences d'OP).
Thomas Dickey