Bagaimana memformat angka floating point dengan tepat 2 digit signifikan di bash?

17

Saya ingin mencetak angka floating point dengan tepat dua digit signifikan di bash (mungkin menggunakan alat umum seperti awk, bc, dc, perl dll).

Contoh:

  • 76543 harus dicetak sebagai 76000
  • 0,0076543 harus dicetak sebagai 0,0076

Dalam kedua kasus angka yang signifikan adalah 7 dan 6. Saya telah membaca beberapa jawaban untuk masalah yang sama seperti:

Bagaimana cara membulatkan angka floating point di shell?

Bash membatasi presisi variabel floating point

tetapi jawabannya fokus pada membatasi jumlah tempat desimal (mis. bcperintah dengan scale=2atau printfperintah dengan %.2f) alih-alih angka signifikan.

Apakah ada cara mudah untuk memformat angka dengan tepat 2 digit signifikan atau apakah saya harus menulis fungsi saya sendiri?

tafit3
sumber

Jawaban:

13

Jawaban untuk pertanyaan yang ditautkan pertama ini memiliki garis yang hampir dibuang di akhir:

Lihat juga %guntuk pembulatan ke sejumlah digit signifikan.

Jadi Anda cukup menulis

printf "%.2g" "$n"

(tetapi lihat bagian di bawah ini pada pemisah desimal dan lokal, dan perhatikan bahwa non-Bash printftidak perlu mendukung %fdan %g).

Contoh:

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

Tentu saja, Anda sekarang memiliki representasi mantissa-eksponen daripada desimal murni, jadi Anda ingin mengonversi kembali:

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

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

Menyatukan semua ini, dan membungkusnya dalam suatu fungsi:

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

(Catatan - fungsi ini ditulis dalam shell portabel (POSIX), tetapi mengasumsikan bahwa printfmenangani konversi floating-point. Bash memiliki built-in printfyang tidak, jadi Anda baik-baik saja di sini, dan implementasi GNU juga berfungsi, sehingga sebagian besar GNU / Sistem Linux dapat menggunakan Dash dengan aman).

Uji kasus

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

Hasil tes

.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

Catatan tentang pemisah dan lokal desimal

Semua yang bekerja di atas mengasumsikan bahwa karakter radix (juga dikenal sebagai pemisah desimal) ., seperti di sebagian besar lokal bahasa Inggris. Lokal lain menggunakan ,sebagai gantinya, dan beberapa shell memiliki built-in printfyang menghormati lokal. Dalam shell ini, Anda mungkin perlu mengatur LC_NUMERIC=Cuntuk memaksa penggunaan .sebagai karakter radix, atau menulis /usr/bin/printfuntuk mencegah penggunaan versi built-in. Yang terakhir ini diperumit oleh fakta bahwa (setidaknya beberapa versi) tampaknya selalu menguraikan argumen menggunakan ., tetapi mencetak menggunakan pengaturan lokal saat ini.

Toby Speight
sumber
@ Stéphane Chazelas, mengapa Anda mengubah shebang POSIX saya yang diuji dengan hati-hati kembali ke Bash setelah saya menghapus bashismenya? Komentar Anda menyebutkan %f/ %g, tapi itulah printfargumennya, dan orang tidak perlu POSIX printfuntuk memiliki shell POSIX. Saya pikir Anda seharusnya berkomentar daripada mengedit di sana.
Toby Speight
printf %gtidak dapat digunakan dalam skrip POSIX. Memang benar itu ke printfutilitas, tetapi utilitas itu dibangun di sebagian besar shell. OP ditandai sebagai bash, jadi menggunakan bash shebang adalah salah satu cara mudah untuk mendapatkan printf yang mendukung% g. Jika tidak, Anda perlu menambahkan asumsi printf Anda (atau printf bawaan shjika Anda printfdibangun di sana) mendukung non-standar (tetapi cukup umum) %g...
Stéphane Chazelas
dashMemiliki builtin printf(yang mendukung %g). Pada sistem GNU, mkshmungkin satu-satunya shell hari ini yang tidak memiliki builtin printf.
Stéphane Chazelas
Terima kasih atas perbaikan Anda - Saya telah diedit untuk hanya menghapus shebang (karena pertanyaan ditandai bash) dan memindahkan beberapa ke catatan - apakah terlihat benar sekarang?
Toby Speight
1
Sayangnya ini tidak mencetak jumlah digit yang benar jika digit tambahannya nol. Misalnya printf "%.3g\n" 0.400memberi 0,4 bukan 0,400
phiresky
4

TL; DR

Cukup salin dan gunakan fungsi sigfdi bagian ini A reasonably good "significant numbers" function:. Ada tertulis (karena semua kode dalam jawaban ini) bekerja dengan tanda hubung .

Ini akan memberikan printfperkiraan ke bagian integer N dengan $sigdigit.

Tentang pemisah desimal.

Masalah pertama yang harus diselesaikan dengan printf adalah efek dan penggunaan "tanda desimal", yang di AS adalah sebuah titik, dan di DE adalah koma (misalnya). Ini adalah masalah karena apa yang berfungsi untuk beberapa lokal (atau shell) akan gagal dengan beberapa lokal lainnya. Contoh:

$ 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

Salah satu solusi umum (dan salah) adalah untuk mengatur LC_ALL=Cperintah printf. Tapi itu menetapkan tanda desimal ke titik desimal tetap. Untuk lokal di mana koma (atau lainnya) adalah karakter yang umum digunakan yang menjadi masalah.

Solusinya adalah mencari tahu di dalam skrip untuk shell menjalankannya apa pemisah desimal lokal. Itu cukup sederhana:

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

Menghapus nol:

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

Nilai itu digunakan untuk mengubah file dengan daftar tes:

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

Itu membuat proses pada shell atau lokal apa pun secara otomatis valid.


Beberapa dasar.

Ini harus intuitif untuk memotong nomor yang akan diformat dengan format %.*eatau bahkan %.*gprintf. Perbedaan utama antara menggunakan %.*eatau %.*gbagaimana mereka menghitung angka. Satu menggunakan penghitungan penuh, yang lain membutuhkan penghitungan kurang 1:

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

Itu bekerja dengan baik untuk 4 digit signifikan.

Setelah jumlah digit telah dipotong dari angka, kita perlu langkah tambahan untuk memformat angka dengan eksponen yang berbeda dari 0 (seperti di atas).

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

Ini berfungsi dengan benar. Hitungan bilangan bulat (di sebelah kiri tanda desimal) hanya nilai eksponen ($ exp). Hitungan desimal yang dibutuhkan adalah jumlah digit signifikan ($ sig) dikurangi jumlah digit yang sudah digunakan di bagian kiri pemisah desimal:

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

Karena bagian integral dari fformat tidak memiliki batas, sebenarnya tidak perlu mendeklarasikannya secara eksplisit dan kode ini (lebih sederhana) berfungsi:

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

Percobaan pertama.

Fungsi pertama yang dapat melakukan ini dengan cara yang lebih otomatis:

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

Upaya pertama ini bekerja dengan banyak angka tetapi akan gagal dengan angka yang jumlah digit yang tersedia kurang dari jumlah signifikan yang diminta dan eksponen kurang dari -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  

Ini akan menambahkan banyak nol yang tidak diperlukan.

Uji coba kedua.

Untuk mengatasinya kita perlu membersihkan N dari eksponen dan angka nol di belakangnya. Kemudian kita bisa mendapatkan panjang digit efektif yang tersedia dan bekerja dengan itu:

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

Namun, itu menggunakan matematika titik mengambang, dan "tidak ada yang sederhana di titik mengambang": Mengapa angka saya tidak bertambah?

Tapi tidak ada dalam "floating point" yang sederhana.

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

Namun:

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

Mengapa?:

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

Dan, juga, perintahnya printfadalah builtin dari banyak shell.
Apa yang printfdicetak dapat berubah dengan 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

Fungsi "angka signifikan" yang cukup baik:

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

Dan hasilnya adalah:

$ 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

sumber
0

Jika Anda sudah memiliki nomor tersebut sebagai string, yaitu "3456" atau "0,003756", maka Anda berpotensi melakukannya hanya menggunakan manipulasi string. Berikut ini dari atas kepala saya, dan tidak diuji secara menyeluruh, dan menggunakan sed, tetapi pertimbangkan:

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

Di mana pada dasarnya Anda menanggalkan dan menyimpan barang "-0.000" di awal, lalu gunakan operasi substring sederhana pada yang lain. Satu peringatan tentang hal di atas adalah bahwa beberapa 0 di depan tidak dihapus. Saya akan meninggalkan itu sebagai latihan.

John Allsup
sumber
1
Lebih dari sebuah latihan: ia tidak mengisi bilangan bulat dengan nol, juga tidak menghitung titik desimal tertanam. Tapi ya, itu bisa dilakukan dengan menggunakan pendekatan ini (meskipun mencapai itu mungkin di luar keterampilan OP).
Thomas Dickey