Rekap Bagian 1 menemukan enam masalah yang berkerumun di satu tema: data yang tidak lengkap. Data hilang muncul dalam dua wujud yang perlakuannya berbeda: sel NaN — barisnya ada tetapi nilainya tidak — dan baris yang sama sekali tidak ada, sehingga pandas tidak tahu bahwa 17 Januari seharusnya tercatat. Sel NaN cukup diisi; baris hilang harus dilengkapi lebih dulu dari kalender penuh agar menjadi NaN, baru kemudian diisi. Skrip tujuh langkah pada bagian ini mengerjakan keduanya sampai dataset utuh 270 baris, lalu mengemasnya menjadi fungsi muat_bersih() yang dipakai Bagian 3 dan 4.

Skrip Tujuh Langkah Pembersihan #

Skrip perbaiki_cuaca.py menjalankan ketujuh langkah sambil mencetak efek masing-masing, lalu membandingkan hasilnya dengan fungsi muat_bersih() pada buat_cuaca.py.

python
"""Memperbaiki dataset cuaca: duplikat, nilai tak valid, tanggal hilang, NaN."""
from pathlib import Path
import tempfile

import numpy as np
import pandas as pd
from pandas.testing import assert_frame_equal

from buat_cuaca import KOTA, buat_cuaca, muat_bersih


if __name__ == "__main__":
    with tempfile.TemporaryDirectory() as folder:
        berkas = Path(folder) / "cuaca.csv"
        buat_cuaca().to_csv(berkas, index=False)
        mentah = pd.read_csv(berkas)

    # langkah 1: buang baris duplikat
    df = mentah.drop_duplicates()
    print(f"1) duplikat dihapus         : {len(mentah)} -> {len(df)} baris")

    # langkah 2: kolom tanggal menjadi datetime64
    df["tanggal"] = pd.to_datetime(df["tanggal"])
    print(f"2) dtype tanggal            : {df['tanggal'].dtype}")

    # langkah 3: perbaiki nilai yang melanggar batas fisik
    n_ekstrem = int((df["suhu_max"] > 45).sum())
    n_negatif = int((df["curah_hujan"] < 0).sum())
    df.loc[df["suhu_max"] > 45, "suhu_max"] = np.nan
    tukar = df["suhu_min"] > df["suhu_max"]
    df.loc[tukar, ["suhu_min", "suhu_max"]] = df.loc[
        tukar, ["suhu_max", "suhu_min"]
    ].to_numpy()
    df.loc[df["curah_hujan"] < 0, "curah_hujan"] = np.nan
    print(f"3) suhu_max 55.0 -> NaN     : {n_ekstrem} baris")
    print(f"   suhu_min/suhu_max ditukar: {int(tukar.sum())} baris")
    print(f"   curah_hujan negatif      : {n_negatif} sel jadi NaN")

    # langkah 4: deteksi tanggal hilang per kota
    lengkap = pd.date_range("2026-01-01", "2026-03-31", freq="D")
    for kota in KOTA:
        ada = pd.DatetimeIndex(df.loc[df["kota"] == kota, "tanggal"])
        hilang = lengkap.difference(ada)
        daftar = ", ".join(d.strftime("%Y-%m-%d") for d in hilang)
        print(f"4) {kota}: {len(hilang)} tanggal hilang -> {daftar or 'tidak ada'}")

    # langkah 5: lengkapi kalender lalu isi NaN
    bagian = []
    for kota in KOTA:
        g = df[df["kota"] == kota].set_index("tanggal").asfreq("D")
        g["kota"] = kota
        g[["suhu_min", "suhu_max", "curah_hujan"]] = g[
            ["suhu_min", "suhu_max", "curah_hujan"]
        ].interpolate(method="time")
        g["kelembapan"] = g["kelembapan"].ffill()
        bagian.append(g)
    df = pd.concat(bagian).reset_index()
    df["kelembapan"] = df["kelembapan"].astype("int64")
    print(f"5) dataset lengkap          : {len(df)} baris (90 hari x 3 kota)")

    # langkah 6: interpolasi linier vs berbasis waktu
    g_bandung = bagian[0]
    lin = g_bandung["suhu_max"].interpolate(method="linear")
    waktu = g_bandung["suhu_max"].interpolate(method="time")
    selisih = np.abs(lin - waktu).max()
    print(f"6) Bandung suhu_max, linear vs time: selisih maksimum {selisih:.1e}")
    ilustrasi = pd.Series(
        [10.0, np.nan, np.nan, 30.0],
        index=pd.to_datetime(["2026-01-01", "2026-01-02", "2026-01-08", "2026-01-09"]),
    )
    print(f"   ilustrasi gap tak seragam linear: {ilustrasi.interpolate(method='linear').round(2).tolist()}")
    print(f"   ilustrasi gap tak seragam time  : {ilustrasi.interpolate(method='time').round(2).tolist()}")

    # langkah 7: validasi fisik akhir
    print("7) validasi fisik:")
    print(f"   suhu_min <= suhu_max : {(df['suhu_min'] <= df['suhu_max']).all()}")
    print(f"   suhu di rentang 0-45 : {df['suhu_min'].between(0, 45).all() and df['suhu_max'].between(0, 45).all()}")
    print(f"   curah_hujan >= 0     : {(df['curah_hujan'] >= 0).all()}")
    print(f"   kelembapan 30-100    : {df['kelembapan'].between(30, 100).all()}")
    print(f"   tanpa NaN            : {df.isna().sum().sum() == 0}")

    assert_frame_equal(df, muat_bersih(mentah))
    print("validasi: hasil langkah manual == muat_bersih() pada buat_cuaca.py -> identik")

Output yang Dihasilkan:

text
1) duplikat dihapus         : 268 -> 263 baris
2) dtype tanggal            : datetime64[us]
3) suhu_max 55.0 -> NaN     : 3 baris
   suhu_min/suhu_max ditukar: 2 baris
   curah_hujan negatif      : 4 sel jadi NaN
4) Bandung: 2 tanggal hilang -> 2026-01-17, 2026-01-18
4) Medan: 4 tanggal hilang -> 2026-02-14, 2026-02-15, 2026-02-16, 2026-02-17
4) Yogyakarta: 1 tanggal hilang -> 2026-02-08
5) dataset lengkap          : 270 baris (90 hari x 3 kota)
6) Bandung suhu_max, linear vs time: selisih maksimum 0.0e+00
   ilustrasi gap tak seragam linear: [10.0, 16.67, 23.33, 30.0]
   ilustrasi gap tak seragam time  : [10.0, 12.5, 27.5, 30.0]
7) validasi fisik:
   suhu_min <= suhu_max : True
   suhu di rentang 0-45 : True
   curah_hujan >= 0     : True
   kelembapan 30-100    : True
   tanpa NaN            : True
validasi: hasil langkah manual == muat_bersih() pada buat_cuaca.py -> identik

Membuang Duplikat dan Memperbaiki Nilai Tak Valid #

Tiga langkah pertama membersihkan isi baris. drop_duplicates() menghapus lima salinan persis sehingga 268 baris menjadi 263; pd.to_datetime() mengubah kolom tanggal menjadi datetime64[us]. Langkah ketiga menangani pelanggaran batas fisik, dan di sinilah keputusan pentingnya: nilai 55.0 tidak langsung diganti angka lain, melainkan dijadikan NaN terlebih dahulu — pandas tidak menebak, dan pengisian nilainya diserahkan ke interpolasi yang membaca tetangga sebelum-sesudahnya. Pelanggaran suhu_min > suhu_max diperlakukan berbeda karena penyebabnya jelas, sensor tertukar, sehingga dua baris itu cukup ditukar kembali lewat df.loc[tukar, ["suhu_min", "suhu_max"]] = ...to_numpy(). Empat curah hujan negatif kembali menjadi NaN karena hujan tidak mungkin negatif.

Mendeteksi dan Melengkapi Tanggal yang Hilang #

Langkah keempat menjawab pertanyaan “tanggal apa saja yang tidak tercatat?”. Kalender penuh dibangun dengan pd.date_range("2026-01-01", "2026-03-31", freq="D") — 90 tanggal — lalu lengkap.difference(ada) mengurangkan tanggal yang ada per kota. Hasilnya persis seperti rancangan Bagian 1: Bandung kehilangan 2026-01-17 dan 2026-01-18, Medan kehilangan empat hari 14 sampai 17 Februari, dan Yogyakarta kehilangan 2026-02-08. Metode difference() pada DatetimeIndex mengembalikan selisih himpunan tanggal, sehingga daftar ini bisa dicetak apa adanya.

Langkah kelima melengkapi kalender. Untuk setiap kota, set_index("tanggal").asfreq("D") menambahkan baris pada setiap tanggal yang belum ada, dan baris baru itu berisi NaN di semua kolom — termasuk kolom kota yang bertipe teks; karena teks tidak bisa diinterpolasi, kolom itu diisi langsung dengan g["kota"] = kota. Hasil per kota digabung dengan pd.concat(), lalu reset_index() mengembalikan tanggal sebagai kolom; totalnya 270 baris, 90 hari x 3 kota tanpa kekurangan. Baris baru bisa juga langsung berisi nilai: asfreq("D", fill_value=...) untuk nilai tetap atau asfreq("D").ffill() untuk penyalinan maju — pola kedua dipakai untuk kelembapan pada langkah keenam. Kolom kelembapan naik dtype-nya dari int64 ke float64 selama menampung NaN, lalu kembali ke int64 lewat astype setelah terisi penuh.

Mengisi NaN: ffill, interpolate, dan Validasi Fisik #

Langkah keenam memilih teknik pengisian sesuai watak kolomnya. Kelembapan berubah lambat antarhari, sehingga ffill() — menyalin nilai hari sebelumnya — cukup dan aman. Suhu dan curah hujan lebih dinamis, sehingga keduanya diisi dengan interpolate(method="time"), interpolasi linier yang bobotnya dihitung dari jarak waktu antarstempel, bukan dari posisi baris. Pada dataset ini kedua varian memberi hasil yang sama persis — selisih maksimum 0.0e+00 — karena setelah asfreq("D") kalendernya utuh dan jarak antarbaris selalu satu hari. Perbedaannya baru terlihat saat stempel waktu tidak seragam, seperti pada ilustrasi empat baris berceelah enam hari: linear menghitung berdasar posisi (16.67 dan 23.33), time berdasarkan tanggal (12.5 dan 27.5).

Langkah ketujuh menutup pekerjaan dengan gerbang mutu: lima pemeriksaan fisik (suhu_min <= suhu_max, suhu di rentang 0-45, curah hujan nonnegatif, kelembapan 30-100, tanpa NaN) semuanya True, lalu assert_frame_equal(df, muat_bersih(mentah)) memastikan prosedur manual kita identik dengan fungsi kemasan muat_bersih() pada buat_cuaca.py. Fungsi kemasan itulah yang membuat Bagian 3 dan Bagian 4 tidak perlu mengulang tujuh langkah ini; pola perbandingan dua jalur Anda kenal dari section Pengujian dan Debugging.

Dengan 270 baris bersih di tangan, pertanyaan berikutnya bukan lagi “apakah datanya benar”, melainkan “apa polanya”. Bagian 3 mulai membaca pola itu: rata-rata bergerak tujuh hari, rekap bulanan per kota, jumlah hari hujan, dan hari-hari ekstrem.

Sumber bacaan resmi: pandas.DataFrame.duplicated , pandas.DataFrame.asfreq , dan pandas.DataFrame.interpolate .