Jak sformatować liczbę zmiennoprzecinkową dokładnie 2 cyframi znaczącymi w bash?

17

Chcę wydrukować liczbę zmiennoprzecinkową z dokładnie dwiema cyframi znaczącymi w bash (być może przy użyciu zwykłego narzędzia, takiego jak awk, bc, dc, perl itp.).

Przykłady:

  • 76543 należy wydrukować jako 76000
  • 0,0076543 należy wydrukować jako 0,0076

W obu przypadkach znaczącymi cyframi są 7 i 6. Przeczytałem kilka odpowiedzi na podobne problemy, takie jak:

Jak zaokrąglać liczby zmiennoprzecinkowe w powłoce?

Bash ograniczająca precyzję zmiennych zmiennoprzecinkowych

ale odpowiedzi koncentrują się na ograniczeniu liczby miejsc dziesiętnych (np. bcpolecenie za pomocą scale=2lub printfpolecenie za pomocą %.2f) zamiast cyfr znaczących.

Czy istnieje prosty sposób sformatowania liczby przy użyciu dokładnie 2 cyfr znaczących, czy też muszę napisać własną funkcję?

tafit3
źródło

Odpowiedzi:

13

Ta odpowiedź na pierwsze połączone pytanie ma na końcu linię, która jest niemal wyrzucana:

Zobacz także informacje %go zaokrąglaniu do określonej liczby cyfr znaczących.

Więc możesz po prostu pisać

printf "%.2g" "$n"

(ale zapoznaj się z poniższą sekcją dotyczącą separatora dziesiętnego i ustawień regionalnych oraz zauważ, że non-Bash printfnie musi obsługiwać %fi %g).

Przykłady:

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

Oczywiście masz teraz reprezentację mantysy-wykładnika zamiast czystego dziesiętnego, więc będziesz chciał przekonwertować:

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

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

Złożenie tego wszystkiego razem i zawinięcie w funkcję:

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

(Uwaga - ta funkcja jest napisana w przenośnej (POSIX) powłoce, ale zakłada, że printfobsługuje konwersje zmiennoprzecinkowe. Bash ma wbudowaną funkcję printf, więc wszystko jest w porządku, a implementacja GNU również działa, więc większość GNU / Systemy Linux mogą bezpiecznie korzystać z Dasha).

Przypadki testowe

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

Wyniki testu

.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

Uwaga na temat separatora dziesiętnego i ustawień regionalnych

Wszystkie powyższe prace zakładają, że znak podstawnika (znany również jako separator dziesiętny) jest ., jak w większości angielskich lokalizacjach. ,Zamiast tego używają innych ustawień regionalnych , a niektóre powłoki mają wbudowaną funkcję, printfktóra respektuje ustawienia regionalne. W tych powłokach może być konieczne ustawienie LC_NUMERIC=Cwymuszania użycia .znaku radix lub zapis, /usr/bin/printfaby uniemożliwić użycie wbudowanej wersji. To ostatnie jest skomplikowane przez fakt, że (przynajmniej niektóre wersje) wydają się zawsze analizować argumenty za pomocą ., ale drukować przy użyciu bieżących ustawień regionalnych.

Toby Speight
źródło
@ Stéphane Chazelas, dlaczego zmieniłeś mój dokładnie przetestowany shebang powłoki POSIX z powrotem na Bash po tym, jak usunąłem bashism? Twój komentarz wspomina %f/ %g, ale taki jest printfargument i nie trzeba POSIX-a, printfaby mieć powłokę POSIX. Myślę, że powinieneś skomentować zamiast tam edytować.
Toby Speight
printf %gnie można używać w skrypcie POSIX. To prawda printf, że to zależy od narzędzia, ale to narzędzie jest wbudowane w większość powłok. OP oznaczone jako bash, więc użycie shebang bash jest jednym łatwym sposobem na uzyskanie printf, który obsługuje% g. W przeciwnym razie musisz dodać założenie, że twoje printf (lub printf wbudowane w twoje shif, jeśli printfjest tam wbudowane) obsługuje niestandardowe (ale dość powszechne) %g...
Stéphane Chazelas
dashma wbudowane printf(które obsługuje %g). W systemach GNU mkshjest to prawdopodobnie jedyna powłoka w dzisiejszych czasach, która nie będzie miała wbudowanego printf.
Stéphane Chazelas,
Dzięki za ulepszenia - edytowałem, aby po prostu usunąć shebang (ponieważ pytanie jest oznaczone bash) i przenieść niektóre z nich do notatek - czy teraz wygląda to poprawnie?
Toby Speight
1
Niestety nie wyświetla to poprawnej liczby cyfr, jeśli końcowe cyfry są zerami. Na przykład printf "%.3g\n" 0.400daje 0,4, a nie 0,400
phiresky
4

TL; DR

Po prostu skopiuj i użyj funkcji sigfz tej sekcji A reasonably good "significant numbers" function:. Jest napisany (jak cały kod w tej odpowiedzi) do pracy z myślnikiem .

Daje printfprzybliżenie do całkowitej liczby N za pomocą $sigcyfr.

O separatorze dziesiętnym.

Pierwszym problemem do rozwiązania za pomocą printf jest efekt i użycie „znaku dziesiętnego”, który w USA jest kropką, a w DE przecinkiem (na przykład). Jest to problem, ponieważ to, co działa dla niektórych ustawień narodowych (lub powłoki), zawiedzie w przypadku niektórych innych ustawień regionalnych. Przykład:

$ 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

Jednym z powszechnych (i niepoprawnych rozwiązań) jest ustawienie LC_ALL=Cdla polecenia printf. Ale to ustawia znak dziesiętny na stały punkt dziesiętny. W przypadku lokalizacji, w których przecinek (lub inny) jest najczęściej używanym znakiem, który stanowi problem.

Rozwiązaniem jest sprawdzenie w skrypcie powłoki, w której działa, co to jest separator dziesiętny ustawień narodowych. To całkiem proste:

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

Usuwanie zer:

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

Ta wartość służy do zmiany pliku z listą testów:

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

Dzięki temu przebiegi w dowolnej powłoce lub ustawieniach regionalnych są automatycznie poprawne.


Niektóre podstawy.

Intuicyjne powinno być cięcie liczby, która ma zostać sformatowana za pomocą formatu, %.*ea nawet %.*gprintf. Główną różnicą między używaniem %.*elub %.*gjest to, jak liczą cyfry. Jeden używa pełnej liczby, drugi potrzebuje liczby mniejszej 1:

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

To działało dobrze dla 4 cyfr znaczących.

Po wycięciu liczby cyfr z liczby potrzebujemy dodatkowego kroku, aby sformatować liczby z wykładnikami innymi niż 0 (tak jak powyżej).

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

To działa poprawnie. Liczba części całkowitych (po lewej stronie znaku dziesiętnego) jest tylko wartością wykładnika ($ exp). Potrzebna liczba miejsc po przecinku to liczba cyfr znaczących ($ sig) pomniejszona o liczbę cyfr użytą już w lewej części separatora dziesiętnego:

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

Ponieważ integralna część fformatu nie ma ograniczeń, w rzeczywistości nie ma potrzeby jawnego deklarowania go, a ten (prostszy) kod działa:

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

Pierwsza próba.

Pierwsza funkcja, która może to zrobić w bardziej zautomatyzowany sposób:

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

Ta pierwsza próba działa z wieloma liczbami, ale zakończy się niepowodzeniem z liczbami, dla których liczba dostępnych cyfr jest mniejsza niż żądana znacząca liczba, a wykładnik jest mniejszy niż -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  

Doda to wiele zer, które nie są potrzebne.

Druga próba.

Aby rozwiązać ten problem, musimy wyczyścić N wykładnika i wszelkich zer końcowych. Następnie możemy uzyskać efektywną długość cyfr i pracować z tym:

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

Jednak używa to matematyki zmiennoprzecinkowej i „nic nie jest proste w zmiennoprzecinkowym”: Dlaczego moje liczby się nie sumują?

Ale nic w „punkcie zmiennoprzecinkowym” nie jest proste.

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

Jednak:

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

Dlaczego?:

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

A także polecenie printfjest wbudowane w wiele powłok.
Jakie printfwydruki mogą się zmienić wraz z powłoką:

$ 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

Dość dobra funkcja „liczb znaczących”:

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

A wyniki są następujące:

$ 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

źródło
0

Jeśli masz już numer jako ciąg, czyli „3456” lub „0,003756”, możesz potencjalnie zrobić to tylko przy użyciu manipulacji ciągiem. To, co dzieje się poniżej mojej głowy, nie zostało dokładnie przetestowane i używa sed, ale zastanów się:

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

Tam, gdzie w zasadzie rozbierasz i zapisujesz jakieś „-0.000” rzeczy na początku, użyj prostej operacji podciągania na pozostałych. Jednym z powyższych zastrzeżeń jest to, że wiele wiodących zer nie jest usuwanych. Zostawię to jako ćwiczenie.

John Allsup
źródło
1
Więcej niż ćwiczenie: nie uzupełnia liczb całkowitych zerami, ani nie uwzględnia osadzonego przecinka dziesiętnego. Ale tak, jest to wykonalne przy użyciu tego podejścia (chociaż osiągnięcie tego może przekraczać umiejętności OP).
Thomas Dickey,