Wie formatiere ich Gleitkommazahlen mit genau 2 signifikanten Stellen in Bash?

17

Ich möchte die Gleitkommazahl mit genau zwei signifikanten Stellen in Bash drucken (möglicherweise mit einem gängigen Tool wie awk, bc, dc, perl usw.).

Beispiele:

  • 76543 sollte als 76000 gedruckt werden
  • 0,0076543 sollte als 0,0076 gedruckt werden

In beiden Fällen sind die signifikanten Ziffern 7 und 6. Ich habe einige Antworten für ähnliche Probleme gelesen wie:

Wie rundet man Gleitkommazahlen in der Shell?

Begrenzungsgenauigkeit von Gleitkommavariablen

Die Antworten konzentrieren sich jedoch auf die Begrenzung der Anzahl der Dezimalstellen (z. B. bcBefehl mit scale=2oder printfBefehl mit %.2f) anstelle von signifikanten Ziffern.

Gibt es eine einfache Möglichkeit, die Zahl mit genau zwei signifikanten Stellen zu formatieren, oder muss ich meine eigene Funktion schreiben?

tafit3
quelle

Antworten:

13

Diese Antwort auf die erste verknüpfte Frage hat die fast wegwerfbare Zeile am Ende:

Siehe auch %gzum Runden auf eine bestimmte Anzahl von signifikanten Stellen.

Sie können also einfach schreiben

printf "%.2g" "$n"

(Beachten Sie jedoch den folgenden Abschnitt zu Dezimaltrennzeichen und Gebietsschema, und beachten Sie, dass Nicht-Bash printfnicht unterstützt werden muss %fund %g).

Beispiele:

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

Natürlich haben Sie jetzt eher eine Mantissen-Exponenten-Darstellung als eine reine Dezimalzahl. Sie möchten also Folgendes zurückkonvertieren:

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

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

Füge das alles zusammen und packe es in eine Funktion:

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

(Hinweis - Diese Funktion ist in einer portablen (POSIX) Shell geschrieben, setzt dies jedoch voraus printf die Gleitkommakonvertierungen handhabt. Bash verfügt über eine integrierte Funktion printf, sodass Sie hier in Ordnung sind und die GNU-Implementierung auch funktioniert, so dass die meisten GNUs / Linux-Systeme können Dash problemlos verwenden).

Testfälle

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

Testergebnisse

.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

Ein Hinweis zu Dezimaltrennzeichen und Gebietsschema

Bei allen obigen Arbeiten wird davon ausgegangen, dass das Radixzeichen (auch als Dezimaltrennzeichen bezeichnet) .wie in den meisten englischen Ländereinstellungen verwendet wird. Andere Gebietsschemas verwenden ,stattdessen und einige Shells verfügen über ein integriertes printfGebietsschema, das das Gebietsschema berücksichtigt. In diesen Shells müssen Sie möglicherweise festlegen LC_NUMERIC=C, dass die Verwendung eines .Radixzeichens erzwungen wird, oder Sie müssen schreiben /usr/bin/printf, um die Verwendung der integrierten Version zu verhindern. Letzteres wird durch die Tatsache erschwert, dass (zumindest in einigen Versionen) Argumente scheinbar immer mit syntaktisch analysiert ., aber mit den aktuellen Gebietsschemaeinstellungen gedruckt werden.

Toby Speight
quelle
@ Stéphane Chazelas, warum hast du meinen sorgfältig getesteten POSIX-Shell-Shebang wieder in Bash geändert, nachdem ich den Bashismus entfernt habe? Ihr Kommentar erwähnt %f/ %g, aber das ist das printfArgument, und man braucht kein POSIX printf, um eine POSIX-Shell zu haben. Ich denke, du hättest dort kommentieren statt editieren sollen.
Toby Speight
printf %gkann nicht in einem POSIX-Skript verwendet werden. Es ist wahr, es liegt am printfDienstprogramm, aber dieses Dienstprogramm ist in den meisten Shells integriert. Das OP ist als bash markiert, daher ist die Verwendung eines bash shebang eine einfache Möglichkeit, ein printf zu erhalten, das% g unterstützt. Andernfalls müssten Sie eine hinzufügen, sofern Ihr printf (oder das printf, das in Ihrem shif integriert printfist) das nicht standardmäßige (aber durchaus übliche) Format unterstützt %g...
Stéphane Chazelas
dashhab ein eingebautes printf(welches unterstützt %g). Auf GNU-Systemen mkshist dies wahrscheinlich die einzige Shell, die heutzutage keine eingebaute hat printf.
Stéphane Chazelas
Vielen Dank für Ihre Verbesserungen. Ich habe gerade das Wort shebang entfernt (da die Frage mit einem Tag versehen ist bash) und einige davon in Notizen verwandelt. Sieht es jetzt richtig aus?
Toby Speight
4

TL; DR

Einfach kopieren und die Funktion sigfim Abschnitt verwenden A reasonably good "significant numbers" function:. Es ist geschrieben (wie der gesamte Code in dieser Antwort), um mit Bindestrich zu arbeiten .

Es wird die printfAnnäherung an den ganzzahligen Teil von N mit $sigZiffern geben.

Über das Dezimaltrennzeichen.

Das erste Problem, das mit printf gelöst werden muss, ist der Effekt und die Verwendung des "Dezimalzeichens", das in den USA ein Punkt und in DE ein Komma ist (zum Beispiel). Dies ist ein Problem, da das, was für ein Gebietsschema (oder eine Shell) funktioniert, bei einem anderen Gebietsschema fehlschlägt. Beispiel:

$ 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

Eine häufige (und falsche) Lösung besteht darin, LC_ALL=Cden Befehl printf festzulegen. Dadurch wird die Dezimalstelle jedoch auf einen festen Dezimalpunkt gesetzt. Für Gebietsschemata, bei denen ein Komma (oder ein anderes) das häufig verwendete Zeichen ist, das ein Problem darstellt.

Die Lösung besteht darin, im Skript herauszufinden, in welcher Shell das Dezimaltrennzeichen für das Gebietsschema ausgeführt wird. Das ist ganz einfach:

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

Nullen entfernen:

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

Dieser Wert wird verwendet, um die Datei mit der Liste der Tests zu ändern:

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

Das macht die Läufe auf jeder Shell oder jedem Gebietsschema automatisch gültig.


Einige Grundlagen.

Es sollte intuitiv sein, die zu formatierende Zahl mit dem Format %.*eoder sogar %.*gprintf auszuschneiden. Der Hauptunterschied zwischen der Verwendung von %.*eoder %.*gbesteht darin, wie die Ziffern gezählt werden. Einer verwendet die volle Zählung, der andere benötigt die Zählung minus 1:

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

Das funktionierte gut für 4 signifikante Stellen.

Nachdem die Anzahl der Stellen aus der Zahl herausgeschnitten wurde, müssen wir einen zusätzlichen Schritt ausführen, um Zahlen mit Exponenten ungleich 0 (wie oben) zu formatieren.

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

Das funktioniert einwandfrei. Die Zählung des ganzzahligen Teils (links von der Dezimalstelle) entspricht nur dem Wert des Exponenten ($ exp). Die Anzahl der benötigten Dezimalstellen ist die Anzahl der signifikanten Stellen ($ sig) abzüglich der Anzahl der Stellen, die bereits im linken Teil des Dezimaltrennzeichens verwendet wurden:

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

Da der integrale Teil des fFormats keine Begrenzung hat, muss er nicht explizit deklariert werden, und dieser (einfachere) Code funktioniert:

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

Erster Versuch.

Eine erste Funktion, die dies automatisiert ausführen könnte:

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

Dieser erste Versuch funktioniert mit vielen Zahlen, schlägt jedoch mit Zahlen fehl, für die die Anzahl der verfügbaren Stellen geringer als die angeforderte signifikante Anzahl und der Exponent kleiner als -4 ist:

   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  

Es werden viele Nullen hinzugefügt, die nicht benötigt werden.

Zweiter Versuch.

Um dies zu lösen, müssen wir N des Exponenten und alle nachfolgenden Nullen entfernen. Dann können wir die effektive Länge der verfügbaren Ziffern ermitteln und damit arbeiten:

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

In diesem Fall wird jedoch Gleitkomma-Mathematik verwendet, und "im Gleitkomma ist nichts einfach": Warum addieren sich meine Zahlen nicht?

Aber nichts in "Fließkomma" ist einfach.

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

Jedoch:

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

Warum?:

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

Außerdem besteht der Befehl printfaus vielen Muscheln.
Welche printfAusdrucke können sich mit der Shell ändern:

$ 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

Eine einigermaßen gute "signifikante Zahl" -Funktion:

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

Und die Ergebnisse sind:

$ 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

quelle
0

Wenn Sie die Nummer bereits als Zeichenfolge haben, dh als "3456" oder "0.003756", können Sie dies möglicherweise nur mit der Zeichenfolgenmanipulation tun. Das Folgende ist von der Spitze meines Kopfes und nicht gründlich getestet und verwendet sed, aber bedenken Sie:

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

Wo Sie im Grunde genommen alle "-0.000" Sachen am Anfang entfernen und speichern, dann verwenden Sie einen einfachen Teilstring-Vorgang für den Rest. Eine Einschränkung in Bezug auf das Obige ist, dass mehrere führende Nullen nicht entfernt werden. Ich lasse das als Übung.

John Allsup
quelle
1
Mehr als eine Übung: weder wird die ganze Zahl mit Nullen aufgefüllt, noch wird der eingebettete Dezimalpunkt berücksichtigt. Aber ja, es ist machbar mit diesem Ansatz (obwohl dies über die Fähigkeiten von OP hinausgehen kann).
Thomas Dickey