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).

Lima istilah yang perlu Anda kenal sekarang:
- GNSS: sistem satelit penentu posisi. Ponsel Anda memakainya untuk menunjukkan letak Anda.
- GeoPackage: satu berkas
.gpkgyang 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 #
- Buat folder
KPH_Contoh. Salin folderpaket-i3ke dalamnya. Paket itu memuatGnss_Uji.gpkgdanSurvei_Lapangan.gpkg. - Skrip 20.2 membuat berkas
Survei_KPH.gpkgdi folderKPH_Contoh. Skrip 20.3, 20.4, 20.5, dan 20.9 memakainya. - Jalankan skrip dari Plugins ► Python Console ► Show Editor (Bab 3). Ganti jalur di bagian atas skrip dengan jalur Anda.

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.

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

Skrip utuh:
# -*- 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.
| Baris | Tulisan | Artinya |
|---|---|---|
| 1-4 | komentar | Baris 1 memberi tahu Python bahwa berkas memakai huruf UTF-8. Baris 2-4 memuat judul, penulis, dan cara menjalankan. |
| 5-7 | import math, import os, from qgis.core import QgsVectorLayer | Panggil perkakas hitung (math), perkakas jalur (os, tidak dipakai di skrip ini), dan pembuka layer vektor. |
| 9 | JALUR = r"D:/KPH_Contoh/paket-i3/Gnss_Uji.gpkg" | Lokasi berkas data. Ganti dengan jalur Anda. Huruf r membuat teks dibaca apa adanya. |
| 11-12 | pembacaan = 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. |
| 13 | assert pembacaan.isValid() and referensi.isValid(), "..." | Penjaga. Bila salah satu layer gagal terbuka, skrip berhenti dan menampilkan pesan itu. assert berarti "pastikan". |
| 15 | titik_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. |
| 18 | per_mode = {} | Kamus kosong (Bab 5). Nanti: kunci adalah nama mode, isinya daftar koordinat. |
| 19-21 | for 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.
| Baris | Tulisan | Artinya |
|---|---|---|
| 24-30 | def 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. |
| 26 | u = sorted(nilai) | Urutkan dari terkecil. |
| 27-29 | k = (len(u) - 1) * q / 100.0 lalu a, b | Hitung posisi (boleh pecahan). a adalah posisi bulat di bawahnya, b posisi bulat di atasnya (tidak melewati ujung). |
| 30 | return u[a] + (u[b] - u[a]) * (k - a) | Campur dua isi terdekat sesuai sisa pecahan. Itu yang disebut interpolasi linear. |
| 33 | print("%-15s %8s ..." % ("Mode", "Bias", ...)) | Cetak kepala tabel. %-15s memberi lebar 15 huruf, rata kiri. %8s memberi lebar 8, rata kanan. |
| 34 | for mode, pts in per_mode.items(): | Ulangi tiap mode. .items() memberi pasangan kunci dan isinya. |
| 35 | jarak = [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-37 | cx = sum(x for x, _ in pts) / len(pts) dan cy = ... | Titik rata-rata: jumlah semua x dibagi banyaknya. Tanda _ membuang bagian yang tidak dipakai. |
| 38 | bias = math.hypot(cx - titik_ref.x(), cy - titik_ref.y()) | Bias: jarak titik rata-rata ke titik benar. |
| 39 | presisi = 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. |
| 40 | rmse = math.sqrt(sum(j * j for j in jarak) / len(jarak)) | Galat RMS: akar dari rata-rata kuadrat jarak ke titik benar. |
| 41 | print("%-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):
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.040Dari 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.gpkgdi 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 judulR90) 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"menghasilkanKeyError: 'Moda'di baris 21. Salin nama kolom dari tabel atribut. - Layer referensi kosong. Perintah
next(...)di baris 15 berhenti denganStopIterationbila 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 ostidak dipakai. Tidak ada yang rusak.
Latihan variasi.
- Ganti R95 menjadi R90.
- 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:
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.

Skrip utuh:
# -*- 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.
| Baris | Tulisan | Artinya |
|---|---|---|
| 1-4 | komentar | Judul, penulis, dan hasil yang diharapkan: Survei_KPH.gpkg. |
| 5-6 | import os dan from qgis.core import (...) | Panggil perkakas. Tanda kurung memungkinkan daftar panjang dipecah ke beberapa baris. QgsCoordinateReferenceSystem diimpor tetapi tidak dipakai. |
| 8 | KELUAR = r"D:/KPH_Contoh/Survei_KPH.gpkg" | Lokasi berkas yang akan dibuat. Ganti dengan folder proyek Anda. |
| 9 | os.makedirs(os.path.dirname(KELUAR), exist_ok=True) | os.path.dirname mengambil bagian folder dari jalur. Folder itu dibuat bila belum ada, tanpa bertanya. |
| 10 | assert not os.path.exists(KELUAR), "..." | Penjaga: bila berkas sudah ada, skrip berhenti dengan pesan. Tujuannya agar kerja Anda tidak tertimpa. |
| 13-16 | skema = ("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). |
| 17 | sementara = QgsVectorLayer(skema, "Titik_Survei", "memory") | Buat layer kosong di memori dari skema itu. Kata "memory" menunjuk penyedia data layer memori. |
| 18-21 | opsi = QgsVectorFileWriter.SaveVectorOptions() lalu empat penugasan | Siapkan pengaturan penulisan. Format GPKG, nama layer di dalam berkas, dan huruf UTF-8. Tiap baris mengisi satu sifat objek opsi. |
| 22 | hasil = 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. |
| 23 | assert hasil[0] == QgsVectorFileWriter.NoError, hasil | Pastikan status pertama (hasil[0]) berarti "tanpa galat". Bila tidak, tampilkan isi hasil. |
Blok B: baris 26 sampai 41, dua tabel acuan.
| Baris | Tulisan | Artinya |
|---|---|---|
| 26-27 | acuan = {"Ref_Kondisi": ("Kondisi", [...]), "Ref_Jenis": ("Jenis", [...])} | Kamus: kunci adalah nama tabel, isinya sepasang (nama kolom, daftar nilai). |
| 28 | for nama, (kolom, nilai) in acuan.items(): | Ulangi dua kali. Tanda kurung di tengah membuka pasangan bersarang, sehingga tiga kotak terisi sekaligus: nama, kolom, nilai. |
| 29 | tabel = QgsVectorLayer("None?field=%s:string(20)" % kolom, nama, "memory") | Layer memori tanpa geometri (kata None), dengan satu kolom teks. Tanda %s diisi nama kolom. |
| 30 | tabel.startEditing() | Mulai mode sunting, seperti menekan tombol pensil di QGIS. |
| 31-34 | for 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. |
| 35 | tabel.commitChanges() | Simpan sunting ke layer memori. |
| 36-39 | o = QgsVectorFileWriter.SaveVectorOptions() lalu pengaturan | Pengaturan penulisan seperti blok A, dengan satu tambahan: CreateOrOverwriteLayer. Artinya: tambahkan layer ke berkas yang sudah ada, jangan timpa berkasnya. |
| 40-41 | h = ...writeAsVectorFormatV3(tabel, KELUAR, ..., o) lalu assert | Tulis tabel ke berkas yang sama, lalu pastikan tanpa galat. |
Blok C: baris 44 sampai 46, memeriksa isi.
| Baris | Tulisan | Artinya |
|---|---|---|
| 44-45 | for nama in ["Titik_Survei", "Ref_Kondisi", "Ref_Jenis"]: lalu lyr = QgsVectorLayer(...) | Buka kembali tiap layer dari berkas, sebagai bukti bahwa tertulis. |
| 46 | print(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:
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:32749bila data Anda memakai sistem koordinat lain. Pada uji, menambah&field=Catatan:string(100)memunculkan kolomCatatansebagai kolom terakhir. - Baris 26-27, isi acuan. Ganti nilai pilihan. Pada uji, menambah
"Akasia"di daftar jenis menghasilkanRef_Jenisdengan 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 namaKELUARatau 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.
QgsCoordinateReferenceSystemtidak dipakai. Kotakhasil(baris 22) danh(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.
- Tambahkan kolom
Catatan(teks 100 huruf) keTitik_Survei. - Tambahkan "Akasia" ke daftar jenis pohon.
Contoh jawaban nomor 1, baris 16 menjadi (jangan lupa hapus berkas lama dulu):
"&field=Surveyor:string(30)&field=Akurasi_m:double&field=Foto:string(100)&field=Catatan:string(100)")Contoh jawaban nomor 2, baris 27 menjadi:
"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.

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).

Skrip utuh:
# -*- 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 = NonePenjelasan baris demi baris. Nomor baris mengikuti berkas di atas.
| Baris | Tulisan | Artinya |
|---|---|---|
| 1-5 | komentar | Baris 5 mengingatkan: jalankan setelah Skrip 20.2. |
| 6-7 | from osgeo import ogr, gdal dan from qgis.core import QgsVectorLayer, QgsVectorLayerUtils | Panggil perkakas GDAL untuk berkas vektor (ogr) dan raster (gdal), serta perkakas QGIS. QgsVectorLayerUtils tidak dipakai. |
| 9-10 | gdal.UseExceptions() dan ogr.UseExceptions() | Nyalakan "alarm". Tanpa baris ini, GDAL diam saja saat gagal. Dengan baris ini, kegagalan menghentikan skrip dengan pesan galat. |
| 11 | GPKG = r"D:/KPH_Contoh/Survei_KPH.gpkg" | Lokasi berkas hasil Skrip 20.2. Ganti dengan jalur Anda. |
| 13 | ds = ogr.Open(GPKG, 1) | Buka berkas. Angka 1 berarti mode tulis. Kotak ds adalah "berkas yang terbuka". |
| 14 | lyr = ds.GetLayerByName("Titik_Survei") | Ambil layer Titik_Survei dari berkas itu. |
| 17-19 | ds.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-21 | ds.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. |
| 24 | for kolom, domain in [("Kondisi", "Kondisi_Pohon"), ("Tinggi_Phn", "Tinggi_Pohon")]: | Ulangi dua kali. Tiap pasangan: nama kolom dan nama domain yang akan dipasang. |
| 25-26 | i = lyr.GetLayerDefn().GetFieldIndex(kolom) dan lama = ...GetFieldDefn(i) | Cari nomor urut kolom, lalu ambil definisinya yang lama. |
| 27 | fd = ogr.FieldDefn(lama.GetName(), lama.GetType()) | Buat salinan definisi kolom. Komentar di baris itu menjelaskan: definisi yang lama tidak boleh diubah langsung. |
| 28-29 | fd.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-31 | print("Domain di berkas:", ds.GetFieldDomainNames()) lalu ds = None | Cetak nama domain yang kini ada. Lalu ds = None menutup berkas. Di GDAL, perubahan baru benar-benar tertulis saat kotaknya dikosongkan. |
| 34-37 | uji = QgsVectorLayer(GPKG + ..., "uji", "ogr") lalu perulangan | Buka 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-41 | ds = ogr.Open(GPKG, 1) lalu lyr = ... | Buka lagi dalam mode tulis, untuk uji berikutnya. |
| 42-47 | f = ogr.Feature(lyr.GetLayerDefn()) lalu SetGeometry, SetField, CreateFeature | Susun 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. |
| 48 | print("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. |
| 49 | ds.ExecuteSQL("DELETE FROM Titik_Survei WHERE ID_Survei = 'UJI-DOMAIN'") | Bersihkan baris uji dengan perintah SQL (dijelaskan di Skrip 20.7). |
| 50 | ds = None | Tutup berkas. |
Hasil. Layar menampilkan:
Domain di berkas: ['Kondisi_Pohon', 'Tinggi_Pohon']
Widget Kondisi : ValueMap
Widget Tinggi_Phn : Range
Nilai 'ngawur' dan tinggi 250 m: tertulis tanpa ditolakDari 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.0dan keterangan"Tinggi pohon 0 sampai 50 m"menghasilkan domain dengan batas atas 50,0 (dicek lewatGetMaxAsDouble()). - Baris 24, pasangan kolom dan domain. Tambah pasangan untuk kolom lain. Pada uji, menambah domain daftar
Jenis_Pohondan pasangan("Jenis", "Jenis_Pohon")membuatJenisikut bertipeValueMap. - 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-DOMAINditulis 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.
- Ubah batas atas tinggi menjadi 50 m.
- Tambahkan domain daftar
Jenis_Pohon(Jati, Mahoni, Sengon) untuk kolomJenis.
Contoh jawaban nomor 1, baris 20-21 menjadi:
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:
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.

Skrip utuh:
# -*- 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.
| Baris | Tulisan | Artinya |
|---|---|---|
| 1-5 | komentar | Judul, penulis, dan pesan: jalankan setelah Skrip 20.2. |
| 6-8 | from qgis.core import (...) | Satu daftar panjang perkakas QGIS dalam tiga baris. Sebagian, misalnya QgsEditFormConfig, tidak dipakai langsung. |
| 10 | GPKG = r"D:/KPH_Contoh/Survei_KPH.gpkg" | Lokasi berkas. Sama dengan KELUAR di Skrip 20.2. Ganti dengan jalur Anda. |
| 12-13 | lyr = QgsVectorLayer(...) lalu assert lyr.isValid() | Buka layer Titik_Survei dan pastikan terbuka. Bila gagal, skrip berhenti tanpa pesan (tidak ada teks setelah assert). |
| 14 | idx = lyr.fields().indexOf | Simpan sebuah fungsi di kotak. Tanpa tanda kurung, yang disimpan adalah fungsinya, bukan hasilnya. Sesudahnya, idx("Kondisi") memberi nomor urut kolom Kondisi. |
| 17-18 | def 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).
| Baris | Tulisan | Artinya |
|---|---|---|
| 23-24 | pilihan = [...] 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. |
| 25 | widget("KPH", "ValueMap", {"map": [{"KPH Alpha": "KPH Alpha"}, ...]}) | Value Map untuk KPH, tiga isinya ditulis langsung. |
| 28-29 | ref_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-33 | widget("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-37 | widget("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-41 | widget("Tgl_Survei", "DateTime", {...}) | Widget tanggal dengan kalender pop-up. Format tampil dan simpan yyyy-MM-dd. |
| 42-44 | widget("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).
| Baris | Tulisan | Artinya |
|---|---|---|
| 47 | K = QgsFieldConstraints | Beri nama pendek K untuk perkakas panjang ini, agar baris-baris berikutnya muat. |
| 48-50 | lyr.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-52 | lyr.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-54 | lyr.setConstraintExpression(idx("Akurasi_m"), ...) lalu K.ConstraintStrengthSoft | Aturan lunak: akurasi sebaiknya 5 m atau kurang. Bila lebih, hanya muncul peringatan. |
| 57-58 | lyr.setDefaultValueDefinition(idx("Tgl_Survei"), QgsDefaultValue("to_date(now())")) dan baris untuk Surveyor | Nilai bawaan lewat ekspresi. Tanggal diisi tanggal hari ini. Surveyor diisi nama akun yang sedang masuk (@user_account_name). |
| 59-60 | lyr.setDefaultValueDefinition(idx("ID_Survei"), QgsDefaultValue("...")) | Nilai bawaan ID_Survei: ekspresi panjang yang dijelaskan di bawah tabel. |
| 63-65 | ALIAS = {"ID_Survei": "ID survei", ...} | Kamus: nama kolom asli di kiri, label ramah di kanan. |
| 66-67 | for 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.
| Baris | Tulisan | Artinya |
|---|---|---|
| 70-71 | cfg = lyr.editFormConfig() lalu cfg.setLayout(Qgis.AttributeFormLayout.DragAndDrop) | Ambil pengaturan formulir layer, lalu pilih perancang "seret dan lepas" (drag and drop). |
| 72-74 | cfg.clearTabs(), akar = cfg.invisibleRootContainer(), akar.clear() | Kosongkan susunan lama. akar adalah wadah induk yang tak tampak, tempat semua grup akan dimasukkan. |
| 77-83 | def 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-88 | kelompok("Identitas", [...]), kelompok("Pohon", [...]), kelompok("Bukti", [...]) | Tiga grup: Identitas (empat kolom), Pohon (empat kolom), dan Bukti (foto dan akurasi). |
| 89 | lyr.setEditFormConfig(cfg) | Terapkan susunan baru ke layer. |
| 92 | hasil_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. |
| 93 | print("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.
| Baris | Tulisan | Artinya |
|---|---|---|
| 96-98 | baru = QgsVectorLayer(...), pesan, ok = baru.loadDefaultStyle(), print(...) | Buka layer baru dari berkas, lalu minta ia memuat gaya bawaan dari dalam berkas. Cetak apakah berhasil. |
| 99-101 | print("Widget Kondisi:", ...) dan dua baris lain | Cetak jenis widget, tata letak, dan alias yang terbaca ulang. Ini bukti gaya benar-benar tersimpan. |
| 104-114 | def 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-118 | print("Fitur salah :", periksa({...})) dan print("Fitur benar :", ...) | Uji dua fitur: satu bermasalah (tinggi 250, kondisi kosong, akurasi 8,4), satu baik. |
| 121-124 | from 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-128 | uji = QgsVectorLayer(...), uji.loadDefaultStyle(), print("Tabel acuan ditemukan:", ...) | Layer baru memuat gaya, lalu cek bahwa widget Jenis menemukan tabel acuan lewat nama layernya. |
| 129-130 | baris = QgsVectorLayerUtils.createFeature(uji) lalu print(...) | Buat fitur baru dengan semua nilai bawaan terisi, lalu cetak ID_Survei, Tgl_Survei, dan Surveyor. |
| 133-138 | cfg_uji = uji.editFormConfig() lalu perulangan | Cetak 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):
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: DateTimeDari 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, danStep. - Baris 53, aturan akurasi. Pada uji,
"Akurasi_m" <= 3dengan 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 aliasTinggi_Phndi 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 layerlayer_milik_sayake proyek yang terbuka sebelum menjalankan skrip. Sesudahnya daftar layer berisi['Ref_Jenis', 'layer_milik_saya'], jadi layer saya selamat. Bila baris itu digantiQgsProject.instance()laluremoveAllMapLayers(), 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 (
addFeaturelalucommitChanges). Hasilnyacommit: Truedan 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
AssertionErrortanpa pesan, karenaassertdi baris itu tidak diberi teks. Periksa jalur dan isi folder. - Baris 29 menambah
Ref_Jeniske proyek yang terbuka. Widget Value Relation membutuhkannya di tahap 6. Menjalankan skrip berulang-ulang bisa menumpuk layerRef_Jeniskembar 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.
- Ubah batas akurasi lunak menjadi 3 m, lalu uji fitur berakurasi 4 m.
- Ganti label (alias)
Tinggi_Phnmenjadi "Tinggi (meter)".
Contoh jawaban nomor 1, baris 53 menjadi:
lyr.setConstraintExpression(idx("Akurasi_m"), '"Akurasi_m" <= 3', "Akurasi lebih dari 3 m: ulangi pengukuran bila bisa")Lalu tambahkan satu baris setelah baris 118:
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.

Skrip utuh:
# -*- 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.
| Baris | Tulisan | Artinya |
|---|---|---|
| 1-5 | komentar | Komentar menekankan: bekerja pada SALINAN, dan butuh python-qgis (modul osgeo). |
| 6-7 | import shutil dan from osgeo import ogr | shutil adalah perkakas menyalin berkas. ogr membuka dan mengubah berkas vektor. |
| 9 | ogr.UseExceptions() | Nyalakan alarm: kegagalan GDAL menghentikan skrip, atau bisa ditangkap dengan try. |
| 10-11 | ASLI = ... dan SALINAN = ... | Dua jalur: berkas asli dari Skrip 20.2, dan nama salinannya. Ganti sesuai komputer Anda. |
| 12 | shutil.copy(ASLI, SALINAN) | Salin berkas. Bila SALINAN sudah ada, ia ditimpa tanpa peringatan. |
| 14 | ds = ogr.Open(SALINAN, 1) | Buka salinan dengan mode tulis. |
| 15 | PILIHAN = "'Sehat','Terserang Hama','Tebangan Liar'" | Teks daftar tiga pilihan, dengan kutip tunggal agar siap dimasukkan ke SQL. |
| 16 | for 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-20 | ds.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. |
| 22 | lyr = ds.GetLayerByName("Titik_Survei") | Ambil layer yang akan dicoba. |
| 23 | for coba in ["sehat", "Sehat"]: | Dua percobaan: huruf kecil (salah) lalu huruf besar di awal (benar). |
| 24-27 | f = 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-30 | try: lalu lyr.CreateFeature(f) dan print(... DITERIMA) | Coba tulis titik ke berkas. Bila berhasil, cetak DITERIMA. |
| 31-32 | except 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. |
| 33 | ds = None | Tutup berkas. |
| 35-36 | ds = ogr.Open(SALINAN) lalu print(... GetFeatureCount()) | Buka lagi tanpa mode tulis, dan hitung baris yang benar-benar tersimpan. |
Hasil. Layar menampilkan:
Kondisi 'sehat' -> DITOLAK: Kondisi harus salah satu dari tiga pilihan baku
Kondisi 'Sehat' -> DITERIMA
Jumlah baris tersimpan: 1Dari 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
ASLIdanSALINAN. - Baris 15, PILIHAN. Ganti daftar pilihan sah. Juga ubah pesan di baris 20 bila jumlah pilihannya berubah. Pada uji, menambah
'Mati'kePILIHAN, dengan uji["Mati", "Sehat"], menghasilkanKondisi 'Mati' -> DITERIMA,Kondisi 'Sehat' -> DITERIMA, dan 2 baris tersimpan. - Baris 16, nama dan kejadian pagar. Ganti nama pagar. Untuk memagari kolom lain, ubah
Kondisidi kejadian dan di syaratWHEN. - Baris 23, nilai uji. Ganti dengan nilai lain yang ingin dicoba.
Jebakan.
- NOT IN peka huruf besar dan kecil. Itu persis yang diperagakan:
sehatditolak karena tidak sama denganSehat. - 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.
- Tambahkan "Mati" sebagai pilihan sah, lalu uji nilai
MatidanSehat. - Tampilkan pesan galat lengkap tanpa dipotong.
Contoh jawaban nomor 1, baris 15 dan 23 menjadi:
PILIHAN = "'Sehat','Terserang Hama','Tebangan Liar','Mati'"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:
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.

Skrip utuh:
# -*- 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.
| Baris | Tulisan | Artinya |
|---|---|---|
| 1-4 | komentar | Judul, penulis, dan penegasan: hanya MEMBACA, tidak mengubah data. |
| 5 | from collections import Counter | Panggil perkakas penghitung. Counter menghitung berapa kali tiap isi muncul. |
| 6 | from qgis.core import QgsVectorLayer, QgsGeometry | Panggil pembuka layer. QgsGeometry tidak dipakai langsung. |
| 8 | JALUR = r"D:/KPH_Contoh/paket-i3/Survei_Lapangan.gpkg" | Lokasi data. Ganti dengan jalur Anda. |
| 9 | BAKU = {"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". |
| 10 | PERIODE = ("2026-09-01", "2026-09-12") | Dua tanggal sah, ditulis sebagai teks tahun-bulan-hari. Teks bentuk ini bisa dibandingkan urutannya seperti tanggal. |
| 11-12 | BATAS_TINGGI = (0, 60) dan BATAS_AKURASI = 5.0 | Batas tinggi pohon dan batas akurasi GNSS. Tiga angka ini adalah pengaturan kriteria Anda. |
| 14-16 | titik = ..., batas = ..., assert ... | Buka dua layer dan pastikan terbuka. |
| 17 | fitur = list(titik.getFeatures()) | Baca semua titik sekali jadi daftar. Dengan begitu bisa dipakai berulang-ulang di pemeriksaan berikutnya. |
| 18 | petak = {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. |
| 20 | temuan = {} | Kamus kosong untuk menampung hasil: nama pemeriksaan menjadi kunci, daftar ID bermasalah menjadi isinya. |
| 23-24 | def 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.
| Baris | Tulisan | Artinya |
|---|---|---|
| 28 | catat("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() == ""). |
| 30 | catat("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. |
| 38 | catat("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-41 | hitung_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-44 | hitung_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-46 | catat("Koordinat kembar", [... if (round(...), round(...)) in kembar]) | Ambil semua titik yang koordinatnya termasuk kembar. Karena itu SV-049 muncul dua kali di hasil. |
| 48 | di_luar, salah_label = [], [] | Siapkan dua daftar kosong dalam satu baris. |
| 49-55 | for 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-57 | catat("Di luar semua petak", di_luar) dan catat("Label KPH tidak sesuai letak", salah_label) | Simpan dua hasil. |
| 59-61 | print("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:
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-025Dari 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.0memberiAkurasi lebih dari 3 m 18temuan (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 anddi baris 33, 36, dan 38 membuat skrip tidak berhenti bilaTinggi_Phn,Tgl_Survei, atauAkurasi_mkosong. Tanpa pengaman, Python berhenti dengan galat sepertiTypeError: '<=' 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 kolomKondisiyang 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 anddi 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
withintidak menghitung titik yang persis di garis batas. [kemungkinan: dari dokumentasi fungsi, belum diuji] - Impor tak terpakai.
QgsGeometrytidak dipakai langsung.
Latihan variasi.
- Ganti batas akurasi menjadi 3 m.
- 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"):
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 JOINmempertahankan semua baris tabel kiri. - UPDATE: ubah isi baris.

Skrip utuh:
# -*- 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 = NonePenjelasan baris demi baris. Tiga blok. Nomor baris mengikuti berkas di atas.
Blok A: baris 1 sampai 15, menyiapkan salinan.
| Baris | Tulisan | Artinya |
|---|---|---|
| 1-5 | komentar | Penegasan: bekerja pada SALINAN, dan bahasa SQL-nya milik SQLite. |
| 6-8 | import os, import shutil, from osgeo import ogr | Panggil perkakas jalur, salin berkas, dan GDAL. |
| 10 | ogr.UseExceptions() | Nyalakan alarm: kesalahan SQL menghentikan skrip dengan pesan. |
| 11-12 | ASLI = ... dan SALINAN = ... | Dua jalur: berkas asli (dari paket-i3), dan salinan kerja. Ganti keduanya. |
| 13 | os.makedirs(os.path.dirname(SALINAN), exist_ok=True) | Pastikan folder salinan ada. |
| 14-15 | shutil.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.
| Baris | Tulisan | Artinya |
|---|---|---|
| 18 | def jalankan(judul, sql): | Resep yang menerima judul dan satu kalimat SQL. Dipakai sepuluh kali di blok C. |
| 19-20 | print("\n-- " + judul) lalu print(sql) | Cetak judul dan kalimat SQL-nya. Kode \n berarti baris baru, jadi ada baris kosong sebelum judul. |
| 21 | hasil = ds.ExecuteSQL(sql) | Kirim kalimat ke basis data. Jawabannya berupa tabel hasil. |
| 22-23 | if hasil is None: lalu return | Kalimat seperti UPDATE tidak menghasilkan tabel. Bila hasilnya kosong, return langsung keluar dari resep. |
| 24 | nama = [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. |
| 25 | print(... .join(nama)) | Cetak nama kolom, disambung dengan teks pemisah (spasi, garis tegak, spasi) lewat join. |
| 26-27 | for 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. |
| 28 | ds.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.
| No | Kalimat SQL (ringkas) | Artinya |
|---|---|---|
| 1 | SELECT COUNT(*) AS jumlah FROM Titik_Survei | Hitung semua baris. AS jumlah memberi nama kolom hasil. |
| 2 | SELECT ID_Survei, Kondisi, Tinggi_Phn FROM Titik_Survei LIMIT 3 | Pilih tiga kolom saja, dan hanya tiga baris pertama (LIMIT 3). |
| 3 | ... WHERE Kondisi = 'Tebangan Liar' ORDER BY ID_Survei | Saring baris yang kondisinya Tebangan Liar, urutkan menurut ID. |
| 4 | SELECT Kondisi, COUNT(*) AS jumlah ... GROUP BY Kondisi ORDER BY jumlah DESC | Kelompokkan per kondisi, hitung tiap kelompok, urutkan dari terbanyak (DESC berarti menurun). |
| 5 | SELECT KPH, COUNT(*) AS n, ROUND(AVG(Tinggi_Phn), 1) AS rata_tinggi ... WHERE Tinggi_Phn BETWEEN 0 AND 60 GROUP BY KPH | Rata-rata tinggi per petak, hanya dari tinggi yang wajar (0 sampai 60). AVG rata-rata, ROUND(..., 1) satu desimal. |
| 6 | SELECT ID_Survei, COUNT(*) AS n ... GROUP BY ID_Survei HAVING COUNT(*) > 1 | Cari ID ganda. HAVING menyaring kelompok sesudah dihitung. WHERE menyaring baris sebelum dikelompokkan. |
| 7 | 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 | Gabungkan 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. |
| 8 | 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 | Hitung titik per petak lewat nama. JOIN biasa hanya menyimpan baris yang cocok. |
| 9 | UPDATE 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:
-- 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 | NoneDari 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
ASLIdanSALINAN. - 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
ogrinfosesudah skrip selesai: di salinan,SV-005danSV-012bernilaiSehat. Di berkas asli tetapsehatdanSehat(dengan spasi). Jadids = Nonedi baris 50 sudah cukup untuk menyimpan perubahan. - Kalimat SQL salah ketik. Pada uji,
SELECmenghasilkanRuntimeError: In ExecuteSQL(): sqlite3_prepare_v2(SELEC COUNT(*) AS jumlah FROM Titik_Survei): near "SELEC": syntax error. Nama kolom yang salah, misalnyaKondisii, menghasilkanno 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
SALINANdi baris 15 menjadiASLIhanya 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.
- Tampilkan tiga pohon tertinggi.
- Hitung rata-rata diameter per jenis pohon.
Contoh jawaban, ganti isi baris 31-49 dengan dua pemanggilan ini (dan biarkan ds = None di akhir):
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".


Skrip utuh:
# -*- 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.
| Baris | Tulisan | Artinya |
|---|---|---|
| 1-5 | komentar | Penegasan: hanya MEMBACA data, proyek boleh kosong. |
| 6-8 | from qgis.core import (...) dan from qgis.PyQt.QtCore import QMetaType | Panggil perkakas QGIS. QgsExpression tidak dipakai langsung. QMetaType memberi nama jenis kolom, misalnya teks. |
| 10-12 | JALUR = ..., titik = ..., batas = ... | Lokasi data dan dua layer. Ganti jalur dengan milik Anda. |
| 13 | QgsProject.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.
| Baris | Tulisan | Artinya |
|---|---|---|
| 15-24 | ATURAN = {"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-20 | NOT 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.
| Baris | Tulisan | Artinya |
|---|---|---|
| 26 | print("Jumlah baris:", titik.featureCount()) | Cetak jumlah baris, sebagai pembanding. |
| 27-28 | konteks = 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. |
| 29 | for nama, ekspresi in ATURAN.items(): | Ulangi untuk tiap aturan. |
| 30 | permintaan = QgsFeatureRequest().setFilterExpression(ekspresi).setExpressionContext(konteks) | Buat permintaan data dengan saringan ekspresi, lalu pasang konteksnya. Dua pemanggilan disambung dengan titik. |
| 31 | n = len(list(titik.getFeatures(permintaan))) | Minta layer baris yang cocok, kumpulkan jadi daftar, hitung. |
| 32 | print("%-26s %d" % (nama, n)) | Cetak nama aturan dan jumlah pelanggar. |
Blok D: baris 34 sampai 40, kolom virtual dan sebarannya.
| Baris | Tulisan | Artinya |
|---|---|---|
| 35 | cabang = " ".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. |
| 36 | titik.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-39 | hitung = {} lalu for f in titik.getFeatures(): dan hitung[...] = hitung.get(f["Status_Mutu"], 0) + 1 | Penghitung manual. hitung.get(kunci, 0) mengambil isi kunci itu, atau 0 bila belum ada. Tambah satu untuk tiap baris. |
| 40 | print("Status_Mutu:", hitung) | Cetak sebaran status. |
Hasil. Layar menampilkan:
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 diStatus_Mutuhanya 10 (tiga baris sudah lebih dulu kena aturan lain). Satu barisLabel KPH tidak sesuaipun 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
OKdengan label lain.
Jebakan.
- Kolom virtual tidak disimpan. Pada uji, setelah skrip jalan,
Status_Mutuada 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'xtetap tampil utuh diStatus_Mutukarena kutipnya digandakan. Bila Anda mengubah baris 35 dan membuangreplace, seluruhStatus_Mutubisa 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-qgispolos, tanpa aplikasi QGIS yang disiapkan lebih dulu, skrip ini berhenti denganSegmentation faultpada uji (kode keluar 139 di Git Bash; di PowerShell -1073741819). MemanggilProcessing.initialize()saja tidak menolong: skrip tetap berhenti. Yang menolong adalah menyiapkan aplikasinya di awal skrip, yaitufrom qgis.core import QgsApplication,app = QgsApplication([], False), laluapp.initQgis(). Pada uji, dengan tiga baris itu skrip berjalan benar, dengan atau tanpaProcessing.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.
- Tambahkan aturan "Diameter lebih dari 45 cm".
- Ubah batas akurasi menjadi 3 m.
Contoh jawaban nomor 1, tambahkan satu baris di kamus ATURAN (misalnya sebelum aturan Label KPH tidak sesuai):
"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:
"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.


Skrip utuh:
# -*- 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.
| Baris | Tulisan | Artinya |
|---|---|---|
| 1-5 | komentar | Judul dan prasyarat: jalankan setelah Skrip 20.2 dan 20.4. |
| 6-10 | import os, import shutil, import processing, from collections import Counter, from qgis.core import (...) | Panggil perkakas. os dan QgsProject diimpor tetapi tidak dipakai. |
| 12-13 | KERJA = r"D:/KPH_Contoh" dan ASLI = KERJA + "/Survei_KPH.gpkg" | Folder kerja, dan jalur berkas asli yang dibuat Skrip 20.2. Tanda + menyambung teks. |
| 14 | punya = {} | Kamus kosong untuk menyimpan layer tiap surveyor. |
| 15 | for 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. |
| 16 | salinan = "%s/%s.gpkg" % (KERJA, nama) | Rakit jalur berkas salinan, misalnya D:/KPH_Contoh/surveyor1.gpkg. |
| 17 | shutil.copy(ASLI, salinan) | Tiap surveyor membawa salinan sendiri. Salinan lama ditimpa tanpa tanya. |
| 18-19 | lyr = 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. |
| 20 | lyr.startEditing() | Mulai mode sunting. |
| 21 | f = QgsVectorLayerUtils.createFeature(lyr, QgsGeometry.fromPointXY(QgsPointXY(x, y))) | Buat satu fitur baru di titik (x, y), dengan semua nilai bawaan terisi, termasuk ID. |
| 22-23 | f["Kondisi"] = "Sehat" dan f["Tinggi_Phn"] = 12.0 | Isi dua kolom. |
| 24 | lyr.addFeature(f) | Tambahkan ke layer. |
| 25 | assert lyr.commitChanges(), lyr.commitErrors() | Simpan sunting. Bila gagal, skrip berhenti dan menampilkan daftar galatnya. |
| 26-27 | punya[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.
| Baris | Tulisan | Artinya |
|---|---|---|
| 29-30 | gabungan = 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. |
| 31 | hitung = Counter(f["ID_Survei"] for f in gabungan.getFeatures()) | Hitung berapa kali tiap ID muncul. |
| 32 | print("Jumlah baris setelah digabung:", gabungan.featureCount()) | Cetak jumlah baris hasil gabungan. |
| 33 | print("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:
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)danLAYERSberisi ketiga layer (list(punya.values())) menghasilkan tiga IDSV-001danID 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 menampilkansurveyor1 membuat ID: None,surveyor2 membuat ID: None, danID ganda: {None: 2}. Itu bukan bukti keberhasilan. Itu tanda gaya tidak termuat. - Butuh Python Console QGIS. Modul
processinghanya ada di dalam QGIS. Dari terminalpython-qgispolos, layar menampilkanModuleNotFoundError: No module named 'processing'. - Salinan lama ditimpa. Baris 17 menimpa
surveyor1.gpkgdansurveyor2.gpkgbila sudah ada. - Hasil gabungan hanya di memori. Layer
Gabunganhilang saat QGIS ditutup. Ekspor dulu bila ingin menyimpannya. - Impor tak terpakai.
osdanQgsProjecttidak dipakai.
Latihan variasi.
- Tambahkan surveyor ketiga.
- Beri tiap surveyor awalan ID sendiri, supaya tidak bentrok.
Contoh jawaban nomor 1, baris 15 dan 30 menjadi:
for nama, (x, y) in {"surveyor1": (312050, 9996050), "surveyor2": (312150, 9996060), "surveyor3": (312250, 9996070)}.items(): "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:
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 #
- Apa beda akurasi dan presisi pada pembacaan GNSS? Angka mana di Skrip 20.1 yang mengukur masing-masing?
- Mengapa domain di Skrip 20.3 tidak menolak nilai
ngawur, padahal pagar di Skrip 20.5 menolaksehat? - Mengapa
ID gandabernilai 2 di Skrip 20.8, tetapi 1 di Skrip 20.6?
Jawaban.
- 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.
- 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.
- 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_Contohadalah 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.gpkgdari 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_Jenistiap 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 #
| Skrip | Mengubah berkas? | Butuh dulu | Dijalankan dari |
|---|---|---|---|
| 20.1 statistik GNSS | Tidak, hanya membaca | tidak ada | Konsol QGIS atau python-qgis |
| 20.2 buat GeoPackage | Ya, membuat berkas baru | tidak ada | Konsol QGIS atau python-qgis |
| 20.3 domain | Ya, menanam domain | 20.2 | Konsol QGIS atau python-qgis |
| 20.4 formulir | Ya, menulis gaya | 20.2 | Konsol QGIS atau python-qgis |
| 20.5 pagar | Ya, pada salinan | 20.2 | python-qgis |
| 20.6 cek kualitas | Tidak, hanya membaca | tidak ada | Konsol QGIS atau python-qgis |
| 20.7 SQL dasar | Ya, pada salinan | tidak ada | python-qgis |
| 20.8 ekspresi kualitas | Tidak, kolom virtual di memori | tidak ada | Konsol QGIS (terminal bila Processing disiapkan) |
| 20.9 gabung dan bentrok ID | Ya, membuat dua salinan | 20.2 dan 20.4 | Konsol QGIS saja |