Lewati ke isi
Profil penulisSeri Buku GIS Kehutanan dan Pertanian/ PY

BAB 20: Titik yang Bisa Dipercaya: Survei Lapangan dan Basis Data dengan Skrip

Studi kasus: "Titik ini benar-benar di sini?" #

Tim surveyor KPH Contoh mencatat titik pohon memakai ponsel. Kepala Seksi mengajukan tiga pertanyaan. Seberapa tepat titik dari ponsel itu? Bagaimana menyiapkan wadah data supaya surveyor tidak salah ketik? Dan setelah data masuk, bagaimana memastikan mutunya? Anda bisa menjawab semuanya lewat menu QGIS. Dengan skrip, jawabannya bisa diulang untuk survei berikutnya, dan pemeriksaannya tidak bergantung pada ingatan.

Konsep: survei yang bisa dipercaya, dalam tiga kalimat #

Data survei itu seperti buku catatan lapangan: berguna hanya bila isinya benar dan bisa diperiksa. Pekerjaan Anda ada tiga. Pertama, mengukur seberapa tepat alat Anda (Skrip 20.1). Kedua, menyiapkan wadah dan penjaga nilai agar salah ketik tertahan sejak awal (Skrip 20.2 sampai 20.5). Ketiga, memeriksa dan menanyai data yang sudah masuk (Skrip 20.6 sampai 20.9).

Ilustrasi 20.1: Isi satu berkas GeoPackage survei
Skema isi Survei_KPH.gpkg: layer Titik_Survei, tabel Ref_Kondisi dan Ref_Jenis, domain, dan gaya tersimpan Formulir_Survei

Lima istilah yang perlu Anda kenal sekarang:

  • GNSS: sistem satelit penentu posisi. Ponsel Anda memakainya untuk menunjukkan letak Anda.
  • GeoPackage: satu berkas .gpkg yang bisa memuat banyak layer, tabel, dan pengaturan tampilan. Isinya basis data SQLite.
  • Tabel tanpa geometri: tabel biasa tanpa titik, garis, atau poligon, misalnya daftar pilihan.
  • Domain: daftar atau rentang nilai yang sah untuk satu kolom.
  • SQL: bahasa untuk bertanya kepada basis data.

Siapkan dulu: folder, data, dan urutan skrip #

  1. Buat folder KPH_Contoh. Salin folder paket-i3 ke dalamnya. Paket itu memuat Gnss_Uji.gpkg dan Survei_Lapangan.gpkg.
  2. Skrip 20.2 membuat berkas Survei_KPH.gpkg di folder KPH_Contoh. Skrip 20.3, 20.4, 20.5, dan 20.9 memakainya.
  3. Jalankan skrip dari Plugins ► Python Console ► Show Editor (Bab 3). Ganti jalur di bagian atas skrip dengan jalur Anda.
Ilustrasi 20.2: Siapa memakai hasil siapa
Skema sembilan skrip: 20.2 membuat Survei_KPH.gpkg yang dipakai 20.3, 20.4, 20.5, dan 20.9, dan 20.9 juga butuh gaya dari 20.4. Skrip 20.1, 20.6, 20.7, dan 20.8 berdiri sendiri

Satu catatan tentang tempat menjalankan. Skrip 20.1 sampai 20.7 juga berjalan dari terminal python-qgis biasa. Skrip 20.8 dan 20.9 paling aman dari Python Console QGIS. Pada uji, Skrip 20.9 dari terminal biasa gagal dengan ModuleNotFoundError: No module named 'processing'. Skrip 20.8 dari terminal biasa, tanpa aplikasi QGIS yang disiapkan lebih dulu, berhenti dengan pesan Segmentation fault (kode keluar 139 di Git Bash; di PowerShell kodenya -1073741819). Memanggil Processing.initialize() saja tidak cukup. Bila aplikasinya disiapkan lebih dulu dengan QgsApplication([], False) lalu initQgis() (lihat Bab 21), Skrip 20.8 berjalan tanpa masalah itu. Nomor skrip di bab ini (20.1 dan seterusnya) mengikuti bab ini. Di dalam berkas, nomor yang tertulis mengikuti Buku I3 (misalnya "SKRIP I3.1").

Skrip 20.1: Seberapa tepat titik GNSS? #

Berkas i3_01_statistik_gnss.py. Di dalam berkasnya tertulis "SKRIP I3.1".

Tujuan. Mengukur seberapa tepat dan seberapa rapat pembacaan GNSS dari empat cara ukur, dibandingkan dengan satu titik yang posisinya diketahui benar.

Bahan. Paket paket-i3, berkas Gnss_Uji.gpkg dengan dua layer: Gnss_Uji (400 pembacaan, 100 untuk tiap Mode) dan Titik_Referensi (satu titik). Koordinatnya UTM zona 49 Selatan, satuan meter. [terbukti: diperiksa dengan ogrinfo]

Gagasan pokoknya: bayangkan menembak sasaran. Bila semua peluru menumpuk di satu sisi, tembakan Anda presisi tetapi tidak akurat. Bila peluru tersebar tetapi rata-ratanya di tengah, Anda akurat tetapi tidak presisi. Yang kita cari: keduanya baik.

Ilustrasi 20.3: Empat sebaran titik GNSS
Skema empat sebaran pembacaan GNSS di sekitar titik referensi, dengan titik rata-rata dan lingkaran R95 untuk tiap cara ukur, dari data sintetis paket-i3

Istilah baru untuk skrip ini:

  • Titik referensi: titik yang koordinat benarnya sudah diketahui, dipakai sebagai kunci jawaban.
  • Bias: jarak antara titik rata-rata semua pembacaan dan titik referensi. Ukuran akurasi.
  • Presisi: sebaran pembacaan di sekitar titik rata-ratanya sendiri.
  • Galat RMS (RMSE): satu angka yang memuat bias dan sebaran sekaligus.
  • CEP50 dan R95: jarak yang mencakup separuh pembacaan, dan jarak yang mencakup 95 dari 100 pembacaan. R95 menjawab pertanyaan "seburuk apa kemungkinan terburuknya".
Ilustrasi 20.4: Alur skrip 20.1
Skema enam tahap skrip: impor dan jalur, buka dua layer, ambil titik referensi, kelompokkan koordinat per mode, fungsi persentil, hitung dan cetak tiap mode

Skrip utuh:

PYTHONUnduh berkas
# -*- coding: utf-8 -*-
# [SKRIP I3.1] Menghitung akurasi dan presisi GNSS dari 100 pembacaan per mode terhadap titik referensi.
# Penulis: Badar Mubarok Yogaswara
# Dijalankan di Python Console QGIS (atau python-qgis.bat). Ubah JALUR bila folder data Anda berbeda.
import math
import os
from qgis.core import QgsVectorLayer

JALUR = r"D:/KPH_Contoh/paket-i3/Gnss_Uji.gpkg"

pembacaan = QgsVectorLayer(JALUR + "|layername=Gnss_Uji", "Gnss_Uji", "ogr")
referensi = QgsVectorLayer(JALUR + "|layername=Titik_Referensi", "Titik_Referensi", "ogr")
assert pembacaan.isValid() and referensi.isValid(), "Layer tidak terbaca: periksa JALUR"

titik_ref = next(referensi.getFeatures()).geometry().asPoint()

# kelompokkan koordinat per mode
per_mode = {}
for fitur in pembacaan.getFeatures():
    p = fitur.geometry().asPoint()
    per_mode.setdefault(fitur["Mode"], []).append((p.x(), p.y()))


def persentil(nilai, q):
    """Persentil dengan interpolasi linear (sama dengan numpy.percentile)."""
    u = sorted(nilai)
    k = (len(u) - 1) * q / 100.0
    a = int(math.floor(k))
    b = min(a + 1, len(u) - 1)
    return u[a] + (u[b] - u[a]) * (k - a)


print("%-15s %8s %8s %8s %8s %8s" % ("Mode", "Bias", "Presisi", "RMSE", "CEP50", "R95"))
for mode, pts in per_mode.items():
    jarak = [math.hypot(x - titik_ref.x(), y - titik_ref.y()) for x, y in pts]       # galat ke titik benar
    cx = sum(x for x, _ in pts) / len(pts)
    cy = sum(y for _, y in pts) / len(pts)
    bias = math.hypot(cx - titik_ref.x(), cy - titik_ref.y())                          # akurasi (geser rata-rata)
    presisi = math.sqrt(sum((x - cx) ** 2 + (y - cy) ** 2 for x, y in pts) / len(pts))  # sebaran di sekitar rata-rata
    rmse = math.sqrt(sum(j * j for j in jarak) / len(jarak))
    print("%-15s %8.3f %8.3f %8.3f %8.3f %8.3f" % (mode, bias, presisi, rmse, persentil(jarak, 50), persentil(jarak, 95)))

Penjelasan baris demi baris. Dua blok. Nomor baris mengikuti berkas di atas.

Blok A: baris 1 sampai 21, membaca dan mengelompokkan.

BarisTulisanArtinya
1-4komentarBaris 1 memberi tahu Python bahwa berkas memakai huruf UTF-8. Baris 2-4 memuat judul, penulis, dan cara menjalankan.
5-7import math, import os, from qgis.core import QgsVectorLayerPanggil perkakas hitung (math), perkakas jalur (os, tidak dipakai di skrip ini), dan pembuka layer vektor.
9JALUR = r"D:/KPH_Contoh/paket-i3/Gnss_Uji.gpkg"Lokasi berkas data. Ganti dengan jalur Anda. Huruf r membuat teks dibaca apa adanya.
11-12pembacaan = QgsVectorLayer(JALUR + ..., "Gnss_Uji", "ogr")Buka dua layer dari satu berkas. Sumber datanya adalah JALUR, disambung tanda garis tegak dan layername= plus nama layer. Isian terakhir "ogr" adalah pembaca data vektor.
13assert pembacaan.isValid() and referensi.isValid(), "..."Penjaga. Bila salah satu layer gagal terbuka, skrip berhenti dan menampilkan pesan itu. assert berarti "pastikan".
15titik_ref = next(referensi.getFeatures()).geometry().asPoint()Ambil titik referensi. getFeatures() memberi daftar fitur, next(...) mengambil isi pertamanya, lalu .geometry().asPoint() mengubahnya menjadi koordinat x dan y.
18per_mode = {}Kamus kosong (Bab 5). Nanti: kunci adalah nama mode, isinya daftar koordinat.
19-21for fitur in pembacaan.getFeatures(): lalu per_mode.setdefault(fitur["Mode"], []).append((p.x(), p.y()))Telusuri 400 pembacaan. setdefault(kunci, []) mengambil daftar milik mode itu, atau membuat daftar kosong bila belum ada. .append((x, y)) menambahkan sepasang koordinat ke daftar itu. Hasilnya empat kunci, masing-masing berisi 100 pasang.

Blok B: baris 24 sampai 41, menghitung.

BarisTulisanArtinya
24-30def persentil(nilai, q):Resep yang menghitung persentil: nilai di mana q persen data berada di bawahnya. Docstring menyebut hasilnya sama dengan numpy.percentile. Saya mencocokkannya pada data ini sampai enam desimal, dan hasilnya sama.
26u = sorted(nilai)Urutkan dari terkecil.
27-29k = (len(u) - 1) * q / 100.0 lalu a, bHitung posisi (boleh pecahan). a adalah posisi bulat di bawahnya, b posisi bulat di atasnya (tidak melewati ujung).
30return u[a] + (u[b] - u[a]) * (k - a)Campur dua isi terdekat sesuai sisa pecahan. Itu yang disebut interpolasi linear.
33print("%-15s %8s ..." % ("Mode", "Bias", ...))Cetak kepala tabel. %-15s memberi lebar 15 huruf, rata kiri. %8s memberi lebar 8, rata kanan.
34for mode, pts in per_mode.items():Ulangi tiap mode. .items() memberi pasangan kunci dan isinya.
35jarak = [math.hypot(x - titik_ref.x(), y - titik_ref.y()) for x, y in pts]Hitung jarak lurus tiap pembacaan ke titik benar. math.hypot adalah rumus Pythagoras. Tulisan for x, y in pts membuka tiap pasangan menjadi dua kotak.
36-37cx = sum(x for x, _ in pts) / len(pts) dan cy = ...Titik rata-rata: jumlah semua x dibagi banyaknya. Tanda _ membuang bagian yang tidak dipakai.
38bias = math.hypot(cx - titik_ref.x(), cy - titik_ref.y())Bias: jarak titik rata-rata ke titik benar.
39presisi = math.sqrt(sum((x - cx) ** 2 + (y - cy) ** 2 for x, y in pts) / len(pts))Presisi: akar dari rata-rata kuadrat jarak tiap pembacaan ke titik rata-ratanya sendiri.
40rmse = math.sqrt(sum(j * j for j in jarak) / len(jarak))Galat RMS: akar dari rata-rata kuadrat jarak ke titik benar.
41print("%-15s %8.3f ..." % (mode, bias, presisi, rmse, persentil(jarak, 50), persentil(jarak, 95)))Cetak satu baris. CEP50 adalah persentil ke-50, dan R95 persentil ke-95. %8.3f mencetak angka dengan tiga desimal.

Hasil. Layar menampilkan (satuannya meter):

HASIL
Mode                Bias  Presisi     RMSE    CEP50      R95
Ponsel_Terbuka     1.100    3.137    3.324    2.943    5.233
Ponsel_Tajuk       1.696    5.618    5.868    5.227   10.157
Diferensial        0.239    0.743    0.780    0.630    1.439
RTK_Fix            0.002    0.024    0.024    0.020    0.040

Dari hasil ini Anda tahu: ponsel di area terbuka punya R95 sekitar 5,2 m, dan ponsel di bawah tajuk sekitar 10,2 m, dua kali lebih buruk. Alat diferensial R95-nya 1,4 m, dan RTK 4 cm. Bias ponsel (1,1 dan 1,7 m) jauh lebih kecil daripada sebarannya. Artinya rata-rata banyak pembacaan menolong, tetapi satu pembacaan tunggal bisa meleset beberapa meter. Data ini sintetis. Angkanya bukan spesifikasi alat apa pun, jadi ukur sendiri alat Anda di titik referensi.

Bagian yang boleh diubah.

  • Baris 9, jalur. Ganti dengan lokasi Gnss_Uji.gpkg di komputer Anda.
  • Baris 11-12, nama layer. Ganti untuk data GNSS Anda sendiri.
  • Baris 21, nama kolom Mode. Kolom yang membedakan cara ukur. Ganti dengan kolom pengelompok lain.
  • Baris 41, angka 50 dan 95. Ganti dengan persentil lain. Pada uji, angka 90 (dan judul R90) menghasilkan R90 4,829 untuk ponsel terbuka, 8,742 untuk ponsel di bawah tajuk, 1,241 untuk diferensial, dan 0,036 untuk RTK.

Jebakan.

  • Jalur salah. Layar menampilkan AssertionError: Layer tidak terbaca: periksa JALUR. Pesan itu datang dari baris 13. Periksa jalur dan nama layer.
  • Nama kolom salah ketik. Pada uji, menulis "Moda" alih-alih "Mode" menghasilkan KeyError: 'Moda' di baris 21. Salin nama kolom dari tabel atribut.
  • Layer referensi kosong. Perintah next(...) di baris 15 berhenti dengan StopIteration bila tidak ada titik. [kemungkinan: perilaku standar Python, belum diuji pada layer ini]
  • Satuan. Angka di tabel adalah meter karena data berkoordinat UTM. Pada data berkoordinat derajat, angka jarak tidak bermakna. Cek sistem koordinat layer Anda.
  • Impor tak terpakai. import os tidak dipakai. Tidak ada yang rusak.

Latihan variasi.

  1. Ganti R95 menjadi R90.
  2. Cetak hanya cara ukur yang R95-nya kurang dari 2 m.

Contoh jawaban nomor 1: ubah "R95" di baris 33 menjadi "R90", dan persentil(jarak, 95) di baris 41 menjadi persentil(jarak, 90). Layar menampilkan angka R90 seperti di atas.

Contoh jawaban nomor 2, baris 41 menjadi dua baris:

PYTHON
    if persentil(jarak, 95) < 2:
        print("%-15s %8.3f %8.3f %8.3f %8.3f %8.3f" % (mode, bias, presisi, rmse, persentil(jarak, 50), persentil(jarak, 95)))

Layar hanya menampilkan dua baris data: Diferensial dan RTK_Fix.

Skrip 20.2: Menyiapkan lemari survei #

Berkas i3_02_buat_geopackage.py. Di dalam berkasnya tertulis "SKRIP I3.2".

Tujuan. Membuat satu berkas GeoPackage kosong untuk survei: satu layer titik Titik_Survei (sepuluh kolom) dan dua tabel daftar pilihan, Ref_Kondisi dan Ref_Jenis.

Bahan. Tidak ada data masukan. Skrip menulis Survei_KPH.gpkg ke folder KPH_Contoh. Skrip menolak berjalan bila berkas itu sudah ada.

Istilah baru untuk skrip ini:

  • Layer memori: layer sementara yang hidup di memori komputer, belum menjadi berkas.
  • Skema: daftar kolom beserta jenisnya.
  • Penulis berkas (QgsVectorFileWriter): perkakas QGIS yang menulis layer ke berkas.

Skrip ini bekerja dalam enam tahap.

Ilustrasi 20.5: Alur skrip 20.2
Skema enam tahap skrip: tetapkan KELUAR dan cegah menimpa, layer titik sementara, tulis ke GeoPackage, siapkan dua tabel acuan, tiap tabel diisi lalu ditulis, periksa isi berkas

Skrip utuh:

PYTHONUnduh berkas
# -*- coding: utf-8 -*-
# [SKRIP I3.2] Membuat GeoPackage survei kosong (layer Titik_Survei + tabel acuan Ref_Kondisi dan Ref_Jenis)
# Penulis: Badar Mubarok Yogaswara
# Dijalankan di Python Console QGIS atau python-qgis.bat. Hasil: Survei_KPH.gpkg di folder KELUAR.
import os
from qgis.core import (QgsVectorLayer, QgsVectorFileWriter, QgsCoordinateReferenceSystem, QgsProject, QgsFeature)

KELUAR = r"D:/KPH_Contoh/Survei_KPH.gpkg"     # ganti dengan folder proyek Anda
os.makedirs(os.path.dirname(KELUAR), exist_ok=True)
assert not os.path.exists(KELUAR), "Berkas sudah ada: ganti nama atau pindahkan dulu agar tidak tertimpa"

# 1. Titik_Survei: titik berkoordinat UTM 49S dengan sepuluh field
skema = ("Point?crs=EPSG:32749"
         "&field=ID_Survei:string(10)&field=KPH:string(20)&field=Jenis:string(20)&field=Kondisi:string(20)"
         "&field=Tinggi_Phn:double&field=Diameter_cm:double&field=Tgl_Survei:date"
         "&field=Surveyor:string(30)&field=Akurasi_m:double&field=Foto:string(100)")
sementara = QgsVectorLayer(skema, "Titik_Survei", "memory")
opsi = QgsVectorFileWriter.SaveVectorOptions()
opsi.driverName = "GPKG"
opsi.layerName = "Titik_Survei"
opsi.fileEncoding = "UTF-8"
hasil = QgsVectorFileWriter.writeAsVectorFormatV3(sementara, KELUAR, QgsProject.instance().transformContext(), opsi)
assert hasil[0] == QgsVectorFileWriter.NoError, hasil

# 2. Dua tabel acuan (tanpa geometri) ditambahkan ke berkas yang sama
acuan = {"Ref_Kondisi": ("Kondisi", ["Sehat", "Terserang Hama", "Tebangan Liar"]),
         "Ref_Jenis": ("Jenis", ["Jati", "Mahoni", "Sengon"])}
for nama, (kolom, nilai) in acuan.items():
    tabel = QgsVectorLayer("None?field=%s:string(20)" % kolom, nama, "memory")
    tabel.startEditing()
    for v in nilai:
        f = QgsFeature(tabel.fields())
        f[kolom] = v
        tabel.addFeature(f)
    tabel.commitChanges()
    o = QgsVectorFileWriter.SaveVectorOptions()
    o.driverName = "GPKG"
    o.layerName = nama
    o.actionOnExistingFile = QgsVectorFileWriter.CreateOrOverwriteLayer      # tambah layer, jangan timpa berkas
    h = QgsVectorFileWriter.writeAsVectorFormatV3(tabel, KELUAR, QgsProject.instance().transformContext(), o)
    assert h[0] == QgsVectorFileWriter.NoError, h

# 3. Periksa isi berkas
for nama in ["Titik_Survei", "Ref_Kondisi", "Ref_Jenis"]:
    lyr = QgsVectorLayer(KELUAR + "|layername=" + nama, nama, "ogr")
    print(nama, "valid:", lyr.isValid(), "| baris:", lyr.featureCount(), "| field:", [f.name() for f in lyr.fields()])

Penjelasan baris demi baris. Tiga blok. Nomor baris mengikuti berkas di atas.

Blok A: baris 1 sampai 23, layer titik.

BarisTulisanArtinya
1-4komentarJudul, penulis, dan hasil yang diharapkan: Survei_KPH.gpkg.
5-6import os dan from qgis.core import (...)Panggil perkakas. Tanda kurung memungkinkan daftar panjang dipecah ke beberapa baris. QgsCoordinateReferenceSystem diimpor tetapi tidak dipakai.
8KELUAR = r"D:/KPH_Contoh/Survei_KPH.gpkg"Lokasi berkas yang akan dibuat. Ganti dengan folder proyek Anda.
9os.makedirs(os.path.dirname(KELUAR), exist_ok=True)os.path.dirname mengambil bagian folder dari jalur. Folder itu dibuat bila belum ada, tanpa bertanya.
10assert not os.path.exists(KELUAR), "..."Penjaga: bila berkas sudah ada, skrip berhenti dengan pesan. Tujuannya agar kerja Anda tidak tertimpa.
13-16skema = ("Point?crs=EPSG:32749" "&field=ID_Survei:string(10)" ...)Satu teks panjang disambung dari beberapa potongan di dalam kurung. Isinya: jenis geometri (titik), sistem koordinat (UTM 49 Selatan), lalu sepuluh kolom dengan pola field=nama:jenis. Jenisnya string(10) (teks sampai 10 huruf), double (angka desimal), dan date (tanggal).
17sementara = QgsVectorLayer(skema, "Titik_Survei", "memory")Buat layer kosong di memori dari skema itu. Kata "memory" menunjuk penyedia data layer memori.
18-21opsi = QgsVectorFileWriter.SaveVectorOptions() lalu empat penugasanSiapkan pengaturan penulisan. Format GPKG, nama layer di dalam berkas, dan huruf UTF-8. Tiap baris mengisi satu sifat objek opsi.
22hasil = QgsVectorFileWriter.writeAsVectorFormatV3(sementara, KELUAR, ..., opsi)Tulis layer sementara ke berkas. Isian ketiga, transformContext(), adalah aturan transformasi koordinat proyek, yang wajib diberikan. Hasilnya sepasang: status dan pesan.
23assert hasil[0] == QgsVectorFileWriter.NoError, hasilPastikan status pertama (hasil[0]) berarti "tanpa galat". Bila tidak, tampilkan isi hasil.

Blok B: baris 26 sampai 41, dua tabel acuan.

BarisTulisanArtinya
26-27acuan = {"Ref_Kondisi": ("Kondisi", [...]), "Ref_Jenis": ("Jenis", [...])}Kamus: kunci adalah nama tabel, isinya sepasang (nama kolom, daftar nilai).
28for nama, (kolom, nilai) in acuan.items():Ulangi dua kali. Tanda kurung di tengah membuka pasangan bersarang, sehingga tiga kotak terisi sekaligus: nama, kolom, nilai.
29tabel = QgsVectorLayer("None?field=%s:string(20)" % kolom, nama, "memory")Layer memori tanpa geometri (kata None), dengan satu kolom teks. Tanda %s diisi nama kolom.
30tabel.startEditing()Mulai mode sunting, seperti menekan tombol pensil di QGIS.
31-34for v in nilai: lalu f = QgsFeature(tabel.fields()), f[kolom] = v, tabel.addFeature(f)Untuk tiap nilai, buat satu baris, isi kolomnya, dan tambahkan ke tabel.
35tabel.commitChanges()Simpan sunting ke layer memori.
36-39o = QgsVectorFileWriter.SaveVectorOptions() lalu pengaturanPengaturan penulisan seperti blok A, dengan satu tambahan: CreateOrOverwriteLayer. Artinya: tambahkan layer ke berkas yang sudah ada, jangan timpa berkasnya.
40-41h = ...writeAsVectorFormatV3(tabel, KELUAR, ..., o) lalu assertTulis tabel ke berkas yang sama, lalu pastikan tanpa galat.

Blok C: baris 44 sampai 46, memeriksa isi.

BarisTulisanArtinya
44-45for nama in ["Titik_Survei", "Ref_Kondisi", "Ref_Jenis"]: lalu lyr = QgsVectorLayer(...)Buka kembali tiap layer dari berkas, sebagai bukti bahwa tertulis.
46print(nama, "valid:", lyr.isValid(), ..., [f.name() for f in lyr.fields()])Cetak nama, apakah terbuka, jumlah baris, dan daftar nama kolom. Bagian terakhir adalah list comprehension (Bab 6).

Hasil. Layar menampilkan:

HASIL
Titik_Survei valid: True | baris: 0 | field: ['fid', 'ID_Survei', 'KPH', 'Jenis', 'Kondisi', 'Tinggi_Phn', 'Diameter_cm', 'Tgl_Survei', 'Surveyor', 'Akurasi_m', 'Foto']
Ref_Kondisi valid: True | baris: 3 | field: ['fid', 'Kondisi']
Ref_Jenis valid: True | baris: 3 | field: ['fid', 'Jenis']

Dari hasil ini Anda tahu: ketiganya terbuka. Titik_Survei kosong (0 baris) tetapi sudah punya kolom. Dua tabel acuan masing-masing tiga baris. Kolom fid ikut dibuat otomatis oleh GeoPackage sebagai nomor baris.

Bagian yang boleh diubah.

  • Baris 8, KELUAR. Lokasi dan nama berkas.
  • Baris 13-16, skema. Tambah, hapus, atau ganti kolom. Ganti EPSG:32749 bila data Anda memakai sistem koordinat lain. Pada uji, menambah &field=Catatan:string(100) memunculkan kolom Catatan sebagai kolom terakhir.
  • Baris 26-27, isi acuan. Ganti nilai pilihan. Pada uji, menambah "Akasia" di daftar jenis menghasilkan Ref_Jenis dengan 4 baris.

Jebakan.

  • Berkas sudah ada. Menjalankan skrip dua kali berhenti di baris 10 dengan AssertionError: Berkas sudah ada: ganti nama atau pindahkan dulu agar tidak tertimpa. Itu memang disengaja. Ganti nama KELUAR atau pindahkan berkas lama.
  • Folder dibuat tanpa tanya. Baris 9 membuat folder baru bila belum ada. Bila Anda salah ketik jalur, folder yang salah ikut dibuat.
  • Impor tak terpakai dan nama kotak. QgsCoordinateReferenceSystem tidak dipakai. Kotak hasil (baris 22) dan h (baris 40) dipakai untuk hal serupa dengan nama berbeda. Itu tidak salah, hanya kurang konsisten.
  • Skrip ini prasyarat. Skrip 20.3, 20.4, 20.5, dan 20.9 butuh Survei_KPH.gpkg. Jalankan ini dulu.

Latihan variasi.

  1. Tambahkan kolom Catatan (teks 100 huruf) ke Titik_Survei.
  2. Tambahkan "Akasia" ke daftar jenis pohon.

Contoh jawaban nomor 1, baris 16 menjadi (jangan lupa hapus berkas lama dulu):

PYTHON
         "&field=Surveyor:string(30)&field=Akurasi_m:double&field=Foto:string(100)&field=Catatan:string(100)")

Contoh jawaban nomor 2, baris 27 menjadi:

PYTHON
         "Ref_Jenis": ("Jenis", ["Jati", "Mahoni", "Sengon", "Akasia"])}

Layar menampilkan Titik_Survei valid: True | baris: 0 | field: [..., 'Foto', 'Catatan'] dan Ref_Jenis valid: True | baris: 4 | field: ['fid', 'Jenis'].

Tiga lapis penjagaan: peta untuk Skrip 20.3 sampai 20.5 #

Tiga skrip berikut menjaga isian Kondisi dengan tiga cara yang kekuatannya berbeda. Anggap saja tiga lapis pagar di kebun. Pagar pertama dipasang di dalam berkas, pagar kedua di formulir QGIS, dan pagar ketiga adalah penjaga di pintu basis data. Hasil uji menunjukkan hanya pagar ketiga yang menolak penulis mana pun, termasuk skrip.

Ilustrasi 20.6: Tiga lapis penjagaan
Skema: lapis 1 domain di dalam berkas (skrip menulis nilai ngawur: diterima), lapis 2 formulir dan aturan isian QGIS (skrip tidak lewat formulir: tidak dicegah), lapis 3 pagar di berkas (skrip menulis sehat huruf kecil: ditolak)

Skrip 20.3: Menanam domain nilai di dalam berkas #

Berkas i3_03_domain_geopackage.py. Di dalam berkasnya tertulis "SKRIP I3.3". Jalankan setelah Skrip 20.2.

Tujuan. Menanam dua domain di dalam GeoPackage: daftar kondisi pohon, dan rentang tinggi pohon 0 sampai 60 m. Lalu membuktikan dua hal: QGIS membaca domain itu, tetapi penulisan lewat program tidak otomatis ditolak.

Bahan. Survei_KPH.gpkg hasil Skrip 20.2. Skrip ini mengubah berkas itu (membuka dengan mode tulis). Jalankan dari terminal python-qgis atau dari Python Console QGIS. Komentar di berkas menyebut python-qgis.bat karena butuh modul osgeo. [CEK: modul osgeo tersedia di Python Console QGIS Anda]

Istilah baru untuk skrip ini:

  • Domain daftar: kolom hanya boleh berisi nilai dari daftar. Domain rentang: kolom hanya boleh berisi angka dalam rentang.
  • Widget: kotak isian di formulir, misalnya kotak pilihan atau kotak angka.
  • Mode tulis: membuka berkas dengan izin mengubah isinya.
  • WKT: cara menulis bentuk geometri sebagai teks, misalnya POINT(312100 9996100).
Ilustrasi 20.7: Alur skrip 20.3
Skema lima tahap skrip: buka berkas mode tulis, buat dua domain, pasang ke dua kolom, QGIS membaca domain, uji tulis nilai ngawur lalu hapus

Skrip utuh:

PYTHONUnduh berkas
# -*- coding: utf-8 -*-
# [SKRIP I3.3] Domain nilai yang tersimpan DI DALAM GeoPackage (daftar Kondisi dan rentang Tinggi_Phn),
# lalu membuktikan dua hal: QGIS ikut membacanya, dan penulisan lewat program tidak otomatis ditolak.
# Penulis: Badar Mubarok Yogaswara
# Jalankan setelah skrip I3.2, dengan python-qgis.bat (butuh modul osgeo.ogr). Bekerja pada berkas Survei_KPH.gpkg.
from osgeo import ogr, gdal
from qgis.core import QgsVectorLayer, QgsVectorLayerUtils

gdal.UseExceptions()
ogr.UseExceptions()
GPKG = r"D:/KPH_Contoh/Survei_KPH.gpkg"

ds = ogr.Open(GPKG, 1)
lyr = ds.GetLayerByName("Titik_Survei")

# 1. Buat dua domain pada tingkat berkas
ds.AddFieldDomain(ogr.CreateCodedFieldDomain(
    "Kondisi_Pohon", "Kondisi pohon hasil survei", ogr.OFTString, ogr.OFSTNone,
    {"Sehat": "Sehat", "Terserang Hama": "Terserang Hama", "Tebangan Liar": "Tebangan Liar"}))
ds.AddFieldDomain(ogr.CreateRangeFieldDomain(
    "Tinggi_Pohon", "Tinggi pohon 0 sampai 60 m", ogr.OFTReal, ogr.OFSTNone, 0.0, True, 60.0, True))

# 2. Pasang domain ke kolomnya
for kolom, domain in [("Kondisi", "Kondisi_Pohon"), ("Tinggi_Phn", "Tinggi_Pohon")]:
    i = lyr.GetLayerDefn().GetFieldIndex(kolom)
    lama = lyr.GetLayerDefn().GetFieldDefn(i)
    fd = ogr.FieldDefn(lama.GetName(), lama.GetType())          # salinan definisi kolom; yang lama tidak boleh diubah langsung
    fd.SetDomainName(domain)
    lyr.AlterFieldDefn(i, fd, ogr.ALTER_DOMAIN_FLAG)
print("Domain di berkas:", ds.GetFieldDomainNames())
ds = None

# 3. QGIS membaca domain itu sebagai widget formulir
uji = QgsVectorLayer(GPKG + "|layername=Titik_Survei", "uji", "ogr")
for kolom in ["Kondisi", "Tinggi_Phn"]:
    w = uji.editorWidgetSetup(uji.fields().indexOf(kolom))
    print("Widget %-11s: %s" % (kolom, w.type()))

# 4. Domain bukan gembok: nilai di luar daftar tetap bisa tertulis lewat program
ds = ogr.Open(GPKG, 1)
lyr = ds.GetLayerByName("Titik_Survei")
f = ogr.Feature(lyr.GetLayerDefn())
f.SetGeometry(ogr.CreateGeometryFromWkt("POINT(312100 9996100)"))
f.SetField("ID_Survei", "UJI-DOMAIN")
f.SetField("Kondisi", "ngawur")
f.SetField("Tinggi_Phn", 250.0)
lyr.CreateFeature(f)
print("Nilai 'ngawur' dan tinggi 250 m: tertulis tanpa ditolak")
ds.ExecuteSQL("DELETE FROM Titik_Survei WHERE ID_Survei = 'UJI-DOMAIN'")       # bersihkan baris uji
ds = None

Penjelasan baris demi baris. Nomor baris mengikuti berkas di atas.

BarisTulisanArtinya
1-5komentarBaris 5 mengingatkan: jalankan setelah Skrip 20.2.
6-7from osgeo import ogr, gdal dan from qgis.core import QgsVectorLayer, QgsVectorLayerUtilsPanggil perkakas GDAL untuk berkas vektor (ogr) dan raster (gdal), serta perkakas QGIS. QgsVectorLayerUtils tidak dipakai.
9-10gdal.UseExceptions() dan ogr.UseExceptions()Nyalakan "alarm". Tanpa baris ini, GDAL diam saja saat gagal. Dengan baris ini, kegagalan menghentikan skrip dengan pesan galat.
11GPKG = r"D:/KPH_Contoh/Survei_KPH.gpkg"Lokasi berkas hasil Skrip 20.2. Ganti dengan jalur Anda.
13ds = ogr.Open(GPKG, 1)Buka berkas. Angka 1 berarti mode tulis. Kotak ds adalah "berkas yang terbuka".
14lyr = ds.GetLayerByName("Titik_Survei")Ambil layer Titik_Survei dari berkas itu.
17-19ds.AddFieldDomain(ogr.CreateCodedFieldDomain("Kondisi_Pohon", ..., {...}))Buat domain daftar bernama Kondisi_Pohon lalu tambahkan ke berkas. Isiannya: nama, keterangan, jenis teks, dan kamus tiga nilai. Di kamus ini, kiri adalah nilai yang disimpan dan kanan adalah teks yang tampil. Keduanya sama.
20-21ds.AddFieldDomain(ogr.CreateRangeFieldDomain("Tinggi_Pohon", ..., 0.0, True, 60.0, True))Buat domain rentang. Isiannya: nama, keterangan, jenis angka desimal, batas bawah 0, True bila batas bawah ikut sah, batas atas 60, dan True bila batas atas ikut sah.
24for kolom, domain in [("Kondisi", "Kondisi_Pohon"), ("Tinggi_Phn", "Tinggi_Pohon")]:Ulangi dua kali. Tiap pasangan: nama kolom dan nama domain yang akan dipasang.
25-26i = lyr.GetLayerDefn().GetFieldIndex(kolom) dan lama = ...GetFieldDefn(i)Cari nomor urut kolom, lalu ambil definisinya yang lama.
27fd = ogr.FieldDefn(lama.GetName(), lama.GetType())Buat salinan definisi kolom. Komentar di baris itu menjelaskan: definisi yang lama tidak boleh diubah langsung.
28-29fd.SetDomainName(domain) lalu lyr.AlterFieldDefn(i, fd, ogr.ALTER_DOMAIN_FLAG)Pasang nama domain ke salinan, lalu terapkan ke kolom. Bendera ALTER_DOMAIN_FLAG memberi tahu GDAL bahwa yang diubah hanya domainnya.
30-31print("Domain di berkas:", ds.GetFieldDomainNames()) lalu ds = NoneCetak nama domain yang kini ada. Lalu ds = None menutup berkas. Di GDAL, perubahan baru benar-benar tertulis saat kotaknya dikosongkan.
34-37uji = QgsVectorLayer(GPKG + ..., "uji", "ogr") lalu perulanganBuka layer yang sama lewat QGIS. Untuk tiap kolom, tanyakan widget formulirnya dengan editorWidgetSetup(...), lalu cetak jenisnya (w.type()). Pola %-11s memberi lebar 11 huruf.
40-41ds = ogr.Open(GPKG, 1) lalu lyr = ...Buka lagi dalam mode tulis, untuk uji berikutnya.
42-47f = ogr.Feature(lyr.GetLayerDefn()) lalu SetGeometry, SetField, CreateFeatureSusun satu titik uji dengan isian ngawur: ID_Survei UJI-DOMAIN, kondisi ngawur, dan tinggi 250 m. Lalu tulis ke berkas. Bila domain adalah gembok, penulisan ini ditolak.
48print("Nilai 'ngawur' dan tinggi 250 m: tertulis tanpa ditolak")Cetak kesimpulan. Teks ini tertulis tetap. Bila baris 47 menolak, skrip sudah berhenti sebelum sampai sini.
49ds.ExecuteSQL("DELETE FROM Titik_Survei WHERE ID_Survei = 'UJI-DOMAIN'")Bersihkan baris uji dengan perintah SQL (dijelaskan di Skrip 20.7).
50ds = NoneTutup berkas.

Hasil. Layar menampilkan:

HASIL
Domain di berkas: ['Kondisi_Pohon', 'Tinggi_Pohon']
Widget Kondisi    : ValueMap
Widget Tinggi_Phn : Range
Nilai 'ngawur' dan tinggi 250 m: tertulis tanpa ditolak

Dari hasil ini Anda tahu: dua domain tersimpan di berkas, dan QGIS membacanya sebagai kotak pilihan (ValueMap) dan kotak angka (Range). Tetapi nilai ngawur dan tinggi 250 m tertulis tanpa ditolak. Domain adalah petunjuk bagi formulir, bukan gembok.

Bagian yang boleh diubah.

  • Baris 11, jalur. Ganti dengan lokasi Survei_KPH.gpkg.
  • Baris 17-21, isi domain. Ganti daftar kondisi atau batas rentang. Pada uji, batas 50.0 dan keterangan "Tinggi pohon 0 sampai 50 m" menghasilkan domain dengan batas atas 50,0 (dicek lewat GetMaxAsDouble()).
  • Baris 24, pasangan kolom dan domain. Tambah pasangan untuk kolom lain. Pada uji, menambah domain daftar Jenis_Pohon dan pasangan ("Jenis", "Jenis_Pohon") membuat Jenis ikut bertipe ValueMap.
  • Baris 35, daftar kolom yang dicek. Tambah nama kolom untuk melihat widget-nya.

Jebakan.

  • Menjalankan dua kali. Layar menampilkan RuntimeError: A domain of identical name already exists. Domain dengan nama sama tidak bisa ditambah dua kali. Hanya jalankan sekali per berkas.
  • Skrip 20.2 belum dijalankan. Layar menampilkan RuntimeError: ...Survei_KPH.gpkg: No such file or directory. Cek isi folder Anda.
  • Skrip berhenti di tengah jalan. Baris uji UJI-DOMAIN ditulis di baris 47 dan dihapus di baris 49. Bila skrip berhenti di antaranya, baris uji itu tertinggal di data Anda. [kemungkinan: dari bacaan kode]
  • Mengubah berkas asli. Skrip ini membuka dengan mode tulis. Beri salinan cadangan bila berkas Anda berharga.
  • Domain bukan gembok. Ini inti hasil uji. Jangan mengira data otomatis bersih karena ada domain. Pemeriksaan di Skrip 20.6 tetap perlu.

Latihan variasi.

  1. Ubah batas atas tinggi menjadi 50 m.
  2. Tambahkan domain daftar Jenis_Pohon (Jati, Mahoni, Sengon) untuk kolom Jenis.

Contoh jawaban nomor 1, baris 20-21 menjadi:

PYTHON
ds.AddFieldDomain(ogr.CreateRangeFieldDomain(
    "Tinggi_Pohon", "Tinggi pohon 0 sampai 50 m", ogr.OFTReal, ogr.OFSTNone, 0.0, True, 50.0, True))

Contoh jawaban nomor 2, sisipkan sebelum baris 20, dan tambahkan pasangan ("Jenis", "Jenis_Pohon") di baris 24:

PYTHONUnduh berkas
ds.AddFieldDomain(ogr.CreateCodedFieldDomain(
    "Jenis_Pohon", "Jenis pohon", ogr.OFTString, ogr.OFSTNone,
    {"Jati": "Jati", "Mahoni": "Mahoni", "Sengon": "Sengon"}))

Layar menampilkan Domain di berkas: ['Jenis_Pohon', 'Kondisi_Pohon', 'Tinggi_Pohon']. Bila Anda juga menambah "Jenis" ke daftar di baris 35, layar menambah baris Widget Jenis : ValueMap.

Skrip 20.4: Merancang formulir isian #

Berkas i3_04_formulir_widget.py. Di dalam berkasnya tertulis "SKRIP I3.4". Ini skrip terpanjang di bab ini (138 baris, sembilan tahap). Penjelasannya dibagi lima blok. Jalankan setelah Skrip 20.2.

Tujuan. Mengatur formulir isian untuk layer Titik_Survei: kotak pilihan, batas angka, aturan wajib isi, nilai otomatis, label ramah, dan tata letak berkelompok. Semuanya disimpan sebagai gaya bawaan di dalam GeoPackage.

Bahan. Survei_KPH.gpkg hasil Skrip 20.2 (Skrip 20.3 boleh sudah atau belum dijalankan). Skrip ini menulis tabel gaya di dalam berkas. Skrip ini juga memakai proyek QGIS yang sedang terbuka. Jalankan dari Python Console QGIS atau terminal python-qgis.

Gagasannya: gaya (style) QGIS menyimpan cara sebuah layer tampil, termasuk bentuk formulirnya. Bila gaya disimpan di dalam GeoPackage, siapa pun yang membuka berkas itu mendapat formulir yang sama, tanpa berkas tambahan.

Ilustrasi 20.8: Alur skrip 20.4
Skema sembilan tahap skrip: buka layer dan fungsi widget, domain nilai dengan tujuh widget, aturan isian, nilai bawaan dan alias, tata letak tiga grup, simpan gaya ke berkas, tiga tahap uji

Skrip utuh:

PYTHONUnduh berkas
# -*- coding: utf-8 -*-
# [SKRIP I3.4] Memasang domain nilai (Value Map, Value Relation, Range), aturan isian, nilai bawaan, widget foto,
# dan tata letak formulir pada layer Titik_Survei, lalu menyimpannya sebagai gaya bawaan di dalam GeoPackage.
# Penulis: Badar Mubarok Yogaswara
# Jalankan setelah skrip I3.2. Dijalankan di Python Console QGIS atau python-qgis.bat.
from qgis.core import (QgsVectorLayer, QgsEditorWidgetSetup, QgsFieldConstraints, QgsDefaultValue, QgsEditFormConfig,
                       QgsAttributeEditorContainer, QgsAttributeEditorField, Qgis, QgsProject, QgsFeature, QgsGeometry,
                       QgsPointXY, QgsVectorLayerUtils)

GPKG = r"D:/KPH_Contoh/Survei_KPH.gpkg"          # sama dengan KELUAR pada skrip I3.2

lyr = QgsVectorLayer(GPKG + "|layername=Titik_Survei", "Titik_Survei", "ogr")
assert lyr.isValid()
idx = lyr.fields().indexOf


def widget(nama_field, jenis, konfig):
    lyr.setEditorWidgetSetup(idx(nama_field), QgsEditorWidgetSetup(jenis, konfig))


# 1. Domain nilai ------------------------------------------------------------------------------
# a) Value Map: daftar pendek yang tetap. Isi peta: {teks yang tampil: nilai yang disimpan}
pilihan = ["Sehat", "Terserang Hama", "Tebangan Liar"]
widget("Kondisi", "ValueMap", {"map": [{p: p} for p in pilihan]})
widget("KPH", "ValueMap", {"map": [{"KPH Alpha": "KPH Alpha"}, {"KPH Beta": "KPH Beta"}, {"KPH Gamma": "KPH Gamma"}]})

# b) Value Relation: pilihan diambil dari tabel acuan, jadi mengubah daftar cukup mengubah tabel
ref_jenis = QgsVectorLayer(GPKG + "|layername=Ref_Jenis", "Ref_Jenis", "ogr")
QgsProject.instance().addMapLayer(ref_jenis)
# LayerName dan LayerSource ikut disimpan agar pilihan tetap ketemu saat gaya dibaca di proyek lain
widget("Jenis", "ValueRelation", {"Layer": ref_jenis.id(), "LayerName": "Ref_Jenis", "LayerSource": ref_jenis.source(),
                                  "LayerProviderName": "ogr", "Key": "Jenis", "Value": "Jenis", "AllowNull": False,
                                  "OrderByValue": True, "AllowMulti": False, "NofColumns": 1, "FilterExpression": ""})

# c) Range: angka dibatasi rentang
widget("Tinggi_Phn", "Range", {"Min": 0.0, "Max": 60.0, "Step": 0.1, "Style": "SpinBox", "Precision": 1, "AllowNull": False})
widget("Diameter_cm", "Range", {"Min": 0.0, "Max": 200.0, "Step": 0.5, "Style": "SpinBox", "Precision": 1, "AllowNull": False})

# d) Tanggal dan foto
widget("Tgl_Survei", "DateTime", {"display_format": "yyyy-MM-dd", "field_format": "yyyy-MM-dd", "calendar_popup": True,
                                  "allow_null": False})
widget("Foto", "ExternalResource", {"DocumentViewer": 1, "DocumentViewerHeight": 0, "DocumentViewerWidth": 0,
                                    "FileWidget": True, "FileWidgetButton": True, "RelativeStorage": 1,
                                    "StorageMode": 0})

# 2. Aturan isian (constraint) --------------------------------------------------------------------
K = QgsFieldConstraints
lyr.setFieldConstraint(idx("ID_Survei"), K.ConstraintNotNull, K.ConstraintStrengthHard)
lyr.setFieldConstraint(idx("ID_Survei"), K.ConstraintUnique, K.ConstraintStrengthHard)
lyr.setFieldConstraint(idx("Kondisi"), K.ConstraintNotNull, K.ConstraintStrengthHard)
lyr.setConstraintExpression(idx("Tinggi_Phn"), '"Tinggi_Phn" BETWEEN 0 AND 60', "Tinggi pohon harus 0 sampai 60 m")
lyr.setFieldConstraint(idx("Tinggi_Phn"), K.ConstraintExpression, K.ConstraintStrengthHard)
lyr.setConstraintExpression(idx("Akurasi_m"), '"Akurasi_m" <= 5', "Akurasi lebih dari 5 m: ulangi pengukuran bila bisa")
lyr.setFieldConstraint(idx("Akurasi_m"), K.ConstraintExpression, K.ConstraintStrengthSoft)      # soft = hanya peringatan

# 3. Nilai bawaan ---------------------------------------------------------------------------------
lyr.setDefaultValueDefinition(idx("Tgl_Survei"), QgsDefaultValue("to_date(now())"))
lyr.setDefaultValueDefinition(idx("Surveyor"), QgsDefaultValue("@user_account_name"))
lyr.setDefaultValueDefinition(idx("ID_Survei"), QgsDefaultValue(
    "'SV-' || lpad(to_string(coalesce(maximum(to_int(substr(\"ID_Survei\", 4))), 0) + 1), 3, '0')"))

# Label yang ramah dibaca (alias) lengkap dengan satuan
ALIAS = {"ID_Survei": "ID survei", "KPH": "Petak KPH", "Jenis": "Jenis pohon", "Kondisi": "Kondisi pohon",
         "Tinggi_Phn": "Tinggi pohon (m)", "Diameter_cm": "Diameter batang (cm)", "Tgl_Survei": "Tanggal survei",
         "Surveyor": "Nama surveyor", "Akurasi_m": "Akurasi GNSS (m)", "Foto": "Foto"}
for nm, al in ALIAS.items():
    lyr.setFieldAlias(idx(nm), al)

# 4. Tata letak formulir (Drag and drop designer) -----------------------------------------------------
cfg = lyr.editFormConfig()
cfg.setLayout(Qgis.AttributeFormLayout.DragAndDrop)
cfg.clearTabs()
akar = cfg.invisibleRootContainer()
akar.clear()


def kelompok(judul, field_field, kolom=1):
    c = QgsAttributeEditorContainer(judul, akar)
    c.setType(Qgis.AttributeEditorContainerType.GroupBox)
    c.setColumnCount(kolom)
    for nm in field_field:
        c.addChildElement(QgsAttributeEditorField(nm, idx(nm), c))
    akar.addChildElement(c)


kelompok("Identitas", ["ID_Survei", "KPH", "Surveyor", "Tgl_Survei"])
kelompok("Pohon", ["Jenis", "Kondisi", "Tinggi_Phn", "Diameter_cm"])
kelompok("Bukti", ["Foto", "Akurasi_m"])
lyr.setEditFormConfig(cfg)

# 5. Simpan sebagai gaya bawaan di dalam GeoPackage (tabel layer_styles) -----------------------------------
hasil_simpan, galat = lyr.saveStyleToDatabaseV2("Formulir_Survei", "Formulir survei pohon", True, "")
print("Simpan gaya ke GeoPackage:", hasil_simpan, galat or "(tanpa pesan galat)")

# 6. Uji: layer baru yang membaca ulang berkas langsung memakai formulir ini
baru = QgsVectorLayer(GPKG + "|layername=Titik_Survei", "uji_baca", "ogr")
pesan, ok = baru.loadDefaultStyle()
print("Gaya bawaan terbaca:", ok)
print("Widget Kondisi:", baru.editorWidgetSetup(baru.fields().indexOf("Kondisi")).type())
print("Widget Tinggi_Phn:", baru.editorWidgetSetup(baru.fields().indexOf("Tinggi_Phn")).type())
print("Tata letak:", baru.editFormConfig().layout(), "| alias Tinggi_Phn:", baru.attributeAlias(baru.fields().indexOf("Tinggi_Phn")))

# 7. Uji aturan: satu fitur salah (tinggi 250 m, kondisi kosong) dan satu fitur benar
def periksa(atribut):
    f = QgsFeature(baru.fields())
    f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(312100, 9996100)))
    for k, v in atribut.items():
        f[k] = v
    out = []
    for nm in ["ID_Survei", "Kondisi", "Tinggi_Phn", "Akurasi_m"]:
        ok, galat = QgsVectorLayerUtils.validateAttribute(baru, f, baru.fields().indexOf(nm))
        if not ok:
            out.append("%s: %s" % (nm, "; ".join(galat)))
    return out


print("Fitur salah :", periksa({"ID_Survei": "SV-001", "Kondisi": None, "Tinggi_Phn": 250.0, "Akurasi_m": 8.4}))
print("Fitur benar :", periksa({"ID_Survei": "SV-001", "Kondisi": "Sehat", "Tinggi_Phn": 12.0, "Akurasi_m": 2.0}))

# 8. Uji: di proyek baru, tabel acuan dicari lewat nama layer, dan nilai bawaan terisi
from qgis.core import QgsValueRelationFieldFormatter
proyek = QgsProject()                                   # proyek baru; proyek milik pengguna tidak dikosongkan
acuan = QgsVectorLayer(GPKG + "|layername=Ref_Jenis", "Ref_Jenis", "ogr")
proyek.addMapLayer(acuan)
uji = QgsVectorLayer(GPKG + "|layername=Titik_Survei", "uji_proyek_baru", "ogr")
uji.loadDefaultStyle()
konfig = uji.editorWidgetSetup(uji.fields().indexOf("Jenis")).config()
print("Tabel acuan ditemukan:", QgsValueRelationFieldFormatter.resolveLayer(konfig, proyek))
baris = QgsVectorLayerUtils.createFeature(uji)
print("Nilai bawaan -> ID_Survei:", baris["ID_Survei"], "| Tgl_Survei:", baris["Tgl_Survei"].toString("yyyy-MM-dd"), "| Surveyor:", baris["Surveyor"])

# 9. Uji: tata letak formulir terbaca ulang dari gaya yang tersimpan (kelompok dan isinya)
print("Susunan formulir:")
cfg_uji = uji.editFormConfig()                          # simpan dulu di variabel agar tidak hilang saat dibaca
for anak in cfg_uji.invisibleRootContainer().children():
    print("- [%s]" % anak.name(), ", ".join(c.name() for c in anak.children()))
print("Widget Foto:", uji.editorWidgetSetup(uji.fields().indexOf("Foto")).type(),
      "| widget Tgl_Survei:", uji.editorWidgetSetup(uji.fields().indexOf("Tgl_Survei")).type())

Penjelasan baris demi baris. Nomor baris mengikuti berkas di atas.

Blok A: baris 1 sampai 18, persiapan.

BarisTulisanArtinya
1-5komentarJudul, penulis, dan pesan: jalankan setelah Skrip 20.2.
6-8from qgis.core import (...)Satu daftar panjang perkakas QGIS dalam tiga baris. Sebagian, misalnya QgsEditFormConfig, tidak dipakai langsung.
10GPKG = r"D:/KPH_Contoh/Survei_KPH.gpkg"Lokasi berkas. Sama dengan KELUAR di Skrip 20.2. Ganti dengan jalur Anda.
12-13lyr = QgsVectorLayer(...) lalu assert lyr.isValid()Buka layer Titik_Survei dan pastikan terbuka. Bila gagal, skrip berhenti tanpa pesan (tidak ada teks setelah assert).
14idx = lyr.fields().indexOfSimpan sebuah fungsi di kotak. Tanpa tanda kurung, yang disimpan adalah fungsinya, bukan hasilnya. Sesudahnya, idx("Kondisi") memberi nomor urut kolom Kondisi.
17-18def widget(nama_field, jenis, konfig): lalu lyr.setEditorWidgetSetup(...)Resep pembantu: pasang satu widget ke satu kolom. jenis adalah nama widget (teks), dan konfig adalah pengaturannya (kamus). Resep ini dipakai tujuh kali di blok B.

Blok B: baris 21 sampai 44, tujuh widget. Istilah baru: Value Map (kotak pilihan dari daftar tetap), Value Relation (kotak pilihan dari tabel acuan), dan Range (kotak angka dengan batas).

BarisTulisanArtinya
23-24pilihan = [...] lalu widget("Kondisi", "ValueMap", {"map": [{p: p} for p in pilihan]})Value Map untuk Kondisi. Isi map adalah daftar kamus kecil {teks yang tampil: nilai yang disimpan}. Karena keduanya sama, tiap kamus berbentuk {p: p}. Daftarnya dibuat dengan list comprehension dari pilihan.
25widget("KPH", "ValueMap", {"map": [{"KPH Alpha": "KPH Alpha"}, ...]})Value Map untuk KPH, tiga isinya ditulis langsung.
28-29ref_jenis = QgsVectorLayer(...) lalu QgsProject.instance().addMapLayer(ref_jenis)Buka tabel acuan Ref_Jenis, lalu masukkan ke proyek yang sedang terbuka. Widget Value Relation butuh layer itu ada di proyek.
31-33widget("Jenis", "ValueRelation", {...})Value Relation untuk Jenis. Layer, LayerName, dan LayerSource menunjuk tabel acuan. Komentar di baris 30 menjelaskan: semuanya disimpan agar pilihan tetap ketemu saat gaya dibaca di proyek lain. Key dan Value adalah kolom nilai dan kolom tampil. AllowNull False melarang kosong. OrderByValue True mengurutkan pilihan.
36-37widget("Tinggi_Phn", "Range", {"Min": 0.0, "Max": 60.0, "Step": 0.1, ...})Range untuk Tinggi_Phn dan Diameter_cm. Min dan Max batas angka, Step kelipatan tiap klik, Precision jumlah desimal tampil.
40-41widget("Tgl_Survei", "DateTime", {...})Widget tanggal dengan kalender pop-up. Format tampil dan simpan yyyy-MM-dd.
42-44widget("Foto", "ExternalResource", {...})Widget foto. Di antarmuka QGIS, ia bernama Attachment. Pengaturannya memuat tampilan gambar terpadu, tombol pilih berkas, dan penyimpanan jalur relatif. Arti tiap angka mengikuti pilihan di jendela Attachment. [CEK]

Blok C: baris 46 sampai 67, aturan isian, nilai bawaan, dan alias. Istilah baru: aturan isian (constraint: syarat sebuah isian), tegas (menolak simpan) lawan lunak (hanya memberi peringatan), dan nilai bawaan (isian yang terisi sendiri).

BarisTulisanArtinya
47K = QgsFieldConstraintsBeri nama pendek K untuk perkakas panjang ini, agar baris-baris berikutnya muat.
48-50lyr.setFieldConstraint(idx("ID_Survei"), K.ConstraintNotNull, K.ConstraintStrengthHard)Pasang aturan tegas: ID_Survei tidak boleh kosong, dan harus unik (baris 49). Aturan serupa untuk Kondisi di baris 50.
51-52lyr.setConstraintExpression(idx("Tinggi_Phn"), '...', "pesan") lalu setFieldConstraint(...)Aturan berupa ekspresi: tinggi harus antara 0 dan 60, dengan pesan galat. Baris 52 mengaktifkannya sebagai aturan tegas.
53-54lyr.setConstraintExpression(idx("Akurasi_m"), ...) lalu K.ConstraintStrengthSoftAturan lunak: akurasi sebaiknya 5 m atau kurang. Bila lebih, hanya muncul peringatan.
57-58lyr.setDefaultValueDefinition(idx("Tgl_Survei"), QgsDefaultValue("to_date(now())")) dan baris untuk SurveyorNilai bawaan lewat ekspresi. Tanggal diisi tanggal hari ini. Surveyor diisi nama akun yang sedang masuk (@user_account_name).
59-60lyr.setDefaultValueDefinition(idx("ID_Survei"), QgsDefaultValue("..."))Nilai bawaan ID_Survei: ekspresi panjang yang dijelaskan di bawah tabel.
63-65ALIAS = {"ID_Survei": "ID survei", ...}Kamus: nama kolom asli di kiri, label ramah di kanan.
66-67for nm, al in ALIAS.items(): lalu lyr.setFieldAlias(idx(nm), al)Pasang label ramah ke tiap kolom. Nama kolom di basis data tidak berubah.

Ekspresi nilai bawaan ID_Survei di baris 59-60 dibaca dari dalam ke luar. substr("ID_Survei", 4) mengambil teks mulai huruf keempat, yaitu 001 dari SV-001. to_int(...) mengubahnya menjadi angka 1. maximum(...) mencari angka terbesar di seluruh kolom. coalesce(..., 0) memakai 0 bila kolom masih kosong. Lalu ditambah 1, diubah jadi teks (to_string), diisi nol di depan sampai tiga huruf (lpad), dan diawali SV-. Hasil pada tabel kosong: SV-001.

Blok D: baris 69 sampai 93, tata letak dan menyimpan gaya.

BarisTulisanArtinya
70-71cfg = lyr.editFormConfig() lalu cfg.setLayout(Qgis.AttributeFormLayout.DragAndDrop)Ambil pengaturan formulir layer, lalu pilih perancang "seret dan lepas" (drag and drop).
72-74cfg.clearTabs(), akar = cfg.invisibleRootContainer(), akar.clear()Kosongkan susunan lama. akar adalah wadah induk yang tak tampak, tempat semua grup akan dimasukkan.
77-83def kelompok(judul, field_field, kolom=1):Resep membuat satu grup: buat wadah bernama judul, bertipe kotak grup (GroupBox), jumlah lajur kolom (bawaan 1), isi dengan kolom-kolom dari daftar, lalu tambahkan ke akar.
86-88kelompok("Identitas", [...]), kelompok("Pohon", [...]), kelompok("Bukti", [...])Tiga grup: Identitas (empat kolom), Pohon (empat kolom), dan Bukti (foto dan akurasi).
89lyr.setEditFormConfig(cfg)Terapkan susunan baru ke layer.
92hasil_simpan, galat = lyr.saveStyleToDatabaseV2("Formulir_Survei", "Formulir survei pohon", True, "")Simpan gaya ke dalam GeoPackage (di tabel bernama layer_styles). Isiannya: nama gaya, keterangan, True agar dipakai sebagai gaya bawaan, dan teks kosong (isi berkas formulir buatan sendiri, yang tidak dipakai di sini). Arti tiap isian dibaca dari dokumentasi API QGIS. [CEK] Hasilnya sepasang: status dan pesan.
93print("Simpan gaya ke GeoPackage:", hasil_simpan, galat or "(tanpa pesan galat)")Cetak status. Bentuk a or b berarti: pakai a bila ada isinya, kalau kosong pakai b.

Blok E: baris 95 sampai 138, empat uji.

BarisTulisanArtinya
96-98baru = QgsVectorLayer(...), pesan, ok = baru.loadDefaultStyle(), print(...)Buka layer baru dari berkas, lalu minta ia memuat gaya bawaan dari dalam berkas. Cetak apakah berhasil.
99-101print("Widget Kondisi:", ...) dan dua baris lainCetak jenis widget, tata letak, dan alias yang terbaca ulang. Ini bukti gaya benar-benar tersimpan.
104-114def periksa(atribut):Resep uji aturan. Buat satu fitur sementara di memori (tidak disimpan), isi dari kamus atribut, lalu tanya QGIS tiap kolom: lolos aturan atau tidak (QgsVectorLayerUtils.validateAttribute). Pesan galat dikumpulkan di daftar out. Kotak ok dan galat dipakai lagi dengan arti lain dibanding baris 92.
117-118print("Fitur salah :", periksa({...})) dan print("Fitur benar :", ...)Uji dua fitur: satu bermasalah (tinggi 250, kondisi kosong, akurasi 8,4), satu baik.
121-124from qgis.core import QgsValueRelationFieldFormatter, proyek = QgsProject(), acuan = ..., proyek.addMapLayer(acuan)Impor boleh di tengah skrip. Lalu buat proyek baru yang terpisah (QgsProject(), dengan tanda kurung), dan masukkan hanya tabel acuan ke proyek itu. Tujuannya meniru proyek bersih. Proyek yang sedang Anda buka tidak disentuh.
125-128uji = QgsVectorLayer(...), uji.loadDefaultStyle(), print("Tabel acuan ditemukan:", ...)Layer baru memuat gaya, lalu cek bahwa widget Jenis menemukan tabel acuan lewat nama layernya.
129-130baris = QgsVectorLayerUtils.createFeature(uji) lalu print(...)Buat fitur baru dengan semua nilai bawaan terisi, lalu cetak ID_Survei, Tgl_Survei, dan Surveyor.
133-138cfg_uji = uji.editFormConfig() lalu perulanganCetak susunan grup formulir dan isinya, plus jenis widget Foto dan Tgl_Survei. Komentar baris 134 menjelaskan: simpan di kotak dulu agar tidak hilang saat dibaca.

Hasil. Layar menampilkan (tanggal dan nama akun mengikuti komputer Anda):

HASIL
Simpan gaya ke GeoPackage: SaveStyleResult.Success (tanpa pesan galat)
Gaya bawaan terbaca: True
Widget Kondisi: ValueMap
Widget Tinggi_Phn: Range
Tata letak: 1 | alias Tinggi_Phn: Tinggi pohon (m)
Fitur salah : ['Kondisi: value is NULL', 'Tinggi_Phn: Tinggi pohon harus 0 sampai 60 m check failed', 'Akurasi_m: Akurasi lebih dari 5 m: ulangi pengukuran bila bisa check failed']
Fitur benar : []
Tabel acuan ditemukan: <QgsVectorLayer: 'Ref_Jenis' (ogr)>
Nilai bawaan -> ID_Survei: SV-001 | Tgl_Survei: 2026-10-07 | Surveyor: [nama akun Windows Anda]
Susunan formulir:
- [Identitas] ID_Survei, KPH, Surveyor, Tgl_Survei
- [Pohon] Jenis, Kondisi, Tinggi_Phn, Diameter_cm
- [Bukti] Foto, Akurasi_m
Widget Foto: ExternalResource | widget Tgl_Survei: DateTime

Dari hasil ini Anda tahu: gaya tersimpan dan terbaca ulang. Fitur salah menghasilkan tiga peringatan (kondisi kosong, tinggi di luar rentang, akurasi lebih dari 5 m), sedangkan fitur benar lolos. ID bawaan SV-001. Pesan galat dari QGIS berbahasa Inggris (value is NULL, check failed). ExternalResource adalah nama internal widget yang di antarmuka bernama Attachment.

Bagian yang boleh diubah.

  • Baris 10, jalur. Ganti dengan lokasi Survei_KPH.gpkg.
  • Baris 23, pilihan. Ganti daftar kondisi. Samakan dengan domain di Skrip 20.3.
  • Baris 36-37, batas angka. Ubah Min, Max, dan Step.
  • Baris 53, aturan akurasi. Pada uji, "Akurasi_m" <= 3 dengan pesan "Akurasi lebih dari 3 m" membuat fitur berakurasi 4 m mendapat peringatan.
  • Baris 60, awalan 'SV-'. Ganti awalan ID.
  • Baris 63-65, ALIAS. Ganti label. Pada uji, "Tinggi (meter)" tercetak sebagai alias Tinggi_Phn di baris 101.
  • Baris 86-88, grup. Tambah, hapus, atau pindahkan kolom antargrup.

Jebakan.

  • Baris 122 memakai proyek terpisah, supaya proyek Anda aman. Tahap 8 butuh proyek bersih untuk menguji bahwa tabel acuan ditemukan lewat nama layer. Baris proyek = QgsProject() membuat proyek baru di memori. Pada uji, saya menambah layer layer_milik_saya ke proyek yang terbuka sebelum menjalankan skrip. Sesudahnya daftar layer berisi ['Ref_Jenis', 'layer_milik_saya'], jadi layer saya selamat. Bila baris itu diganti QgsProject.instance() lalu removeAllMapLayers(), seluruh layer proyek Anda terhapus. Jangan mengubahnya begitu.
  • Aturan formulir tidak menahan skrip. Pada uji, saya memuat gaya itu ke layer, lalu menulis satu fitur berkondisi kosong dan tinggi 250 m lewat kode (addFeature lalu commitChanges). Hasilnya commit: True dan satu baris tersimpan. Aturan isian bekerja di formulir, bukan saat skrip menulis. Untuk menolak penulisan skrip, lihat pagar di Skrip 20.5.
  • Skrip 20.2 belum dijalankan. Skrip berhenti di baris 13 dengan AssertionError tanpa pesan, karena assert di baris itu tidak diberi teks. Periksa jalur dan isi folder.
  • Baris 29 menambah Ref_Jenis ke proyek yang terbuka. Widget Value Relation membutuhkannya di tahap 6. Menjalankan skrip berulang-ulang bisa menumpuk layer Ref_Jenis kembar di proyek itu. Hapus yang lama bila perlu.
  • ID otomatis bisa bentrok. Rumus "terbesar ditambah satu" aman hanya bila satu orang yang menulis. Dua surveyor luring mendapat nomor yang sama. Skrip 20.9 membuktikannya.
  • Sembilan tahap dalam satu berkas. Bila ada yang salah, ubah satu blok dulu, jalankan, lalu lanjut ke blok berikutnya.

Latihan variasi.

  1. Ubah batas akurasi lunak menjadi 3 m, lalu uji fitur berakurasi 4 m.
  2. Ganti label (alias) Tinggi_Phn menjadi "Tinggi (meter)".

Contoh jawaban nomor 1, baris 53 menjadi:

PYTHON
lyr.setConstraintExpression(idx("Akurasi_m"), '"Akurasi_m" <= 3', "Akurasi lebih dari 3 m: ulangi pengukuran bila bisa")

Lalu tambahkan satu baris setelah baris 118:

PYTHON
print("Akurasi 4 m  :", periksa({"ID_Survei": "SV-001", "Kondisi": "Sehat", "Tinggi_Phn": 12.0, "Akurasi_m": 4.0}))

Layar menampilkan Akurasi 4 m : ['Akurasi_m: Akurasi lebih dari 3 m: ulangi pengukuran bila bisa check failed'].

Contoh jawaban nomor 2, di kamus ALIAS baris 64 ganti "Tinggi_Phn": "Tinggi pohon (m)" menjadi "Tinggi_Phn": "Tinggi (meter)". Layar menampilkan Tata letak: 1 | alias Tinggi_Phn: Tinggi (meter).

Skrip 20.5: Pagar di dalam berkas #

Berkas i3_05_pagar_di_berkas.py. Di dalam berkasnya tertulis "SKRIP I3.5". Jalankan setelah Skrip 20.2. Skrip ini bekerja pada salinan berkas.

Tujuan. Memasang "pagar" di dalam GeoPackage yang menolak nilai Kondisi di luar tiga pilihan baku, lalu mencoba dengan nilai yang salah (sehat huruf kecil) dan yang benar (Sehat).

Bahan. Survei_KPH.gpkg hasil Skrip 20.2. Skrip menyalinnya ke Survei_KPH_pagar.gpkg, lalu bekerja di salinan itu. Skrip memakai osgeo, bukan qgis.core, jadi bisa dijalankan dari terminal python-qgis.

Istilah baru untuk skrip ini:

  • Trigger (di bab ini disebut pagar): aturan di dalam basis data yang dijalankan otomatis setiap ada data masuk atau berubah.
  • try dan except: cara mencoba sesuatu dan menangani kegagalannya tanpa membuat skrip berhenti (Bab 10).
  • Salinan kerja: berkas tiruan untuk bereksperimen supaya yang asli aman.
Ilustrasi 20.9: Alur skrip 20.5
Skema lima tahap skrip: salin berkas asli, buka salinan dan siapkan pilihan, pasang dua pagar, coba tambah dua titik, tutup berkas lalu hitung baris

Skrip utuh:

PYTHONUnduh berkas
# -*- coding: utf-8 -*-
# [SKRIP I3.5] Pagar di dalam berkas: trigger SQLite yang menolak nilai Kondisi di luar tiga pilihan baku.
# Penulis: Badar Mubarok Yogaswara
# Bekerja pada SALINAN GeoPackage dari skrip I3.2 (jangan dipasang dulu pada berkas asli sebelum Anda yakin).
# Dijalankan memakai python-qgis.bat (butuh modul osgeo.ogr).
import shutil
from osgeo import ogr

ogr.UseExceptions()
ASLI = r"D:/KPH_Contoh/Survei_KPH.gpkg"
SALINAN = r"D:/KPH_Contoh/Survei_KPH_pagar.gpkg"
shutil.copy(ASLI, SALINAN)

ds = ogr.Open(SALINAN, 1)
PILIHAN = "'Sehat','Terserang Hama','Tebangan Liar'"
for nama, kejadian in [("cek_kondisi_tambah", "INSERT"), ("cek_kondisi_ubah", "UPDATE OF Kondisi")]:
    ds.ExecuteSQL(
        "CREATE TRIGGER %s BEFORE %s ON Titik_Survei "
        "WHEN NEW.Kondisi IS NULL OR NEW.Kondisi NOT IN (%s) "
        "BEGIN SELECT RAISE(ABORT, 'Kondisi harus salah satu dari tiga pilihan baku'); END;" % (nama, kejadian, PILIHAN))

lyr = ds.GetLayerByName("Titik_Survei")
for coba in ["sehat", "Sehat"]:
    f = ogr.Feature(lyr.GetLayerDefn())
    f.SetGeometry(ogr.CreateGeometryFromWkt("POINT(312100 9996100)"))
    f.SetField("ID_Survei", "UJI-" + coba)
    f.SetField("Kondisi", coba)
    try:
        lyr.CreateFeature(f)
        print("Kondisi '%s' -> DITERIMA" % coba)
    except Exception as e:
        print("Kondisi '%s' -> DITOLAK: %s" % (coba, str(e).split(":")[-1].strip()))
ds = None

ds = ogr.Open(SALINAN)
print("Jumlah baris tersimpan:", ds.GetLayerByName("Titik_Survei").GetFeatureCount())

Penjelasan baris demi baris. Nomor baris mengikuti berkas di atas.

BarisTulisanArtinya
1-5komentarKomentar menekankan: bekerja pada SALINAN, dan butuh python-qgis (modul osgeo).
6-7import shutil dan from osgeo import ogrshutil adalah perkakas menyalin berkas. ogr membuka dan mengubah berkas vektor.
9ogr.UseExceptions()Nyalakan alarm: kegagalan GDAL menghentikan skrip, atau bisa ditangkap dengan try.
10-11ASLI = ... dan SALINAN = ...Dua jalur: berkas asli dari Skrip 20.2, dan nama salinannya. Ganti sesuai komputer Anda.
12shutil.copy(ASLI, SALINAN)Salin berkas. Bila SALINAN sudah ada, ia ditimpa tanpa peringatan.
14ds = ogr.Open(SALINAN, 1)Buka salinan dengan mode tulis.
15PILIHAN = "'Sehat','Terserang Hama','Tebangan Liar'"Teks daftar tiga pilihan, dengan kutip tunggal agar siap dimasukkan ke SQL.
16for nama, kejadian in [("cek_kondisi_tambah", "INSERT"), ("cek_kondisi_ubah", "UPDATE OF Kondisi")]:Ulangi dua kali. Satu pagar untuk penambahan baris (INSERT), satu untuk perubahan kolom Kondisi (UPDATE OF Kondisi).
17-20ds.ExecuteSQL("CREATE TRIGGER %s BEFORE %s ON Titik_Survei WHEN ... BEGIN ... END;" % (...))Satu kalimat SQL panjang yang dirakit dari potongan. Bacalah: "buat pagar bernama X, sebelum kejadian Y pada tabel Titik_Survei, bila kondisi baru kosong atau tidak ada di daftar, batalkan dengan pesan: Kondisi harus salah satu dari tiga pilihan baku". Perintah RAISE(ABORT, ...) yang membatalkan. Tiga tanda %s diisi nama, kejadian, dan daftar pilihan.
22lyr = ds.GetLayerByName("Titik_Survei")Ambil layer yang akan dicoba.
23for coba in ["sehat", "Sehat"]:Dua percobaan: huruf kecil (salah) lalu huruf besar di awal (benar).
24-27f = ogr.Feature(...), f.SetGeometry(...), f.SetField("ID_Survei", "UJI-" + coba), f.SetField("Kondisi", coba)Susun satu titik uji. Tanda + menyambung teks UJI- dengan nilai coba.
28-30try: lalu lyr.CreateFeature(f) dan print(... DITERIMA)Coba tulis titik ke berkas. Bila berhasil, cetak DITERIMA.
31-32except Exception as e: lalu print(... str(e).split(":")[-1].strip())Bila gagal, blok ini yang jalan. Pesan galat e diubah jadi teks, dipotong di tanda titik dua terakhir ([-1] berarti yang paling belakang), dan spasi di tepi dibuang. Hasilnya hanya pesan pagar.
33ds = NoneTutup berkas.
35-36ds = ogr.Open(SALINAN) lalu print(... GetFeatureCount())Buka lagi tanpa mode tulis, dan hitung baris yang benar-benar tersimpan.

Hasil. Layar menampilkan:

HASIL
Kondisi 'sehat' -> DITOLAK: Kondisi harus salah satu dari tiga pilihan baku
Kondisi 'Sehat' -> DITERIMA
Jumlah baris tersimpan: 1

Dari hasil ini Anda tahu: sehat (huruf kecil) ditolak oleh pagar, dan Sehat diterima. Hanya satu baris tersimpan. Beda dengan Skrip 20.3: pagar ini menolak siapa pun yang menulis, termasuk skrip.

Bagian yang boleh diubah.

  • Baris 10-11, jalur. Ganti ASLI dan SALINAN.
  • Baris 15, PILIHAN. Ganti daftar pilihan sah. Juga ubah pesan di baris 20 bila jumlah pilihannya berubah. Pada uji, menambah 'Mati' ke PILIHAN, dengan uji ["Mati", "Sehat"], menghasilkan Kondisi 'Mati' -> DITERIMA, Kondisi 'Sehat' -> DITERIMA, dan 2 baris tersimpan.
  • Baris 16, nama dan kejadian pagar. Ganti nama pagar. Untuk memagari kolom lain, ubah Kondisi di kejadian dan di syarat WHEN.
  • Baris 23, nilai uji. Ganti dengan nilai lain yang ingin dicoba.

Jebakan.

  • NOT IN peka huruf besar dan kecil. Itu persis yang diperagakan: sehat ditolak karena tidak sama dengan Sehat.
  • Salinan lama ditimpa. Pada uji, menjalankan skrip dua kali tidak menimbulkan galat, dan jumlah barisnya tetap 1. Salinan lama (dan pekerjaan di dalamnya) hilang tiap kali.
  • Berkas asli tidak ada. Layar menampilkan FileNotFoundError: [Errno 2] No such file or directory: .... Jalankan Skrip 20.2 dulu.
  • Pesan dipotong. Tanpa pemotongan di baris 32, pesan mentahnya failed to execute insert : Kondisi harus salah satu dari tiga pilihan baku. Pemotongan memakai tanda titik dua terakhir. Bila format pesan GDAL berubah di versi lain, hasil potongan bisa berbeda. [kemungkinan]
  • Pagar membuat penulisan lebih ketat. Surveyor yang mengetik di aplikasi lapangan akan mendapat penolakan juga. Pasang pagar hanya bila aturannya sudah pasti.

Latihan variasi.

  1. Tambahkan "Mati" sebagai pilihan sah, lalu uji nilai Mati dan Sehat.
  2. Tampilkan pesan galat lengkap tanpa dipotong.

Contoh jawaban nomor 1, baris 15 dan 23 menjadi:

PYTHON
PILIHAN = "'Sehat','Terserang Hama','Tebangan Liar','Mati'"
PYTHON
for coba in ["Mati", "Sehat"]:

Layar menampilkan Kondisi 'Mati' -> DITERIMA, Kondisi 'Sehat' -> DITERIMA, dan Jumlah baris tersimpan: 2.

Contoh jawaban nomor 2, baris 32 menjadi:

PYTHON
        print("Kondisi '%s' -> DITOLAK: %s" % (coba, str(e)))

Layar menampilkan Kondisi 'sehat' -> DITOLAK: failed to execute insert : Kondisi harus salah satu dari tiga pilihan baku.

Skrip 20.6: Memeriksa mutu data survei #

Berkas i3_06_cek_kualitas.py. Di dalam berkasnya tertulis "SKRIP I3.6".

Tujuan. Menjalankan delapan pemeriksaan pada data survei (kosong, ejaan tidak baku, rentang tinggi, tanggal, akurasi, ID ganda, koordinat kembar, letak titik) dan mencetak daftar ID yang bermasalah. Skrip ini hanya membaca, tidak mengubah apa pun.

Bahan. Survei_Lapangan.gpkg dari paket-i3: layer Titik_Survei (61 titik, kolom ID_Survei, KPH, Kondisi, Tinggi_Phn, Tgl_Survei, Akurasi_m) dan layer Batas_KPH (tiga petak, kolom NAMA_KPH). Skrip tidak butuh skrip lain.

Gagasannya seperti daftar periksa barang masuk: ada yang kosong, ada yang di luar rentang, ada yang ganda, ada yang letaknya aneh. Aturan yang ditulis sekali bisa dijalankan setiap kali data baru datang.

Istilah baru untuk skrip ini:

  • Kualitas data: seberapa layak data dipakai untuk tujuannya.
  • Kelengkapan: apakah isian wajib sudah terisi.
  • Keabsahan: apakah nilainya ada di daftar atau rentang yang sah.
  • Konsistensi: apakah bagian-bagian data saling cocok, misalnya label petak dan letak titik.
  • Keunikan: apakah ada baris ganda.
Ilustrasi 20.10: Alur skrip 20.6
Skema enam tahap skrip: konfigurasi dan buka layer, wadah temuan dan fungsi catat, pemeriksaan satu sampai lima, pemeriksaan ganda, pemeriksaan letak titik, cetak hasil

Skrip utuh:

PYTHONUnduh berkas
# -*- coding: utf-8 -*-
# [SKRIP I3.6] Pemeriksa kualitas data survei: kelengkapan, nilai baku, rentang, duplikat, letak, dan akurasi GNSS.
# Penulis: Badar Mubarok Yogaswara
# Dijalankan di Python Console QGIS atau python-qgis.bat. Hanya MEMBACA data; tidak mengubah apa pun.
from collections import Counter
from qgis.core import QgsVectorLayer, QgsGeometry

JALUR = r"D:/KPH_Contoh/paket-i3/Survei_Lapangan.gpkg"
BAKU = {"Sehat", "Terserang Hama", "Tebangan Liar"}
PERIODE = ("2026-09-01", "2026-09-12")          # tanggal survei yang sah (teks ISO, bisa dibandingkan urutannya)
BATAS_TINGGI = (0, 60)
BATAS_AKURASI = 5.0

titik = QgsVectorLayer(JALUR + "|layername=Titik_Survei", "Titik_Survei", "ogr")
batas = QgsVectorLayer(JALUR + "|layername=Batas_KPH", "Batas_KPH", "ogr")
assert titik.isValid() and batas.isValid(), "Layer tidak terbaca: periksa JALUR"
fitur = list(titik.getFeatures())
petak = {f["NAMA_KPH"]: f.geometry() for f in batas.getFeatures()}

temuan = {}                                        # nama pemeriksaan -> daftar ID_Survei bermasalah


def catat(nama, ids):
    temuan[nama] = sorted(ids)


# 1. Kelengkapan: Kondisi tidak boleh kosong
catat("Kondisi kosong", [f["ID_Survei"] for f in fitur if f["Kondisi"] is None or str(f["Kondisi"]).strip() == ""])
# 2. Nilai baku: Kondisi harus persis salah satu pilihan (huruf besar-kecil dan spasi dihitung)
catat("Kondisi tidak baku", [f["ID_Survei"] for f in fitur if f["Kondisi"] is not None and f["Kondisi"] not in BAKU])
# 3. Rentang: tinggi pohon masuk akal
catat("Tinggi di luar rentang", [f["ID_Survei"] for f in fitur
                                 if f["Tinggi_Phn"] is not None and not (BATAS_TINGGI[0] <= f["Tinggi_Phn"] <= BATAS_TINGGI[1])])
# 4. Tanggal di luar periode survei
catat("Tanggal di luar periode", [f["ID_Survei"] for f in fitur
                                  if f["Tgl_Survei"] is not None and not (PERIODE[0] <= f["Tgl_Survei"].toString("yyyy-MM-dd") <= PERIODE[1])])
# 5. Akurasi GNSS buruk
catat("Akurasi lebih dari %.0f m" % BATAS_AKURASI, [f["ID_Survei"] for f in fitur if f["Akurasi_m"] is not None and f["Akurasi_m"] > BATAS_AKURASI])
# 6. ID ganda
hitung_id = Counter(f["ID_Survei"] for f in fitur)
catat("ID ganda", [i for i, n in hitung_id.items() if n > 1])
# 7. Koordinat kembar (dibulatkan ke 1 mm)
hitung_xy = Counter((round(f.geometry().asPoint().x(), 3), round(f.geometry().asPoint().y(), 3)) for f in fitur)
kembar = {xy for xy, n in hitung_xy.items() if n > 1}
catat("Koordinat kembar", [f["ID_Survei"] for f in fitur
                           if (round(f.geometry().asPoint().x(), 3), round(f.geometry().asPoint().y(), 3)) in kembar])
# 8. Letak: titik harus berada di dalam salah satu petak, dan petaknya harus sama dengan label KPH
di_luar, salah_label = [], []
for f in fitur:
    g = f.geometry()
    dalam = [nama for nama, pg in petak.items() if g.within(pg)]
    if not dalam:
        di_luar.append(f["ID_Survei"])
    elif f["KPH"] not in dalam:
        salah_label.append(f["ID_Survei"])
catat("Di luar semua petak", di_luar)
catat("Label KPH tidak sesuai letak", salah_label)

print("Jumlah baris diperiksa:", len(fitur))
for nama, ids in temuan.items():
    print("%-32s %2d  %s" % (nama, len(ids), ", ".join(ids)))

Penjelasan baris demi baris. Dua blok besar. Nomor baris mengikuti berkas di atas.

Blok A: baris 1 sampai 24, persiapan.

BarisTulisanArtinya
1-4komentarJudul, penulis, dan penegasan: hanya MEMBACA, tidak mengubah data.
5from collections import CounterPanggil perkakas penghitung. Counter menghitung berapa kali tiap isi muncul.
6from qgis.core import QgsVectorLayer, QgsGeometryPanggil pembuka layer. QgsGeometry tidak dipakai langsung.
8JALUR = r"D:/KPH_Contoh/paket-i3/Survei_Lapangan.gpkg"Lokasi data. Ganti dengan jalur Anda.
9BAKU = {"Sehat", "Terserang Hama", "Tebangan Liar"}Himpunan (set): kumpulan isi tanpa kembar, dengan kurung kurawal tanpa titik dua. Cepat untuk menjawab "ada di daftar atau tidak".
10PERIODE = ("2026-09-01", "2026-09-12")Dua tanggal sah, ditulis sebagai teks tahun-bulan-hari. Teks bentuk ini bisa dibandingkan urutannya seperti tanggal.
11-12BATAS_TINGGI = (0, 60) dan BATAS_AKURASI = 5.0Batas tinggi pohon dan batas akurasi GNSS. Tiga angka ini adalah pengaturan kriteria Anda.
14-16titik = ..., batas = ..., assert ...Buka dua layer dan pastikan terbuka.
17fitur = list(titik.getFeatures())Baca semua titik sekali jadi daftar. Dengan begitu bisa dipakai berulang-ulang di pemeriksaan berikutnya.
18petak = {f["NAMA_KPH"]: f.geometry() for f in batas.getFeatures()}Kamus yang dibuat dengan satu baris perulangan: nama petak di kiri, bentuk poligonnya di kanan.
20temuan = {}Kamus kosong untuk menampung hasil: nama pemeriksaan menjadi kunci, daftar ID bermasalah menjadi isinya.
23-24def catat(nama, ids): lalu temuan[nama] = sorted(ids)Resep kecil: simpan daftar ID yang sudah diurutkan di bawah nama pemeriksaan.

Blok B: baris 27 sampai 61, delapan pemeriksaan dan cetakan.

BarisTulisanArtinya
28catat("Kondisi kosong", [f["ID_Survei"] for f in fitur if f["Kondisi"] is None or str(f["Kondisi"]).strip() == ""])Pemeriksaan 1, kelengkapan. List comprehension dengan syarat if: ambil ID titik yang Kondisi-nya kosong (is None) atau hanya berisi spasi (str(...).strip() == "").
30catat("Kondisi tidak baku", [... if f["Kondisi"] is not None and f["Kondisi"] not in BAKU])Pemeriksaan 2, keabsahan nilai. Yang terisi tetapi tidak ada di BAKU. Huruf besar-kecil dan spasi dihitung.
32-33... if f["Tinggi_Phn"] is not None and not (BATAS_TINGGI[0] <= f["Tinggi_Phn"] <= BATAS_TINGGI[1])Pemeriksaan 3, rentang. Bentuk a <= x <= b membaca "x antara a dan b". Tanda not membalik: ambil yang di luar rentang. Bagian is not None and adalah pengaman: isian kosong (NULL) dilewati dulu, sehingga skrip tidak berhenti.
35-36... if f["Tgl_Survei"] is not None and not (PERIODE[0] <= f["Tgl_Survei"].toString("yyyy-MM-dd") <= PERIODE[1])Pemeriksaan 4, tanggal. toString("yyyy-MM-dd") mengubah tanggal menjadi teks bentuk tahun-bulan-hari, agar bisa dibandingkan dengan PERIODE. Pengamannya sama: tanggal kosong dilewati.
38catat("Akurasi lebih dari %.0f m" % BATAS_AKURASI, [... if f["Akurasi_m"] is not None and f["Akurasi_m"] > BATAS_AKURASI])Pemeriksaan 5, akurasi GNSS. Nama pemeriksaan ikut berubah mengikuti BATAS_AKURASI. Akurasi kosong dilewati.
40-41hitung_id = Counter(f["ID_Survei"] for f in fitur) lalu catat("ID ganda", [i for i, n in hitung_id.items() if n > 1])Pemeriksaan 6, keunikan. Hitung kemunculan tiap ID, lalu ambil ID yang muncul lebih dari satu kali.
43-44hitung_xy = Counter((round(x, 3), round(y, 3)) ...) lalu kembar = {xy for xy, n in hitung_xy.items() if n > 1}Pemeriksaan 7, koordinat kembar. Koordinat dibulatkan ke tiga desimal (satu milimeter), lalu dihitung. Pasangan (x, y) dipakai sebagai kunci. Baris 44 membuat himpunan pasangan yang muncul lebih dari sekali.
45-46catat("Koordinat kembar", [... if (round(...), round(...)) in kembar])Ambil semua titik yang koordinatnya termasuk kembar. Karena itu SV-049 muncul dua kali di hasil.
48di_luar, salah_label = [], []Siapkan dua daftar kosong dalam satu baris.
49-55for f in fitur: lalu dalam = [...], if not dalam:, elif f["KPH"] not in dalam:Pemeriksaan 8, letak. Untuk tiap titik, cari nama petak yang memuatnya (g.within(pg)). Bila tidak ada petak, titik di luar. Bila ada tetapi label KPH titik tidak termasuk petak itu, label salah. Pola if ... elif memilih salah satu cabang.
56-57catat("Di luar semua petak", di_luar) dan catat("Label KPH tidak sesuai letak", salah_label)Simpan dua hasil.
59-61print("Jumlah baris diperiksa:", len(fitur)) dan perulangan for nama, ids in temuan.items():Cetak jumlah baris, lalu satu baris per pemeriksaan: nama (lebar 32), jumlah, dan daftar ID disambung koma.

Hasil. Layar menampilkan:

HASIL
Jumlah baris diperiksa: 61
Kondisi kosong                    2  SV-031, SV-040
Kondisi tidak baku                3  SV-005, SV-012, SV-020
Tinggi di luar rentang            2  SV-007, SV-033
Tanggal di luar periode           1  SV-050
Akurasi lebih dari 5 m            2  SV-015, SV-044
ID ganda                          1  SV-049
Koordinat kembar                  4  SV-049, SV-049, SV-051, SV-058
Di luar semua petak               3  SV-059, SV-060, SV-061
Label KPH tidak sesuai letak      2  SV-010, SV-025

Dari hasil ini Anda tahu: dari 61 titik, ada sembilan jenis temuan. Dua baris tanpa kondisi, tiga ejaan tidak baku, dua tinggi di luar 0 sampai 60 m, satu tanggal di luar periode, dua akurasi lebih dari 5 m, satu ID ganda, empat titik berkoordinat kembar, tiga titik di luar semua petak, dan dua titik yang labelnya tidak cocok dengan letak. Jumlah baris, kondisi kosong, tidak baku, tinggi, tanggal, akurasi, dan ID ganda sama dengan yang tercatat di hasil_diharapkan.json pada paket data. Satu titik bisa kena beberapa pemeriksaan. Karena itu jumlah temuan tidak sama dengan jumlah titik yang bermasalah.

Bagian yang boleh diubah.

  • Baris 8, jalur. Ganti dengan lokasi data Anda.
  • Baris 9, BAKU. Daftar nilai sah untuk Kondisi. Samakan dengan domain di Skrip 20.3.
  • Baris 10, PERIODE. Rentang tanggal survei Anda.
  • Baris 11-12, batas tinggi dan akurasi. Pada uji, BATAS_AKURASI = 3.0 memberi Akurasi lebih dari 3 m 18 temuan (dari 2). Judul pemeriksaan ikut berubah otomatis berkat %.0f.
  • Baris 27-57, pemeriksaan baru. Tambah satu panggilan catat(...) dengan syarat Anda sendiri.

Jebakan.

  • Isian kosong pada kolom angka dan tanggal tidak ditandai. Pengaman is not None and di baris 33, 36, dan 38 membuat skrip tidak berhenti bila Tinggi_Phn, Tgl_Survei, atau Akurasi_m kosong. Tanpa pengaman, Python berhenti dengan galat seperti TypeError: '<=' not supported between instances of 'int' and 'NoneType'. Tetapi isian kosong itu juga tidak masuk daftar "di luar rentang", "di luar periode", atau "akurasi lebih dari 5 m". Hanya kolom Kondisi yang punya pemeriksaan kosong (pemeriksaan 1). Untuk kolom lain, tambah pemeriksaan kosong sendiri. [terbukti: skrip jalan sampai selesai pada data uji berisi NULL]
  • Pemeriksaan baru perlu pengaman sendiri. Pengaman hanya dipasang pada tiga pemeriksaan di atas. Untuk kolom lain, tulis is not None and di depan syarat, seperti pada contoh jawaban latihan 2.
  • Satu titik, banyak temuan. Jumlah temuan dihitung per pemeriksaan. Titik dengan dua masalah muncul dua kali.
  • Koordinat kembar dibulatkan ke milimeter. Bila satuan peta Anda bukan meter, tiga desimal bermakna lain.
  • Titik tepat di tepi petak. Fungsi within tidak menghitung titik yang persis di garis batas. [kemungkinan: dari dokumentasi fungsi, belum diuji]
  • Impor tak terpakai. QgsGeometry tidak dipakai langsung.

Latihan variasi.

  1. Ganti batas akurasi menjadi 3 m.
  2. Tambahkan pemeriksaan: diameter lebih dari 45 cm.

Contoh jawaban nomor 1, baris 12 menjadi BATAS_AKURASI = 3.0. Layar menampilkan baris Akurasi lebih dari 3 m dengan 18 ID.

Contoh jawaban nomor 2, sisipkan sebelum baris 39 (komentar "6. ID ganda"):

PYTHON
catat("Diameter lebih dari 45 cm", [f["ID_Survei"] for f in fitur if f["Diameter_cm"] is not None and f["Diameter_cm"] > 45])

Layar menampilkan Diameter lebih dari 45 cm 13 SV-005, SV-006, SV-010, SV-019, SV-033, SV-038, SV-045, SV-046, SV-048, SV-050, SV-051, SV-052, SV-058.

Skrip 20.7: Bertanya ke basis data dengan SQL #

Berkas i3_07_sql_dasar.py. Di dalam berkasnya tertulis "SKRIP I3.7". Skrip ini bekerja pada salinan data, karena satu kalimatnya (UPDATE) mengubah isi.

Tujuan. Menjalankan sepuluh kalimat SQL dasar pada GeoPackage survei dan melihat hasilnya: menghitung, menyaring, mengelompokkan, menggabungkan tabel, lalu memperbaiki ejaan.

Bahan. Survei_Lapangan.gpkg dari paket-i3 (layer Titik_Survei, Ref_Kondisi, Batas_KPH). Skrip menyalinnya ke Survei_Lapangan_salinan.gpkg. Bahasa SQL di GeoPackage adalah SQL milik SQLite.

Gagasannya: SQL itu seperti bertanya kepada petugas arsip dengan kalimat yang baku. Anda tidak mencari satu per satu. Anda cukup menyebut tabel, syarat, dan bentuk jawaban yang diinginkan.

Lima istilah baru untuk skrip ini:

  • SELECT ... FROM: pilih kolom dari sebuah tabel.
  • WHERE: saring baris yang memenuhi syarat.
  • GROUP BY dan HAVING: kelompokkan baris, lalu (dengan HAVING) saring kelompoknya.
  • JOIN: gabungkan dua tabel lewat kolom yang sama. LEFT JOIN mempertahankan semua baris tabel kiri.
  • UPDATE: ubah isi baris.
Ilustrasi 20.11: Alur skrip 20.7
Skema empat tahap skrip: salin berkas dan buka salinan, fungsi jalankan, sepuluh pertanyaan SQL, tutup berkas

Skrip utuh:

PYTHONUnduh berkas
# -*- coding: utf-8 -*-
# [SKRIP I3.7] SQL dasar pada GeoPackage survei: SELECT, WHERE, ORDER BY, GROUP BY, HAVING, JOIN, UPDATE.
# Penulis: Badar Mubarok Yogaswara
# Bekerja pada SALINAN data (UPDATE mengubah isi). Dijalankan memakai python-qgis.bat (modul osgeo.ogr).
# Bahasa SQL yang dipakai GeoPackage adalah SQL milik SQLite.
import os
import shutil
from osgeo import ogr

ogr.UseExceptions()
ASLI = r"D:/KPH_Contoh/paket-i3/Survei_Lapangan.gpkg"
SALINAN = r"D:/KPH_Contoh/Survei_Lapangan_salinan.gpkg"
os.makedirs(os.path.dirname(SALINAN), exist_ok=True)
shutil.copy(ASLI, SALINAN)
ds = ogr.Open(SALINAN, 1)


def jalankan(judul, sql):
    print("\n-- " + judul)
    print(sql)
    hasil = ds.ExecuteSQL(sql)
    if hasil is None:                              # perintah tanpa tabel hasil (UPDATE, DELETE)
        return
    nama = [hasil.GetLayerDefn().GetFieldDefn(i).GetName() for i in range(hasil.GetLayerDefn().GetFieldCount())]
    print(" | ".join(nama))
    for baris in hasil:
        print(" | ".join(str(baris.GetField(i)) for i in range(len(nama))))
    ds.ReleaseResultSet(hasil)


jalankan("1. Berapa baris? ", "SELECT COUNT(*) AS jumlah FROM Titik_Survei")
jalankan("2. Pilih kolom dan batasi baris", "SELECT ID_Survei, Kondisi, Tinggi_Phn FROM Titik_Survei LIMIT 3")
jalankan("3. Saring dengan WHERE", "SELECT ID_Survei, KPH, Tinggi_Phn FROM Titik_Survei WHERE Kondisi = 'Tebangan Liar' ORDER BY ID_Survei")
jalankan("4. Hitung per kondisi (GROUP BY)", "SELECT Kondisi, COUNT(*) AS jumlah FROM Titik_Survei GROUP BY Kondisi ORDER BY jumlah DESC")
jalankan("5. Rata-rata tinggi per KPH (hanya nilai wajar)",
         "SELECT KPH, COUNT(*) AS n, ROUND(AVG(Tinggi_Phn), 1) AS rata_tinggi FROM Titik_Survei "
         "WHERE Tinggi_Phn BETWEEN 0 AND 60 GROUP BY KPH ORDER BY KPH")
jalankan("6. Cari ID ganda (HAVING)", "SELECT ID_Survei, COUNT(*) AS n FROM Titik_Survei GROUP BY ID_Survei HAVING COUNT(*) > 1")
jalankan("7. Gabung dengan tabel acuan: Kondisi yang tidak ada di daftar baku (LEFT JOIN)",
         "SELECT t.ID_Survei, t.Kondisi FROM Titik_Survei t LEFT JOIN Ref_Kondisi r ON t.Kondisi = r.Kondisi "
         "WHERE r.Kondisi IS NULL ORDER BY t.ID_Survei")
jalankan("8. Gabung dengan batas KPH lewat nama (JOIN)",
         "SELECT b.NAMA_KPH, COUNT(*) AS jumlah_titik FROM Titik_Survei t JOIN Batas_KPH b ON t.KPH = b.NAMA_KPH "
         "GROUP BY b.NAMA_KPH ORDER BY b.NAMA_KPH")
jalankan("9. Perbaiki ejaan (UPDATE) pada salinan",
         "UPDATE Titik_Survei SET Kondisi = 'Sehat' WHERE LOWER(TRIM(Kondisi)) = 'sehat'")
jalankan("10. Periksa lagi setelah UPDATE",
         "SELECT t.ID_Survei, t.Kondisi FROM Titik_Survei t LEFT JOIN Ref_Kondisi r ON t.Kondisi = r.Kondisi "
         "WHERE r.Kondisi IS NULL ORDER BY t.ID_Survei")
ds = None

Penjelasan baris demi baris. Tiga blok. Nomor baris mengikuti berkas di atas.

Blok A: baris 1 sampai 15, menyiapkan salinan.

BarisTulisanArtinya
1-5komentarPenegasan: bekerja pada SALINAN, dan bahasa SQL-nya milik SQLite.
6-8import os, import shutil, from osgeo import ogrPanggil perkakas jalur, salin berkas, dan GDAL.
10ogr.UseExceptions()Nyalakan alarm: kesalahan SQL menghentikan skrip dengan pesan.
11-12ASLI = ... dan SALINAN = ...Dua jalur: berkas asli (dari paket-i3), dan salinan kerja. Ganti keduanya.
13os.makedirs(os.path.dirname(SALINAN), exist_ok=True)Pastikan folder salinan ada.
14-15shutil.copy(ASLI, SALINAN) lalu ds = ogr.Open(SALINAN, 1)Salin berkas (menimpa salinan lama tanpa tanya), lalu buka salinan dengan mode tulis.

Blok B: baris 18 sampai 28, fungsi jalankan.

BarisTulisanArtinya
18def jalankan(judul, sql):Resep yang menerima judul dan satu kalimat SQL. Dipakai sepuluh kali di blok C.
19-20print("\n-- " + judul) lalu print(sql)Cetak judul dan kalimat SQL-nya. Kode \n berarti baris baru, jadi ada baris kosong sebelum judul.
21hasil = ds.ExecuteSQL(sql)Kirim kalimat ke basis data. Jawabannya berupa tabel hasil.
22-23if hasil is None: lalu returnKalimat seperti UPDATE tidak menghasilkan tabel. Bila hasilnya kosong, return langsung keluar dari resep.
24nama = [hasil.GetLayerDefn().GetFieldDefn(i).GetName() for i in range(...)]Ambil nama semua kolom hasil. List comprehension memakai range(...), deret angka 0, 1, 2, dan seterusnya sebanyak kolom.
25print(... .join(nama))Cetak nama kolom, disambung dengan teks pemisah (spasi, garis tegak, spasi) lewat join.
26-27for baris in hasil: lalu print(...join(str(baris.GetField(i)) for i in range(len(nama))))Untuk tiap baris hasil, ambil isi tiap kolom (GetField(i)), ubah ke teks (str), dan cetak disambung dengan pemisah yang sama. Isian kosong tercetak None.
28ds.ReleaseResultSet(hasil)Lepas tabel hasil setelah selesai. Kebiasaan merapikan.

Blok C: baris 31 sampai 50, sepuluh pertanyaan. Tiap pertanyaan memanggil jalankan(judul, sql). Berikut kalimat SQL-nya dan artinya.

NoKalimat SQL (ringkas)Artinya
1SELECT COUNT(*) AS jumlah FROM Titik_SurveiHitung semua baris. AS jumlah memberi nama kolom hasil.
2SELECT ID_Survei, Kondisi, Tinggi_Phn FROM Titik_Survei LIMIT 3Pilih tiga kolom saja, dan hanya tiga baris pertama (LIMIT 3).
3... WHERE Kondisi = 'Tebangan Liar' ORDER BY ID_SurveiSaring baris yang kondisinya Tebangan Liar, urutkan menurut ID.
4SELECT Kondisi, COUNT(*) AS jumlah ... GROUP BY Kondisi ORDER BY jumlah DESCKelompokkan per kondisi, hitung tiap kelompok, urutkan dari terbanyak (DESC berarti menurun).
5SELECT KPH, COUNT(*) AS n, ROUND(AVG(Tinggi_Phn), 1) AS rata_tinggi ... WHERE Tinggi_Phn BETWEEN 0 AND 60 GROUP BY KPHRata-rata tinggi per petak, hanya dari tinggi yang wajar (0 sampai 60). AVG rata-rata, ROUND(..., 1) satu desimal.
6SELECT ID_Survei, COUNT(*) AS n ... GROUP BY ID_Survei HAVING COUNT(*) > 1Cari ID ganda. HAVING menyaring kelompok sesudah dihitung. WHERE menyaring baris sebelum dikelompokkan.
7SELECT t.ID_Survei, t.Kondisi FROM Titik_Survei t LEFT JOIN Ref_Kondisi r ON t.Kondisi = r.Kondisi WHERE r.Kondisi IS NULLGabungkan titik dengan tabel acuan lewat kolom Kondisi. Huruf t dan r adalah nama pendek tabel. Baris yang tidak punya pasangan di acuan memberi r.Kondisi kosong. Saring yang kosong: itulah kondisi tidak baku.
8SELECT b.NAMA_KPH, COUNT(*) AS jumlah_titik FROM Titik_Survei t JOIN Batas_KPH b ON t.KPH = b.NAMA_KPH GROUP BY b.NAMA_KPHHitung titik per petak lewat nama. JOIN biasa hanya menyimpan baris yang cocok.
9UPDATE Titik_Survei SET Kondisi = 'Sehat' WHERE LOWER(TRIM(Kondisi)) = 'sehat'Perbaiki ejaan. TRIM membuang spasi di tepi, LOWER mengubah ke huruf kecil. Semua yang berbunyi "sehat" diubah menjadi Sehat.
10(sama dengan nomor 7)Periksa lagi setelah perbaikan.

Hasil. Layar menampilkan:

HASIL
-- 1. Berapa baris? 
SELECT COUNT(*) AS jumlah FROM Titik_Survei
jumlah
61

-- 2. Pilih kolom dan batasi baris
SELECT ID_Survei, Kondisi, Tinggi_Phn FROM Titik_Survei LIMIT 3
ID_Survei | Kondisi | Tinggi_Phn
SV-001 | Terserang Hama | 9.8
SV-002 | Terserang Hama | 16.7
SV-003 | Sehat | 24.5

-- 3. Saring dengan WHERE
SELECT ID_Survei, KPH, Tinggi_Phn FROM Titik_Survei WHERE Kondisi = 'Tebangan Liar' ORDER BY ID_Survei
ID_Survei | KPH | Tinggi_Phn
SV-011 | KPH Beta | 28.4
SV-014 | KPH Beta | 29.3
SV-038 | KPH Beta | 25.0
SV-044 | KPH Beta | 4.3
SV-054 | KPH Gamma | 28.7

-- 4. Hitung per kondisi (GROUP BY)
SELECT Kondisi, COUNT(*) AS jumlah FROM Titik_Survei GROUP BY Kondisi ORDER BY jumlah DESC
Kondisi | jumlah
Sehat | 32
Terserang Hama | 19
Tebangan Liar | 5
None | 2
sehat | 1
Terserang hama | 1
Sehat  | 1

-- 5. Rata-rata tinggi per KPH (hanya nilai wajar)
SELECT KPH, COUNT(*) AS n, ROUND(AVG(Tinggi_Phn), 1) AS rata_tinggi FROM Titik_Survei WHERE Tinggi_Phn BETWEEN 0 AND 60 GROUP BY KPH ORDER BY KPH
KPH | n | rata_tinggi
KPH Alpha | 18 | 18.4
KPH Beta | 21 | 18.8
KPH Gamma | 20 | 17.7

-- 6. Cari ID ganda (HAVING)
SELECT ID_Survei, COUNT(*) AS n FROM Titik_Survei GROUP BY ID_Survei HAVING COUNT(*) > 1
ID_Survei | n
SV-049 | 2

-- 7. Gabung dengan tabel acuan: Kondisi yang tidak ada di daftar baku (LEFT JOIN)
SELECT t.ID_Survei, t.Kondisi FROM Titik_Survei t LEFT JOIN Ref_Kondisi r ON t.Kondisi = r.Kondisi WHERE r.Kondisi IS NULL ORDER BY t.ID_Survei
ID_Survei | Kondisi
SV-005 | sehat
SV-012 | Sehat 
SV-020 | Terserang hama
SV-031 | None
SV-040 | None

-- 8. Gabung dengan batas KPH lewat nama (JOIN)
SELECT b.NAMA_KPH, COUNT(*) AS jumlah_titik FROM Titik_Survei t JOIN Batas_KPH b ON t.KPH = b.NAMA_KPH GROUP BY b.NAMA_KPH ORDER BY b.NAMA_KPH
NAMA_KPH | jumlah_titik
KPH Alpha | 19
KPH Beta | 21
KPH Gamma | 21

-- 9. Perbaiki ejaan (UPDATE) pada salinan
UPDATE Titik_Survei SET Kondisi = 'Sehat' WHERE LOWER(TRIM(Kondisi)) = 'sehat'

-- 10. Periksa lagi setelah UPDATE
SELECT t.ID_Survei, t.Kondisi FROM Titik_Survei t LEFT JOIN Ref_Kondisi r ON t.Kondisi = r.Kondisi WHERE r.Kondisi IS NULL ORDER BY t.ID_Survei
ID_Survei | Kondisi
SV-020 | Terserang hama
SV-031 | None
SV-040 | None

Dari hasil ini Anda tahu empat hal. Pertama, ada 61 baris. Kedua, nomor 4 membongkar masalah ejaan: selain tiga nilai baku, ada sehat, Terserang hama, Sehat (dengan spasi di ujung), dan dua nilai kosong (None). Ketiga, nomor 7 menangkap lima baris tidak baku, termasuk dua yang kosong (SV-031 dan SV-040). Keempat, nomor 10 menunjukkan UPDATE memperbaiki dua baris (SV-005 dan SV-012). Sisanya, Terserang hama dan dua yang kosong, tidak tertangkap aturan UPDATE itu, jadi masih harus diperbaiki dengan cara lain.

Bagian yang boleh diubah.

  • Baris 11-12, jalur. Ganti ASLI dan SALINAN.
  • Baris 31-49, kalimat SQL. Ganti syarat, kolom, dan tabel. Ini inti latihan Anda.
  • Baris 37, BETWEEN 0 AND 60. Ubah rentang tinggi wajar.
  • Baris 46, perbaikan. Pakai pola UPDATE tabel SET kolom = nilai WHERE syarat, tetapi hanya di salinan.

Jebakan.

  • UPDATE tersimpan di salinan, bukan di asli. Saya memeriksanya dengan ogrinfo sesudah skrip selesai: di salinan, SV-005 dan SV-012 bernilai Sehat. Di berkas asli tetap sehat dan Sehat (dengan spasi). Jadi ds = None di baris 50 sudah cukup untuk menyimpan perubahan.
  • Kalimat SQL salah ketik. Pada uji, SELEC menghasilkan RuntimeError: In ExecuteSQL(): sqlite3_prepare_v2(SELEC COUNT(*) AS jumlah FROM Titik_Survei): near "SELEC": syntax error. Nama kolom yang salah, misalnya Kondisii, menghasilkan no such column: Kondisii. Pesan menyebut apa yang salah.
  • Salinan lama ditimpa. Baris 14 menimpa tanpa tanya. Perbaikan di salinan yang belum Anda simpan ke tempat lain hilang.
  • Jangan arahkan UPDATE ke berkas asli. Ganti SALINAN di baris 15 menjadi ASLI hanya bila Anda sungguh ingin mengubah data asli, dan setelah membuat cadangan.
  • Isian kosong tampil None. Itu cara Python menulis "tidak ada nilai". Dalam SQL, bandingkan kosong dengan IS NULL, bukan = NULL.

Latihan variasi.

  1. Tampilkan tiga pohon tertinggi.
  2. Hitung rata-rata diameter per jenis pohon.

Contoh jawaban, ganti isi baris 31-49 dengan dua pemanggilan ini (dan biarkan ds = None di akhir):

PYTHON
jalankan("L1. Tiga pohon tertinggi", "SELECT ID_Survei, KPH, Tinggi_Phn FROM Titik_Survei ORDER BY Tinggi_Phn DESC LIMIT 3")
jalankan("L2. Rata-rata diameter per jenis", "SELECT Jenis, COUNT(*) AS n, ROUND(AVG(Diameter_cm), 1) AS rata_diameter FROM Titik_Survei GROUP BY Jenis ORDER BY Jenis")

Layar menampilkan, untuk nomor 1: SV-033 | KPH Gamma | 250.0, SV-014 | KPH Beta | 29.3, dan SV-054 | KPH Gamma | 28.7. Untuk nomor 2: Jati | 21 | 34.6, Mahoni | 21 | 31.3, dan Sengon | 19 | 27.5. Perhatikan nilai 250: itu pohon setinggi 250 m, jelas salah ketik. SQL membantu menemukannya.

Skrip 20.8: Aturan kualitas sebagai ekspresi QGIS #

Berkas i3_08_ekspresi_kualitas.py. Di dalam berkasnya tertulis "SKRIP I3.8". Jalankan dari Python Console QGIS. Dari terminal python-qgis polos, aplikasi QGIS harus disiapkan dulu (lihat Jebakan).

Tujuan. Menulis aturan kualitas sebagai ekspresi QGIS, menghitung berapa baris yang melanggar tiap aturan, lalu menggabung semua aturan menjadi satu kolom virtual Status_Mutu.

Bahan. Survei_Lapangan.gpkg dari paket-i3 (layer Titik_Survei dan Batas_KPH). Skrip hanya membaca data. Kolom virtual yang dibuatnya hidup di memori dan tidak ditulis ke berkas. [terbukti: daftar kolom di berkas tidak berubah]

Istilah baru untuk skrip ini:

  • Ekspresi QGIS: rumus yang QGIS pahami, misalnya "Tinggi_Phn" NOT BETWEEN 0 AND 60. Anda pernah memakainya di Select by Expression dan Field Calculator.
  • QgsFeatureRequest: permintaan data dengan saringan: "berikan hanya baris yang memenuhi ekspresi ini".
  • Konteks ekspresi: lingkungan tempat ekspresi dihitung. Isinya variabel dan layer di proyek.
  • Kolom virtual: kolom yang dihitung dari ekspresi tiap kali dibaca, dan tidak disimpan di berkas.
  • CASE WHEN: urutan pemeriksaan "bila A maka X, bila B maka Y, selain itu Z".
Ilustrasi 20.12: Select by Expression lawan QgsFeatureRequest
Skema dua cara memakai ekspresi: Select by Expression menyorot baris di layer, sedangkan QgsFeatureRequest mengirim baris yang cocok ke skrip tanpa menyorot
Ilustrasi 20.13: Alur skrip 20.8
Skema enam tahap skrip: buka layer dan masukkan ke proyek, daftar delapan aturan, siapkan konteks, hitung tiap aturan, susun kolom virtual, hitung sebaran Status_Mutu

Skrip utuh:

PYTHONUnduh berkas
# -*- coding: utf-8 -*-
# [SKRIP I3.8] Aturan kualitas data sebagai ekspresi QGIS: tiap aturan dihitung dengan QgsFeatureRequest (filter ekspresi)
# dan semuanya digabung menjadi satu kolom virtual Status_Mutu.
# Penulis: Badar Mubarok Yogaswara
# Dijalankan di Python Console QGIS (proyek boleh kosong). Hanya MEMBACA data.
from qgis.core import (QgsVectorLayer, QgsProject, QgsExpression, QgsFeatureRequest, QgsField, QgsExpressionContext,
                       QgsExpressionContextUtils)
from qgis.PyQt.QtCore import QMetaType

JALUR = r"D:/KPH_Contoh/paket-i3/Survei_Lapangan.gpkg"
titik = QgsVectorLayer(JALUR + "|layername=Titik_Survei", "Titik_Survei", "ogr")
batas = QgsVectorLayer(JALUR + "|layername=Batas_KPH", "Batas_KPH", "ogr")
QgsProject.instance().addMapLayers([titik, batas])      # ekspresi overlay_within memerlukan layer batas ada di proyek

ATURAN = {
    "Kondisi kosong": '"Kondisi" IS NULL',
    "Kondisi tidak baku": '"Kondisi" IS NOT NULL AND "Kondisi" NOT IN (\'Sehat\',\'Terserang Hama\',\'Tebangan Liar\')',
    "Tinggi di luar 0-60": '"Tinggi_Phn" NOT BETWEEN 0 AND 60',
    "Akurasi lebih dari 5 m": '"Akurasi_m" > 5',
    "Tanggal di luar periode": '"Tgl_Survei" NOT BETWEEN to_date(\'2026-09-01\') AND to_date(\'2026-09-12\')',
    "ID ganda": 'count("ID_Survei", group_by:="ID_Survei") > 1',
    "Di luar semua petak": 'NOT overlay_within(\'Batas_KPH\')',
    "Label KPH tidak sesuai": 'overlay_within(\'Batas_KPH\', "NAMA_KPH")[0] <> "KPH"',
}

print("Jumlah baris:", titik.featureCount())
konteks = QgsExpressionContext()
konteks.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(titik))    # agar count(), overlay_*() bekerja
for nama, ekspresi in ATURAN.items():
    permintaan = QgsFeatureRequest().setFilterExpression(ekspresi).setExpressionContext(konteks)
    n = len(list(titik.getFeatures(permintaan)))
    print("%-26s %d" % (nama, n))

# Satu kolom virtual: kata kunci masalah pertama yang ditemukan, atau OK
cabang = " ".join("WHEN %s THEN '%s'" % (e, n.replace("'", "''")) for n, e in ATURAN.items())
titik.addExpressionField("CASE %s ELSE 'OK' END" % cabang, QgsField("Status_Mutu", QMetaType.Type.QString))
hitung = {}
for f in titik.getFeatures():
    hitung[f["Status_Mutu"]] = hitung.get(f["Status_Mutu"], 0) + 1
print("Status_Mutu:", hitung)

Penjelasan baris demi baris. Empat blok. Nomor baris mengikuti berkas di atas.

Blok A: baris 1 sampai 13, membuka layer.

BarisTulisanArtinya
1-5komentarPenegasan: hanya MEMBACA data, proyek boleh kosong.
6-8from qgis.core import (...) dan from qgis.PyQt.QtCore import QMetaTypePanggil perkakas QGIS. QgsExpression tidak dipakai langsung. QMetaType memberi nama jenis kolom, misalnya teks.
10-12JALUR = ..., titik = ..., batas = ...Lokasi data dan dua layer. Ganti jalur dengan milik Anda.
13QgsProject.instance().addMapLayers([titik, batas])Masukkan kedua layer ke proyek yang sedang terbuka. Komentar di baris itu menjelaskan: fungsi overlay_within mencari layer batas di proyek. Setiap kali skrip dijalankan, layer ditambah lagi.

Blok B: baris 15 sampai 24, kamus aturan.

BarisTulisanArtinya
15-24ATURAN = {"Kondisi kosong": '"Kondisi" IS NULL', ...}Kamus delapan aturan. Kunci adalah nama aturan, isi adalah ekspresi QGIS sebagai teks. Kutip tunggal membungkus ekspresi, jadi tanda kutip tunggal di dalamnya ditulis dengan garis miring terbalik.
16'"Kondisi" IS NULL'Kondisi kosong.
17'"Kondisi" IS NOT NULL AND "Kondisi" NOT IN (...)'Terisi tetapi tidak ada di daftar baku. Bagian IS NOT NULL perlu karena NOT IN tidak menangkap isian kosong.
18-20NOT BETWEEN 0 AND 60, > 5, NOT BETWEEN to_date(...) AND to_date(...)Tinggi di luar rentang, akurasi lebih dari 5 m, dan tanggal di luar periode.
21'count("ID_Survei", group_by:="ID_Survei") > 1'ID ganda: hitung baris ber-ID sama, lebih dari satu berarti ganda.
22'NOT overlay_within(\'Batas_KPH\')'Titik tidak berada di dalam poligon layer Batas_KPH mana pun.
23'overlay_within(\'Batas_KPH\', "NAMA_KPH")[0] <> "KPH"'Nama petak tempat titik berada berbeda dari label KPH di titik itu. Tanda [0] mengambil isi pertama.

Blok C: baris 26 sampai 32, menghitung tiap aturan.

BarisTulisanArtinya
26print("Jumlah baris:", titik.featureCount())Cetak jumlah baris, sebagai pembanding.
27-28konteks = QgsExpressionContext() lalu konteks.appendScopes(...globalProjectLayerScopes(titik))Siapkan lingkungan hitung: variabel global, proyek, dan layer titik. Tanpa ini, fungsi seperti count() dan overlay_within() tidak bekerja.
29for nama, ekspresi in ATURAN.items():Ulangi untuk tiap aturan.
30permintaan = QgsFeatureRequest().setFilterExpression(ekspresi).setExpressionContext(konteks)Buat permintaan data dengan saringan ekspresi, lalu pasang konteksnya. Dua pemanggilan disambung dengan titik.
31n = len(list(titik.getFeatures(permintaan)))Minta layer baris yang cocok, kumpulkan jadi daftar, hitung.
32print("%-26s %d" % (nama, n))Cetak nama aturan dan jumlah pelanggar.

Blok D: baris 34 sampai 40, kolom virtual dan sebarannya.

BarisTulisanArtinya
35cabang = " ".join("WHEN %s THEN '%s'" % (e, n.replace("'", "''")) for n, e in ATURAN.items())Rakit bagian WHEN aturan THEN 'nama' untuk tiap aturan, lalu sambung dengan spasi. Bagian n.replace("'", "''") mengganti tiap kutip tunggal di nama aturan dengan dua kutip tunggal. Dalam teks ekspresi, dua kutip berarti satu kutip biasa. Tanpa ini, kutip di nama aturan menutup teks lebih awal dan merusak ekspresi.
36titik.addExpressionField("CASE %s ELSE 'OK' END" % cabang, QgsField("Status_Mutu", QMetaType.Type.QString))Tambah kolom virtual Status_Mutu. Isinya: aturan pertama yang cocok, atau OK bila tidak ada. Urutan di kamus menentukan siapa yang menang. Penulisan QMetaType.Type.QString adalah gaya QGIS 4. [CEK: belum dicoba di QGIS 3.x]
37-39hitung = {} lalu for f in titik.getFeatures(): dan hitung[...] = hitung.get(f["Status_Mutu"], 0) + 1Penghitung manual. hitung.get(kunci, 0) mengambil isi kunci itu, atau 0 bila belum ada. Tambah satu untuk tiap baris.
40print("Status_Mutu:", hitung)Cetak sebaran status.

Hasil. Layar menampilkan:

HASIL
Jumlah baris: 61
Kondisi kosong             2
Kondisi tidak baku         3
Tinggi di luar 0-60        2
Akurasi lebih dari 5 m     2
Tanggal di luar periode    1
ID ganda                   2
Di luar semua petak        3
Label KPH tidak sesuai     2
Status_Mutu: {'OK': 44, 'Kondisi tidak baku': 3, 'Tinggi di luar 0-60': 2, 'Label KPH tidak sesuai': 2, 'Akurasi lebih dari 5 m': 2, 'Kondisi kosong': 2, 'ID ganda': 2, 'Tanggal di luar periode': 1, 'Di luar semua petak': 3}

Dari hasil ini Anda tahu: jumlah pelanggar tiap aturan cocok dengan Skrip 20.6, kecuali satu hal. ID ganda di sini 2, sedangkan di Skrip 20.6 hanya 1. Sebabnya: Skrip 20.6 menghitung ID yang ganda (SV-049, satu ID). Skrip ini menghitung baris yang ber-ID ganda (dua baris SV-049). Lalu Status_Mutu memperlihatkan 44 baris OK dan 17 baris bermasalah, dan 44 + 17 = 61. Tidak ada baris yang kena dua aturan sekaligus pada data ini, jadi jumlah 17 sama dengan jumlah semua pelanggaran.

Bagian yang boleh diubah.

  • Baris 10, jalur. Ganti dengan lokasi data Anda.
  • Baris 15-24, aturan. Tambah, hapus, atau ubah ekspresi dan batasnya. Pada uji, menambah "Diameter lebih dari 45 cm": '"Diameter_cm" > 45' menghasilkan 13 pelanggar, tetapi di Status_Mutu hanya 10 (tiga baris sudah lebih dulu kena aturan lain). Satu baris Label KPH tidak sesuai pun tergeser. Ingat: aturan pertama yang cocok menang.
  • Baris 19, batas akurasi. Pada uji, > 3 (dan nama aturan "Akurasi lebih dari 3 m") menghasilkan 18 pelanggar.
  • Baris 36, ELSE 'OK'. Ganti kata OK dengan label lain.

Jebakan.

  • Kolom virtual tidak disimpan. Pada uji, setelah skrip jalan, Status_Mutu ada di layer di memori, tetapi tidak ada di daftar kolom bila layer dibaca ulang dari berkas. Bila Anda butuh kolom tetap, buat dengan cara lain.
  • Menjalankan ulang menambah layer kembar. Pada uji, dua kali menjalankan menghasilkan daftar layer proyek ['Batas_KPH', 'Batas_KPH', 'Titik_Survei', 'Titik_Survei']. Hapus layer lama dulu, atau mulai dari proyek kosong.
  • Kutip tunggal di nama aturan sudah diamankan di baris 35. Pada uji, nama aturan Akurasi lebih dari 5 m'x tetap tampil utuh di Status_Mutu karena kutipnya digandakan. Bila Anda mengubah baris 35 dan membuang replace, seluruh Status_Mutu bisa bernilai kosong ({None: 61}) tanpa pesan galat. Pengaman ini hanya untuk nama aturan. Kutip di dalam ekspresi aturan tetap harus Anda tulis dengan benar.
  • Paling aman dari Python Console QGIS. Dari terminal python-qgis polos, tanpa aplikasi QGIS yang disiapkan lebih dulu, skrip ini berhenti dengan Segmentation fault pada uji (kode keluar 139 di Git Bash; di PowerShell -1073741819). Memanggil Processing.initialize() saja tidak menolong: skrip tetap berhenti. Yang menolong adalah menyiapkan aplikasinya di awal skrip, yaitu from qgis.core import QgsApplication, app = QgsApplication([], False), lalu app.initQgis(). Pada uji, dengan tiga baris itu skrip berjalan benar, dengan atau tanpa Processing.initialize() (lihat Bab 21).
  • Batas akurasi tertulis di dua tempat. Aturan di baris 19 dan batas di Skrip 20.4. Bila Anda mengubah satu, ubah yang lain.

Latihan variasi.

  1. Tambahkan aturan "Diameter lebih dari 45 cm".
  2. Ubah batas akurasi menjadi 3 m.

Contoh jawaban nomor 1, tambahkan satu baris di kamus ATURAN (misalnya sebelum aturan Label KPH tidak sesuai):

PYTHON
    "Diameter lebih dari 45 cm": '"Diameter_cm" > 45',

Layar menampilkan Diameter lebih dari 45 cm 13 dan, di Status_Mutu, 'OK': 35 dengan 'Diameter lebih dari 45 cm': 10.

Contoh jawaban nomor 2, baris 19 menjadi:

PYTHON
    "Akurasi lebih dari 3 m": '"Akurasi_m" > 3',

Layar menampilkan Akurasi lebih dari 3 m 18 dan 'OK': 33 di Status_Mutu.

Skrip 20.9: Dua surveyor, satu nomor yang sama #

Berkas i3_09_gabung_dan_bentrok_id.py. Di dalam berkasnya tertulis "SKRIP I3.9". Jalankan setelah Skrip 20.2 dan 20.4, dari Python Console QGIS.

Tujuan. Menunjukkan mengapa nomor ID otomatis "terbesar ditambah satu" bisa bentrok bila dua surveyor bekerja luring dari salinan yang sama. Lalu menggabungkan hasil keduanya dan mencari ID ganda.

Bahan. Survei_KPH.gpkg hasil Skrip 20.2, lengkap dengan gaya dari Skrip 20.4. Skrip membuat dua salinan (surveyor1.gpkg, surveyor2.gpkg) di folder KPH_Contoh, menambah satu titik di tiap salinan, lalu menggabungkannya di memori.

Istilah baru untuk skrip ini:

  • Luring (offline): bekerja tanpa jaringan.
  • Bentrok ID: dua baris berbeda mendapat nomor pengenal yang sama.
  • Menggabung (merge): menyatukan isi dua layer menjadi satu layer.
Ilustrasi 20.14: Dua surveyor mendapat nomor yang sama
Skema: dua salinan berkas Survei_KPH.gpkg diberikan ke dua surveyor, keduanya menambah titik dan mendapat ID SV-001, setelah digabung muncul ID ganda SV-001 dua kali
Ilustrasi 20.15: Alur skrip 20.9
Skema lima tahap skrip: tiap surveyor menyalin berkas, membuka layer dan memuat gaya, mengedit dan menyimpan titik, menggabungkan dua layer, menghitung ID ganda

Skrip utuh:

PYTHONUnduh berkas
# -*- coding: utf-8 -*-
# [SKRIP I3.9] Dua surveyor bekerja luring dari salinan yang sama. Skrip ini menunjukkan mengapa nomor ID otomatis
# "maksimum + 1" bisa bentrok, lalu menggabungkan hasil keduanya dengan Processing dan mencari ID ganda.
# Penulis: Badar Mubarok Yogaswara
# Jalankan setelah skrip I3.2 dan I3.4 (butuh Survei_KPH.gpkg beserta gayanya). Dijalankan di Python Console QGIS.
import os
import shutil
import processing
from collections import Counter
from qgis.core import QgsVectorLayer, QgsVectorLayerUtils, QgsGeometry, QgsPointXY, QgsProject

KERJA = r"D:/KPH_Contoh"
ASLI = KERJA + "/Survei_KPH.gpkg"
punya = {}
for nama, (x, y) in {"surveyor1": (312050, 9996050), "surveyor2": (312150, 9996060)}.items():
    salinan = "%s/%s.gpkg" % (KERJA, nama)
    shutil.copy(ASLI, salinan)                                   # tiap surveyor membawa salinan sendiri
    lyr = QgsVectorLayer(salinan + "|layername=Titik_Survei", nama, "ogr")
    lyr.loadDefaultStyle()                                       # ID otomatis datang dari gaya yang tersimpan
    lyr.startEditing()
    f = QgsVectorLayerUtils.createFeature(lyr, QgsGeometry.fromPointXY(QgsPointXY(x, y)))
    f["Kondisi"] = "Sehat"
    f["Tinggi_Phn"] = 12.0
    lyr.addFeature(f)
    assert lyr.commitChanges(), lyr.commitErrors()
    punya[nama] = lyr
    print("%s membuat ID: %s" % (nama, f["ID_Survei"]))

gabungan = processing.run("native:mergevectorlayers", {
    "LAYERS": [punya["surveyor1"], punya["surveyor2"]], "CRS": "EPSG:32749", "OUTPUT": "memory:Gabungan"})["OUTPUT"]
hitung = Counter(f["ID_Survei"] for f in gabungan.getFeatures())
print("Jumlah baris setelah digabung:", gabungan.featureCount())
print("ID ganda:", {k: v for k, v in hitung.items() if v > 1})

Penjelasan baris demi baris. Dua blok. Nomor baris mengikuti berkas di atas.

Blok A: baris 1 sampai 27, tiap surveyor membuat satu titik.

BarisTulisanArtinya
1-5komentarJudul dan prasyarat: jalankan setelah Skrip 20.2 dan 20.4.
6-10import os, import shutil, import processing, from collections import Counter, from qgis.core import (...)Panggil perkakas. os dan QgsProject diimpor tetapi tidak dipakai.
12-13KERJA = r"D:/KPH_Contoh" dan ASLI = KERJA + "/Survei_KPH.gpkg"Folder kerja, dan jalur berkas asli yang dibuat Skrip 20.2. Tanda + menyambung teks.
14punya = {}Kamus kosong untuk menyimpan layer tiap surveyor.
15for nama, (x, y) in {"surveyor1": (312050, 9996050), "surveyor2": (312150, 9996060)}.items():Ulangi dua kali. Kamus ditulis langsung di baris for: nama surveyor dan koordinat titik yang akan ia buat. Tanda kurung (x, y) membuka pasangan koordinat.
16salinan = "%s/%s.gpkg" % (KERJA, nama)Rakit jalur berkas salinan, misalnya D:/KPH_Contoh/surveyor1.gpkg.
17shutil.copy(ASLI, salinan)Tiap surveyor membawa salinan sendiri. Salinan lama ditimpa tanpa tanya.
18-19lyr = QgsVectorLayer(...) lalu lyr.loadDefaultStyle()Buka layer Titik_Survei di salinan itu, lalu muat gaya bawaannya. Nilai bawaan ID otomatis datang dari gaya yang disimpan Skrip 20.4.
20lyr.startEditing()Mulai mode sunting.
21f = QgsVectorLayerUtils.createFeature(lyr, QgsGeometry.fromPointXY(QgsPointXY(x, y)))Buat satu fitur baru di titik (x, y), dengan semua nilai bawaan terisi, termasuk ID.
22-23f["Kondisi"] = "Sehat" dan f["Tinggi_Phn"] = 12.0Isi dua kolom.
24lyr.addFeature(f)Tambahkan ke layer.
25assert lyr.commitChanges(), lyr.commitErrors()Simpan sunting. Bila gagal, skrip berhenti dan menampilkan daftar galatnya.
26-27punya[nama] = lyr dan print("%s membuat ID: %s" % (nama, f["ID_Survei"]))Simpan layer di kamus, lalu cetak ID yang dibuat.

Blok B: baris 29 sampai 33, menggabung dan mencari ID ganda.

BarisTulisanArtinya
29-30gabungan = processing.run("native:mergevectorlayers", {...})["OUTPUT"]Gabungkan dua layer jadi satu. LAYERS adalah daftar layer yang digabung. CRS memilih sistem koordinat hasil. Nilai "memory:Gabungan" berarti hasil disimpan di layer memori bernama Gabungan.
31hitung = Counter(f["ID_Survei"] for f in gabungan.getFeatures())Hitung berapa kali tiap ID muncul.
32print("Jumlah baris setelah digabung:", gabungan.featureCount())Cetak jumlah baris hasil gabungan.
33print("ID ganda:", {k: v for k, v in hitung.items() if v > 1})Cetak kamus berisi hanya ID yang muncul lebih dari satu kali. Kamus dibuat dengan satu baris perulangan dan syarat if.

Hasil. Layar menampilkan:

HASIL
surveyor1 membuat ID: SV-001
surveyor2 membuat ID: SV-001
Jumlah baris setelah digabung: 2
ID ganda: {'SV-001': 2}

Dari hasil ini Anda tahu: kedua surveyor mendapat SV-001. Setelah digabung ada dua baris, dan ID SV-001 muncul dua kali. Rumus "terbesar ditambah satu" memang cuma bekerja bila hanya satu orang yang mengedit. Kedua surveyor mulai dari salinan yang sama, jadi sama-sama melihat "terbesar = kosong" dan mengambil nomor 1.

Bagian yang boleh diubah.

  • Baris 12, KERJA. Ganti dengan folder kerja Anda.
  • Baris 15, nama dan koordinat surveyor. Tambah surveyor atau ganti titiknya. Pada uji, menambah "surveyor3": (312250, 9996070) dan LAYERS berisi ketiga layer (list(punya.values())) menghasilkan tiga ID SV-001 dan ID ganda: {'SV-001': 3}.
  • Baris 22-23, isian. Ubah nilai kondisi dan tinggi titik.
  • Baris 30, CRS. Ganti bila data Anda memakai sistem koordinat lain.

Jebakan.

  • Gaya dari Skrip 20.4 belum tersimpan. ID otomatis tidak terisi, nilainya None. Pada uji, layar menampilkan surveyor1 membuat ID: None, surveyor2 membuat ID: None, dan ID ganda: {None: 2}. Itu bukan bukti keberhasilan. Itu tanda gaya tidak termuat.
  • Butuh Python Console QGIS. Modul processing hanya ada di dalam QGIS. Dari terminal python-qgis polos, layar menampilkan ModuleNotFoundError: No module named 'processing'.
  • Salinan lama ditimpa. Baris 17 menimpa surveyor1.gpkg dan surveyor2.gpkg bila sudah ada.
  • Hasil gabungan hanya di memori. Layer Gabungan hilang saat QGIS ditutup. Ekspor dulu bila ingin menyimpannya.
  • Impor tak terpakai. os dan QgsProject tidak dipakai.

Latihan variasi.

  1. Tambahkan surveyor ketiga.
  2. Beri tiap surveyor awalan ID sendiri, supaya tidak bentrok.

Contoh jawaban nomor 1, baris 15 dan 30 menjadi:

PYTHON
for nama, (x, y) in {"surveyor1": (312050, 9996050), "surveyor2": (312150, 9996060), "surveyor3": (312250, 9996070)}.items():
PYTHON
    "LAYERS": list(punya.values()), "CRS": "EPSG:32749", "OUTPUT": "memory:Gabungan"})["OUTPUT"]

Layar menampilkan surveyor3 membuat ID: SV-001, Jumlah baris setelah digabung: 3, dan ID ganda: {'SV-001': 3}.

Contoh jawaban nomor 2, sisipkan satu baris sebelum baris 22:

PYTHON
    f["ID_Survei"] = "S%s-001" % nama[-1]

Layar menampilkan surveyor1 membuat ID: S1-001, surveyor2 membuat ID: S2-001, Jumlah baris setelah digabung: 2, dan ID ganda: {}. Awalan per surveyor membuat nomor tidak bentrok. Tanda nama[-1] mengambil huruf terakhir nama, yaitu angka 1 atau 2.

Cek paham #

  1. Apa beda akurasi dan presisi pada pembacaan GNSS? Angka mana di Skrip 20.1 yang mengukur masing-masing?
  2. Mengapa domain di Skrip 20.3 tidak menolak nilai ngawur, padahal pagar di Skrip 20.5 menolak sehat?
  3. Mengapa ID ganda bernilai 2 di Skrip 20.8, tetapi 1 di Skrip 20.6?

Jawaban.

  1. Akurasi adalah seberapa dekat rata-rata pembacaan ke titik benar: angka Bias. Presisi adalah seberapa rapat pembacaan satu sama lain: angka Presisi. Angka RMSE, CEP50, dan R95 memuat keduanya.
  2. Domain hanya petunjuk bagi formulir QGIS. Penulisan lewat skrip tidak melewati formulir. Pagar (trigger) dijalankan oleh basis data setiap ada tulisan masuk, siapa pun penulisnya.
  3. Skrip 20.6 menghitung ID yang ganda (satu ID: SV-049). Skrip 20.8 menghitung baris yang ber-ID ganda (dua baris SV-049).

Kesalahan umum #

  • Membiarkan jalur contoh. Jalur D:/KPH_Contoh adalah milik penulis. Perbaikan: ganti dengan jalur Anda di bagian atas tiap skrip.
  • Menjalankan skrip tidak berurutan. Skrip 20.3, 20.4, 20.5, dan 20.9 butuh Survei_KPH.gpkg dari Skrip 20.2. Perbaikan: jalankan 20.2 lebih dulu, dan jangan jalankan 20.2 dua kali.
  • Menjalankan Skrip 20.4 berkali-kali di proyek yang sama. Baris 29 menambah layer Ref_Jenis tiap kali jalan, sehingga layer kembar menumpuk. Perbaikan: hapus layer lama, atau mulai dari proyek kosong.
  • Mengira domain atau aturan formulir sudah menjaga data. Uji menunjukkan penulisan lewat skrip tetap lolos. Perbaikan: pasang pagar (Skrip 20.5) bila data penting, dan tetap periksa mutu (Skrip 20.6 sampai 20.8).

Ringkasan dan latihan #

  • Akurasi (bias) dan presisi (sebaran) berbeda. R95 memberi gambaran terburuk yang jujur.
  • GeoPackage memuat banyak layer, tabel acuan, domain, dan gaya dalam satu berkas.
  • Tiga lapis penjagaan: domain di berkas, formulir QGIS, dan pagar (trigger). Hanya pagar yang menolak skrip.
  • Kualitas data diperiksa dengan aturan yang ditulis sekali: dengan Python (Skrip 20.6), SQL (20.7), atau ekspresi QGIS (20.8).
  • ID otomatis "terbesar ditambah satu" bentrok bila banyak orang bekerja luring. Beri awalan per surveyor.

Latihan. Skrip 20.5 memagari kolom Kondisi. Aturan mana di Skrip 20.8 (misalnya tinggi di luar 0 sampai 60) tidak bisa ditangkap oleh pagar Kondisi itu? Pagar baru seperti apa yang Anda butuhkan, dan di bagian mana dari kalimat CREATE TRIGGER yang Anda ganti?

Tabel perbandingan: sembilan skrip sekilas #

SkripMengubah berkas?Butuh duluDijalankan dari
20.1 statistik GNSSTidak, hanya membacatidak adaKonsol QGIS atau python-qgis
20.2 buat GeoPackageYa, membuat berkas barutidak adaKonsol QGIS atau python-qgis
20.3 domainYa, menanam domain20.2Konsol QGIS atau python-qgis
20.4 formulirYa, menulis gaya20.2Konsol QGIS atau python-qgis
20.5 pagarYa, pada salinan20.2python-qgis
20.6 cek kualitasTidak, hanya membacatidak adaKonsol QGIS atau python-qgis
20.7 SQL dasarYa, pada salinantidak adapython-qgis
20.8 ekspresi kualitasTidak, kolom virtual di memoritidak adaKonsol QGIS (terminal bila Processing disiapkan)
20.9 gabung dan bentrok IDYa, membuat dua salinan20.2 dan 20.4Konsol QGIS saja