Proyek Akhir: Sistem Informasi Hutan, Kebun, Pertanian, Perikanan, Peternakan

CatatanDi bab ini, Anda akan:
  • Melihat gambaran utuh sistem yang dirakit dari semua seri buku ini
  • Memilih jalur fokus (web, data/statistik, GIS, ML/AI) dan sektor fokus (kehutanan, pertanian, perikanan, peternakan)
  • Mengikuti daftar tahap beserta bab bahan tiap tahap
  • Menjalankan kerangka proyek kecil yang menyambungkan data, validasi, basis data, analisis, API, laporan, dan tes
  • Menilai hasil kerja Anda sendiri dengan rubrik

Semua skrip bab ini ada di folder kode/bab69/. Di versi web, Anda juga bisa mengunduh semuanya sekaligus (zip).

Anda sudah menanam banyak benih: dari program “Halo, Lahan!” di Bab 4 sampai pemroses citra terdistribusi di Bab 67. Kini saatnya panen: merakit semuanya menjadi satu kebun yang utuh. Bayangkan Proyek Akhir sebagai membangun stasiun lapangan terpadu: data masuk dari lapangan dan sensor, dirapikan dan disimpan, dianalisis, dipelajari oleh model, ditanyai lewat asisten AI, lalu disajikan lewat API dan dasbor yang dijaga tes dan dikemas rapi.

Catatan kejujuran. Bab ini tidak mengajarkan hal baru; ia menyambungkan hal-hal lama. Bagian “kerangka proyek kecil” di bawah benar-benar dijalankan dan keluarannya asli. Tetapi itu kerangka awal untuk belajar, bukan sistem produksi: datanya tiruan (fiktif), basisnya SQLite, dan tidak ada keamanan, pemantauan, atau skala sungguhan. Bagian besar lain (citra satelit, model ML, agent AI, Docker, CI) hanya ditunjuk ke bab asalnya dan tidak dijalankan ulang di sini; Docker bahkan tidak terpasang di komputer penulis.

Gambaran besar: arsitektur

Arsitektur Proyek Akhir. Jalur bergaris tebal adalah yang dijalankan di bab ini.

Bacalah dari atas ke bawah. Sumber (lapangan, citra, dokumen) masuk ke lapisan validasi dan penyimpanan: tempat setiap data diperiksa sebelum disimpan, karena data yang salah di awal akan merusak segalanya di akhir. Dari penyimpanan, data mengalir ke tiga pengolah: analisis, model ML, dan agent AI. Hasilnya disajikan lewat API dan dasbor. Di bawah semuanya ada pita dasar: tes, Docker, CI, dan keamanan.

Pilih jalur dan sektor fokus

Anda tidak harus mengerjakan semuanya dengan kedalaman sama. Pilih satu jalur sebagai tulang punggung, lalu satu sektor sebagai bahan.

Jalur fokus Anda mendalami Bab utama
Web API, dasbor, aplikasi, pengemasan, deployment 21, 34, 36, 37, 42, 43, 45, 48, 49
Data dan statistik pembersihan, basis data, statistik, regresi, data besar 22–29, 50, 53
GIS peta vektor dan raster, citra, geospasial lanjut 38–40, 51, 52
ML dan AI model klasik, deep learning, deteksi objek, LLM, agent 54–62
Sektor fokus Indikator contoh di kerangka Bab bahan khusus
Kehutanan diameter pohon (cm) 38, 39, 51, 56, 59
Pertanian/perkebunan hasil panen (kg) 26, 27, 39, 54, 59
Perikanan bobot rata-rata ikan (g), kualitas air 26, 52, 56, 59
Peternakan bobot ternak (kg) 27, 52, 57, 59
TipTIPS

Jalur dan sektor tidak mengunci Anda. Anda boleh mulai dari jalur web dengan sektor perikanan, lalu bulan depan menambah jalur ML. Yang penting: selesaikan satu alur ujung ke ujung dulu, baru perluas.

Struktur folder proyek

Susunlah proyek Anda sendiri seperti ini (kerangka di bab ini mengikuti sebagian besar susunan ini; Bab 18, 19, 45 menjelaskan alasannya):

sistem-informasi-lapangan/
  README.md                 apa ini, cara menjalankan, status
  pyproject.toml            nama, versi, dependensi (Bab 17, 45)
  src/sistem_lapangan/      kode utama dalam modul kecil
    skema.py                aturan data (Pydantic)
    validasi.py             baca dan periksa data mentah
    basis_data.py           simpan dan baca (SQLite, nanti PostGIS)
    analisis.py             pandas dan statistik
    laporan.py              laporan teks, Excel, PDF
    api.py                  FastAPI
  tes/                      pytest (Bab 18)
  data/                     contoh data tiruan, bukan data rahasia
  notebook/                 eksplorasi (Jupyter)
  docs/                     catatan desain dan keputusan
  Dockerfile                kemasan (Bab 48)
  .github/workflows/        CI (Bab 20, 48)
  .gitignore                jangan unggah rahasia dan hasil sementara (Bab 20, 49)

Daftar tahap dan bab bahannya

Kerjakan berurutan; tiap tahap menghasilkan sesuatu yang bisa diperiksa.

Tahap Yang dibuat Bab bahan
1. Rancang dan siapkan tujuan, indikator, struktur folder, environment, git 2, 17, 19, 20
2. Data lapangan dan IoT CSV/JSON hasil survei, logger sensor 13, 14, 15, 52
3. Validasi aturan data, penolakan baris salah 12, 22, 34
4. Penyimpanan basis data dan aturan unik 24, 40, 50
5. Pipeline citra citra satelit/drone menjadi peta dan indeks 38, 39, 51, 53
6. Analisis dan statistik ringkasan, uji, regresi, laporan 23, 25, 26, 27, 29
7. Model ML dan deteksi objek klasifikasi, deteksi pohon/ikan/ternak 54, 55, 56, 58, 59
8. Agent AI asisten tanya-jawab dokumen, pemanggilan alat 60, 61, 62, 63
9. API dan dasbor layanan data, tampilan per sektor 36, 42, 43
10. Kemas dan jaga tes, kualitas, Docker, CI, keamanan 18, 19, 44, 45, 48, 49
11. Etalase README, portofolio 63, 68

Kerangka proyek kecil yang bisa dijalankan

Sekarang bagian praktiknya. Kerangka ini memotong alur ujung ke ujung menjadi delapan skrip di atas satu paket kecil bernama sistem_lapangan. Alurnya:

CSV tiruan → validasi Pydantic → SQLite → analisis pandas → API FastAPI (diuji lewat TestClient) → laporan teks → tes pytest → pemeriksaan alur.

Setiap skrip memakai hasil skrip sebelumnya (berkas disimpan di subfolder _tmp/), jadi jalankan berurutan dari 01 sampai 08. Skrip 01 selalu memulai dari nol, sehingga hasilnya sama setiap kali.

CatatanINGAT

Semua data di sini fiktif: delapan unit (dua per sektor), lima pengamatan mingguan tiap unit, ditambah beberapa baris yang sengaja kotor. Angka (diameter, hasil, bobot) hanyalah contoh latihan, bukan anjuran teknis. Sektor memakai satu indikator utama masing-masing: hutan diameter_cm, pertanian hasil_kg, perikanan bobot_rata_g, peternakan bobot_kg.

Aturan data: skema.py

Inilah jantung kerangka: satu kelas yang menyatakan apa itu pengamatan yang sah. Pydantic (Bab 34) memeriksa tipe, dan model_validator memeriksa hubungan antarkolom (indikator harus cocok dengan sektor, nilai harus dalam rentang wajar).

skema.py
"""Aturan data: satu pengamatan lapangan dan batas wajar nilainya (angka contoh, fiktif)."""

from datetime import date
from typing import Literal

from pydantic import BaseModel, Field, model_validator

# Setiap sektor punya satu indikator utama.
INDIKATOR = {
    "hutan": "diameter_cm",
    "pertanian": "hasil_kg",
    "perikanan": "bobot_rata_g",
    "peternakan": "bobot_kg",
}

# Batas wajar (bawah, atas) per indikator. Angka contoh, bukan anjuran teknis.
RENTANG = {
    "diameter_cm": (1.0, 300.0),
    "hasil_kg": (0.0, 5000.0),
    "bobot_rata_g": (1.0, 3000.0),
    "bobot_kg": (1.0, 1500.0),
}


class Pengamatan(BaseModel):
    unit: str = Field(min_length=1, max_length=10)
    sektor: Literal["hutan", "pertanian", "perikanan", "peternakan"]
    tanggal: date
    indikator: str
    nilai: float

    @model_validator(mode="after")
    def periksa_silang(self):
        harus = INDIKATOR[self.sektor]
        if self.indikator != harus:
            raise ValueError(f"sektor {self.sektor} memakai indikator {harus}, bukan {self.indikator}")
        bawah, atas = RENTANG[self.indikator]
        if not bawah <= self.nilai <= atas:
            raise ValueError(f"{self.indikator} harus {bawah} sampai {atas}, bukan {self.nilai}")
        return self

Tahap 1: membuat data tiruan

01_buat_data_tiruan.py
# Bab 69 - Proyek Akhir, tahap 1: membuat data lapangan tiruan
from sistem_lapangan import KERJA
from sistem_lapangan.data_tiruan import tulis_csv

# Mulai dari nol: hapus sisa hasil lama supaya setiap jalankan menghasilkan hal yang sama.
for nama in ("lapangan.db", "laporan.txt"):
    (KERJA / nama).unlink(missing_ok=True)

jumlah = tulis_csv(KERJA / "lapangan_mentah.csv")
print("baris data (tanpa judul kolom):", jumlah)
print("-- lima baris pertama --")
with (KERJA / "lapangan_mentah.csv").open(encoding="utf-8") as f:
    for _ in range(6):
        print(f.readline().rstrip())
print("-- enam baris terakhir (sengaja kotor atau ganda) --")
isi = (KERJA / "lapangan_mentah.csv").read_text(encoding="utf-8").splitlines()
for baris in isi[-6:]:
    print(baris)
baris data (tanpa judul kolom): 46
-- lima baris pertama --
unit,sektor,tanggal,indikator,nilai
H1,hutan,2026-01-05,diameter_cm,32.5
H1,hutan,2026-01-12,diameter_cm,32.8
H1,hutan,2026-01-19,diameter_cm,33.2
H1,hutan,2026-01-26,diameter_cm,33.6
H1,hutan,2026-02-02,diameter_cm,34.2
-- enam baris terakhir (sengaja kotor atau ganda) --
H1,hutan,2026-02-09,diameter_cm,-5
K1,perikanan,2026-02-09,bobot_kg,540
P2,pertanian,31-02-2026,hasil_kg,250
T1,empang,2026-02-09,bobot_kg,400
H2,hutan,2026-02-09,diameter_cm,900
H1,hutan,2026-01-05,diameter_cm,32.5

Ada 46 baris data: 40 baris wajar (4 sektor × 2 unit × 5 pekan), lalu 5 baris sengaja salah (nilai negatif, indikator tidak cocok, tanggal tidak ada, sektor tidak dikenal, nilai di luar rentang), lalu 1 baris yang ganda (salinan persis baris pertama). Data nyata pun begini: campur aduk.

Tahap 2: validasi

02_validasi_pydantic.py
# Bab 69 - Proyek Akhir, tahap 2: validasi dengan Pydantic
from sistem_lapangan import KERJA
from sistem_lapangan.validasi import validasi_csv

sah, ditolak = validasi_csv(KERJA / "lapangan_mentah.csv")
print("sah:", len(sah), "| ditolak:", len(ditolak))
for nomor, alasan in ditolak:
    print(f"baris {nomor} di berkas: {alasan}")
print("contoh objek sah:", sah[0])
sah: 41 | ditolak: 5
baris 42 di berkas: baris: Value error, diameter_cm harus 1.0 sampai 300.0, bukan -5.0
baris 43 di berkas: baris: Value error, sektor perikanan memakai indikator bobot_rata_g, bukan bobot_kg
baris 44 di berkas: tanggal: Input should be a valid date or datetime, invalid character in year
baris 45 di berkas: sektor: Input should be 'hutan', 'pertanian', 'perikanan' or 'peternakan'
baris 46 di berkas: baris: Value error, diameter_cm harus 1.0 sampai 300.0, bukan 900.0
contoh objek sah: unit='H1' sektor='hutan' tanggal=datetime.date(2026, 1, 5) indikator='diameter_cm' nilai=32.5

Dari 46 baris, 41 lolos dan 5 ditolak, masing-masing dengan alasan yang jelas dan nomor baris di berkas (baris 1 adalah judul kolom, jadi data mulai di baris 2). Perhatikan baris ganda lolos validasi: wajar, karena secara isi ia sah. Menolaknya adalah tugas tahap berikutnya.

Tahap 3: penyimpanan di SQLite

basis_data.py
"""Penyimpanan SQLite: satu tabel pengamatan dengan aturan unik."""

import sqlite3
from pathlib import Path

from .skema import Pengamatan

SKEMA_SQL = """
CREATE TABLE IF NOT EXISTS pengamatan (
    id INTEGER PRIMARY KEY,
    unit TEXT NOT NULL,
    sektor TEXT NOT NULL,
    tanggal TEXT NOT NULL,
    indikator TEXT NOT NULL,
    nilai REAL NOT NULL CHECK (nilai >= 0),
    UNIQUE (unit, tanggal, indikator)
)
"""


def sambung(jalur: Path) -> sqlite3.Connection:
    jalur.parent.mkdir(parents=True, exist_ok=True)
    koneksi = sqlite3.connect(jalur)
    koneksi.row_factory = sqlite3.Row
    koneksi.execute(SKEMA_SQL)
    return koneksi


def simpan(jalur: Path, daftar: list[Pengamatan]) -> tuple[int, int]:
    """Mengembalikan (jumlah baru, jumlah ganda yang dilewati)."""
    koneksi = sambung(jalur)
    baru = 0
    try:
        with koneksi:        # with = transaksi: semua tersimpan atau tidak sama sekali
            for p in daftar:
                kursor = koneksi.execute(
                    "INSERT OR IGNORE INTO pengamatan (unit, sektor, tanggal, indikator, nilai) "
                    "VALUES (?, ?, ?, ?, ?)",
                    (p.unit, p.sektor, p.tanggal.isoformat(), p.indikator, p.nilai),
                )
                baru += kursor.rowcount
    finally:
        koneksi.close()
    return baru, len(daftar) - baru


def baca(jalur: Path, sektor: str | None = None) -> list[dict]:
    sql = "SELECT unit, sektor, tanggal, indikator, nilai FROM pengamatan"
    argumen: tuple = ()
    if sektor:
        sql += " WHERE sektor = ?"
        argumen = (sektor,)
    sql += " ORDER BY sektor, unit, tanggal"
    koneksi = sambung(jalur)
    try:
        return [dict(b) for b in koneksi.execute(sql, argumen)]
    finally:
        koneksi.close()
03_simpan_sqlite.py
# Bab 69 - Proyek Akhir, tahap 3: simpan ke SQLite
from sistem_lapangan import KERJA
from sistem_lapangan.basis_data import baca, simpan
from sistem_lapangan.validasi import validasi_csv

sah, ditolak = validasi_csv(KERJA / "lapangan_mentah.csv")
baru, ganda = simpan(KERJA / "lapangan.db", sah)
print(f"tersimpan baru: {baru} | dilewati karena ganda: {ganda}")

# Dijalankan dua kali, hasilnya tetap sama: tidak ada baris bertambah.
baru2, ganda2 = simpan(KERJA / "lapangan.db", sah)
print(f"simpan ulang -> baru: {baru2} | ganda: {ganda2}")

semua = baca(KERJA / "lapangan.db")
print("jumlah baris di basis data:", len(semua))
print("baris pertama:", semua[0])
tersimpan baru: 40 | dilewati karena ganda: 1
simpan ulang -> baru: 0 | ganda: 41
jumlah baris di basis data: 40
baris pertama: {'unit': 'H1', 'sektor': 'hutan', 'tanggal': '2026-01-05', 'indikator': 'diameter_cm', 'nilai': 32.5}

Aturan UNIQUE (unit, tanggal, indikator) membuat basis data menolak duplikat; INSERT OR IGNORE melewatinya tanpa error. Hasilnya: 40 baru, 1 ganda dilewati. Menyimpan kedua kali menambah 0 baris: operasi ini aman diulang (idempoten). Itu sifat penting bagi pipeline yang berjalan terjadwal.

Tahap 4: analisis dengan pandas

04_analisis_pandas.py
# Bab 69 - Proyek Akhir, tahap 4: analisis dengan pandas
import pandas as pd

from sistem_lapangan import KERJA
from sistem_lapangan.analisis import laju_per_unit, muat, ringkasan_sektor, tandai_anomali

SEKTOR_FOKUS = "perikanan"      # ganti dengan sektor pilihan Anda: hutan, pertanian, perikanan, peternakan

pd.set_option("display.width", 100)
df = muat(KERJA / "lapangan.db")
print("bentuk tabel (baris, kolom):", df.shape)
print()
print(ringkasan_sektor(df).to_string(index=False))
print()
laju = laju_per_unit(df)
print(laju.to_string(index=False))
print()
print(f"-- fokus: {SEKTOR_FOKUS} --")
print(df[df["sektor"] == SEKTOR_FOKUS].pivot(index="tanggal", columns="unit", values="nilai"))
print()
anomali = tandai_anomali(df, batas_z=1.5)
print("nilai yang ditandai (|z| > 1,5):", len(anomali))
bentuk tabel (baris, kolom): (40, 5)

    sektor    indikator  banyak  rata_rata  terkecil  terbesar
     hutan  diameter_cm      10      34.03      32.5      35.5
 perikanan bobot_rata_g      10     597.15     539.4     659.8
 pertanian     hasil_kg      10     256.53     242.2     270.5
peternakan     bobot_kg      10     369.87     351.8     388.0

    sektor unit  naik_per_minggu
     hutan   H1             0.42
     hutan   H2             0.35
 perikanan   K1            27.61
 perikanan   K2            30.07
 pertanian   P1             6.55
 pertanian   P2             5.53
peternakan   T1             8.64
peternakan   T2             9.06

-- fokus: perikanan --
unit           K1     K2
tanggal                 
2026-01-05  545.4  539.4
2026-01-12  571.7  563.5
2026-01-19  593.9  591.5
2026-01-26  619.0  631.6
2026-02-02  659.8  655.7

nilai yang ditandai (|z| > 1,5): 3

Tabel berisi 40 baris dan 5 kolom. Ringkasan per sektor menunjukkan, misalnya, rata-rata bobot ikan 597,15 g dari 10 pengamatan. Tabel kedua adalah laju naik per minggu tiap unit (kemiringan garis lurus; bobot ikan di kolam K2 naik sekitar 30,07 g per pekan pada data tiruan ini). Variabel SEKTOR_FOKUS di atas skrip bisa Anda ganti ke sektor pilihan Anda. Terakhir, 3 nilai ditandai karena menyimpang lebih dari 1,5 simpangan baku dari rata-rata unitnya; ambang ini sekadar contoh.

Tahap 5: API FastAPI diuji dengan TestClient

api.py
"""API FastAPI di atas basis data SQLite."""

from pathlib import Path

from fastapi import FastAPI, HTTPException

from . import basis_data
from .analisis import muat, ringkasan_sektor
from .skema import Pengamatan


def buat_app(jalur_db: Path) -> FastAPI:
    app = FastAPI(title="Sistem Informasi Lapangan (tiruan)")

    @app.get("/sehat")
    def sehat():
        return {"status": "ok", "jumlah": len(basis_data.baca(jalur_db))}

    @app.get("/pengamatan", response_model=list[Pengamatan])
    def daftar(sektor: str | None = None):
        return basis_data.baca(jalur_db, sektor)

    @app.post("/pengamatan", response_model=Pengamatan, status_code=201)
    def tambah(p: Pengamatan):
        baru, _ = basis_data.simpan(jalur_db, [p])
        if not baru:
            raise HTTPException(409, "pengamatan itu sudah ada")
        return p

    @app.get("/ringkasan")
    def ringkasan():
        return ringkasan_sektor(muat(jalur_db)).to_dict(orient="records")

    return app
05_api_testclient.py
# Bab 69 - Proyek Akhir, tahap 5: API FastAPI diuji lewat TestClient
from fastapi.testclient import TestClient

from sistem_lapangan import KERJA
from sistem_lapangan.api import buat_app

klien = TestClient(buat_app(KERJA / "lapangan.db"))

print("sehat:", klien.get("/sehat").json())
perikanan = klien.get("/pengamatan", params={"sektor": "perikanan"}).json()
print("pengamatan perikanan:", len(perikanan), "| pertama:", perikanan[0])

baru = {"unit": "K1", "sektor": "perikanan", "tanggal": "2026-03-02",
        "indikator": "bobot_rata_g", "nilai": 700.0}
print("tambah baru:", klien.post("/pengamatan", json=baru).status_code)
print("tambah lagi (sama):", klien.post("/pengamatan", json=baru).status_code)

salah = dict(baru, tanggal="2026-03-09", nilai=-1)
jawab = klien.post("/pengamatan", json=salah)
print("nilai tidak masuk akal:", jawab.status_code, "->", jawab.json()["detail"][0]["msg"])

for baris in klien.get("/ringkasan").json():
    print(baris["sektor"], baris["indikator"], baris["banyak"], baris["rata_rata"])
sehat: {'status': 'ok', 'jumlah': 40}
pengamatan perikanan: 10 | pertama: {'unit': 'K1', 'sektor': 'perikanan', 'tanggal': '2026-01-05', 'indikator': 'bobot_rata_g', 'nilai': 545.4}
tambah baru: 201
tambah lagi (sama): 409
nilai tidak masuk akal: 422 -> Value error, bobot_rata_g harus 1.0 sampai 3000.0, bukan -1.0
hutan diameter_cm 10 34.03
perikanan bobot_rata_g 11 606.5
pertanian hasil_kg 10 256.53
peternakan bobot_kg 10 369.87

TestClient memanggil aplikasi tanpa menyalakan server, jadi cepat dan bisa dipakai di tes. Perhatikan tiga kode status: 201 (berhasil dibuat), 409 (konflik: sudah ada), dan 422 (data tidak masuk akal; aturan skema.py yang sama dipakai ulang oleh API). Setelah menambah satu baris, perikanan punya 11 pengamatan dan rata-rata berubah menjadi 606,5 g. Data di basis sekarang 41 baris, dan itu yang akan dibaca tahap berikutnya.

Tahap 6: laporan

laporan.py
"""Menyusun laporan teks dari hasil analisis."""

from pathlib import Path

import pandas as pd


def susun(ringkasan: pd.DataFrame, laju: pd.DataFrame, jumlah_ditolak: int) -> str:
    baris = ["LAPORAN SISTEM INFORMASI LAPANGAN (data tiruan)", "=" * 48]
    baris.append(f"Baris mentah ditolak saat validasi: {jumlah_ditolak}")
    baris.append("")
    baris.append("Ringkasan per sektor:")
    for _, b in ringkasan.iterrows():
        baris.append(
            f"- {b['sektor']:<10} {b['indikator']:<13} rata-rata {b['rata_rata']:>7.2f} "
            f"(dari {b['banyak']} pengamatan)"
        )
    baris.append("")
    baris.append("Unit dengan kenaikan tercepat per sektor:")
    for sektor, grup in laju.groupby("sektor"):
        teratas = grup.sort_values("naik_per_minggu", ascending=False).iloc[0]
        baris.append(f"- {sektor:<10} {teratas['unit']}: +{teratas['naik_per_minggu']} per minggu")
    baris.append("")
    baris.append("Catatan: semua angka adalah data tiruan untuk latihan.")
    return "\n".join(baris) + "\n"


def tulis(jalur: Path, teks: str) -> None:
    jalur.parent.mkdir(parents=True, exist_ok=True)
    jalur.write_text(teks, encoding="utf-8")
06_laporan.py
# Bab 69 - Proyek Akhir, tahap 6: laporan teks
from sistem_lapangan import KERJA
from sistem_lapangan.analisis import laju_per_unit, muat, ringkasan_sektor
from sistem_lapangan.laporan import susun, tulis
from sistem_lapangan.validasi import validasi_csv

_, ditolak = validasi_csv(KERJA / "lapangan_mentah.csv")
df = muat(KERJA / "lapangan.db")      # sudah memuat satu baris tambahan dari tahap 5
teks = susun(ringkasan_sektor(df), laju_per_unit(df), len(ditolak))
tulis(KERJA / "laporan.txt", teks)
print(teks)
print("laporan ditulis ke berkas laporan.txt di folder _tmp")
LAPORAN SISTEM INFORMASI LAPANGAN (data tiruan)
================================================
Baris mentah ditolak saat validasi: 5

Ringkasan per sektor:
- hutan      diameter_cm   rata-rata   34.03 (dari 10 pengamatan)
- perikanan  bobot_rata_g  rata-rata  606.50 (dari 11 pengamatan)
- pertanian  hasil_kg      rata-rata  256.53 (dari 10 pengamatan)
- peternakan bobot_kg      rata-rata  369.87 (dari 10 pengamatan)

Unit dengan kenaikan tercepat per sektor:
- hutan      H1: +0.42 per minggu
- perikanan  K2: +30.07 per minggu
- pertanian  P1: +6.55 per minggu
- peternakan T2: +9.06 per minggu

Catatan: semua angka adalah data tiruan untuk latihan.

laporan ditulis ke berkas laporan.txt di folder _tmp

Laporan menyusun hasil analisis menjadi teks yang bisa dibaca atasan, lengkap dengan jumlah baris yang ditolak dan catatan bahwa angkanya tiruan. Anda bisa menggantinya menjadi Excel atau PDF dengan teknik Bab 15.

Tahap 7: tes pytest

test_sistem.py
"""Tes kerangka Sistem Informasi Lapangan. Memakai folder sementara, tidak menyentuh _tmp."""

import pytest
from fastapi.testclient import TestClient

from sistem_lapangan.api import buat_app
from sistem_lapangan.basis_data import baca, simpan
from sistem_lapangan.data_tiruan import tulis_csv
from sistem_lapangan.skema import Pengamatan
from sistem_lapangan.validasi import validasi_csv


@pytest.fixture
def siap(tmp_path):
    csv = tmp_path / "mentah.csv"
    tulis_csv(csv)
    sah, ditolak = validasi_csv(csv)
    db = tmp_path / "lapangan.db"
    simpan(db, sah)
    return db, sah, ditolak


def test_validasi_menolak_baris_kotor(siap):
    _, sah, ditolak = siap
    assert len(sah) == 41      # 40 sah + 1 salinan ganda
    assert len(ditolak) == 5


def test_nilai_negatif_ditolak():
    with pytest.raises(ValueError):
        Pengamatan(unit="H1", sektor="hutan", tanggal="2026-01-05", indikator="diameter_cm", nilai=-5)


def test_indikator_harus_cocok_sektor():
    with pytest.raises(ValueError, match="memakai indikator"):
        Pengamatan(unit="K1", sektor="perikanan", tanggal="2026-01-05", indikator="bobot_kg", nilai=500)


def test_simpan_dua_kali_tidak_menggandakan(siap):
    db, sah, _ = siap
    assert len(baca(db)) == 40
    assert simpan(db, sah) == (0, 41)
    assert len(baca(db)) == 40


def test_api_tambah_dan_konflik(siap):
    db, _, _ = siap
    klien = TestClient(buat_app(db))
    baru = {"unit": "T1", "sektor": "peternakan", "tanggal": "2026-03-02",
            "indikator": "bobot_kg", "nilai": 400.0}
    assert klien.post("/pengamatan", json=baru).status_code == 201
    assert klien.post("/pengamatan", json=baru).status_code == 409
    assert klien.get("/sehat").json()["jumlah"] == 41


def test_api_menolak_nilai_salah(siap):
    db, _, _ = siap
    klien = TestClient(buat_app(db))
    salah = {"unit": "H1", "sektor": "hutan", "tanggal": "2026-03-02",
             "indikator": "diameter_cm", "nilai": 900}
    assert klien.post("/pengamatan", json=salah).status_code == 422
07_jalankan_tes.py
# Bab 69 - Proyek Akhir, tahap 7: menjalankan tes pytest
import os
import re
import subprocess
import sys
from pathlib import Path

folder = Path(__file__).resolve().parent
env = dict(os.environ, PYTHONPATH=str(folder), NO_COLOR="1")
hasil = subprocess.run(
    [sys.executable, "-m", "pytest", "tes", "-v", "-p", "no:cacheprovider", "--color=no"],
    cwd=folder, env=env, text=True, encoding="utf-8",
    stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
)
for baris in hasil.stdout.splitlines():
    if baris.startswith(("platform ", "rootdir:", "configfile:", "plugins:", "cachedir:")):
        continue
    print(re.sub(r" in \d+\.\d+s( \(.*?\))?", "", baris))
print("kode keluar:", hasil.returncode)
============================= test session starts =============================
collecting ... collected 6 items

tes/test_sistem.py::test_validasi_menolak_baris_kotor PASSED             [ 16%]
tes/test_sistem.py::test_nilai_negatif_ditolak PASSED                    [ 33%]
tes/test_sistem.py::test_indikator_harus_cocok_sektor PASSED             [ 50%]
tes/test_sistem.py::test_simpan_dua_kali_tidak_menggandakan PASSED       [ 66%]
tes/test_sistem.py::test_api_tambah_dan_konflik PASSED                   [ 83%]
tes/test_sistem.py::test_api_menolak_nilai_salah PASSED                  [100%]

============================== 6 passed ==============================
kode keluar: 0

Enam tes lulus, dan tes memakai folder sementara sehingga tidak mengganggu hasil tahap-tahap sebelumnya. Pegang prinsip ini: kalau sesuatu penting, tulis tesnya. Baris yang tertera di keluaran berasal dari pytest sungguhan; skrip hanya membuang baris yang berubah-ubah (jalur dan waktu).

Tahap 8: memeriksa alur ujung ke ujung

08_periksa_alur.py
# Bab 69 - Proyek Akhir, tahap 8: pemeriksaan otomatis alur ujung ke ujung
from sistem_lapangan import KERJA
from sistem_lapangan.basis_data import baca
from sistem_lapangan.validasi import validasi_csv

sah, ditolak = validasi_csv(KERJA / "lapangan_mentah.csv")
di_basis = len(baca(KERJA / "lapangan.db"))
laporan = (KERJA / "laporan.txt").read_text(encoding="utf-8")

pemeriksaan = [
    ("CSV mentah ada", (KERJA / "lapangan_mentah.csv").exists()),
    ("ada baris yang ditolak validasi", len(ditolak) == 5),
    ("baris di basis data = sah - ganda + 1 tambahan API", di_basis == len(sah) - 1 + 1),
    ("laporan menyebut semua empat sektor",
     all(s in laporan for s in ("hutan", "pertanian", "perikanan", "peternakan"))),
    ("laporan memuat catatan data tiruan", "data tiruan" in laporan),
]
for nama, lulus in pemeriksaan:
    print(("LULUS  " if lulus else "GAGAL  ") + nama)
print(f"{sum(l for _, l in pemeriksaan)} dari {len(pemeriksaan)} pemeriksaan lulus")
LULUS  CSV mentah ada
LULUS  ada baris yang ditolak validasi
LULUS  baris di basis data = sah - ganda + 1 tambahan API
LULUS  laporan menyebut semua empat sektor
LULUS  laporan memuat catatan data tiruan
5 dari 5 pemeriksaan lulus

Pemeriksaan terakhir ini mencocokkan angka antar-tahap: berkas ada, 5 baris ditolak, basis data berisi 41 baris, laporan memuat keempat sektor. Kalau salah satu “GAGAL”, Anda tahu tahap mana yang perlu ditelusuri. Kebiasaan membuktikan, bukan mengira, adalah ciri sistem yang bisa dipercaya.

Kerangka ini memakai SQLite agar bisa jalan di mana saja tanpa pemasangan. Untuk data spasial dan banyak pengguna, ganti lapisan basis_data.py dengan PostgreSQL + PostGIS (Bab 24 dan 40). Karena akses data hanya lewat satu modul, penggantian itu tidak perlu menyentuh skema, analisis, atau tes. Itulah gunanya memisahkan modul.

Dari kerangka ke sistem utuh

Kerangka di atas baru menyentuh tahap 1 sampai 4, 6, 9 (API), dan 10 (tes). Untuk melengkapinya:

  1. Citra dan model (tahap 5 dan 7): tambahkan modul yang membaca citra (Bab 39, 51), menghitung indeks, dan menyimpan hasilnya ke basis data; lalu latih model sederhana (Bab 54) dan, bila sektor Anda cocok, deteksi objek (Bab 56, 59). Lihat dulu apakah komputer Anda punya GPU (Bab 55).
  2. Agent AI (tahap 8): bungkus fungsi ringkasan dan pencarian data sebagai alat bagi agent (Bab 62), dengan batas izin yang ketat dan tanpa menaruh kunci API di kode (Bab 49, 60).
  3. Dasbor (tahap 9): tampilkan /ringkasan dan peta di Streamlit atau Dash (Bab 43).
  4. Kemas dan jaga (tahap 10): tulis Dockerfile dan alur CI mengikuti Bab 48, jalankan audit dependensi (Bab 49), dan periksa lisensi data dan pustaka (Bab 63).

Rubrik penilaian mandiri

Nilai diri Anda jujur. Beri 0 (belum), 1 (sebagian), atau 2 (ya, dengan bukti) pada tiap baris. Bukti berarti sesuatu yang bisa ditunjukkan: keluaran perintah, berkas tes, atau tangkapan layar.

Kriteria Bukti yang dicari
Alur ujung ke ujung berjalan satu perintah atau urutan skrip dari data mentah sampai laporan
Data divalidasi baris salah ditolak dengan alasan jelas, bukan diam-diam
Penyimpanan aman diulang menjalankan dua kali tidak menggandakan data
Analisis benar dan bisa dijelaskan angka cocok dengan hitungan manual pada contoh kecil
API punya tes tes untuk jalur sukses dan jalur gagal (201, 409, 422)
Kode rapi lolos ruff, ada docstring dan type hint pada fungsi penting (Bab 19, 33)
Rahasia dan data pribadi aman tidak ada kunci di repo; .gitignore benar (Bab 20, 49)
Dapat dipakai orang lain README yang jelas, dependensi tercatat, bisa dijalankan dari nol (Bab 17, 68)
Jujur soal batas README menyebut data tiruan/nyata, apa yang diuji dan tidak
Lisensi dan etika dicek lisensi data dan pustaka tercatat (Bab 63)

Jumlahkan: 0–7 = masih rangka, kerjakan alurnya dulu; 8–14 = sudah berjalan, perkuat tes dan dokumentasi; 15–20 = siap dipamerkan di portofolio. Batas-batas ini sekadar anjuran penulis, bukan standar resmi.

Latihan

  1. Jalankan kerangka dari 01 sampai 08. Lalu ubah SEKTOR_FOKUS di 04_analisis_pandas.py ke sektor lain dan catat apa yang berubah.
  2. Tambahkan satu baris kotor baru di data_tiruan.py (misalnya unit kosong). Jalankan ulang dan pastikan jumlah baris ditolak berubah menjadi 6 di tahap 2. Perbarui tes yang menyebut angka 5.
  3. Tambahkan titik akhir GET /unit/{kode} di api.py yang mengembalikan pengamatan satu unit (dan 404 bila tidak ada), lengkap dengan dua tes.
  4. Tantangan: ganti basis_data.py agar memakai SQLAlchemy (Bab 24) tanpa mengubah skrip 01 sampai 08 selain impor. Tes harus tetap lulus.

Ringkasan Bab

  • Proyek Akhir merakit seluruh buku: data → validasi → penyimpanan → analisis/model/agent → API dan dasbor → tes, kemasan, dan CI.
  • Pilih satu jalur (web, data/statistik, GIS, ML/AI) dan satu sektor, lalu selesaikan satu alur ujung ke ujung dulu.
  • Kerangka kecil di bab ini dijalankan sungguhan pada data tiruan: 46 baris mentah, 41 lolos validasi, 40 tersimpan, satu ditambah lewat API, dan 6 tes lulus.
  • Ini kerangka belajar, bukan sistem produksi.
  • Nilai diri dengan rubrik yang menuntut bukti.

Selanjutnya, di Bagian Sepuluh: daftar-daftar sepuluh butir yang ringkas untuk dibaca ulang: kesalahan pemula, kebiasaan baik, pustaka penting, sumber data gratis, ide proyek lanjutan, dan cara terus belajar.