Data Understanding (NO₂ Medan)#

Studi Kasus: Konsentrasi NO₂ di Kota Medan#

1.1 Sumber Data#

Data konsentrasi gas Nitrogen Dioksida (NO₂) diperoleh dari platform Copernicus Data Space Ecosystem (CDSE) melalui layanan OpenEO API pada alamat openeo.dataspace.copernicus.eu. Data diambil dari produk citra satelit Sentinel-5P yang menyediakan informasi kualitas udara berbasis pengamatan atmosfer.

Secara umum, alur pengambilan data dilakukan dengan langkah-langkah berikut:

  • Koleksi Data: SENTINEL_5P_L2, yaitu produk Level-2 dari satelit Sentinel-5P yang berisi pengukuran konsentrasi berbagai gas atmosfer, termasuk NO₂.

  • Rentang Waktu: Data diunduh untuk periode 20 September 2024 hingga 20 Oktober 2024 sebagai contoh periode pengamatan selama satu bulan.

  • Wilayah Pengamatan (Area of Interest / AOI): Ditentukan dalam bentuk poligon (GeoJSON) yang mencakup wilayah Kota Medan, dengan koordinat geografis sekitar longitude 98.48825704511182 dan latitude 3.7073584868121117.

  • Band Data yang Digunakan: NO₂ — merepresentasikan kolom atau kanal pengamatan konsentrasi Nitrogen Dioksida.

1.2 Proses Pengambilan dan Agregasi Data#

Tahapan pengambilan data dilakukan melalui skrip Python menggunakan OpenEO dengan pipeline sebagai berikut:

  1. Penyaringan Nilai Tidak Valid: Nilai NO₂ yang bersifat negatif atau tidak logis ditandai sebagai invalid dan dikeluarkan dari proses perhitungan.

  2. Agregasi Temporal: Data diolah menggunakan fungsi aggregate_temporal_period(period="day", reducer="mean") untuk menghasilkan rata-rata harian dari pengamatan citra satelit selama periode yang dipilih.

  3. Agregasi Spasial: Fungsi aggregate_spatial(geometries=aoi, reducer="mean") digunakan untuk menghitung rata-rata spasial nilai NO₂ di seluruh area poligon Kota Medan.

  4. Ekspor Data: Hasil pengolahan disimpan dalam format CSV melalui proses batch export agar mudah digunakan untuk analisis lanjutan (misalnya untuk preprocessing dan modeling).

1.3 Area Pengamatan#

Wilayah analisis mencakup area Kota Medan, Sumatera Utara, yang d idefinisikan melalui GeoJSON polygon sebagai batas wilayah pengamatan. Area ini dipilih karena merupakan salah satu kota besar dengan kepadatan lalu lintas dan aktivitas industri yang cukup tinggi, sehingga cocok dijadikan lokasi studi kasus pemantauan kualitas udara berbasis citra satelit.

image.png

Import Library python yang digunakan#

import openeo
import pandas as pd
import matplotlib.pyplot as plt
import os
!pip install openeo
Requirement already satisfied: openeo in /usr/local/lib/python3.12/dist-packages (0.45.0)
Requirement already satisfied: requests>=2.26.0 in /usr/local/lib/python3.12/dist-packages (from openeo) (2.32.4)
Requirement already satisfied: urllib3>=1.9.0 in /usr/local/lib/python3.12/dist-packages (from openeo) (2.5.0)
Requirement already satisfied: shapely>=1.6.4 in /usr/local/lib/python3.12/dist-packages (from openeo) (2.1.2)
Requirement already satisfied: numpy>=1.17.0 in /usr/local/lib/python3.12/dist-packages (from openeo) (2.0.2)
Requirement already satisfied: xarray<2025.01.2,>=0.12.3 in /usr/local/lib/python3.12/dist-packages (from openeo) (2025.1.1)
Requirement already satisfied: pandas>0.20.0 in /usr/local/lib/python3.12/dist-packages (from openeo) (2.2.2)
Requirement already satisfied: pystac>=1.5.0 in /usr/local/lib/python3.12/dist-packages (from openeo) (1.14.1)
Requirement already satisfied: deprecated>=1.2.12 in /usr/local/lib/python3.12/dist-packages (from openeo) (1.2.18)
Requirement already satisfied: wrapt<2,>=1.10 in /usr/local/lib/python3.12/dist-packages (from deprecated>=1.2.12->openeo) (1.17.3)
Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas>0.20.0->openeo) (2.9.0.post0)
Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas>0.20.0->openeo) (2025.2)
Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas>0.20.0->openeo) (2025.2)
Requirement already satisfied: charset_normalizer<4,>=2 in /usr/local/lib/python3.12/dist-packages (from requests>=2.26.0->openeo) (3.4.4)
Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.12/dist-packages (from requests>=2.26.0->openeo) (3.11)
Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.12/dist-packages (from requests>=2.26.0->openeo) (2025.10.5)
Requirement already satisfied: packaging>=23.2 in /usr/local/lib/python3.12/dist-packages (from xarray<2025.01.2,>=0.12.3->openeo) (25.0)
Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas>0.20.0->openeo) (1.17.0)

Koneksi ke Copernicus Data Space#

connection = openeo.connect("openeo.dataspace.copernicus.eu").authenticate_oidc()
Authenticated using refresh token.

Scraping Data#

aoi = {
    "type": "Polygon",
    "coordinates": [
        [
           [98.48825704511182, 3.7073584868121117],
           [98.48825704511182, 3.424509844415482],
           [98.842944131384, 3.424509844415482],
           [98.842944131384, 3.7073584868121117],
           [98.48825704511182, 3.7073584868121117],
        ]
    ],
}

s5p = connection.load_collection(
    "SENTINEL_5P_L2",
    spatial_extent={
        "west": 98.48825704511182,
        "south": 3.424509844415482,
        "east": 98.842944131384,
        "north": 3.7073584868121117,
    },
    temporal_extent=["2024-10-02", "2025-11-01"],
    bands=["NO2"],
)
# 4. Mask nilai negatif (data invalid)
def mask_invalid(x):
    return x < 0

s5p_masked = s5p.mask(s5p.apply(mask_invalid))

# 5. Agregasi temporal harian
daily_mean = s5p_masked.aggregate_temporal_period(period="day", reducer="mean")

# 6. Agregasi spasial (rata-rata dalam AOI)
daily_mean_aoi = daily_mean.aggregate_spatial(geometries=aoi, reducer="mean")

# 7. Jalankan batch job dan hasilkan file CSV
job = daily_mean_aoi.execute_batch(out_format="CSV",)

# 8. Unduh hasil job
results = job.get_results()
results.download_files("no2_results")
0:00:00 Job 'j-25102910020342ffb40536626dc1ef06': send 'start'
0:00:13 Job 'j-25102910020342ffb40536626dc1ef06': queued (progress 0%)
0:00:18 Job 'j-25102910020342ffb40536626dc1ef06': queued (progress 0%)
0:00:24 Job 'j-25102910020342ffb40536626dc1ef06': queued (progress 0%)
0:00:32 Job 'j-25102910020342ffb40536626dc1ef06': queued (progress 0%)
0:00:42 Job 'j-25102910020342ffb40536626dc1ef06': queued (progress 0%)
0:00:55 Job 'j-25102910020342ffb40536626dc1ef06': queued (progress 0%)
0:01:10 Job 'j-25102910020342ffb40536626dc1ef06': running (progress N/A)
0:01:30 Job 'j-25102910020342ffb40536626dc1ef06': running (progress N/A)
0:01:54 Job 'j-25102910020342ffb40536626dc1ef06': running (progress N/A)
0:02:24 Job 'j-25102910020342ffb40536626dc1ef06': running (progress N/A)
0:03:01 Job 'j-25102910020342ffb40536626dc1ef06': running (progress N/A)
0:03:48 Job 'j-25102910020342ffb40536626dc1ef06': running (progress N/A)
0:04:46 Job 'j-25102910020342ffb40536626dc1ef06': running (progress N/A)
0:05:47 Job 'j-25102910020342ffb40536626dc1ef06': running (progress N/A)
0:06:47 Job 'j-25102910020342ffb40536626dc1ef06': finished (progress 100%)
[PosixPath('no2_results/timeseries.csv'),
 PosixPath('no2_results/job-results.json')]
import os
import shutil

old_name = "no2_results/timeseries.csv"
new_name = "no2_results/no2_medan_data.csv"

if os.path.exists(old_name):
    shutil.move(old_name, new_name)
    print(f"File renamed from 'timeseries.csv' to 'no2_medan_data.csv'")
else:
    print("File timeseries.csv not found")
File renamed from 'timeseries.csv' to 'no2_medan_data.csv'
# 9. Baca file CSV hasil
import os
df =pd.read_csv(os.path.join("no2_results", "no2_medan_data.csv"))
# 10. Pastikan kolom tanggal benar
df["date"] = pd.to_datetime(df["date"])

# 11. Buat kolom bulan (YYYY-MM)
df["month"] = df["date"].dt.to_period("M")

# 12. Hitung rata-rata NO2 per bulan
df_monthly = df.groupby("month", as_index=False)["NO2"].mean()

df
/tmp/ipython-input-3399061483.py:5: UserWarning: Converting to PeriodArray/Index representation will drop timezone information.
  df["month"] = df["date"].dt.to_period("M")
date feature_index NO2 month
0 2025-10-18 00:00:00+00:00 0 0.000025 2025-10
1 2025-10-19 00:00:00+00:00 0 0.000042 2025-10
2 2025-10-22 00:00:00+00:00 0 NaN 2025-10
3 2025-10-16 00:00:00+00:00 0 0.000038 2025-10
4 2025-10-20 00:00:00+00:00 0 NaN 2025-10
... ... ... ... ...
391 2025-10-04 00:00:00+00:00 0 0.000026 2025-10
392 2025-10-03 00:00:00+00:00 0 NaN 2025-10
393 2025-10-05 00:00:00+00:00 0 0.000032 2025-10
394 2025-09-29 00:00:00+00:00 0 NaN 2025-09
395 2025-10-31 00:00:00+00:00 0 NaN 2025-10

396 rows × 4 columns

1.4 Struktur Kolom#

Setelah proses batch job selesai dijalankan dan hasilnya diekspor ke format CSV, data kemudian dibaca menggunakan pandas.DataFrame. Berdasarkan hasil cuplikan data dan kode pemrosesan yang digunakan, terdapat tiga kolom utama dalam dataset hasil ekstraksi, yaitu:

1. Kolom date#

  • Tipe Data: datetime
    (dikonversi menggunakan pd.to_datetime(df["date"]) pada kode).

  • Format Contoh: 2025-10-01 00:00:00+00:00
    Format tersebut menunjukkan waktu dalam zona waktu UTC (+00:00).

  • Makna: Kolom ini berisi tanggal representatif dari periode agregasi, di mana setiap baris mencerminkan nilai rata-rata harian hasil perhitungan NO₂.
    Karena proses agregasi dilakukan dengan period="day", maka setiap baris pada kolom date merepresentasikan satu hari penuh hasil pengamatan.

2. Kolom feature_index#

  • Tipe Data: integer
    (umumnya bernilai 0 untuk setiap baris).

  • Makna: Kolom ini menunjukkan indeks fitur spasial (feature tile) yang dihasilkan oleh koleksi OpenEO.
    Pada kasus ini, karena dilakukan agregasi spasial terhadap satu AOI (Area of Interest), maka nilai feature_index hampir selalu 0.
    Namun, pada situasi lain di mana pipeline menghasilkan lebih dari satu area atau spatial tile, kolom ini berfungsi untuk membedakan setiap geometri spasial yang diolah.

3. Kolom NO2#

  • Tipe Data: float
    (nilai numerik desimal kecil, dapat berupa NaN bila tidak ada data valid).

  • Makna: Kolom ini berisi nilai rata-rata konsentrasi NO₂ hasil gabungan antara agregasi spasial dan temporal di area pengamatan (AOI) untuk setiap tanggal.
    Nilai NO2 mewakili tingkat kepadatan gas NO₂ yang diukur oleh sensor Sentinel-5P.

  • Rentang Nilai: Biasanya sangat kecil (misalnya 0.000033, 0.000054), yang menunjukkan unit kepadatan gas (perlu dikonfirmasi dengan metadata produk Sentinel-5P).

  • Nilai NaN: Menunjukkan tidak ada pengamatan valid untuk hari tersebut. Hal ini dapat terjadi apabila seluruh nilai piksel pada hari tersebut tidak lolos filter (misalnya karena nilai negatif atau tertutup awan).

Secara keseluruhan, struktur data yang dihasilkan cukup sederhana namun kaya informasi, terdiri dari:

Kolom

Tipe

Deskripsi

date

datetime

Tanggal pengamatan harian NO₂

feature_index

integer

Indeks fitur spasial (biasanya 0)

NO2

float

Nilai rata-rata NO₂ harian hasil agregasi spasial-temporal

Dataset dengan struktur seperti ini sangat sesuai untuk analisis deret waktu (time series) dan tahap berikutnya yaitu pembuatan model prediksi kualitas udara menggunakan pendekatan supervised learning.

1.5 Indentifikasi Missing Value#

NO2 = pd.read_csv('no2_results/no2_medan_data.csv')
NO2 = NO2.sort_values(by='date')
NO2.isnull().sum()
0
date 0
feature_index 0
NO2 143

missing_count = NO2.isnull().sum()
missing_percent = (missing_count / len(NO2)) * 100

missing_table = pd.DataFrame({
    'Missing Count': missing_count,
    'Missing Percent (%)': missing_percent.round(2)
})

missing_table
Missing Count Missing Percent (%)
date 0 0.00
feature_index 0 0.00
NO2 143 36.11

Jumlah Missing Value#

missing_count = NO2.isnull().sum()
missing_percent = (missing_count / len(NO2)) * 100

fig, ax = plt.subplots(figsize=(7,5))

bars = ax.bar(missing_count.index, missing_count)

ax.set_title("Frekuensi Missing Values per Kolom")
ax.set_ylabel("Jumlah Missing")

for i, v in enumerate(missing_count):
    ax.text(i, v + 0.5, f"{v} ({missing_percent[i]:.1f}%)",
            ha='center', va='bottom', fontsize=9)

plt.tight_layout()
plt.show()
/tmp/ipython-input-3930139431.py:12: FutureWarning: Series.__getitem__ treating keys as positions is deprecated. In a future version, integer keys will always be treated as labels (consistent with DataFrame behavior). To access a value by position, use `ser.iloc[pos]`
  ax.text(i, v + 0.5, f"{v} ({missing_percent[i]:.1f}%)",
_images/5072eb4204dd2f169d7b9d53c50cb77faac3f8841d0afb3ca6bd0c91e162467a.png

Tabel Informasi Dataset#

info_table = pd.DataFrame({
    "Kolom": df.columns,
    "Tipe Data": df.dtypes.astype(str),
    "Jumlah Data Tidak Kosong": len(df) - df.isna().sum(),
    "Jumlah Missing": df.isna().sum()
})
info_table
Kolom Tipe Data Jumlah Data Tidak Kosong Jumlah Missing
date date datetime64[ns, UTC] 396 0
feature_index feature_index int64 396 0
NO2 NO2 float64 253 143
month month period[M] 396 0

1.6 Visualisasi Time Series NO₂#

# Baca data
df = pd.read_csv("no2_results/no2_medan_data.csv")

df["date"] = pd.to_datetime(df["date"])

df["month"] = df["date"].dt.to_period("M")

df_monthly = df.groupby("month", as_index=False)["NO2"].mean()

# 13. Visualisasi hasil
plt.figure(figsize=(10,5))
plt.plot(df_monthly["month"].astype(str), df_monthly["NO2"], marker="o", color="blue", label="NO₂ Rata-rata per Bulan")
plt.title("Rata-rata Bulanan NO₂ (Sentinel-5P) di Bangkalan")
plt.xlabel("Bulan")
plt.ylabel("Konsentrasi NO₂")
plt.xticks(rotation=45)
plt.grid(True)
plt.legend()
plt.tight_layout()
plt.show()
/tmp/ipython-input-2664469499.py:6: UserWarning: Converting to PeriodArray/Index representation will drop timezone information.
  df["month"] = df["date"].dt.to_period("M")
_images/43caaf20ab07c951293cd849778fdd0698c8ed7fe86c0108ef7d15dcbcc7e054.png