Spaces:
Running
Running
Commit ·
b81a86b
0
Parent(s):
Space deploy v5: orphan snapshot of main, zero binary files
Browse filesHugging Face now rejects any push whose history carries binaries outside Xet
storage, and it scans the whole history, not the tip. v4 deleted the heavy
files in its final commit but still carried them across 70 ancestors, so the
pre-receive hook declined it.
This is the same shape as the v3 deploy branch: a single orphan commit holding
one snapshot of the tree, with every png/jpg/pdf/docx dropped and the README
image embeds replaced by pointers to the GitHub repo. Only favicon.ico stays,
which HF accepted on v3.
Content equals main at bb0f34a: real BPS data, quota and auth code, and the
out-of-coverage commodity reply.
This view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +50 -0
- .github/workflows/test.yml +69 -0
- .gitignore +158 -0
- .python-version +1 -0
- Dockerfile +39 -0
- LICENSE +21 -0
- README.en.md +212 -0
- README.md +240 -0
- README_v10.md +639 -0
- README_v11.md +692 -0
- README_v12.md +729 -0
- README_v13.md +224 -0
- REAL_DATA_METHODOLOGY.md +194 -0
- analysis/__init__.py +8 -0
- analysis/forecast_timesfm.py +309 -0
- analysis/precompute_anomalies.py +83 -0
- analysis/price_anomaly.py +513 -0
- analysis/run_anomaly_report.py +190 -0
- benchmarks/_metrics.py +241 -0
- benchmarks/anomaly_detector_gap.py +279 -0
- benchmarks/dashboard_load.py +129 -0
- benchmarks/equity_comparison.py +617 -0
- benchmarks/equity_comparison_constrained.py +484 -0
- benchmarks/greedy_vs_optimal.py +518 -0
- benchmarks/latency.py +189 -0
- benchmarks/national_scale.py +185 -0
- benchmarks/output/equity_comparison.md +41 -0
- benchmarks/output/equity_comparison_constrained.md +99 -0
- dashboard/.env.example +26 -0
- dashboard/.gitignore +44 -0
- dashboard/AGENTS.md +5 -0
- dashboard/CLAUDE.md +1 -0
- dashboard/app/account/page.tsx +142 -0
- dashboard/app/components/AccountMenu.tsx +78 -0
- dashboard/app/components/AnomalyPanel.tsx +155 -0
- dashboard/app/components/ForecastPanel.tsx +258 -0
- dashboard/app/components/MapView.tsx +103 -0
- dashboard/app/favicon.ico +0 -0
- dashboard/app/forgot-password/ForgotPasswordForm.tsx +96 -0
- dashboard/app/forgot-password/page.tsx +19 -0
- dashboard/app/globals.css +36 -0
- dashboard/app/layout.tsx +36 -0
- dashboard/app/lib/api.ts +187 -0
- dashboard/app/lib/auth.tsx +166 -0
- dashboard/app/lib/devauth.ts +50 -0
- dashboard/app/lib/guest.ts +34 -0
- dashboard/app/lib/supabase.ts +37 -0
- dashboard/app/login/LoginForm.tsx +214 -0
- dashboard/app/login/page.tsx +19 -0
- dashboard/app/page.tsx +0 -0
.dockerignore
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Keep the HF Spaces Docker image lean — only ship what runtime needs.
|
| 2 |
+
#
|
| 3 |
+
# Without this file, `COPY . .` in the Dockerfile drags in venv/ (~150 MB),
|
| 4 |
+
# dashboard/node_modules, .git history, test caches, etc. — bloating the
|
| 5 |
+
# image, slowing the build, and exposing local-only files inside the
|
| 6 |
+
# container.
|
| 7 |
+
|
| 8 |
+
# Python virtualenvs + caches
|
| 9 |
+
venv/
|
| 10 |
+
.venv/
|
| 11 |
+
env/
|
| 12 |
+
__pycache__/
|
| 13 |
+
*.pyc
|
| 14 |
+
*.pyo
|
| 15 |
+
.pytest_cache/
|
| 16 |
+
.mypy_cache/
|
| 17 |
+
.ruff_cache/
|
| 18 |
+
|
| 19 |
+
# Frontend (built/served separately on Vercel — not needed in the API image)
|
| 20 |
+
dashboard/node_modules/
|
| 21 |
+
dashboard/.next/
|
| 22 |
+
dashboard/out/
|
| 23 |
+
node_modules/
|
| 24 |
+
|
| 25 |
+
# VCS + IDE
|
| 26 |
+
.git/
|
| 27 |
+
.gitignore
|
| 28 |
+
.github/
|
| 29 |
+
.vscode/
|
| 30 |
+
.idea/
|
| 31 |
+
|
| 32 |
+
# Local docs/build artifacts that don't ship with the API
|
| 33 |
+
docs/*.pdf
|
| 34 |
+
docs/*.docx
|
| 35 |
+
docs/generate_*.py
|
| 36 |
+
|
| 37 |
+
# Secrets — must never enter the image (set via HF Space env vars instead)
|
| 38 |
+
.env
|
| 39 |
+
.env.*
|
| 40 |
+
*.pem
|
| 41 |
+
*.key
|
| 42 |
+
|
| 43 |
+
# OS junk
|
| 44 |
+
.DS_Store
|
| 45 |
+
Thumbs.db
|
| 46 |
+
|
| 47 |
+
# Dev-only directories (won't be exercised in the deployed image)
|
| 48 |
+
benchmarks/
|
| 49 |
+
examples/
|
| 50 |
+
tests/
|
.github/workflows/test.yml
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: tests
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [main]
|
| 8 |
+
workflow_dispatch:
|
| 9 |
+
|
| 10 |
+
concurrency:
|
| 11 |
+
group: ${{ github.workflow }}-${{ github.ref }}
|
| 12 |
+
cancel-in-progress: true
|
| 13 |
+
|
| 14 |
+
jobs:
|
| 15 |
+
test:
|
| 16 |
+
name: pytest (${{ matrix.os }} · py${{ matrix.python-version }})
|
| 17 |
+
runs-on: ${{ matrix.os }}
|
| 18 |
+
strategy:
|
| 19 |
+
fail-fast: false
|
| 20 |
+
matrix:
|
| 21 |
+
os: [ubuntu-latest, windows-latest]
|
| 22 |
+
python-version: ["3.11", "3.12"]
|
| 23 |
+
steps:
|
| 24 |
+
- uses: actions/checkout@v4
|
| 25 |
+
|
| 26 |
+
- name: Set up Python
|
| 27 |
+
uses: actions/setup-python@v5
|
| 28 |
+
with:
|
| 29 |
+
python-version: ${{ matrix.python-version }}
|
| 30 |
+
cache: pip
|
| 31 |
+
cache-dependency-path: requirements.txt
|
| 32 |
+
|
| 33 |
+
- name: Install dependencies
|
| 34 |
+
run: |
|
| 35 |
+
python -m pip install --upgrade pip
|
| 36 |
+
pip install -r requirements.txt
|
| 37 |
+
|
| 38 |
+
- name: Run pytest
|
| 39 |
+
run: pytest -q
|
| 40 |
+
|
| 41 |
+
benchmark:
|
| 42 |
+
name: latency benchmark (publish, no gate)
|
| 43 |
+
runs-on: ubuntu-latest
|
| 44 |
+
needs: test
|
| 45 |
+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
| 46 |
+
steps:
|
| 47 |
+
- uses: actions/checkout@v4
|
| 48 |
+
|
| 49 |
+
- name: Set up Python
|
| 50 |
+
uses: actions/setup-python@v5
|
| 51 |
+
with:
|
| 52 |
+
python-version: "3.12"
|
| 53 |
+
cache: pip
|
| 54 |
+
cache-dependency-path: requirements.txt
|
| 55 |
+
|
| 56 |
+
- name: Install dependencies
|
| 57 |
+
run: |
|
| 58 |
+
python -m pip install --upgrade pip
|
| 59 |
+
pip install -r requirements.txt
|
| 60 |
+
|
| 61 |
+
- name: Run latency benchmark
|
| 62 |
+
run: python benchmarks/latency.py | tee benchmark_results.txt
|
| 63 |
+
|
| 64 |
+
- name: Upload benchmark artifact
|
| 65 |
+
uses: actions/upload-artifact@v4
|
| 66 |
+
with:
|
| 67 |
+
name: latency-benchmark-${{ github.sha }}
|
| 68 |
+
path: benchmark_results.txt
|
| 69 |
+
retention-days: 30
|
.gitignore
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
/lib/
|
| 14 |
+
/lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
*.egg-info/
|
| 20 |
+
.installed.cfg
|
| 21 |
+
*.egg
|
| 22 |
+
MANIFEST
|
| 23 |
+
|
| 24 |
+
# Virtual environments
|
| 25 |
+
venv/
|
| 26 |
+
env/
|
| 27 |
+
ENV/
|
| 28 |
+
.venv/
|
| 29 |
+
.env
|
| 30 |
+
|
| 31 |
+
# Pytest
|
| 32 |
+
.pytest_cache/
|
| 33 |
+
.coverage
|
| 34 |
+
htmlcov/
|
| 35 |
+
.tox/
|
| 36 |
+
.cache
|
| 37 |
+
*.cover
|
| 38 |
+
*.log
|
| 39 |
+
|
| 40 |
+
# LaTeX build artifacts (never commit; .tex + .pdf are committed)
|
| 41 |
+
*.aux
|
| 42 |
+
*.out
|
| 43 |
+
*.toc
|
| 44 |
+
*.fls
|
| 45 |
+
*.fdb_latexmk
|
| 46 |
+
*.synctex.gz
|
| 47 |
+
|
| 48 |
+
# Word lock files (when Word has docx open)
|
| 49 |
+
~$*.docx
|
| 50 |
+
~$*.xlsx
|
| 51 |
+
~$*.pptx
|
| 52 |
+
|
| 53 |
+
# IDE
|
| 54 |
+
.vscode/
|
| 55 |
+
.idea/
|
| 56 |
+
*.swp
|
| 57 |
+
*.swo
|
| 58 |
+
*~
|
| 59 |
+
.DS_Store
|
| 60 |
+
Thumbs.db
|
| 61 |
+
|
| 62 |
+
# OS
|
| 63 |
+
.directory
|
| 64 |
+
desktop.ini
|
| 65 |
+
|
| 66 |
+
# Local configs
|
| 67 |
+
*.local
|
| 68 |
+
.env.local
|
| 69 |
+
config.local.json
|
| 70 |
+
|
| 71 |
+
# Claude Code project settings (local-only)
|
| 72 |
+
.claude/
|
| 73 |
+
|
| 74 |
+
# API keys / secrets (defensive — never commit)
|
| 75 |
+
.secrets
|
| 76 |
+
secrets/
|
| 77 |
+
*.pem
|
| 78 |
+
*.key
|
| 79 |
+
api_keys.txt
|
| 80 |
+
|
| 81 |
+
# Temporary extraction artifacts
|
| 82 |
+
v8_extracted.txt
|
| 83 |
+
v9_extracted.txt
|
| 84 |
+
*_extracted.txt
|
| 85 |
+
|
| 86 |
+
# Proposal docx files — internal team artifacts, not for public repo
|
| 87 |
+
AgriFlow_v8.docx
|
| 88 |
+
AgriFlow_v9.docx
|
| 89 |
+
AgriFlow_v10.docx
|
| 90 |
+
docs/AgriFlow_v8.docx
|
| 91 |
+
docs/AgriFlow_v9.docx
|
| 92 |
+
docs/AgriFlow_v10.docx
|
| 93 |
+
docs/AgriFlow_v11.docx
|
| 94 |
+
docs/AgriFlow_v12.docx
|
| 95 |
+
docs/AgriFlow_v13.docx
|
| 96 |
+
docs/AgriFlow_v13_clean.docx
|
| 97 |
+
|
| 98 |
+
# Proposal docx generator scripts — internal toolchain, paired with the
|
| 99 |
+
# gitignored .docx outputs above (kept consistent: if the artefact is private,
|
| 100 |
+
# the script that builds it stays private too).
|
| 101 |
+
docs/generate_v10_docx.py
|
| 102 |
+
docs/generate_v11_docx.py
|
| 103 |
+
|
| 104 |
+
# Internal audit notes (frank code↔doc consistency check; not for public repo)
|
| 105 |
+
docs/AUDIT_v10.md
|
| 106 |
+
|
| 107 |
+
# Proposal PDF intermediates (rendered from docx for wiki ingest)
|
| 108 |
+
docs/AgriFlow_v8.pdf
|
| 109 |
+
docs/AgriFlow_v9.pdf
|
| 110 |
+
docs/AgriFlow_v10.pdf
|
| 111 |
+
docs/AgriFlow_v11.pdf
|
| 112 |
+
docs/AgriFlow_v12.pdf
|
| 113 |
+
|
| 114 |
+
# Proposal opendataloader-pdf-converted markdown (derived from gitignored docx;
|
| 115 |
+
# wiki copy lives at D:/Research/Project Data/k1/raw/documents/agriflow/).
|
| 116 |
+
# Same privacy stance as the .docx originals — internal team artifact.
|
| 117 |
+
docs/AgriFlow_v8.md
|
| 118 |
+
docs/AgriFlow_v9.md
|
| 119 |
+
docs/AgriFlow_v10.md
|
| 120 |
+
docs/AgriFlow_v11.md
|
| 121 |
+
docs/AgriFlow_v12.md
|
| 122 |
+
|
| 123 |
+
# Local scratch space (experiments, transient logs, run trackers)
|
| 124 |
+
.tmp/
|
| 125 |
+
|
| 126 |
+
# Internal deployment notes — not for public repo
|
| 127 |
+
DEPLOY.md
|
| 128 |
+
DEPLOY_RENDER.md
|
| 129 |
+
render.yaml.disabled
|
| 130 |
+
|
| 131 |
+
# Internal data-acquisition guidelines — operational how-to, not for public repo
|
| 132 |
+
*_GUIDE.md
|
| 133 |
+
sample_data/bps_real/DATA_ACQUISITION_GUIDE.md
|
| 134 |
+
|
| 135 |
+
# Draft/scratch READMEs — stale or redundant
|
| 136 |
+
docs/README_v12_DRAFT.md
|
| 137 |
+
|
| 138 |
+
# Handoff documents — session artifacts, internal only
|
| 139 |
+
HANDOFF.md
|
| 140 |
+
HANDOFF.pdf
|
| 141 |
+
|
| 142 |
+
.vercel
|
| 143 |
+
|
| 144 |
+
# TimesFM model cache — model is ~2GB, never commit
|
| 145 |
+
# (model auto-downloads to HF cache dir on first run)
|
| 146 |
+
~/.cache/huggingface/
|
| 147 |
+
.cache/huggingface/
|
| 148 |
+
timesfm_model_cache/
|
| 149 |
+
|
| 150 |
+
# pip temp dirs (disk-full workaround)
|
| 151 |
+
/d/pip-tmp/
|
| 152 |
+
/d/pip-cache/
|
| 153 |
+
|
| 154 |
+
# Dashboard-internal README — not for public repo
|
| 155 |
+
dashboard/README.md
|
| 156 |
+
|
| 157 |
+
# Local subscription/quota state (JSON quota backend)
|
| 158 |
+
.state/
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.12.7
|
Dockerfile
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces — Docker SDK build for the AgriFlow FastAPI backend.
|
| 2 |
+
#
|
| 3 |
+
# Why this exists:
|
| 4 |
+
# HF Spaces (Docker SDK) builds + runs this container; the resulting public
|
| 5 |
+
# URL is what the Vercel dashboard hits via NEXT_PUBLIC_API_URL and what
|
| 6 |
+
# Twilio's WhatsApp Sandbox webhook points at.
|
| 7 |
+
#
|
| 8 |
+
# Port contract:
|
| 9 |
+
# HF Spaces expects the app to listen on 7860 by default. We honour that.
|
| 10 |
+
#
|
| 11 |
+
# Secrets (set in the Space's "Settings → Variables and secrets" UI, never here):
|
| 12 |
+
# GEMINI_API_KEY — from aistudio.google.com
|
| 13 |
+
# TWILIO_ACCOUNT_SID — Twilio console
|
| 14 |
+
# TWILIO_AUTH_TOKEN — Twilio console
|
| 15 |
+
# TWILIO_WHATSAPP_FROM — whatsapp:+14155238886 (sandbox) or your number
|
| 16 |
+
# MOCK_MODE=true — start here; flip to false once Gemini/Twilio keys are set.
|
| 17 |
+
|
| 18 |
+
FROM python:3.12-slim
|
| 19 |
+
|
| 20 |
+
# Avoid stale .pyc + force unbuffered stdout for clean HF log streaming.
|
| 21 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 22 |
+
PYTHONUNBUFFERED=1 \
|
| 23 |
+
PIP_NO_CACHE_DIR=1 \
|
| 24 |
+
PORT=7860
|
| 25 |
+
|
| 26 |
+
WORKDIR /app
|
| 27 |
+
|
| 28 |
+
# Install dependencies first so layer is cacheable when source changes.
|
| 29 |
+
COPY requirements.txt ./
|
| 30 |
+
RUN pip install --upgrade pip && pip install -r requirements.txt
|
| 31 |
+
|
| 32 |
+
# Now copy the rest of the project.
|
| 33 |
+
COPY . .
|
| 34 |
+
|
| 35 |
+
# HF Spaces routes external traffic to this port.
|
| 36 |
+
EXPOSE 7860
|
| 37 |
+
|
| 38 |
+
# Same entrypoint Render would have used, just on port 7860 instead of $PORT.
|
| 39 |
+
CMD ["uvicorn", "whatsapp_bot.server:app", "--host", "0.0.0.0", "--port", "7860"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Hilmi
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.en.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Language / Bahasa: **English** · [Bahasa Indonesia](./README.md)
|
| 2 |
+
|
| 3 |
+
<p align="center"><img src="assets/logo-mark.png" alt="AgriFlow logo" width="300"/></p>
|
| 4 |
+
|
| 5 |
+
<h1 align="center">AgriFlow</h1>
|
| 6 |
+
|
| 7 |
+
<p align="center">
|
| 8 |
+
<strong>AI-Powered Food Security Intelligence Platform</strong><br/>
|
| 9 |
+
<em>Inter-Regional Agricultural Supply–Demand Matching Platform</em>
|
| 10 |
+
</p>
|
| 11 |
+
|
| 12 |
+
<p align="center"><b>Detect · Predict · Distribute</b></p>
|
| 13 |
+
|
| 14 |
+
<p align="center">
|
| 15 |
+
<img src="https://img.shields.io/badge/PIDI-DIGDAYA%20%C3%97%20Hackathon%202026-1B5E20?style=for-the-badge" alt="Hackathon"/>
|
| 16 |
+
<img src="https://img.shields.io/badge/Problem%20Statement-2%20Matching%20Demand–Supply-4CAF50?style=for-the-badge" alt="PS"/>
|
| 17 |
+
<img src="https://img.shields.io/badge/tests-520%20passing-brightgreen?style=for-the-badge" alt="Tests"/>
|
| 18 |
+
</p>
|
| 19 |
+
|
| 20 |
+
> **Project roadmap spans 3 Phases.** Full technical documentation from previous versions is archived at [`README_v13.md`](README_v13.md) (latest snapshot), [`README_v12.md`](README_v12.md), and [`README_v11.md`](README_v11.md).
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
<details>
|
| 25 |
+
<summary><b>🖼️ View Research Poster (click to expand)</b></summary>
|
| 26 |
+
|
| 27 |
+
<br/>
|
| 28 |
+
|
| 29 |
+
<p align="center"><img src="poster/agriflow-poster.jpg" alt="AgriFlow Research Poster" width="100%"/></p>
|
| 30 |
+
|
| 31 |
+
</details>
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
# Phase 1 — Team & Links
|
| 36 |
+
|
| 37 |
+
## Team
|
| 38 |
+
|
| 39 |
+
| Name | Role | LinkedIn |
|
| 40 |
+
|------|------|----------|
|
| 41 |
+
| Chelsea | Data Analyst | [Chelsea](https://linkedin.com/in/chelseaayu) |
|
| 42 |
+
| Hilmi | Data Architect | [Hilmi](https://linkedin.com/in/hilmi888/) |
|
| 43 |
+
| Monika | UX Researcher | [Monika](https://linkedin.com/in/monika-hermiani) |
|
| 44 |
+
| Irpan | Data Engineer | [Irpan](https://linkedin.com/in/irpanpilihanrambe) |
|
| 45 |
+
|
| 46 |
+
## Links
|
| 47 |
+
|
| 48 |
+
| Resource | Link |
|
| 49 |
+
|----------|------|
|
| 50 |
+
| Pitch Deck | [Canva](https://www.canva.com/design/DAHETj2ulzg/VIvgxVkQ6I9R24ucphy2mQ/view) |
|
| 51 |
+
| Dashboard (Live Demo) | [agriflow-engine.vercel.app](https://agriflow-engine.vercel.app/) |
|
| 52 |
+
| Proposal (v13) | [docs/AgriFlow_Proposal_v13.pdf](docs/AgriFlow_Proposal_v13.pdf) |
|
| 53 |
+
|
| 54 |
+
---
|
| 55 |
+
|
| 56 |
+
# Phase 2 — What We Have Built (MVP)
|
| 57 |
+
|
| 58 |
+
## The Problem
|
| 59 |
+
|
| 60 |
+
Every year Indonesia loses trillions of rupiah in food — **40% occurs in distribution, not production**. In one district farmers throw away chilli because prices collapse; in the next district prices spike because supply is scarce. Local governments often discover the crisis **2–3 weeks too late**.
|
| 61 |
+
|
| 62 |
+
## The Solution
|
| 63 |
+
|
| 64 |
+
**AgriFlow matches surplus districts with deficit districts** — like "Uber for food", but aware of perishability, real road distances, and **equity for underserved regions**. Three core functions:
|
| 65 |
+
|
| 66 |
+
- **Detect** — find price anomalies (spikes/drops) from daily price data.
|
| 67 |
+
- **Predict** — forecast prices 30 days ahead.
|
| 68 |
+
- **Distribute** — intelligently and equitably match surplus to deficit.
|
| 69 |
+
|
| 70 |
+
## Architecture (High-Level)
|
| 71 |
+
|
| 72 |
+
```
|
| 73 |
+
REAL DATA SOURCES AGRIFLOW ENGINE ACCESS
|
| 74 |
+
(BPS · PIHPS · OSRM) ┌──────────────────────────┐
|
| 75 |
+
production · consumption ─▶│ DETECT price anomalies │ ──┐
|
| 76 |
+
prices · population │ PREDICT 30-day forecast │ ├──▶ Map dashboard
|
| 77 |
+
per-district East Java │ DISTRIBUTE 4-layer match │ └──▶ WhatsApp bot
|
| 78 |
+
└──────────────────────────┘
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
All three functions (Detect · Predict · Distribute) share one real data source, then served via Dashboard and WhatsApp.
|
| 82 |
+
|
| 83 |
+
📄 **Full methodology detail — rationale, how it works, evaluation, validation, and paper citations: [Architecture Document (PDF)](docs/AgriFlow_Architecture.pdf).**
|
| 84 |
+
|
| 85 |
+
## Features Already Running
|
| 86 |
+
|
| 87 |
+
| Function | Feature | Status |
|
| 88 |
+
|----------|---------|:------:|
|
| 89 |
+
| **Distribute** | 4-layer matching engine (hard constraints → multi-objective scoring → equity) running on **real BPS per-district data (2022)** | ✅ |
|
| 90 |
+
| **Detect** | Price anomaly detection (deseasonalize + robust statistics) on daily PIHPS prices **2021–2025** | ✅ |
|
| 91 |
+
| **Predict** | 30-day price forecasting with **TimesFM 2.0** (time-series foundation model) | ✅ |
|
| 92 |
+
| **Accessibility** | **WhatsApp Chatbot** (ask price & recommendations) + **interactive map Dashboard** | ✅ |
|
| 93 |
+
| **Security** | The site is *login-first*: opening it shows a login page. Judges click **"Masuk sebagai Tamu" (Enter as Guest)** to review without creating an account. A Supabase account system (server-side JWT verification, Row Level Security on 12 tables, password reset) is ready for a subscription model; sensitive subscriber & billing data stays JWT-protected server-side. | ✅ |
|
| 94 |
+
| **Real data** | **6 real commodities** per-district: premium & medium rice, large & cayenne chilli, red & garlic onion + 5 years of PIHPS prices | ✅ |
|
| 95 |
+
|
| 96 |
+
> **Quality:** 520 automated tests pass (521 collected, 1 skipped) — the engine is tested, reproducible, and honest about its limitations (see [Testing & Scenarios](#testing--scenarios) and Phase 3).
|
| 97 |
+
|
| 98 |
+
### Snapshots
|
| 99 |
+
|
| 100 |
+
**Dashboard** — East Java map with per-district surplus/deficit bubbles, a *top matches* list, plus a **price Forecast & Anomaly** panel (all three functions on one screen):
|
| 101 |
+
|
| 102 |
+

|
| 103 |
+
|
| 104 |
+
**WhatsApp Bot** — ask prices, find buyers/suppliers, get price forecasts & anomalies via chat. Supports **Indonesian** and **Javanese** (inclusion for rural farmers):
|
| 105 |
+
|
| 106 |
+
| Indonesian | Javanese |
|
| 107 |
+
|:---:|:---:|
|
| 108 |
+
|  |  |
|
| 109 |
+
|
| 110 |
+
## Testing & Scenarios
|
| 111 |
+
|
| 112 |
+
Because AgriFlow's output drives inter-district food allocation that touches low-HDI districts, claims of "fair" and "robust" must be re-auditable — not just narrative. The test suite locks food-balance figures as *golden numbers* (reproducibility), guards sensitive policy parameters against accidental drift (regression-safety), and tests anomaly detection adversarially.
|
| 113 |
+
|
| 114 |
+
**521 tests collected · 520 pass · 1 skipped · cross-OS on CI.**
|
| 115 |
+
(Skip = `test_timesfm_importorskip`: skipped when the heavy TimesFM library isn't installed on the runner; the forecasting path is still tested via fallback + API contract.)
|
| 116 |
+
|
| 117 |
+
The production server loads **real BPS data by default** (`DATA_BACKEND=csv`, the default). The old synthetic 19-commodity fixture is still used by 13 test files (`DATA_BACKEND=demo`) to exercise engine logic across a wider commodity range — it is never served to users.
|
| 118 |
+
|
| 119 |
+
| Category | Count | Coverage |
|
| 120 |
+
|---|---|---|
|
| 121 |
+
| Per-layer unit (L0–L3) | 73 | IPM tier, distance/perishability constraints, scoring, equity allocation |
|
| 122 |
+
| 24 edge-case scenarios (A–F) | 27+ | Volume, spatial, temporal, disruption, political, quality |
|
| 123 |
+
| Real BPS/PIHPS data validation | 57 | Rice + horticulture 2022 food-balance, reproducible pipeline |
|
| 124 |
+
| Price anomaly detection | 49 | Season-aware S-H-ESD on deseasonalized residuals |
|
| 125 |
+
| Forecast & API | 40 | Forecast/anomaly endpoints + fallback |
|
| 126 |
+
| Baseline & equity | 39 | greedy/uniform/proportional vs AgriFlow + supply-constrained scenario |
|
| 127 |
+
| Ingest & integration | 73 | DB loader, PIHPS ingest, OSRM distance, WhatsApp bot |
|
| 128 |
+
| Dashboard auth & WhatsApp quota | 117 | Supabase login, server-side JWT verification, RLS on 12 tables, password reset, WhatsApp free-tier quota (disabled by default) |
|
| 129 |
+
|
| 130 |
+
The **24 edge-case scenarios** map to real East Java events, e.g.: Ramadan spike (C1), Mt. Semeru eruption in Lumajang → unreachable (D4), multi-district flood of rice belts (D5), fuel-price hike → higher logistics cost (E5), and Bulog contract-reserve priority (E3).
|
| 131 |
+
|
| 132 |
+
**Key results:**
|
| 133 |
+
- **Equity proven under scarcity, at zero efficiency cost.** *This is a hypothetical stress test, not a result from the real BPS data:* on the 2022 data East Java is in fact heavily in surplus (6.6× ratio), so the equity value would not surface. To show how the mechanism works we built a synthetic scarcity scenario (the `surplus_deficit_constrained.csv` fixture, surplus 3962t vs deficit 5249t). In it, pure greedy abandons Madura — Sampang **0%**, Bangkalan **20%**; AgriFlow lifts both to **100%** at *identical aggregate coverage* (0.6649), with Gini dropping (0.3017 → 0.2905). We do not claim an equity advantage under abundance, nor that this scenario comes from real data.
|
| 134 |
+
- **Season-aware anomalies.** A ~60% price drop is flagged, but a pure seasonal pattern (pre-Eid cycle) does **not** trigger false positives; genuine anomalies riding on top of the seasonal pattern are still caught.
|
| 135 |
+
- **The data reveals a structural deficit, not a bug.** Garlic (bawang putih) produces **0 matches** across all 38 districts on the 2022 BPS data — East Java is deficit in garlic in every district, consistent with Indonesia being a net garlic importer. The engine is working correctly; the data is what's speaking.
|
| 136 |
+
|
| 137 |
+
📄 Full detail (why, the 24-scenario list, paper citations): [Architecture Document](docs/AgriFlow_Architecture.pdf) §Testing & Validation.
|
| 138 |
+
|
| 139 |
+
## Why Our Tech Stack Is LEAN (not as large as the original proposal)?
|
| 140 |
+
|
| 141 |
+
The initial proposal listed a large stack (Qdrant, LangChain, Redis, n8n, multi-cloud, etc.). After actually building, we **intentionally cut it** — *honest engineering* for current scale (38 districts in East Java):
|
| 142 |
+
|
| 143 |
+
| Original Plan | What We Use | Reason |
|
| 144 |
+
|---|---|---|
|
| 145 |
+
| Qdrant (separate vector DB) | **Supabase pgvector** | Small corpus — no need for a dedicated vector service |
|
| 146 |
+
| LangChain | **Gemini API directly** | RAG this simple doesn't need a heavy framework |
|
| 147 |
+
| Redis cache | **In-process cache** | Load doesn't require it yet; engine is deterministic |
|
| 148 |
+
| 5 hosting platforms | **2 (HF Spaces + Vercel)** | Fewer failure points, cheaper |
|
| 149 |
+
|
| 150 |
+
**Our principle: use what's sufficient, not what's fashionable.** Big components earn their place when scale justifies them — that's **Phase 3**.
|
| 151 |
+
|
| 152 |
+
---
|
| 153 |
+
|
| 154 |
+
# Phase 3 — Future Plans & Scaling
|
| 155 |
+
|
| 156 |
+
Phase 3 covers two things we keep honestly separate: features we intentionally deferred because they aren't needed at the current scale, and limits we've measured on the running engine and scheduled fixes for.
|
| 157 |
+
|
| 158 |
+
## What's deferred (waiting on data or real load)
|
| 159 |
+
|
| 160 |
+
| Plan | Purpose | Trigger |
|
| 161 |
+
|---|---|---|
|
| 162 |
+
| National scale, 514 districts | From 38 East Java districts to all of Indonesia | spatial partitioning + distance precompute |
|
| 163 |
+
| Exogenous forecasting (ENSO index, Ramadan calendar) | Accuracy improves under climate shocks & holidays | exogenous data available |
|
| 164 |
+
| Broiler chicken & eggs (real data) | Completes the 6 core commodities | per-district broiler & layer-egg production data released |
|
| 165 |
+
| Granular per-city/market prices | Real gap can be Rp5,000 to 15,000/kg (chilli interview) | open market price feed |
|
| 166 |
+
| Facilitating inter-district transactions | Price info alone is "not effective enough" without a buy/sell channel (onion & rice interviews) | distribution partnership |
|
| 167 |
+
| Source transparency & transaction security | User-trust requirement (interviews) | formal partnership stage |
|
| 168 |
+
| Sahabat-AI (Javanese/Madurese) + phone IVR | Inclusion for elderly farmers & feature-phone users | channel-scaling stage |
|
| 169 |
+
| Qdrant / Redis / n8n | Vector scale, caching, orchestration | when real load arrives |
|
| 170 |
+
|
| 171 |
+
## Coverage limits today (the gate is data availability, not architecture)
|
| 172 |
+
|
| 173 |
+
The engine is already ready to process any data it's given; what limits it is the availability of public per-district data. Once a source opens up, the same pipeline processes it immediately with no architecture change.
|
| 174 |
+
|
| 175 |
+
| Coverage today | The gate |
|
| 176 |
+
|---|---|
|
| 177 |
+
| 6 core commodities | awaiting per-district production data for other commodities to be released by BPS |
|
| 178 |
+
| Reference year 2022 | the most complete year across all per-district sources; a newer year is simply ingested when available |
|
| 179 |
+
| Broiler chicken & eggs not yet | the other 13 commodities remain synthetic placeholders, not served to users (`DATA_BACKEND=csv`) |
|
| 180 |
+
| Chilli/onion consumption via national figures | rice consumption is already per-district & used for real; the rest awaits publication |
|
| 181 |
+
| Tier-2 prices (non-IHK districts) | Bapanas Panel Harga is under maintenance; once the feed is restored, 30+ districts are covered immediately |
|
| 182 |
+
|
| 183 |
+
## Quantified engineering debt (scheduled fixes)
|
| 184 |
+
|
| 185 |
+
We measured these two limits ourselves against the engine's own achievable ceiling, with benchmarks committed and reproducible by a judge.
|
| 186 |
+
|
| 187 |
+
1. The allocator is not yet optimal. Measured against the exact LP transportation optimum on real BPS data: the stable tier leaves 25.4% of equity-weighted welfare on the table, the greedy tier 11.1%. Concrete evidence: Sumenep's cabai_merah demand is only 26% filled even though reachable supply (2,662 t within 200 km) exceeds the need (1,418 t), greedy already committed that supply elsewhere first. So this is an optimality problem, not a scarcity problem. Plan: replace with a capacitated min-cost-flow / entropic-OT solver (milliseconds at province scale, provably optimal); greedy stays as v1. Root cause: `matching_engine/allocation.py:307`. Benchmark: `benchmarks/greedy_vs_optimal.py`.
|
| 188 |
+
2. Unify the anomaly detectors. The user-facing anomaly panel already uses robust S-H-ESD (`analysis/price_anomaly.py`). But the internal D3 pre-filter gate (`matching_engine/engine.py:62`) still uses a non-robust 3σ z-score, on 70,953 real PIHPS observations it recalls only 14.4% of validated anomalies, and a D3 flag excludes a node from matching entirely. Plan: point D3 at the same S-H-ESD output (requires changing the `historical_prices` contract). Benchmark: `benchmarks/anomaly_detector_gap.py`.
|
| 189 |
+
|
| 190 |
+
## Scaling Up
|
| 191 |
+
|
| 192 |
+
National-scale growth is gated by the pace of public per-district data opening up, not technical readiness. Our approach: prove value at province scale with real data first, then expand as data becomes available.
|
| 193 |
+
|
| 194 |
+
---
|
| 195 |
+
|
| 196 |
+
## Running (quick technical)
|
| 197 |
+
|
| 198 |
+
```bash
|
| 199 |
+
pip install -r requirements.txt
|
| 200 |
+
python examples/run_demo_real.py # matching demo on real BPS 2022 data
|
| 201 |
+
pytest tests/ # 520 pass, 1 skipped
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
Full engineering detail in [`README_v12.md`](README_v12.md).
|
| 205 |
+
|
| 206 |
+
---
|
| 207 |
+
|
| 208 |
+
## License
|
| 209 |
+
|
| 210 |
+
MIT License — © 2026 Hilmi. See [`LICENSE`](LICENSE).
|
| 211 |
+
|
| 212 |
+
<p align="center"><em>Detect · Predict · Distribute — for Indonesian food security.</em></p>
|
README.md
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AgriFlow API
|
| 3 |
+
emoji: "🌾"
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
Language / Bahasa: [English](./README.en.md) · **Bahasa Indonesia**
|
| 13 |
+
|
| 14 |
+
<h1 align="center">AgriFlow</h1>
|
| 15 |
+
|
| 16 |
+
<p align="center">
|
| 17 |
+
<strong>AI-Powered Food Security Intelligence Platform</strong><br/>
|
| 18 |
+
<em>Platform Matching Demand–Supply Pangan Antarwilayah</em>
|
| 19 |
+
</p>
|
| 20 |
+
|
| 21 |
+
<p align="center"><b>Deteksi · Prediksi · Distribusi</b></p>
|
| 22 |
+
|
| 23 |
+
<p align="center">
|
| 24 |
+
<img src="https://img.shields.io/badge/PIDI-DIGDAYA%20%C3%97%20Hackathon%202026-1B5E20?style=for-the-badge" alt="Hackathon"/>
|
| 25 |
+
<img src="https://img.shields.io/badge/Problem%20Statement-2%20Matching%20Demand–Supply-4CAF50?style=for-the-badge" alt="PS"/>
|
| 26 |
+
<img src="https://img.shields.io/badge/tests-520%20passing-brightgreen?style=for-the-badge" alt="Tests"/>
|
| 27 |
+
</p>
|
| 28 |
+
|
| 29 |
+
> **Roadmap proyek dibagi 3 Phase.** README teknis lengkap versi sebelumnya diarsipkan di [`README_v13.md`](README_v13.md) (snapshot terbaru), [`README_v12.md`](README_v12.md), dan [`README_v11.md`](README_v11.md).
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
<details>
|
| 34 |
+
<summary><b>🖼️ Lihat Research Poster (klik untuk expand)</b></summary>
|
| 35 |
+
|
| 36 |
+
<br/>
|
| 37 |
+
|
| 38 |
+
<p align="center"><em>Poster riset tersedia di repo GitHub (tidak disertakan di Space: HF menolak file biner di luar Xet storage).</em></p>
|
| 39 |
+
|
| 40 |
+
</details>
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
# 📍 Phase 1 — Tim & Tautan
|
| 45 |
+
|
| 46 |
+
## Tim
|
| 47 |
+
|
| 48 |
+
| Nama | Role | LinkedIn |
|
| 49 |
+
|------|------|----------|
|
| 50 |
+
| Chelsea | Data Analyst | [Chelsea](https://linkedin.com/in/chelseaayu) |
|
| 51 |
+
| Hilmi | Data Architect | [Hilmi](https://linkedin.com/in/hilmi888/) |
|
| 52 |
+
| Monika | UX Researcher | [Monika](https://linkedin.com/in/monika-hermiani) |
|
| 53 |
+
| Irpan | Data Engineer | [Irpan](https://linkedin.com/in/irpanpilihanrambe) |
|
| 54 |
+
|
| 55 |
+
## Tautan
|
| 56 |
+
|
| 57 |
+
| Resource | Link |
|
| 58 |
+
|----------|------|
|
| 59 |
+
| Pitch Deck | [Canva](https://www.canva.com/design/DAHETj2ulzg/VIvgxVkQ6I9R24ucphy2mQ/view) |
|
| 60 |
+
| Dashboard (Live Demo) | [agriflow-engine.vercel.app](https://agriflow-engine.vercel.app/) |
|
| 61 |
+
| Proposal (v13) | [docs/AgriFlow_Proposal_v13.pdf](docs/AgriFlow_Proposal_v13.pdf) |
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
# 🚀 Phase 2 — Yang Sudah Kami Bangun (MVP)
|
| 66 |
+
|
| 67 |
+
## Masalah
|
| 68 |
+
|
| 69 |
+
Setiap tahun Indonesia kehilangan triliunan rupiah pangan — **40% terjadi di distribusi, bukan produksi**. Di satu kabupaten petani membuang cabai karena harga jatuh; di kabupaten sebelah harga melonjak karena langka. Pemda sering baru tahu krisis **2–3 minggu kemudian**.
|
| 70 |
+
|
| 71 |
+
## Solusi
|
| 72 |
+
|
| 73 |
+
**AgriFlow mencocokkan kabupaten surplus dengan kabupaten defisit** — seperti "Uber untuk pangan", tapi paham masa simpan (perishability), jarak jalan nyata, dan **keadilan untuk daerah tertinggal**. Tiga fungsi inti:
|
| 74 |
+
|
| 75 |
+
- **Deteksi** — temukan anomali harga (lonjakan/anjlok) dari data harga harian.
|
| 76 |
+
- **Prediksi** — perkirakan harga 30 hari ke depan.
|
| 77 |
+
- **Distribusi** — cocokkan surplus → defisit secara cerdas & adil.
|
| 78 |
+
|
| 79 |
+
## Arsitektur (High-Level)
|
| 80 |
+
|
| 81 |
+
```
|
| 82 |
+
SUMBER DATA NYATA AGRIFLOW ENGINE AKSES
|
| 83 |
+
(BPS · PIHPS · OSRM) ┌──────────────────────────┐
|
| 84 |
+
produksi · konsumsi ──────▶ │ DETEKSI anomali harga │ ──┐
|
| 85 |
+
harga · populasi │ PREDIKSI forecast 30 hr │ ├──▶ Dashboard peta
|
| 86 |
+
per-kabupaten Jatim │ DISTRIBUSI matching 4-lapis│ └──▶ WhatsApp bot
|
| 87 |
+
└──────────────────────────┘
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
Tiga fungsi (Deteksi · Prediksi · Distribusi) berbagi satu sumber data nyata, lalu disajikan lewat Dashboard & WhatsApp.
|
| 91 |
+
|
| 92 |
+
📄 **Detail metodologi tiap fitur — alasan pemilihan metode, cara kerja, evaluasi, validasi, dan sitasi paper: [Dokumen Arsitektur (PDF)](docs/AgriFlow_Architecture.pdf).**
|
| 93 |
+
|
| 94 |
+
## Fitur yang sudah berjalan
|
| 95 |
+
|
| 96 |
+
| Fungsi | Fitur | Status |
|
| 97 |
+
|--------|-------|:------:|
|
| 98 |
+
| **Distribusi** | Matching engine 4-lapis (hard constraints → multi-objective scoring → equity) berjalan di **data BPS asli per-kabupaten (2022)** | ✅ |
|
| 99 |
+
| **Deteksi** | Deteksi anomali harga (deseasonalize + robust statistics) pada harga PIHPS harian **2021–2025** | ✅ |
|
| 100 |
+
| **Prediksi** | Forecasting harga 30 hari dengan **TimesFM 2.0** (foundation model time-series) | ✅ |
|
| 101 |
+
| **Aksesibilitas** | **Chatbot WhatsApp** (tanya harga & rekomendasi) + **Dashboard** peta interaktif | ✅ |
|
| 102 |
+
| **Keamanan** | Situs bersifat *login-first*: membuka website menampilkan halaman login lebih dulu. Juri cukup klik **"Masuk sebagai Tamu"** untuk meninjau tanpa membuat akun. Akun Supabase (JWT terverifikasi server-side, Row Level Security di 12 tabel, reset password) siap untuk model berlangganan; data sensitif (langganan & pembayaran) tetap dijaga verifikasi JWT di sisi server. | ✅ |
|
| 103 |
+
| **Data nyata** | **6 komoditas** real per-kab: beras premium & medium, cabai merah & rawit, bawang merah & putih + harga PIHPS 5 tahun | ✅ |
|
| 104 |
+
|
| 105 |
+
> **Kualitas:** 520 tes otomatis lulus (521 terkumpul, 1 di-skip) — engine teruji, dapat direproduksi, dan jujur soal keterbatasannya (lihat [Pengujian & Skenario](#pengujian--skenario) dan Phase 3).
|
| 106 |
+
|
| 107 |
+
### Cuplikan
|
| 108 |
+
|
| 109 |
+
**Dashboard** — peta Jawa Timur dengan bubble surplus/defisit per kabupaten, daftar *top matches*, plus panel **Forecast & Anomali harga** (ketiga fungsi dalam satu layar):
|
| 110 |
+
|
| 111 |
+
*(Tangkapan layar dashboard ada di repo GitHub.)*
|
| 112 |
+
|
| 113 |
+
**WhatsApp Bot** — tanya harga, cari pembeli/pemasok, prediksi & anomali harga lewat chat. Mendukung **Bahasa Indonesia** dan **Bahasa Jawa** (inklusi petani daerah):
|
| 114 |
+
|
| 115 |
+
*(Tangkapan layar percakapan WhatsApp dalam kedua bahasa ada di repo GitHub.)*
|
| 116 |
+
|
| 117 |
+
## Pengujian & Skenario
|
| 118 |
+
|
| 119 |
+
Karena output AgriFlow menggerakkan alokasi pangan antar-kabupaten yang menyentuh daerah IPM-rendah, klaim "adil" dan "robust" harus dapat diaudit ulang — bukan sekadar narasi. Suite uji mengunci angka food-balance sebagai *golden numbers* (reproducibility), menjaga parameter kebijakan dari pergeseran tak sengaja (regression-safety), dan menguji deteksi anomali secara adversarial.
|
| 120 |
+
|
| 121 |
+
**521 tes terkumpul · 520 lulus · 1 di-skip · lintas-OS di CI.**
|
| 122 |
+
(Skip = `test_timesfm_importorskip`: dilewati jika pustaka TimesFM tak terpasang di runner; jalur forecasting tetap diuji via fallback + kontrak API.)
|
| 123 |
+
|
| 124 |
+
Server produksi memuat **data BPS asli secara default** (`DATA_BACKEND=csv`, bawaan). Fixture sintetis 19-komoditas lama tetap dipakai di 13 file test (`DATA_BACKEND=demo`) untuk menguji logika engine di lebih banyak variasi komoditas — tidak pernah disajikan ke pengguna.
|
| 125 |
+
|
| 126 |
+
| Kategori | Jumlah | Cakupan |
|
| 127 |
+
|---|---|---|
|
| 128 |
+
| Unit per-layer (L0–L3) | 73 | Tier IPM, constraint jarak/perishability, skor, alokasi equity |
|
| 129 |
+
| 24 skenario edge-case (A–F) | 27+ | Volume, spasial, temporal, disrupsi, politis, kualitas |
|
| 130 |
+
| Validasi data nyata BPS/PIHPS | 57 | Food-balance beras + hortikultura 2022, pipeline reproducible |
|
| 131 |
+
| Deteksi anomali harga | 49 | S-H-ESD sadar-musiman pada residual deseasonalized |
|
| 132 |
+
| Forecast & API | 40 | Endpoint forecast/anomali + fallback |
|
| 133 |
+
| Baseline & equity | 39 | greedy/uniform/proporsional vs AgriFlow + skenario langka pasokan |
|
| 134 |
+
| Ingest & integrasi | 73 | DB loader, ingest PIHPS, jarak OSRM, bot WhatsApp |
|
| 135 |
+
| Autentikasi dashboard & kuota WhatsApp | 117 | Login Supabase, verifikasi JWT server-side, RLS 12 tabel, reset password, kuota gratis WhatsApp (nonaktif default) |
|
| 136 |
+
|
| 137 |
+
**24 skenario edge-case** memetakan kejadian nyata Jawa Timur, contohnya: Ramadan spike (C1), erupsi Semeru di Lumajang → unreachable (D4), banjir multi-kabupaten sentra padi (D5), kenaikan BBM → biaya logistik naik (E5), dan prioritas reserve kontrak Bulog (E3).
|
| 138 |
+
|
| 139 |
+
**Hasil kunci:**
|
| 140 |
+
- **Equity terbukti saat pasokan langka, biaya efisiensi nol.** *Ini uji-tekan hipotetis, bukan hasil data BPS asli:* Jawa Timur pada data 2022 justru sangat surplus (rasio 6,6×), sehingga nilai equity tidak akan tampak. Untuk menunjukkan cara kerja mekanismenya kami membangun skenario langka buatan (fixture `surplus_deficit_constrained.csv`, surplus 3962t vs defisit 5249t). Di skenario itu greedy murni menelantarkan Madura — Sampang **0%**, Bangkalan **20%**; AgriFlow mengangkat keduanya ke **100%** dengan *coverage agregat identik* (0.6649) dan Gini turun (0.3017 → 0.2905). Kami tidak mengklaim keunggulan equity saat pasokan melimpah, dan tidak mengklaim skenario ini berasal dari data nyata.
|
| 141 |
+
- **Anomali sadar-musiman.** Penurunan harga ~60% ter-flag, tapi pola musiman murni (siklus jelang Lebaran) **tidak** memicu false positive; anomali genuine di atas pola musiman tetap terdeteksi.
|
| 142 |
+
- **Data mengungkap defisit struktural, bukan bug.** Bawang putih menghasilkan **0 match** di seluruh 38 kabupaten pada data BPS 2022 — Jawa Timur defisit bawang putih di semua kabupaten, konsisten dengan Indonesia sebagai net-importir bawang putih. Engine bekerja benar; datanya yang bicara.
|
| 143 |
+
|
| 144 |
+
📄 Detail lengkap (kenapa, daftar 24 skenario, sitasi paper): [Dokumen Arsitektur](docs/AgriFlow_Architecture.pdf) §Pengujian & Validasi.
|
| 145 |
+
|
| 146 |
+
## Kenapa tech stack kami RINGKAS (bukan sebanyak proposal awal)?
|
| 147 |
+
|
| 148 |
+
Proposal awal mencantumkan stack besar (Qdrant, LangChain, Redis, n8n, multi-cloud, dll). Setelah benar-benar membangun, kami **sengaja memangkasnya** — *honest engineering* untuk skala saat ini (38 kabupaten Jawa Timur):
|
| 149 |
+
|
| 150 |
+
| Rencana awal | Yang kami pakai | Alasan |
|
| 151 |
+
|---|---|---|
|
| 152 |
+
| Qdrant (vector DB terpisah) | **Supabase pgvector** | Korpus kecil — tak perlu service vektor sendiri |
|
| 153 |
+
| LangChain | **Gemini API langsung** | RAG sesederhana ini tak butuh framework berat |
|
| 154 |
+
| Redis cache | **In-process cache** | Beban belum menuntut; engine deterministik |
|
| 155 |
+
| 5 platform hosting | **2 (HF Spaces + Vercel)** | Lebih sedikit titik gagal, lebih murah |
|
| 156 |
+
|
| 157 |
+
**Prinsip kami: pakai yang cukup, bukan yang ramai.** Komponen besar baru bernilai saat skala membenarkannya — dan itulah **Phase 3**.
|
| 158 |
+
|
| 159 |
+
## 🎙️ Validasi Lapangan — Wawancara Petani
|
| 160 |
+
|
| 161 |
+
Kami mewawancarai **4 petani lintas komoditas & skala usaha** — dari petani mapan dengan jaringan pasar sampai petani kecil yang terkurung tengkulak — untuk memvalidasi kebutuhan nyata dan menemukan gap AgriFlow. Tiap baris menyertakan **rekaman audio sebagai bukti**.
|
| 162 |
+
|
| 163 |
+
| Komoditas | Profil Narasumber | Pendapat Singkat | Rekaman & Transkrip |
|
| 164 |
+
|---|---|---|---|
|
| 165 |
+
| **Bawang Merah** | Denisa Septalian — petani penerus, Nganjuk (Ds. Ngudikan, Kec. Wilangan), 5 thn, lahan ±70 ru | **Setuju bersyarat.** Info harga saja "kurang efektif" karena 100% bergantung tengkulak & tak punya akses luar daerah — antusias bila AgriFlow membuka **akses pembeli luar kota**. | [🎧 Audio](https://drive.google.com/drive/folders/1kdF9KPqycrdN9GewRz6YKFUKWfzCaRVh) · [📄 Transkrip](interview/transcript-bawang-merah.md) |
|
| 166 |
+
| **Padi** | Petani 15 thn, lahan ±1 ha; jual gabah ~Rp5.800/kg ke tengkulak yang datang ke sawah | Info harga lintas daerah **membantu** sebagai gambaran; tertarik pembeli luar kota asal prosesnya aman; ragu "ribet" di awal & soal keamanan transaksi. | [🎧 Audio](https://drive.google.com/drive/folders/1-fpMk8UGg41wk1-RZufTyRBNNJwM7he7) · [📄 Transkrip](interview/transcript-padi.md) |
|
| 167 |
+
| **Cabai** | Petani baru (8 bln bertani, tanaman 50 HST), Solo/Karanganyar; sebelumnya jagung | **Sangat tertarik** harga real-time antar daerah untuk hitung kelayakan kirim; info FB/WA kini meleset Rp5.000–15.000/kg & hanya level provinsi. Menekankan UI sederhana untuk petani lansia. | [🎧 Audio](https://drive.google.com/drive/folders/1lStLTY4L_9NW-UXAWrfTXwNc0CuiUQT-) · [📄 Transkrip](interview/transcript-cabai.md) |
|
| 168 |
+
| **Kentang** | Labib — Dieng, Banjarnegara, ±6 ha, 2 thn; jual ke Pasar Induk Kramat Jati | Info antar daerah berguna sebagai **pembanding & referensi keputusan**, tetap utamakan pedagang langganan. Kunci keberhasilan: **akurasi data + sumber jelas + update real-time**. | [🎧 Audio](https://drive.google.com/drive/folders/1mMVWLv6uQQzlD_KNrkI5eSARXHTlDK9K) · [📄 Transkrip](interview/transcript-kentang.md) |
|
| 169 |
+
|
| 170 |
+
### Analisis & Kesimpulan — Nilai Plus AgriFlow yang Tervalidasi
|
| 171 |
+
|
| 172 |
+
- **Masalah inti tervalidasi lintas komoditas.** Keempat petani menyebut keluhan yang sama: harga tidak stabil, panen raya serentak → harga anjlok, dan **butaan informasi harga antar daerah** — persis yang dijawab fungsi **Deteksi + Prediksi**.
|
| 173 |
+
- **WhatsApp sebagai kanal — tervalidasi 4/4.** Semua memilih WhatsApp (bisa dibaca ulang, sudah dipakai semua petani) di atas SMS/aplikasi baru → memperkuat keputusan **WhatsApp bot**.
|
| 174 |
+
- **Matching surplus→defisit menjawab keluhan paling tajam.** "Tidak ada akses keluar daerah" (bawang merah, padi) adalah problem yang langsung diselesaikan **matching engine 4-lapis**; begitu ditawari pembeli luar kota berharga lebih baik, **keempatnya tertarik**.
|
| 175 |
+
- **Prediksi harga punya nilai konkret.** Semua pernah "kesusu" / salah memperkirakan harga (bawang merah sempat jual Rp10.000, dua hari kemudian Rp20.000) → **forecast 30 hari** menjawab kebutuhan ini.
|
| 176 |
+
- **Kesediaan membayar ada** — bersyarat manfaat ekonomi terbukti & data akurat. Tak satu pun menolak model berbayar.
|
| 177 |
+
|
| 178 |
+
> Temuan **gap fitur** dari wawancara (akses transaksi, granularitas harga, transparansi & keamanan) kami petakan secara jujur ke **Phase 3** di bawah.
|
| 179 |
+
|
| 180 |
+
---
|
| 181 |
+
|
| 182 |
+
# 🌐 Phase 3 — Rencana Lanjutan & Scaling
|
| 183 |
+
|
| 184 |
+
Phase 3 memuat dua hal yang kami pisahkan secara jujur: fitur yang sengaja ditunda karena belum dibutuhkan pada skala sekarang, dan batas yang sudah kami ukur pada engine yang berjalan lalu kami jadwalkan perbaikannya.
|
| 185 |
+
|
| 186 |
+
## Yang ditunda (menunggu data atau beban nyata)
|
| 187 |
+
|
| 188 |
+
| Rencana | Untuk apa | Pemicu |
|
| 189 |
+
|---|---|---|
|
| 190 |
+
| Skala nasional 514 kab | Dari 38 kab Jatim ke seluruh Indonesia | spatial partitioning + precompute jarak |
|
| 191 |
+
| Exogenous forecasting (indeks ENSO, kalender Ramadan) | Akurasi naik saat guncangan iklim & hari raya | data eksogen tersedia |
|
| 192 |
+
| Daging ayam & telur (data real) | Melengkapi 6 komoditas inti | produksi broiler & telur-ras per-kab dirilis |
|
| 193 |
+
| Harga granular per kota/pasar | Selisih riil bisa Rp5.000 sampai 15.000/kg (wawancara cabai) | feed harga pasar terbuka |
|
| 194 |
+
| Fasilitasi transaksi antar-daerah | Info harga saja "kurang efektif" tanpa saluran jual-beli (wawancara bawang & padi) | kemitraan penyaluran |
|
| 195 |
+
| Transparansi sumber & keamanan transaksi | Syarat kepercayaan pengguna (wawancara) | tahap kemitraan resmi |
|
| 196 |
+
| Sahabat-AI (Jawa/Madura) + IVR telepon | Inklusi petani lansia & feature-phone | tahap penskalaan kanal |
|
| 197 |
+
| Qdrant / Redis / n8n | Vector scale, caching, orkestrasi | saat beban nyata muncul |
|
| 198 |
+
|
| 199 |
+
## Batas cakupan hari ini (gerbangnya ketersediaan data, bukan arsitektur)
|
| 200 |
+
|
| 201 |
+
Engine sudah siap memproses data apa pun; yang membatasi adalah ketersediaan data publik per-kabupaten. Begitu sumbernya terbuka, pipeline yang sama langsung memprosesnya tanpa ubah arsitektur.
|
| 202 |
+
|
| 203 |
+
| Cakupan sekarang | Gerbangnya |
|
| 204 |
+
|---|---|
|
| 205 |
+
| 6 komoditas inti | menunggu produksi per-kab komoditas lain dirilis BPS |
|
| 206 |
+
| Tahun acuan 2022 | tahun terlengkap di semua sumber per-kab; tahun baru tinggal di-ingest |
|
| 207 |
+
| Daging ayam & telur belum | 13 komoditas lain masih placeholder sintetis, tidak disajikan ke pengguna (`DATA_BACKEND=csv`) |
|
| 208 |
+
| Konsumsi cabai/bawang via angka nasional | konsumsi beras sudah per-kab & dipakai nyata; sisanya menunggu publikasi |
|
| 209 |
+
| Harga Tier-2 (kab non-IHK) | Panel Harga Bapanas dalam pemeliharaan; saat feed pulih, 30+ kab langsung tercakup |
|
| 210 |
+
|
| 211 |
+
## Utang teknis yang sudah kami ukur (perbaikan terjadwal)
|
| 212 |
+
|
| 213 |
+
Dua batas ini kami ukur sendiri terhadap performa maksimal engine, dengan benchmark yang di-commit dan dapat direproduksi juri.
|
| 214 |
+
|
| 215 |
+
1. Allocator belum optimal. Diuji terhadap optimum LP transportation eksak pada data BPS asli: tier stable meninggalkan 25,4% welfare berbobot-ekuitas, tier greedy 11,1%. Bukti nyata: permintaan cabai_merah Sumenep terisi 26% padahal pasokan terjangkau (2.662 ton, radius 200 km) melebihi kebutuhan (1.418 ton), greedy mengalokasikannya lebih dulu ke tempat lain. Jadi ini soal optimalitas, bukan kelangkaan. Rencana: ganti ke solver capacitated min-cost-flow / entropic-OT (milidetik pada skala provinsi, provably optimal); greedy tetap sebagai v1. Akar: `matching_engine/allocation.py:307`. Benchmark: `benchmarks/greedy_vs_optimal.py`.
|
| 216 |
+
2. Satukan detektor anomali. Panel anomali pengguna sudah pakai S-H-ESD robust (`analysis/price_anomaly.py`). Namun gerbang pre-filter D3 internal (`matching_engine/engine.py:62`) masih z-score 3σ non-robust, pada 70.953 observasi PIHPS asli hanya me-recall 14,4% anomali tervalidasi, dan flag D3 mengeluarkan node dari matching sepenuhnya. Rencana: arahkan D3 ke output S-H-ESD yang sama (perlu ubah kontrak `historical_prices`). Benchmark: `benchmarks/anomaly_detector_gap.py`.
|
| 217 |
+
|
| 218 |
+
## Scaling up
|
| 219 |
+
|
| 220 |
+
Peningkatan skala nasional dibatasi laju keterbukaan data publik per-kabupaten, bukan kesiapan teknis. Pendekatan kami: buktikan nilai dulu di skala provinsi dengan data nyata, lalu perluas seiring data tersedia.
|
| 221 |
+
|
| 222 |
+
---
|
| 223 |
+
|
| 224 |
+
## Menjalankan (teknis singkat)
|
| 225 |
+
|
| 226 |
+
```bash
|
| 227 |
+
pip install -r requirements.txt
|
| 228 |
+
python examples/run_demo_real.py # demo matching pada data BPS asli 2022
|
| 229 |
+
pytest tests/ # 520 lulus, 1 di-skip
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
Detail engineering lengkap ada di [`README_v12.md`](README_v12.md).
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## Lisensi
|
| 237 |
+
|
| 238 |
+
MIT License — © 2026 Hilmi. Lihat [`LICENSE`](LICENSE).
|
| 239 |
+
|
| 240 |
+
<p align="center"><em>Deteksi · Prediksi · Distribusi — untuk ketahanan pangan Indonesia.</em></p>
|
README_v10.md
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AgriFlow Matching Engine
|
| 2 |
+
|
| 3 |
+
> **Sub-national pangan matching engine pertama di Indonesia.**
|
| 4 |
+
> Algoritma 4-lapis hybrid yang menghubungkan kabupaten surplus dengan defisit menggunakan stable matching, multi-objective scoring 5 dimensi, dan equity multiplier untuk kabupaten tertinggal — semua untuk komoditas pangan tingkat kabupaten.
|
| 5 |
+
|
| 6 |
+
[]()
|
| 7 |
+
[]()
|
| 8 |
+
[]()
|
| 9 |
+
[]()
|
| 10 |
+
|
| 11 |
+
Submisi **PIDI DIGDAYA × Hackathon 2026** — Bank Indonesia.
|
| 12 |
+
Problem Statement #2: Platform Matching Demand-Supply Antarwilayah.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Daftar Isi
|
| 17 |
+
|
| 18 |
+
- [Apa Ini?](#apa-ini)
|
| 19 |
+
- [Quick Start (5 menit)](#quick-start-5-menit)
|
| 20 |
+
- [Arsitektur 4-Lapis](#arsitektur-4-lapis)
|
| 21 |
+
- [Equity Multiplier (Kalibrasi BPS 2024)](#equity-multiplier-kalibrasi-bps-2024)
|
| 22 |
+
- [19 Skenario Edge Case](#19-skenario-edge-case)
|
| 23 |
+
- [API Usage](#api-usage)
|
| 24 |
+
- [Performance & Validation](#performance--validation)
|
| 25 |
+
- [Data Sources](#data-sources)
|
| 26 |
+
- [Project Structure](#project-structure)
|
| 27 |
+
- [Development Guide](#development-guide)
|
| 28 |
+
- [Status & Roadmap](#status--roadmap)
|
| 29 |
+
- [Documentation](#documentation)
|
| 30 |
+
- [License & Credits](#license--credits)
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
## Apa Ini?
|
| 35 |
+
|
| 36 |
+
**Bayangkan Uber, tapi untuk cabai dan bawang merah.**
|
| 37 |
+
|
| 38 |
+
Setiap hari, Indonesia kehilangan Rp 213-551 triliun pangan karena food loss & waste — 40% di distribusi, bukan produksi. Petani di Sampang membuang cabai karena harga jatuh, sementara pasar Surabaya melonjak 200% karena kelangkaan. Pemda baru tahu krisis 2-3 minggu kemudian.
|
| 39 |
+
|
| 40 |
+
AgriFlow Matching Engine memecahkan ini dengan 6 dimensi yang Uber tidak punya:
|
| 41 |
+
|
| 42 |
+
| Dimensi | Penjelasan |
|
| 43 |
+
|---|---|
|
| 44 |
+
| **Perishability** | Cabai busuk dalam 5 hari, beras tahan 180 hari — engine hitung shelf life |
|
| 45 |
+
| **Equity** | Kabupaten tertinggal IPM rendah (Sampang 66.72) dapat boost +30% |
|
| 46 |
+
| **Climate** | Banjir di rute = re-route otomatis |
|
| 47 |
+
| **Volume** | 1 surplus bisa di-split ke banyak deficit |
|
| 48 |
+
| **Stable Matching** | Guarantee fairness via Gale-Shapley (Nobel Prize Economics 2012) |
|
| 49 |
+
| **Two-tier Confidence** | Data harian PIHPS (Tier 1) pakai algoritma ketat; data mingguan Bapanas (Tier 2) pakai algoritma fleksibel |
|
| 50 |
+
|
| 51 |
+
**Status:** Production-ready untuk skala provinsial (38 kab Jatim) — 106/106 tests pass dalam 0.16s, latency p99 1.4ms (sample) - 55.5ms (stress 361×361).
|
| 52 |
+
|
| 53 |
+
---
|
| 54 |
+
|
| 55 |
+
## Quick Start (5 menit)
|
| 56 |
+
|
| 57 |
+
### Prasyarat
|
| 58 |
+
|
| 59 |
+
- Python 3.10+
|
| 60 |
+
- pip
|
| 61 |
+
- ~50MB disk space
|
| 62 |
+
|
| 63 |
+
### Install
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
git clone https://github.com/masterA88/agriflow_engine.git
|
| 67 |
+
cd agriflow_engine
|
| 68 |
+
python -m venv venv
|
| 69 |
+
# Windows:
|
| 70 |
+
venv\Scripts\activate
|
| 71 |
+
# Linux/Mac:
|
| 72 |
+
source venv/bin/activate
|
| 73 |
+
pip install -r requirements.txt
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
### Verifikasi (semua harus sukses)
|
| 77 |
+
|
| 78 |
+
```bash
|
| 79 |
+
# 1. Generate sample data — 38 kab × 19 komoditas Jatim
|
| 80 |
+
python sample_data/generate_sample_data.py
|
| 81 |
+
# Expected: 5 CSV generated (kabupaten_jatim.csv, komoditas_constraints.csv,
|
| 82 |
+
# surplus_deficit.csv, weather_forecast.csv, historical_price_stats.csv)
|
| 83 |
+
|
| 84 |
+
# 2. Run all tests (106 tests)
|
| 85 |
+
pytest tests/ -v
|
| 86 |
+
# Expected: 106 passed in <1s
|
| 87 |
+
|
| 88 |
+
# 3. Run end-to-end demo
|
| 89 |
+
python examples/run_demo.py
|
| 90 |
+
# Expected: ~32 matches, gross arbitrage ~Rp 16 miliar, latency ~1.5ms
|
| 91 |
+
|
| 92 |
+
# 4. Run latency benchmark
|
| 93 |
+
python benchmarks/latency.py
|
| 94 |
+
# Expected: highest p99 < 60ms (margin >88% vs 500ms target)
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
Kalau langkah 2 atau 3 gagal, lihat [Troubleshooting](#troubleshooting) di bawah.
|
| 98 |
+
|
| 99 |
+
---
|
| 100 |
+
|
| 101 |
+
## Arsitektur 4-Lapis
|
| 102 |
+
|
| 103 |
+
```
|
| 104 |
+
Input: surplus_nodes[], deficit_nodes[], LogisticsContext, weather, historical_prices
|
| 105 |
+
|
| 106 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 107 |
+
│ LAYER 0 — Tier Classification (constraints.determine_tier) │
|
| 108 |
+
│ Klasifikasi setiap kab: Tier 1 HIGH (8 kota IHK PIHPS) atau │
|
| 109 |
+
│ Tier 2 MEDIUM (30 kab non-IHK Bapanas). │
|
| 110 |
+
│ Latency: <1ms (set lookup). │
|
| 111 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 112 |
+
↓
|
| 113 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 114 |
+
│ LAYER 1 — Hard Constraints (constraints.generate_candidates) │
|
| 115 |
+
│ 9 rules filter: komoditas match, distance≤max, age≤shelf, │
|
| 116 |
+
│ volume≥min, no self-match, emergency mode, pemda override, │
|
| 117 |
+
│ Bulog split, BBM-aware distance shrink. │
|
| 118 |
+
│ Output: candidate pairs (top-K per surplus by jarak). │
|
| 119 |
+
│ Latency: <50ms untuk 38×19 (~25k pasang potensial). │
|
| 120 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 121 |
+
↓
|
| 122 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 123 |
+
│ LAYER 2 — Multi-Objective Scoring (scoring.compute_score) │
|
| 124 |
+
│ 5-dimensi weighted: Distance 22% / Volume 22% / Price 22% / │
|
| 125 |
+
│ Perishability 18% / Climate 16%. │
|
| 126 |
+
│ 3 weight schemes: DEFAULT, RAMADAN, IMPORT_POLICY. │
|
| 127 |
+
│ Output: base_score 0-100 per pair. │
|
| 128 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 129 |
+
↓
|
| 130 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 131 |
+
│ LAYER 3 — Equity-Weighted Allocation (allocation.allocate) │
|
| 132 |
+
│ Final = base × equity_multiplier(IPM_deficit). │
|
| 133 |
+
│ Tier 1↔Tier 1 → Modified Gale-Shapley (Nobel 2012). │
|
| 134 |
+
│ Cross-tier / Tier 2 → Greedy with equity priority. │
|
| 135 |
+
│ Output: MatchResult[] dengan confidence label. │
|
| 136 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 137 |
+
↓
|
| 138 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 139 |
+
│ POST-PROCESSING (engine.run_matching) │
|
| 140 |
+
│ Tag flags (RAMADAN_SPIKE, EQUITY_BOOST_30, MADURA_CLUSTER, │
|
| 141 |
+
│ STALE_DATA_24H, HUMANITARIAN_PRIORITY, VOLUME_MISMATCH). │
|
| 142 |
+
│ Identifikasi unmatched + external_opportunities (ekspor). │
|
| 143 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 144 |
+
|
| 145 |
+
Output: MatchingReport(matches, unmatched_*, warnings, run_metadata)
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
**Why 4-layer?** Setiap layer bisa dioptimasi independent, testable secara isolated, dan early-exit di Layer 1 menghemat compute Layer 2/3 yang lebih mahal.
|
| 149 |
+
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
## Equity Multiplier (Kalibrasi BPS 2024)
|
| 153 |
+
|
| 154 |
+
Threshold dikalibrasi sesuai distribusi IPM 2024 BPS Jatim sehingga klaim "+30% boost untuk kab tertinggal" konkret applicable:
|
| 155 |
+
|
| 156 |
+
| IPM Range | Multiplier | Boost | Kab/Kota Jatim |
|
| 157 |
+
|---|---|---|---|
|
| 158 |
+
| `IPM < 68` | **1.30** | **+30%** | Sampang (66.72), Bangkalan (67.70) |
|
| 159 |
+
| `68 ≤ IPM < 72` | 1.15 | +15% | Sumenep, Probolinggo (kab), Bondowoso, Lumajang, Pamekasan, Pacitan, Pasuruan (kab), Situbondo, Jember, Madiun (kab) |
|
| 160 |
+
| `72 ≤ IPM < 78` | 1.05 | +5% | Bojonegoro, Banyuwangi, Tulungagung, Malang (kab), Magetan, Gresik, Mojokerto (kab), Lamongan, Tuban, Ngawi, Kediri (kab), dll |
|
| 161 |
+
| `IPM ≥ 78` | 1.00 | (no boost) | Sidoarjo, Kota Batu, Kota Surabaya, Kota Malang, Kota Kediri, Kota Madiun, dll |
|
| 162 |
+
|
| 163 |
+
**Mengapa kalibrasi:** Threshold v9 lama (`<65 → 1.30`) tidak pernah ter-trigger karena IPM terendah Jatim 2024 = Sampang 66.72. v10 menggeser threshold sehingga klaim "+30% boost" demonstrably valid.
|
| 164 |
+
|
| 165 |
+
**Update IPM tahunan:** Saat BPS publish IPM baru (biasanya BRS Desember), edit di [`sample_data/generate_sample_data.py:KABUPATEN_DATA`](sample_data/generate_sample_data.py) sebagai source of truth, lalu mirror ke [`data_sources/bps.py:IPM_2024_JATIM`](data_sources/bps.py).
|
| 166 |
+
|
| 167 |
+
---
|
| 168 |
+
|
| 169 |
+
## 19 Skenario Edge Case
|
| 170 |
+
|
| 171 |
+
5 kategori, 19 skenario, semua tervalidasi pytest. Detail lengkap di [`docs/AUDIT_v10.md`](docs/AUDIT_v10.md) dan `AgriFlow_v10.docx` Section 5.5.5.
|
| 172 |
+
|
| 173 |
+
### Kategori A — Volume (4 skenario)
|
| 174 |
+
|
| 175 |
+
| Kode | Skenario | Test |
|
| 176 |
+
|---|---|---|
|
| 177 |
+
| A1 | Surplus 1-to-many (1 surplus split ke beberapa deficit) | `TestA1_OneToMany` |
|
| 178 |
+
| A2 | Many-to-1 (multiple surplus untuk 1 deficit besar) | `TestA2_ManyToOne` |
|
| 179 |
+
| A3 | Volume mismatch drastis (<20% ratio → flag warning) | `TestA3_VolumeMismatchDrastis` |
|
| 180 |
+
| A4 | Zero demand (suggest external opportunity) | `TestA4_ZeroDemand` |
|
| 181 |
+
|
| 182 |
+
### Kategori B — Spasial (3 skenario)
|
| 183 |
+
|
| 184 |
+
| Kode | Skenario | Test |
|
| 185 |
+
|---|---|---|
|
| 186 |
+
| B1 | Cross-tier match (Tier 1 ↔ Tier 2) | `TestB1_CrossTier` |
|
| 187 |
+
| B2 | Long distance (jarak > max_distance_km → REJECT) | `TestB2_LongDistance` |
|
| 188 |
+
| B3 | Cluster Madura (4 kab semua surplus → ekspor) | `TestB3_ClusterMadura` |
|
| 189 |
+
|
| 190 |
+
### Kategori C — Temporal (3 skenario)
|
| 191 |
+
|
| 192 |
+
| Kode | Skenario | Test |
|
| 193 |
+
|---|---|---|
|
| 194 |
+
| C1 | Ramadan/Idul Fitri spike (H-21 to H-1, RAMADAN_WEIGHTS) | `TestC1_RamadanSpike` |
|
| 195 |
+
| C2 | Pasca panen raya (oversupply, multiple match) | `TestC2_PostHarvest` |
|
| 196 |
+
| C3 | Stale data >24h (confidence drop bertingkat HIGH→MEDIUM→LOW) | `TestC3_StaleData` |
|
| 197 |
+
|
| 198 |
+
### Kategori D — Disrupsi (5 skenario)
|
| 199 |
+
|
| 200 |
+
| Kode | Skenario | Test |
|
| 201 |
+
|---|---|---|
|
| 202 |
+
| D1 | Banjir rute (BMKG hujan >50mm → climate_score 0.3) | `TestD1_BanjirRute` |
|
| 203 |
+
| D2 | Komoditas hampir rusak (harvest age + transit > shelf) | `TestD2_KomoditasRusak` |
|
| 204 |
+
| D3 | Harga anomali (>3σ dari rolling median → exclude) | `TestD3_HargaAnomali` |
|
| 205 |
+
| D4 | Erupsi gunung (PVMBG MAGMA → UNREACHABLE) | `TestD4_ErupsiGunung` |
|
| 206 |
+
| D5 | Banjir multi-kab (BNPB DIBI → emergency mode) | `TestD5_BanjirMultiKab` |
|
| 207 |
+
|
| 208 |
+
### Kategori E — Politis & Kebijakan (5 skenario)
|
| 209 |
+
|
| 210 |
+
| Kode | Skenario | Test |
|
| 211 |
+
|---|---|---|
|
| 212 |
+
| E1 | Equity tie-break (IPM lebih rendah menang otomatis) | `TestE1_EquityTieBreak` |
|
| 213 |
+
| E2 | Pemda override (`do_not_export_<komoditas>` flag) | `TestE2_PemdaOverride` |
|
| 214 |
+
| E3 | Bulog priority (60% reserve, sisa 40% private) | `TestE3_BulogPriority` |
|
| 215 |
+
| E4 | Import policy aktif (IMPORT_POLICY_WEIGHTS, price weight ↓) | `TestE4_ImportPolicy` |
|
| 216 |
+
| E5 | BBM naik (max_distance shrink, logistics cost ↑) | `TestE5_BBMNaik` |
|
| 217 |
+
|
| 218 |
+
---
|
| 219 |
+
|
| 220 |
+
## API Usage
|
| 221 |
+
|
| 222 |
+
### Programmatic API
|
| 223 |
+
|
| 224 |
+
```python
|
| 225 |
+
from matching_engine import (
|
| 226 |
+
run_matching, SupplyNode, DemandNode,
|
| 227 |
+
Kabupaten, Tier, Commodity, LogisticsContext,
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
# Setup kabupaten (real koordinat & IPM 2024 BPS)
|
| 231 |
+
kediri = Kabupaten(
|
| 232 |
+
id="3506", nama="Kediri",
|
| 233 |
+
latitude=-7.796, longitude=112.170,
|
| 234 |
+
ipm=74.50, tier=Tier.MEDIUM,
|
| 235 |
+
)
|
| 236 |
+
surabaya = Kabupaten(
|
| 237 |
+
id="3578", nama="Kota Surabaya",
|
| 238 |
+
latitude=-7.2575, longitude=112.7521,
|
| 239 |
+
ipm=84.69, tier=Tier.HIGH,
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
# Setup komoditas (constraint per komoditas)
|
| 243 |
+
cabai = Commodity(
|
| 244 |
+
code="cabai_merah", nama="Cabai Merah Besar",
|
| 245 |
+
max_distance_km=200, min_viable_tons=1.0,
|
| 246 |
+
max_fresh_age_days=5,
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
# Run matching
|
| 250 |
+
report = run_matching(
|
| 251 |
+
surplus_nodes=[
|
| 252 |
+
SupplyNode(kediri, cabai, volume_tons=80, price_per_kg=30000),
|
| 253 |
+
],
|
| 254 |
+
deficit_nodes=[
|
| 255 |
+
DemandNode(surabaya, cabai, volume_tons=80, price_per_kg=60000),
|
| 256 |
+
],
|
| 257 |
+
logistics=LogisticsContext(),
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
# Inspect hasil
|
| 261 |
+
for m in report.matches:
|
| 262 |
+
print(f"{m.surplus.kabupaten.nama} → {m.deficit.kabupaten.nama}")
|
| 263 |
+
print(f" Volume: {m.matched_volume_tons}t @ {m.distance_km:.0f}km")
|
| 264 |
+
print(f" Score: {m.final_score:.1f} (base {m.base_score:.1f} × {m.equity_multiplier})")
|
| 265 |
+
print(f" Confidence: {m.confidence.value}, Flags: {m.flags}")
|
| 266 |
+
|
| 267 |
+
# Output:
|
| 268 |
+
# Kediri → Kota Surabaya
|
| 269 |
+
# Volume: 80.0t @ 65km
|
| 270 |
+
# Score: 89.5 (base 89.5 × 1.0)
|
| 271 |
+
# Confidence: MEDIUM, Flags: []
|
| 272 |
+
|
| 273 |
+
print(f"\nLatency: {report.run_metadata['latency_ms']}ms")
|
| 274 |
+
print(f"Candidate pairs evaluated: {report.run_metadata['candidate_pairs_evaluated']}")
|
| 275 |
+
print(f"Warnings: {len(report.warnings)}")
|
| 276 |
+
```
|
| 277 |
+
|
| 278 |
+
### Advanced — Skenario Override
|
| 279 |
+
|
| 280 |
+
```python
|
| 281 |
+
from matching_engine.constraints import set_bulog_procurement
|
| 282 |
+
from datetime import datetime
|
| 283 |
+
|
| 284 |
+
# Skenario E3: Bulog procurement aktif untuk Madiun
|
| 285 |
+
set_bulog_procurement({"3519"})
|
| 286 |
+
|
| 287 |
+
# Skenario E4: Import policy aktif (bobot price diturunkan)
|
| 288 |
+
report = run_matching(
|
| 289 |
+
surplus_nodes=[...],
|
| 290 |
+
deficit_nodes=[...],
|
| 291 |
+
import_policy_active=True, # IMPORT_POLICY_WEIGHTS
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
# Skenario C1: Force Ramadan mode untuk testing
|
| 295 |
+
report = run_matching(
|
| 296 |
+
surplus_nodes=[...],
|
| 297 |
+
deficit_nodes=[...],
|
| 298 |
+
reference_date=datetime(2026, 3, 6), # H-14 Idul Fitri 2026
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
# Skenario E5: BBM naik 20%
|
| 302 |
+
from matching_engine.models import LogisticsContext
|
| 303 |
+
report = run_matching(
|
| 304 |
+
surplus_nodes=[...],
|
| 305 |
+
deficit_nodes=[...],
|
| 306 |
+
logistics=LogisticsContext(
|
| 307 |
+
bbm_price_idr_per_liter=12000,
|
| 308 |
+
bbm_price_baseline=10000,
|
| 309 |
+
),
|
| 310 |
+
)
|
| 311 |
+
```
|
| 312 |
+
|
| 313 |
+
### Advanced — Force Algorithm Strategy
|
| 314 |
+
|
| 315 |
+
```python
|
| 316 |
+
# Force stable matching (Tier 1 algorithm)
|
| 317 |
+
report = run_matching(..., force_strategy="stable")
|
| 318 |
+
|
| 319 |
+
# Force greedy (Tier 2 algorithm) untuk testing
|
| 320 |
+
report = run_matching(..., force_strategy="greedy")
|
| 321 |
+
|
| 322 |
+
# Auto-detect (default): Tier 1↔Tier 1 pairs → stable, else → greedy
|
| 323 |
+
report = run_matching(...)
|
| 324 |
+
```
|
| 325 |
+
|
| 326 |
+
---
|
| 327 |
+
|
| 328 |
+
## Performance & Validation
|
| 329 |
+
|
| 330 |
+
### Test Suite
|
| 331 |
+
|
| 332 |
+
```bash
|
| 333 |
+
$ pytest tests/ --tb=short
|
| 334 |
+
============================= test session starts =============================
|
| 335 |
+
collected 106 items
|
| 336 |
+
|
| 337 |
+
tests/test_layer0_tier.py ................ [ 15%]
|
| 338 |
+
tests/test_layer1_constraints.py ................... [ 33%]
|
| 339 |
+
tests/test_layer2_scoring.py ....................... [ 54%]
|
| 340 |
+
tests/test_layer3_allocation.py .............. [ 67%]
|
| 341 |
+
tests/test_scenarios_disruption.py ......... [ 76%]
|
| 342 |
+
tests/test_scenarios_political.py ........ [ 83%]
|
| 343 |
+
tests/test_scenarios_spatial.py ...... [ 89%]
|
| 344 |
+
tests/test_scenarios_temporal.py ....... [ 96%]
|
| 345 |
+
tests/test_scenarios_volume.py .... [100%]
|
| 346 |
+
|
| 347 |
+
============================= 106 passed in 0.16s =============================
|
| 348 |
+
```
|
| 349 |
+
|
| 350 |
+
### Latency Benchmark
|
| 351 |
+
|
| 352 |
+
```bash
|
| 353 |
+
$ python benchmarks/latency.py
|
| 354 |
+
```
|
| 355 |
+
|
| 356 |
+
| Configuration | N (s × d) | p50 | p95 | p99 | Max |
|
| 357 |
+
|---|---|---|---|---|---|
|
| 358 |
+
| Sample data CSV (realistic) | 40 × 33 | 0.99 ms | 1.26 ms | 1.38 ms | 1.42 ms |
|
| 359 |
+
| Synthetic full Jatim (38×19) | 361 × 361 | 48.37 ms | 53.67 ms | 55.53 ms | 58.43 ms |
|
| 360 |
+
| Stress 100×100 (national scale) | 100 × 100 | 12.62 ms | 14.82 ms | 15.51 ms | 15.65 ms |
|
| 361 |
+
| Stress 200×200 | 200 × 200 | 25.47 ms | 26.92 ms | 27.54 ms | 27.76 ms |
|
| 362 |
+
|
| 363 |
+
**Verdict:** PASS — semua p99 < 500ms target. Highest p99 = 55.53ms (margin 88.9%).
|
| 364 |
+
|
| 365 |
+
### National Scale (Indonesia 514 kab)
|
| 366 |
+
|
| 367 |
+
⚠ **HONEST DISCLOSURE:** Engine v10 saat ini BELUM siap untuk produksi nasional 514 kab. Lihat [`docs/AUDIT_v10.md`](docs/AUDIT_v10.md) Section 3.2 untuk detail. Optimization roadmap (spatial indexing, per-provinsi batching, parallel) sudah ter-quantify untuk Y2-Y3.
|
| 368 |
+
|
| 369 |
+
```bash
|
| 370 |
+
$ python benchmarks/national_scale.py
|
| 371 |
+
```
|
| 372 |
+
|
| 373 |
+
| Scale | Workload | p99 | vs target |
|
| 374 |
+
|---|---|---|---|
|
| 375 |
+
| Provinsi Jatim baseline | 333×389 | **14.2ms** | ✅ 35× under |
|
| 376 |
+
| Multi-provinsi (100 kab) | 948×952 | **94.5ms** | ✅ 5× under |
|
| 377 |
+
| Setengah Indonesia (250 kab) | 2326×2424 | **541.8ms** | ⚠ 1.08× over |
|
| 378 |
+
| **Full Indonesia (514 kab)** | **4859×4907** | **2223.3ms** | ❌ **4.4× over** |
|
| 379 |
+
|
| 380 |
+
---
|
| 381 |
+
|
| 382 |
+
## Data Sources
|
| 383 |
+
|
| 384 |
+
8 connector dengan dual-mode (mock CSV + live API), graceful fallback:
|
| 385 |
+
|
| 386 |
+
| Connector | Sumber | Frekuensi | Auth | Tier |
|
| 387 |
+
|---|---|---|---|---|
|
| 388 |
+
| [`pihps_bi.py`](data_sources/pihps_bi.py) | Bank Indonesia PIHPS | Harian (cut-off 13:00 WIB) | Tidak ada (scrape publik) | Tier 1 |
|
| 389 |
+
| [`bapanas.py`](data_sources/bapanas.py) | Panel Harga Bapanas | Mingguan (Senin) | Tidak ada | Tier 2 |
|
| 390 |
+
| [`bps.py`](data_sources/bps.py) | BPS WebAPI (IPM, produksi) | Tahunan (BRS Desember) | API key gratis | Both |
|
| 391 |
+
| [`bmkg.py`](data_sources/bmkg.py) | BMKG / Open-Meteo (cuaca) | 3-6 jam refresh | Tidak ada (Open-Meteo) | Both |
|
| 392 |
+
| [`pvmbg.py`](data_sources/pvmbg.py) | PVMBG MAGMA (gunung api) | Realtime saat status berubah | Tidak ada | Both |
|
| 393 |
+
| [`bnpb.py`](data_sources/bnpb.py) | BNPB DIBI (bencana) | Realtime | Tidak ada | Both |
|
| 394 |
+
| [`google_maps.py`](data_sources/google_maps.py) | Google Routes / OSRM fallback | Realtime per request | Google API key (paid) / OSRM gratis | Both |
|
| 395 |
+
| [`hijri_calendar.py`](data_sources/hijri_calendar.py) | Aladhan API + hardcoded | Statis | Tidak ada | Both |
|
| 396 |
+
|
| 397 |
+
### Fail-safe Strategy
|
| 398 |
+
|
| 399 |
+
- Live API gagal → fallback ke mock CSV / hardcoded data
|
| 400 |
+
- Weather data tidak tersedia → climate_score = 0.7 (neutral)
|
| 401 |
+
- BPS API gagal → fallback ke `IPM_2024_JATIM` hardcoded
|
| 402 |
+
- BMKG butuh adm4 mapping yang tidak ada → auto-fallback ke Open-Meteo
|
| 403 |
+
- OSRM down → haversine geodesic + asumsi 60 km/h
|
| 404 |
+
|
| 405 |
+
---
|
| 406 |
+
|
| 407 |
+
## Project Structure
|
| 408 |
+
|
| 409 |
+
```
|
| 410 |
+
agriflow_engine/
|
| 411 |
+
├── matching_engine/ # Core engine (5 modules, ~1000 lines)
|
| 412 |
+
│ ├── __init__.py # Public API
|
| 413 |
+
│ ├── models.py # Dataclasses (Kabupaten, Commodity, MatchResult, ...)
|
| 414 |
+
│ ├── constraints.py # Layer 0 + Layer 1 (9 hard constraints)
|
| 415 |
+
│ ├── scoring.py # Layer 2 (5-dim multi-objective scoring)
|
| 416 |
+
│ ├── allocation.py # Layer 3 (Gale-Shapley + Greedy + Equity)
|
| 417 |
+
│ └── engine.py # Main orchestrator + 19 skenario handlers
|
| 418 |
+
├── data_sources/ # 8 connector dual-mode (mock + live)
|
| 419 |
+
│ ├── pihps_bi.py # Tier 1 PIHPS BI
|
| 420 |
+
│ ├── bapanas.py # Tier 2 Bapanas
|
| 421 |
+
│ ├── bps.py # IPM 2024 + produksi BPS
|
| 422 |
+
│ ├── bmkg.py # Cuaca BMKG/Open-Meteo
|
| 423 |
+
│ ├── pvmbg.py # Erupsi gunung PVMBG MAGMA
|
| 424 |
+
│ ├── bnpb.py # Bencana BNPB DIBI
|
| 425 |
+
│ ├── google_maps.py # Routing Google/OSRM
|
| 426 |
+
│ └── hijri_calendar.py # Ramadan/Idul Fitri Aladhan
|
| 427 |
+
├── sample_data/ # CSV 38 kab × 19 komoditas Jatim
|
| 428 |
+
│ ├── generate_sample_data.py # Source of truth — regenerate CSV
|
| 429 |
+
│ ├── loader.py # CSV → engine objects
|
| 430 |
+
│ ├── kabupaten_jatim.csv # 38 kab + IPM 2024 + koordinat
|
| 431 |
+
│ ├── komoditas_constraints.csv # 19 komoditas + spec
|
| 432 |
+
│ ├── surplus_deficit.csv # 73 row sample workload
|
| 433 |
+
│ ├── weather_forecast.csv # 10 route forecast
|
| 434 |
+
│ └── historical_price_stats.csv # 19 commodity rolling stats
|
| 435 |
+
├── tests/ # 106 pytest test
|
| 436 |
+
│ ├── conftest.py # Fixtures (17 kab Jatim + factory)
|
| 437 |
+
│ ├── test_layer0_tier.py # 16 test (tier classification)
|
| 438 |
+
│ ├── test_layer1_constraints.py # 19 test (haversine + viability + Bulog)
|
| 439 |
+
│ ├── test_layer2_scoring.py # 23 test (5-dim scoring + weight schemes)
|
| 440 |
+
│ ├── test_layer3_allocation.py # 14 test (equity + stable + greedy)
|
| 441 |
+
│ ├── test_scenarios_volume.py # 4 test (A1-A4)
|
| 442 |
+
│ ├── test_scenarios_spatial.py # 6 test (B1-B3)
|
| 443 |
+
│ ├── test_scenarios_temporal.py # 7 test (C1-C3)
|
| 444 |
+
│ ├── test_scenarios_disruption.py # 9 test (D1-D5)
|
| 445 |
+
│ └── test_scenarios_political.py # 8 test (E1-E5)
|
| 446 |
+
├── examples/
|
| 447 |
+
│ └── run_demo.py # End-to-end demo dengan output formatted
|
| 448 |
+
├── benchmarks/
|
| 449 |
+
│ ├── latency.py # Multi-config provincial benchmark
|
| 450 |
+
│ └── national_scale.py # National scale stress test (514 kab)
|
| 451 |
+
├── docs/
|
| 452 |
+
│ ├── generate_v10_docx.py # Proposal v10 docx generator
|
| 453 |
+
│ └── AUDIT_v10.md # Audit lengkap (consistency + national scale analysis)
|
| 454 |
+
├── README.md # This file
|
| 455 |
+
├── requirements.txt # Python dependencies
|
| 456 |
+
└── venv/ # (gitignored) virtual env
|
| 457 |
+
```
|
| 458 |
+
|
| 459 |
+
---
|
| 460 |
+
|
| 461 |
+
## Development Guide
|
| 462 |
+
|
| 463 |
+
### Setup Development Environment
|
| 464 |
+
|
| 465 |
+
```bash
|
| 466 |
+
git clone https://github.com/masterA88/agriflow_engine.git
|
| 467 |
+
cd agriflow_engine
|
| 468 |
+
python -m venv venv
|
| 469 |
+
source venv/bin/activate # or venv\Scripts\activate on Windows
|
| 470 |
+
pip install -r requirements.txt
|
| 471 |
+
pip install python-docx # untuk regenerate proposal docx
|
| 472 |
+
```
|
| 473 |
+
|
| 474 |
+
### Workflow
|
| 475 |
+
|
| 476 |
+
1. **Edit code** di `matching_engine/` atau `data_sources/`
|
| 477 |
+
2. **Run test** sebelum commit: `pytest tests/ -v`
|
| 478 |
+
3. **Update sample data** kalau ubah threshold/komoditas: `python sample_data/generate_sample_data.py`
|
| 479 |
+
4. **Run demo** untuk smoke test: `python examples/run_demo.py`
|
| 480 |
+
5. **Run benchmark** kalau perubahan di hot path: `python benchmarks/latency.py`
|
| 481 |
+
6. **Regenerate proposal** kalau perubahan di logic: `python docs/generate_v10_docx.py`
|
| 482 |
+
|
| 483 |
+
### Add New Skenario
|
| 484 |
+
|
| 485 |
+
1. Tambah test class di file yang sesuai (mis. `tests/test_scenarios_volume.py`)
|
| 486 |
+
2. Tambah behavior di `matching_engine/engine.py` post-processing atau Layer yang relevan
|
| 487 |
+
3. Update `AgriFlow_v10.docx` Section 5.5.5 (regenerate via `docs/generate_v10_docx.py`)
|
| 488 |
+
4. Pastikan `pytest tests/ -v` masih PASS
|
| 489 |
+
|
| 490 |
+
### Add New Komoditas
|
| 491 |
+
|
| 492 |
+
1. Tambah row di [`sample_data/generate_sample_data.py:KOMODITAS_DATA`](sample_data/generate_sample_data.py)
|
| 493 |
+
2. Tambah row di [`matching_engine/constraints.py:COMMODITY_SPECS`](matching_engine/constraints.py) (samakan max_distance/min_viable/max_fresh_age)
|
| 494 |
+
3. Run `python sample_data/generate_sample_data.py` untuk regenerate CSV
|
| 495 |
+
4. Update assertion di test kalau komoditas count check
|
| 496 |
+
|
| 497 |
+
### Update IPM Tahunan (saat BPS publish data baru)
|
| 498 |
+
|
| 499 |
+
1. Edit [`sample_data/generate_sample_data.py:KABUPATEN_DATA`](sample_data/generate_sample_data.py) (source of truth)
|
| 500 |
+
2. Mirror ke [`data_sources/bps.py:IPM_2024_JATIM`](data_sources/bps.py)
|
| 501 |
+
3. Run `python sample_data/generate_sample_data.py` untuk regenerate CSV
|
| 502 |
+
4. Re-evaluate equity threshold di [`matching_engine/allocation.py:38`](matching_engine/allocation.py) — apakah masih meaningful trigger untuk distribusi baru?
|
| 503 |
+
5. Run `pytest tests/ -v` — beberapa test mungkin perlu update kalau IPM bergeser
|
| 504 |
+
|
| 505 |
+
---
|
| 506 |
+
|
| 507 |
+
## Status & Roadmap
|
| 508 |
+
|
| 509 |
+
### Current Status: ✅ Provincial-Ready (Jatim)
|
| 510 |
+
|
| 511 |
+
- [x] 4-layer architecture implemented
|
| 512 |
+
- [x] 19 skenario edge case handled
|
| 513 |
+
- [x] 106/106 tests passing
|
| 514 |
+
- [x] Latency p99 1.4ms (sample) - 55.5ms (stress)
|
| 515 |
+
- [x] Equity multiplier kalibrasi BPS 2024 — Sampang & Bangkalan menerima +30% boost
|
| 516 |
+
- [x] 8 data source connector dual-mode
|
| 517 |
+
- [x] Cross-platform demo (Windows/Linux/Mac)
|
| 518 |
+
- [x] Reproducible documentation generator
|
| 519 |
+
|
| 520 |
+
### Roadmap to National Scale (Y2-Y3)
|
| 521 |
+
|
| 522 |
+
⚠ **Honest disclosure:** Engine v10 saat ini BELUM siap untuk produksi nasional 514 kab. p99 untuk full Indonesia = 2.2 detik (4.4× over 500ms target).
|
| 523 |
+
|
| 524 |
+
**Optimization plan** (lihat [`docs/AUDIT_v10.md`](docs/AUDIT_v10.md) Section 6):
|
| 525 |
+
|
| 526 |
+
| Quick Win | Effort | Impact |
|
| 527 |
+
|---|---|---|
|
| 528 |
+
| Fix double-haversine bug di `generate_candidates` | 1 jam | 30-40% speedup Layer 1 |
|
| 529 |
+
| Add geohash precision-5 spatial pre-filter | 1-2 hari | 25-50× speedup Layer 1 |
|
| 530 |
+
| Multiprocessing per komoditas | 2-3 hari | Up to 19× speedup |
|
| 531 |
+
| Distance matrix precompute (Redis cache) | 4 jam | 5-10× speedup |
|
| 532 |
+
| Per-provinsi batching | 1 minggu | 5-15× speedup |
|
| 533 |
+
|
| 534 |
+
**Combined estimate:** ~100-200× speedup → bring p99 dari 2200ms ke ~10-20ms untuk 514 kab nasional.
|
| 535 |
+
|
| 536 |
+
### Data/Coverage Expansion (Y2)
|
| 537 |
+
|
| 538 |
+
- [ ] `TIER_1_KOTA_IHK`: 8 kota Jatim → ~90 kota IHK Indonesia
|
| 539 |
+
- [ ] `IPM_2024_JATIM` → `IPM_2024_INDONESIA` (514 kab/kota)
|
| 540 |
+
- [ ] Cluster definitions: Madura → 10-20 cluster nasional
|
| 541 |
+
- [ ] `GUNUNG_KABUPATEN_MAP`: 6 gunung Jatim → 130+ gunung api Indonesia
|
| 542 |
+
- [ ] Sample data: 38 kab → 514 kab synthetic + real
|
| 543 |
+
|
| 544 |
+
### Architectural Expansion (Y2-Y3)
|
| 545 |
+
|
| 546 |
+
- [ ] Inter-island logistics (transport mode: truck/ferry/cargo plane)
|
| 547 |
+
- [ ] Equity threshold recalibration nasional (range IPM 50-85)
|
| 548 |
+
- [ ] CI/CD pipeline (GitHub Actions: pytest + benchmark assertions)
|
| 549 |
+
- [ ] FastAPI wrapper untuk REST API production
|
| 550 |
+
- [ ] Hybrid stable+greedy untuk skenario campuran tier
|
| 551 |
+
- [ ] LLM integration (Gemini + Sahabat-AI Bahasa Daerah)
|
| 552 |
+
|
| 553 |
+
---
|
| 554 |
+
|
| 555 |
+
## Documentation
|
| 556 |
+
|
| 557 |
+
| Document | Lokasi | Deskripsi |
|
| 558 |
+
|---|---|---|
|
| 559 |
+
| **Proposal v10** | Generate via `python docs/generate_v10_docx.py` | Proposal lengkap 14 section: business + technical (docx tidak ditracking di repo — internal team artifact) |
|
| 560 |
+
| **Audit v10** | [`docs/AUDIT_v10.md`](docs/AUDIT_v10.md) | Consistency check + national scale analysis |
|
| 561 |
+
| **README** | This file | Quick reference + getting started |
|
| 562 |
+
| **Generator script** | [`docs/generate_v10_docx.py`](docs/generate_v10_docx.py) | Regenerate proposal docx dari source |
|
| 563 |
+
| **Code comments** | All `.py` files | Inline docstrings dengan reference ke proposal section |
|
| 564 |
+
|
| 565 |
+
---
|
| 566 |
+
|
| 567 |
+
## Troubleshooting
|
| 568 |
+
|
| 569 |
+
### "ModuleNotFoundError: No module named 'matching_engine'"
|
| 570 |
+
|
| 571 |
+
Make sure di project root saat run script:
|
| 572 |
+
```bash
|
| 573 |
+
cd agriflow_engine
|
| 574 |
+
python examples/run_demo.py
|
| 575 |
+
```
|
| 576 |
+
|
| 577 |
+
Atau install sebagai package:
|
| 578 |
+
```bash
|
| 579 |
+
pip install -e . # editable install (kalau pyproject.toml ada)
|
| 580 |
+
```
|
| 581 |
+
|
| 582 |
+
### Demo crash di Windows: "UnicodeEncodeError: 'charmap' codec"
|
| 583 |
+
|
| 584 |
+
v10 sudah include UTF-8 fix. Pastikan pakai versi terbaru:
|
| 585 |
+
```bash
|
| 586 |
+
git pull
|
| 587 |
+
```
|
| 588 |
+
|
| 589 |
+
Atau set env variable manual:
|
| 590 |
+
```bash
|
| 591 |
+
set PYTHONIOENCODING=utf-8
|
| 592 |
+
python examples/run_demo.py
|
| 593 |
+
```
|
| 594 |
+
|
| 595 |
+
### "FileNotFoundError: sample_data/kabupaten_jatim.csv"
|
| 596 |
+
|
| 597 |
+
CSV belum di-generate. Run dulu:
|
| 598 |
+
```bash
|
| 599 |
+
python sample_data/generate_sample_data.py
|
| 600 |
+
```
|
| 601 |
+
|
| 602 |
+
### pytest collect 0 items
|
| 603 |
+
|
| 604 |
+
Pastikan run dari project root, bukan dari `tests/`:
|
| 605 |
+
```bash
|
| 606 |
+
cd agriflow_engine # not cd agriflow_engine/tests
|
| 607 |
+
pytest tests/
|
| 608 |
+
```
|
| 609 |
+
|
| 610 |
+
---
|
| 611 |
+
|
| 612 |
+
## License & Credits
|
| 613 |
+
|
| 614 |
+
**Lisensi:** Hackathon submission. Code internal AgriFlow team.
|
| 615 |
+
|
| 616 |
+
**Credits:**
|
| 617 |
+
- **Algoritma:** Gale-Shapley Stable Matching (Nobel Prize Economics 2012, Roth & Shapley)
|
| 618 |
+
- **Inspirasi platform:** eNAM (India), MealConnect (Feeding America), FEWS NET (USAID), Uber matching pattern, Food Drop Indiana
|
| 619 |
+
- **Data sumber:** Bank Indonesia (PIHPS), Bapanas (Panel Harga), BPS (BRS Desember 2024 IPM), BMKG, PVMBG MAGMA, BNPB DIBI
|
| 620 |
+
|
| 621 |
+
**Tim AgriFlow:** Hackathon DIGDAYA × PIDI 2026.
|
| 622 |
+
|
| 623 |
+
**Citing:**
|
| 624 |
+
```
|
| 625 |
+
AgriFlow Team. (2026). AgriFlow Matching Engine v10.0:
|
| 626 |
+
Sub-National Pangan Matching dengan Stable Matching + Equity Multiplier.
|
| 627 |
+
PIDI DIGDAYA × Hackathon 2026, Bank Indonesia.
|
| 628 |
+
```
|
| 629 |
+
|
| 630 |
+
---
|
| 631 |
+
|
| 632 |
+
## Pertanyaan & Kontak
|
| 633 |
+
|
| 634 |
+
- Issue tracker: GitHub Issues (this repo)
|
| 635 |
+
- Proposal lengkap: regenerate via `python docs/generate_v10_docx.py` (output: `docs/AgriFlow_v10.docx`, gitignored)
|
| 636 |
+
- Audit teknis: [`docs/AUDIT_v10.md`](docs/AUDIT_v10.md)
|
| 637 |
+
|
| 638 |
+
**Deteksi. Prediksi. Distribusi. Untuk Semua.**
|
| 639 |
+
*AgriFlow — Powered by World-First AI Matching Engine for Sub-National Food Distribution.*
|
README_v11.md
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AgriFlow API
|
| 3 |
+
emoji: "🌾"
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# AgriFlow Matching Engine
|
| 13 |
+
|
| 14 |
+
[](https://github.com/masterA88/agriflow_engine/actions/workflows/test.yml)
|
| 15 |
+
|
| 16 |
+
> **v11.0 release notes (Mei 2026)** — this README has been updated for v11. The previous v10 README is preserved at [`README_v10.md`](README_v10.md) for reference / diff.
|
| 17 |
+
>
|
| 18 |
+
> **v11 contains 3 layers of change vs v10** (engine + scenarios + claims):
|
| 19 |
+
> 1. **Engine fix #1** — `volume_score` now uses **coverage-of-demand** (`min(s,d) / demand.volume_tons`) instead of the old `min/max` ratio. Big-producer-to-small-deficit matches (Tuban 800t→Surabaya 100t beras) no longer get unfairly penalized. Demo impact: equity-boost matches (Lamongan→Bangkalan/Sampang beras) now rank **#1 and #2** with FinalScore 100.0 and 98.4 (was #8 in v10).
|
| 20 |
+
> 2. **Engine fix #2** — `MatchResult.segment_multiplier` added. HORECA/GOVERNMENT/INDUSTRIAL demand now get segment-aware adjustments (±10%) based on supply characteristics; final_score = `base × equity × segment`. Fully auditable via per-match flags (`SEGMENT_HORECA_BULK_BONUS`, `SEGMENT_GOVERNMENT_TIER1_BONUS`, etc.). Greedy deficit-ordering also segment-aware.
|
| 21 |
+
> 3. **Scenario expansion 19 → 24** — added C4 multi-holiday calendar (Imlek/Natal/school-start), D6 route blackout (mudik/demo/maintenance), E6 contract reserve (generalisasi Bulog), F1 grade substitution (premium → medium), F2 demand segmentation. New Kategori F: Kualitas & Segmentasi Komersial.
|
| 22 |
+
> 4. **Claim-precision pass** — every diferensiator claim now references `file:line` in `matching_engine/`. Dropped "World-First" / "first-in-world" marketing; replaced with verifiable specifics. Two-tier confidence, +30% equity boost, stable matching — all now scoped to when they actually fire in production.
|
| 23 |
+
>
|
| 24 |
+
> **Test suite: 134/134 pytest pass in 0.42s** (up from v10's 106/106). See [Status & Roadmap](#status--roadmap) for full details. Engine code remains backward-compatible: RETAIL default segment + opt-in `allow_grade_substitution` flag mean existing callers see identical behavior.
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
> **Sub-national pangan matching engine pertama di Indonesia.**
|
| 29 |
+
> Algoritma 4-lapis purpose-built untuk konteks pangan sub-nasional Indonesia. Layer 3 menggabungkan **Modified Gale-Shapley stable matching** (aktif saat kedua kabupaten Tier 1) dengan **greedy multi-objective + equity priority** (aktif saat ada Tier 2 — produksi Jatim saat ini), multi-objective scoring 5 dimensi, dan equity multiplier untuk kabupaten tertinggal IPM <68 saat menjadi deficit — semua untuk komoditas pangan tingkat kabupaten.
|
| 30 |
+
|
| 31 |
+
[]()
|
| 32 |
+
[]()
|
| 33 |
+
[]()
|
| 34 |
+
[]()
|
| 35 |
+
|
| 36 |
+
Submisi **PIDI DIGDAYA × Hackathon 2026** — Bank Indonesia.
|
| 37 |
+
Problem Statement #2: Platform Matching Demand-Supply Antarwilayah.
|
| 38 |
+
|
| 39 |
+
---
|
| 40 |
+
|
| 41 |
+
## Daftar Isi
|
| 42 |
+
|
| 43 |
+
- [Apa Ini?](#apa-ini)
|
| 44 |
+
- [Quick Start (5 menit)](#quick-start-5-menit)
|
| 45 |
+
- [Arsitektur 4-Lapis](#arsitektur-4-lapis)
|
| 46 |
+
- [Equity Multiplier (Kalibrasi BPS 2024)](#equity-multiplier-kalibrasi-bps-2024)
|
| 47 |
+
- [19 Skenario Edge Case](#19-skenario-edge-case)
|
| 48 |
+
- [API Usage](#api-usage)
|
| 49 |
+
- [Performance & Validation](#performance--validation)
|
| 50 |
+
- [Data Sources](#data-sources)
|
| 51 |
+
- [Project Structure](#project-structure)
|
| 52 |
+
- [Development Guide](#development-guide)
|
| 53 |
+
- [Status & Roadmap](#status--roadmap)
|
| 54 |
+
- [Documentation](#documentation)
|
| 55 |
+
- [License & Credits](#license--credits)
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
## Apa Ini?
|
| 60 |
+
|
| 61 |
+
**Bayangkan Uber, tapi untuk cabai dan bawang merah.**
|
| 62 |
+
|
| 63 |
+
Setiap hari, Indonesia kehilangan Rp 213-551 triliun pangan karena food loss & waste — 40% di distribusi, bukan produksi. Petani di Sampang membuang cabai karena harga jatuh, sementara pasar Surabaya melonjak 200% karena kelangkaan. Pemda baru tahu krisis 2-3 minggu kemudian.
|
| 64 |
+
|
| 65 |
+
AgriFlow Matching Engine memecahkan ini dengan 6 dimensi yang Uber tidak punya:
|
| 66 |
+
|
| 67 |
+
| Dimensi | Penjelasan |
|
| 68 |
+
|---|---|
|
| 69 |
+
| **Perishability** | Cabai busuk dalam 5 hari, beras tahan 180 hari — engine hitung shelf life |
|
| 70 |
+
| **Equity** | Kabupaten tertinggal IPM <68 dapat boost +30% **saat menjadi deficit/penerima** (allocation.py:38–65). Demo Jatim: Sampang (66.72) + Bangkalan (67.70) trigger `EQUITY_BOOST_30` di 2 dari 32 match (Ngawi→Bangkalan/Sampang beras). Rare-but-correct di Jatim; impact tumbuh dengan rollout nasional (Papua IPM ~50). |
|
| 71 |
+
| **Climate** | Banjir di rute = re-route otomatis |
|
| 72 |
+
| **Volume** | 1 surplus bisa di-split ke banyak deficit |
|
| 73 |
+
| **Stable Matching** | Modified Gale-Shapley (Nobel Prize Economics 2012) — fires saat kedua kabupaten Tier 1 (`allocation.py:341–347`). Untuk Jatim 8-IHK / 30-non-IHK saat ini, supply originate dari Tier 2 → production path = greedy-with-equity-priority. Stable matching jadi load-bearing setelah Tier 1 coverage expand nasional (~90 kota IHK). |
|
| 74 |
+
| **Two-tier Confidence** | Setiap match return label `HIGH` / `MEDIUM` / `LOW` (`allocation.py:68–77`). Di Jatim saat ini MEDIUM adalah label default (semua surplus dari non-IHK kab); HIGH require kedua kab Tier 1 (rare di Jatim, achievable nasional); LOW fires saat data >24h stale. Transparant ke user. |
|
| 75 |
+
| **Climate** | Score penalty saat BMKG/Open-Meteo forecast hujan deras di rute (`scoring.py:130–153`): >50mm/day → 0.3, >20mm → 0.6, ≤20mm → 1.0. Fallback neutral 0.7 untuk rute tanpa forecast data (demo: 10 rute weather seeded). Scoring penalty, bukan re-routing logic. |
|
| 76 |
+
|
| 77 |
+
**Status:** Production-ready untuk skala provinsial (38 kab Jatim) — **134/134 tests pass** dalam 0.24s, latency p99 1.4ms (sample) - 55.5ms (stress 361×361). **v11.0 (Mei 2026)** adalah *claim-precision + scoring-quality update*:
|
| 78 |
+
1. Claim-precision: setiap klaim diferensiator di proposal sekarang punya referensi spesifik ke `file:line` di `matching_engine/`
|
| 79 |
+
2. **Scoring fix #1** — `volume_score` direvisi dari `min/max` ke `coverage-of-demand`. Big-producer matches (mis. Tuban 800t → Surabaya 100t beras) tidak lagi di-penalize. Hasil demo: equity-boost matches (Lamongan → Bangkalan/Sampang beras) sekarang ranking **#1 dan #2** dengan FinalScore 100.0 dan 98.4 (sebelumnya ranking #8).
|
| 80 |
+
3. **Scoring fix #2** — `segment_multiplier` baru: HORECA/GOVERNMENT/INDUSTRIAL demand sekarang dapat segment-aware bonus (range ±10%) berdasarkan supply characteristics, plus deficit ordering segment-aware. `final_score = base × equity × segment`. Test acid: HORECA wins atas RETAIL di contested-bulk-supply scenario.
|
| 81 |
+
|
| 82 |
+
24 scenarios coverage tetap 126/126 + 8 new segment/coverage tests = 134/134.
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## Quick Start (5 menit)
|
| 87 |
+
|
| 88 |
+
### Prasyarat
|
| 89 |
+
|
| 90 |
+
- Python 3.10+
|
| 91 |
+
- pip
|
| 92 |
+
- ~50MB disk space
|
| 93 |
+
|
| 94 |
+
### Install
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
git clone https://github.com/masterA88/agriflow_engine.git
|
| 98 |
+
cd agriflow_engine
|
| 99 |
+
python -m venv venv
|
| 100 |
+
# Windows:
|
| 101 |
+
venv\Scripts\activate
|
| 102 |
+
# Linux/Mac:
|
| 103 |
+
source venv/bin/activate
|
| 104 |
+
pip install -r requirements.txt
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
### Verifikasi (semua harus sukses)
|
| 108 |
+
|
| 109 |
+
```bash
|
| 110 |
+
# 1. Generate sample data — 38 kab × 19 komoditas Jatim
|
| 111 |
+
python sample_data/generate_sample_data.py
|
| 112 |
+
# Expected: 5 CSV generated (kabupaten_jatim.csv, komoditas_constraints.csv,
|
| 113 |
+
# surplus_deficit.csv, weather_forecast.csv, historical_price_stats.csv)
|
| 114 |
+
|
| 115 |
+
# 2. Run all tests (106 tests)
|
| 116 |
+
pytest tests/ -v
|
| 117 |
+
# Expected: 106 passed in <1s
|
| 118 |
+
|
| 119 |
+
# 3. Run end-to-end demo
|
| 120 |
+
python examples/run_demo.py
|
| 121 |
+
# Expected: ~32 matches, gross arbitrage ~Rp 16 miliar, latency ~1.5ms
|
| 122 |
+
|
| 123 |
+
# 4. Run latency benchmark
|
| 124 |
+
python benchmarks/latency.py
|
| 125 |
+
# Expected: highest p99 < 60ms (margin >88% vs 500ms target)
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
Kalau langkah 2 atau 3 gagal, lihat [Troubleshooting](#troubleshooting) di bawah.
|
| 129 |
+
|
| 130 |
+
---
|
| 131 |
+
|
| 132 |
+
## Arsitektur 4-Lapis
|
| 133 |
+
|
| 134 |
+
```
|
| 135 |
+
Input: surplus_nodes[], deficit_nodes[], LogisticsContext, weather, historical_prices
|
| 136 |
+
|
| 137 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 138 |
+
│ LAYER 0 — Tier Classification (constraints.determine_tier) │
|
| 139 |
+
│ Klasifikasi setiap kab: Tier 1 HIGH (8 kota IHK PIHPS) atau │
|
| 140 |
+
│ Tier 2 MEDIUM (30 kab non-IHK Bapanas). │
|
| 141 |
+
│ Latency: <1ms (set lookup). │
|
| 142 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 143 |
+
↓
|
| 144 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 145 |
+
│ LAYER 1 — Hard Constraints (constraints.generate_candidates) │
|
| 146 |
+
│ 9 rules filter: komoditas match, distance≤max, age≤shelf, │
|
| 147 |
+
│ volume≥min, no self-match, emergency mode, pemda override, │
|
| 148 |
+
│ Bulog split, BBM-aware distance shrink. │
|
| 149 |
+
│ Output: candidate pairs (top-K per surplus by jarak). │
|
| 150 |
+
│ Latency: <50ms untuk 38×19 (~25k pasang potensial). │
|
| 151 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 152 |
+
↓
|
| 153 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 154 |
+
│ LAYER 2 — Multi-Objective Scoring (scoring.compute_score) │
|
| 155 |
+
│ 5-dimensi weighted: Distance 22% / Volume 22% / Price 22% / │
|
| 156 |
+
│ Perishability 18% / Climate 16%. │
|
| 157 |
+
│ 3 weight schemes: DEFAULT, RAMADAN, IMPORT_POLICY. │
|
| 158 |
+
│ Output: base_score 0-100 per pair. │
|
| 159 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 160 |
+
↓
|
| 161 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 162 |
+
│ LAYER 3 — Equity-Weighted Allocation (allocation.allocate) │
|
| 163 |
+
│ Final = base × equity_multiplier(IPM_deficit). │
|
| 164 |
+
│ Tier 1↔Tier 1 → Modified Gale-Shapley (Nobel 2012). │
|
| 165 |
+
│ Cross-tier / Tier 2 → Greedy with equity priority. │
|
| 166 |
+
│ Output: MatchResult[] dengan confidence label. │
|
| 167 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 168 |
+
↓
|
| 169 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 170 |
+
│ POST-PROCESSING (engine.run_matching) │
|
| 171 |
+
│ Tag flags (RAMADAN_SPIKE, EQUITY_BOOST_30, MADURA_CLUSTER, │
|
| 172 |
+
│ STALE_DATA_24H, HUMANITARIAN_PRIORITY, VOLUME_MISMATCH). │
|
| 173 |
+
│ Identifikasi unmatched + external_opportunities (ekspor). │
|
| 174 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 175 |
+
|
| 176 |
+
Output: MatchingReport(matches, unmatched_*, warnings, run_metadata)
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
**Why 4-layer?** Setiap layer bisa dioptimasi independent, testable secara isolated, dan early-exit di Layer 1 menghemat compute Layer 2/3 yang lebih mahal.
|
| 180 |
+
|
| 181 |
+
---
|
| 182 |
+
|
| 183 |
+
## Equity Multiplier (Kalibrasi BPS 2024)
|
| 184 |
+
|
| 185 |
+
Threshold dikalibrasi sesuai distribusi IPM 2024 BPS Jatim sehingga klaim "+30% boost untuk kab tertinggal" konkret applicable:
|
| 186 |
+
|
| 187 |
+
| IPM Range | Multiplier | Boost | Kab/Kota Jatim |
|
| 188 |
+
|---|---|---|---|
|
| 189 |
+
| `IPM < 68` | **1.30** | **+30%** | Sampang (66.72), Bangkalan (67.70) |
|
| 190 |
+
| `68 ≤ IPM < 72` | 1.15 | +15% | Sumenep, Probolinggo (kab), Bondowoso, Lumajang, Pamekasan, Pacitan, Pasuruan (kab), Situbondo, Jember, Madiun (kab) |
|
| 191 |
+
| `72 ≤ IPM < 78` | 1.05 | +5% | Bojonegoro, Banyuwangi, Tulungagung, Malang (kab), Magetan, Gresik, Mojokerto (kab), Lamongan, Tuban, Ngawi, Kediri (kab), dll |
|
| 192 |
+
| `IPM ≥ 78` | 1.00 | (no boost) | Sidoarjo, Kota Batu, Kota Surabaya, Kota Malang, Kota Kediri, Kota Madiun, dll |
|
| 193 |
+
|
| 194 |
+
**Mengapa kalibrasi:** Threshold v9 lama (`<65 → 1.30`) tidak pernah ter-trigger karena IPM terendah Jatim 2024 = Sampang 66.72. v10 menggeser threshold sehingga klaim "+30% boost" demonstrably valid.
|
| 195 |
+
|
| 196 |
+
**Catatan v11:** Boost +30% applies ke kab IPM <68 **saat menjadi deficit/penerima** (di-multiply dengan `base_score` dari Layer 2 untuk menghasilkan `final_score`). Demo run menunjukkan 2 dari 32 match trigger `EQUITY_BOOST_30` — keduanya Ngawi mengirim beras ke Madura (Bangkalan & Sampang). Saat Sampang sendiri jadi surplus (misal bawang), tidak ada boost karena bukan dia yang menjadi penerima. Honest framing: equity boost menggeser hasil saat low-IPM kab adalah sisi demand, bukan saat low-IPM kab disebutkan saja.
|
| 197 |
+
|
| 198 |
+
**Update IPM tahunan:** Saat BPS publish IPM baru (biasanya BRS Desember), edit di [`sample_data/generate_sample_data.py:KABUPATEN_DATA`](sample_data/generate_sample_data.py) sebagai source of truth, lalu mirror ke [`data_sources/bps.py:IPM_2024_JATIM`](data_sources/bps.py).
|
| 199 |
+
|
| 200 |
+
---
|
| 201 |
+
|
| 202 |
+
## 24 Skenario Edge Case
|
| 203 |
+
|
| 204 |
+
6 kategori, 24 skenario, semua tervalidasi pytest (126/126). Detail lengkap di [`docs/AUDIT_v10.md`](docs/AUDIT_v10.md) dan `AgriFlow_v11.docx` Section 5.5.5. **v11 menambahkan 5 commercial-reality scenarios** (C4 multi-holiday, D6 route blackout, E6 contract reserve, F1 grade substitution, F2 demand segmentation) di atas 19 skenario engineering edge case asli v10.
|
| 205 |
+
|
| 206 |
+
### Kategori A — Volume (4 skenario)
|
| 207 |
+
|
| 208 |
+
| Kode | Skenario | Test |
|
| 209 |
+
|---|---|---|
|
| 210 |
+
| A1 | Surplus 1-to-many (1 surplus split ke beberapa deficit) | `TestA1_OneToMany` |
|
| 211 |
+
| A2 | Many-to-1 (multiple surplus untuk 1 deficit besar) | `TestA2_ManyToOne` |
|
| 212 |
+
| A3 | Volume mismatch drastis (<20% ratio → flag warning) | `TestA3_VolumeMismatchDrastis` |
|
| 213 |
+
| A4 | Zero demand (suggest external opportunity) | `TestA4_ZeroDemand` |
|
| 214 |
+
|
| 215 |
+
### Kategori B — Spasial (3 skenario)
|
| 216 |
+
|
| 217 |
+
| Kode | Skenario | Test |
|
| 218 |
+
|---|---|---|
|
| 219 |
+
| B1 | Cross-tier match (Tier 1 ↔ Tier 2) | `TestB1_CrossTier` |
|
| 220 |
+
| B2 | Long distance (jarak > max_distance_km → REJECT) | `TestB2_LongDistance` |
|
| 221 |
+
| B3 | Cluster Madura (4 kab semua surplus → ekspor) | `TestB3_ClusterMadura` |
|
| 222 |
+
|
| 223 |
+
### Kategori C — Temporal (4 skenario)
|
| 224 |
+
|
| 225 |
+
| Kode | Skenario | Test |
|
| 226 |
+
|---|---|---|
|
| 227 |
+
| C1 | Ramadan/Idul Fitri spike (H-21 to H-1, RAMADAN_WEIGHTS) | `TestC1_RamadanSpike` |
|
| 228 |
+
| C2 | Pasca panen raya (oversupply, multiple match) | `TestC2_PostHarvest` |
|
| 229 |
+
| C3 | Stale data >24h (confidence drop bertingkat HIGH→MEDIUM→LOW) | `TestC3_StaleData` |
|
| 230 |
+
| **C4** | **Multi-holiday calendar (Imlek H-7, Natal H-21, school-start H-14)** dengan weight profile per event | `TestC4_HolidayCalendar` |
|
| 231 |
+
|
| 232 |
+
### Kategori D — Disrupsi (6 skenario)
|
| 233 |
+
|
| 234 |
+
| Kode | Skenario | Test |
|
| 235 |
+
|---|---|---|
|
| 236 |
+
| D1 | Banjir rute (BMKG hujan >50mm → climate_score 0.3) | `TestD1_BanjirRute` |
|
| 237 |
+
| D2 | Komoditas hampir rusak (harvest age + transit > shelf) | `TestD2_KomoditasRusak` |
|
| 238 |
+
| D3 | Harga anomali (>3σ dari rolling median → exclude) | `TestD3_HargaAnomali` |
|
| 239 |
+
| D4 | Erupsi gunung (PVMBG MAGMA → UNREACHABLE) | `TestD4_ErupsiGunung` |
|
| 240 |
+
| D5 | Banjir multi-kab (BNPB DIBI → emergency mode) | `TestD5_BanjirMultiKab` |
|
| 241 |
+
| **D6** | **Route blackout (mudik H+1 Idul Fitri, demo Trans-Jawa, Suramadu maintenance)** dengan wildcard support | `TestD6_RouteBlackout` |
|
| 242 |
+
|
| 243 |
+
### Kategori E — Politis & Kebijakan (6 skenario)
|
| 244 |
+
|
| 245 |
+
| Kode | Skenario | Test |
|
| 246 |
+
|---|---|---|
|
| 247 |
+
| E1 | Equity tie-break (IPM lebih rendah menang otomatis) | `TestE1_EquityTieBreak` |
|
| 248 |
+
| E2 | Pemda override (`do_not_export_<komoditas>` flag) | `TestE2_PemdaOverride` |
|
| 249 |
+
| E3 | Bulog priority (60% reserve, sisa 40% private) | `TestE3_BulogPriority` |
|
| 250 |
+
| E4 | Import policy aktif (IMPORT_POLICY_WEIGHTS, price weight ↓) | `TestE4_ImportPolicy` |
|
| 251 |
+
| E5 | BBM naik (max_distance shrink, logistics cost ↑) | `TestE5_BBMNaik` |
|
| 252 |
+
| **E6** | **Contract reserve generalisasi (Carrefour MoU 70%, Indofood gula 50%, dll)** — Bulog pattern di-generalisir | `TestE6_ContractReserve` |
|
| 253 |
+
|
| 254 |
+
### Kategori F — Kualitas & Segmentasi Komersial (2 skenario, NEW v11)
|
| 255 |
+
|
| 256 |
+
| Kode | Skenario | Test |
|
| 257 |
+
|---|---|---|
|
| 258 |
+
| **F1** | **Grade substitution** — surplus `beras_premium` dapat memenuhi demand `beras_medium` (opt-in `allow_grade_substitution=True`); reverse direction tetap REJECT | `TestF1_GradeSubstitution` |
|
| 259 |
+
| **F2** | **Demand segmentation** — `RETAIL` / `HORECA` / `GOVERNMENT` / `INDUSTRIAL` coexist untuk kab + komoditas yang sama; tiap segment di-match independen dengan flag `SEGMENT_<NAME>` | `TestF2_DemandSegmentation` |
|
| 260 |
+
|
| 261 |
+
---
|
| 262 |
+
|
| 263 |
+
## API Usage
|
| 264 |
+
|
| 265 |
+
### Programmatic API
|
| 266 |
+
|
| 267 |
+
```python
|
| 268 |
+
from matching_engine import (
|
| 269 |
+
run_matching, SupplyNode, DemandNode,
|
| 270 |
+
Kabupaten, Tier, Commodity, LogisticsContext,
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
# Setup kabupaten (real koordinat & IPM 2024 BPS)
|
| 274 |
+
kediri = Kabupaten(
|
| 275 |
+
id="3506", nama="Kediri",
|
| 276 |
+
latitude=-7.796, longitude=112.170,
|
| 277 |
+
ipm=74.50, tier=Tier.MEDIUM,
|
| 278 |
+
)
|
| 279 |
+
surabaya = Kabupaten(
|
| 280 |
+
id="3578", nama="Kota Surabaya",
|
| 281 |
+
latitude=-7.2575, longitude=112.7521,
|
| 282 |
+
ipm=84.69, tier=Tier.HIGH,
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
# Setup komoditas (constraint per komoditas)
|
| 286 |
+
cabai = Commodity(
|
| 287 |
+
code="cabai_merah", nama="Cabai Merah Besar",
|
| 288 |
+
max_distance_km=200, min_viable_tons=1.0,
|
| 289 |
+
max_fresh_age_days=5,
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
# Run matching
|
| 293 |
+
report = run_matching(
|
| 294 |
+
surplus_nodes=[
|
| 295 |
+
SupplyNode(kediri, cabai, volume_tons=80, price_per_kg=30000),
|
| 296 |
+
],
|
| 297 |
+
deficit_nodes=[
|
| 298 |
+
DemandNode(surabaya, cabai, volume_tons=80, price_per_kg=60000),
|
| 299 |
+
],
|
| 300 |
+
logistics=LogisticsContext(),
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
# Inspect hasil
|
| 304 |
+
for m in report.matches:
|
| 305 |
+
print(f"{m.surplus.kabupaten.nama} → {m.deficit.kabupaten.nama}")
|
| 306 |
+
print(f" Volume: {m.matched_volume_tons}t @ {m.distance_km:.0f}km")
|
| 307 |
+
print(f" Score: {m.final_score:.1f} (base {m.base_score:.1f} × {m.equity_multiplier})")
|
| 308 |
+
print(f" Confidence: {m.confidence.value}, Flags: {m.flags}")
|
| 309 |
+
# Realistic output dari demo Jatim:
|
| 310 |
+
# Probolinggo → Kota Surabaya: 120.0t Bawang Merah
|
| 311 |
+
# Score: 89.5 (base 89.5 × 1.00), Confidence: MEDIUM, Flags: []
|
| 312 |
+
# Bangkalan → Gresik: 40.0t Bawang Merah
|
| 313 |
+
# Score: 89.4 (base 85.1 × 1.05), Confidence: MEDIUM, Flags: ['EQUITY_BOOST_05', 'MADURA_CLUSTER']
|
| 314 |
+
# Ngawi → Bangkalan: 250.0t Beras Premium
|
| 315 |
+
# Score: 82.8 (base 63.7 × 1.30), Confidence: MEDIUM, Flags: ['EQUITY_BOOST_30', 'MADURA_CLUSTER']
|
| 316 |
+
|
| 317 |
+
# Output:
|
| 318 |
+
# Kediri → Kota Surabaya
|
| 319 |
+
# Volume: 80.0t @ 65km
|
| 320 |
+
# Score: 89.5 (base 89.5 × 1.0)
|
| 321 |
+
# Confidence: MEDIUM, Flags: []
|
| 322 |
+
|
| 323 |
+
print(f"\nLatency: {report.run_metadata['latency_ms']}ms")
|
| 324 |
+
print(f"Candidate pairs evaluated: {report.run_metadata['candidate_pairs_evaluated']}")
|
| 325 |
+
print(f"Warnings: {len(report.warnings)}")
|
| 326 |
+
```
|
| 327 |
+
|
| 328 |
+
### Advanced — Skenario Override
|
| 329 |
+
|
| 330 |
+
```python
|
| 331 |
+
from matching_engine.constraints import set_bulog_procurement
|
| 332 |
+
from datetime import datetime
|
| 333 |
+
|
| 334 |
+
# Skenario E3: Bulog procurement aktif untuk Madiun
|
| 335 |
+
set_bulog_procurement({"3519"})
|
| 336 |
+
|
| 337 |
+
# Skenario E4: Import policy aktif (bobot price diturunkan)
|
| 338 |
+
report = run_matching(
|
| 339 |
+
surplus_nodes=[...],
|
| 340 |
+
deficit_nodes=[...],
|
| 341 |
+
import_policy_active=True, # IMPORT_POLICY_WEIGHTS
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
# Skenario C1: Force Ramadan mode untuk testing
|
| 345 |
+
report = run_matching(
|
| 346 |
+
surplus_nodes=[...],
|
| 347 |
+
deficit_nodes=[...],
|
| 348 |
+
reference_date=datetime(2026, 3, 6), # H-14 Idul Fitri 2026
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
# Skenario E5: BBM naik 20%
|
| 352 |
+
from matching_engine.models import LogisticsContext
|
| 353 |
+
report = run_matching(
|
| 354 |
+
surplus_nodes=[...],
|
| 355 |
+
deficit_nodes=[...],
|
| 356 |
+
logistics=LogisticsContext(
|
| 357 |
+
bbm_price_idr_per_liter=12000,
|
| 358 |
+
bbm_price_baseline=10000,
|
| 359 |
+
),
|
| 360 |
+
)
|
| 361 |
+
```
|
| 362 |
+
|
| 363 |
+
### Advanced — Force Algorithm Strategy
|
| 364 |
+
|
| 365 |
+
```python
|
| 366 |
+
# Force stable matching (Tier 1 algorithm)
|
| 367 |
+
report = run_matching(..., force_strategy="stable")
|
| 368 |
+
|
| 369 |
+
# Force greedy (Tier 2 algorithm) untuk testing
|
| 370 |
+
report = run_matching(..., force_strategy="greedy")
|
| 371 |
+
|
| 372 |
+
# Auto-detect (default): Tier 1↔Tier 1 pairs → stable, else → greedy
|
| 373 |
+
report = run_matching(...)
|
| 374 |
+
```
|
| 375 |
+
|
| 376 |
+
---
|
| 377 |
+
|
| 378 |
+
## Performance & Validation
|
| 379 |
+
|
| 380 |
+
### Test Suite
|
| 381 |
+
|
| 382 |
+
```bash
|
| 383 |
+
$ pytest tests/ --tb=short
|
| 384 |
+
============================= test session starts =============================
|
| 385 |
+
collected 106 items
|
| 386 |
+
|
| 387 |
+
tests/test_layer0_tier.py ................ [ 15%]
|
| 388 |
+
tests/test_layer1_constraints.py ................... [ 33%]
|
| 389 |
+
tests/test_layer2_scoring.py ....................... [ 54%]
|
| 390 |
+
tests/test_layer3_allocation.py .............. [ 67%]
|
| 391 |
+
tests/test_scenarios_disruption.py ......... [ 76%]
|
| 392 |
+
tests/test_scenarios_political.py ........ [ 83%]
|
| 393 |
+
tests/test_scenarios_spatial.py ...... [ 89%]
|
| 394 |
+
tests/test_scenarios_temporal.py ....... [ 96%]
|
| 395 |
+
tests/test_scenarios_volume.py .... [100%]
|
| 396 |
+
|
| 397 |
+
============================= 106 passed in 0.16s =============================
|
| 398 |
+
```
|
| 399 |
+
|
| 400 |
+
### Latency Benchmark
|
| 401 |
+
|
| 402 |
+
```bash
|
| 403 |
+
$ python benchmarks/latency.py
|
| 404 |
+
```
|
| 405 |
+
|
| 406 |
+
| Configuration | N (s × d) | p50 | p95 | p99 | Max |
|
| 407 |
+
|---|---|---|---|---|---|
|
| 408 |
+
| Sample data CSV (realistic) | 40 × 33 | 0.99 ms | 1.26 ms | 1.38 ms | 1.42 ms |
|
| 409 |
+
| Synthetic full Jatim (38×19) | 361 × 361 | 48.37 ms | 53.67 ms | 55.53 ms | 58.43 ms |
|
| 410 |
+
| Stress 100×100 (national scale) | 100 × 100 | 12.62 ms | 14.82 ms | 15.51 ms | 15.65 ms |
|
| 411 |
+
| Stress 200×200 | 200 × 200 | 25.47 ms | 26.92 ms | 27.54 ms | 27.76 ms |
|
| 412 |
+
|
| 413 |
+
**Verdict:** PASS — semua p99 < 500ms target. Highest p99 = 55.53ms (margin 88.9%).
|
| 414 |
+
|
| 415 |
+
### National Scale (Indonesia 514 kab)
|
| 416 |
+
|
| 417 |
+
⚠ **HONEST DISCLOSURE:** Engine v11 (sama dengan v10 — claim-precision pass, bukan engine change) saat ini BELUM siap untuk produksi nasional 514 kab. Lihat [`docs/AUDIT_v10.md`](docs/AUDIT_v10.md) Section 3.2 untuk detail. Optimization roadmap (spatial indexing, per-provinsi batching, parallel) sudah ter-quantify untuk Y2-Y3.
|
| 418 |
+
|
| 419 |
+
```bash
|
| 420 |
+
$ python benchmarks/national_scale.py
|
| 421 |
+
```
|
| 422 |
+
|
| 423 |
+
| Scale | Workload | p99 | vs target |
|
| 424 |
+
|---|---|---|---|
|
| 425 |
+
| Provinsi Jatim baseline | 333×389 | **14.2ms** | ✅ 35× under |
|
| 426 |
+
| Multi-provinsi (100 kab) | 948×952 | **94.5ms** | ✅ 5× under |
|
| 427 |
+
| Setengah Indonesia (250 kab) | 2326×2424 | **541.8ms** | ⚠ 1.08× over |
|
| 428 |
+
| **Full Indonesia (514 kab)** | **4859×4907** | **2223.3ms** | ❌ **4.4× over** |
|
| 429 |
+
|
| 430 |
+
---
|
| 431 |
+
|
| 432 |
+
## Data Sources
|
| 433 |
+
|
| 434 |
+
8 connector dengan dual-mode (mock CSV + live API), graceful fallback:
|
| 435 |
+
|
| 436 |
+
| Connector | Sumber | Frekuensi | Auth | Tier |
|
| 437 |
+
|---|---|---|---|---|
|
| 438 |
+
| [`pihps_bi.py`](data_sources/pihps_bi.py) | Bank Indonesia PIHPS | Harian (cut-off 13:00 WIB) | Tidak ada (scrape publik) | Tier 1 |
|
| 439 |
+
| [`bapanas.py`](data_sources/bapanas.py) | Panel Harga Bapanas | Mingguan (Senin) | Tidak ada | Tier 2 |
|
| 440 |
+
| [`bps.py`](data_sources/bps.py) | BPS WebAPI (IPM, produksi) | Tahunan (BRS Desember) | API key gratis | Both |
|
| 441 |
+
| [`bmkg.py`](data_sources/bmkg.py) | BMKG / Open-Meteo (cuaca) | 3-6 jam refresh | Tidak ada (Open-Meteo) | Both |
|
| 442 |
+
| [`pvmbg.py`](data_sources/pvmbg.py) | PVMBG MAGMA (gunung api) | Realtime saat status berubah | Tidak ada | Both |
|
| 443 |
+
| [`bnpb.py`](data_sources/bnpb.py) | BNPB DIBI (bencana) | Realtime | Tidak ada | Both |
|
| 444 |
+
| [`google_maps.py`](data_sources/google_maps.py) | Google Routes / OSRM fallback | Realtime per request | Google API key (paid) / OSRM gratis | Both |
|
| 445 |
+
| [`hijri_calendar.py`](data_sources/hijri_calendar.py) | Aladhan API + hardcoded | Statis | Tidak ada | Both |
|
| 446 |
+
|
| 447 |
+
### Fail-safe Strategy
|
| 448 |
+
|
| 449 |
+
- Live API gagal → fallback ke mock CSV / hardcoded data
|
| 450 |
+
- Weather data tidak tersedia → climate_score = 0.7 (neutral)
|
| 451 |
+
- BPS API gagal → fallback ke `IPM_2024_JATIM` hardcoded
|
| 452 |
+
- BMKG butuh adm4 mapping yang tidak ada → auto-fallback ke Open-Meteo
|
| 453 |
+
- OSRM down → haversine geodesic + asumsi 60 km/h
|
| 454 |
+
|
| 455 |
+
---
|
| 456 |
+
|
| 457 |
+
## Project Structure
|
| 458 |
+
|
| 459 |
+
```
|
| 460 |
+
agriflow_engine/
|
| 461 |
+
├── matching_engine/ # Core engine (5 modules, ~1000 lines)
|
| 462 |
+
│ ├── __init__.py # Public API
|
| 463 |
+
│ ├── models.py # Dataclasses (Kabupaten, Commodity, MatchResult, ...)
|
| 464 |
+
│ ├── constraints.py # Layer 0 + Layer 1 (9 hard constraints)
|
| 465 |
+
│ ├── scoring.py # Layer 2 (5-dim multi-objective scoring)
|
| 466 |
+
│ ├── allocation.py # Layer 3 (Gale-Shapley + Greedy + Equity)
|
| 467 |
+
│ └── engine.py # Main orchestrator + 19 skenario handlers
|
| 468 |
+
├── data_sources/ # 8 connector dual-mode (mock + live)
|
| 469 |
+
│ ├── pihps_bi.py # Tier 1 PIHPS BI
|
| 470 |
+
│ ├── bapanas.py # Tier 2 Bapanas
|
| 471 |
+
│ ├── bps.py # IPM 2024 + produksi BPS
|
| 472 |
+
│ ├── bmkg.py # Cuaca BMKG/Open-Meteo
|
| 473 |
+
│ ├── pvmbg.py # Erupsi gunung PVMBG MAGMA
|
| 474 |
+
│ ├── bnpb.py # Bencana BNPB DIBI
|
| 475 |
+
│ ├── google_maps.py # Routing Google/OSRM
|
| 476 |
+
│ └── hijri_calendar.py # Ramadan/Idul Fitri Aladhan
|
| 477 |
+
├── sample_data/ # CSV 38 kab × 19 komoditas Jatim
|
| 478 |
+
│ ├── generate_sample_data.py # Source of truth — regenerate CSV
|
| 479 |
+
│ ├── loader.py # CSV → engine objects
|
| 480 |
+
│ ├── kabupaten_jatim.csv # 38 kab + IPM 2024 + koordinat
|
| 481 |
+
│ ├── komoditas_constraints.csv # 19 komoditas + spec
|
| 482 |
+
│ ├── surplus_deficit.csv # 73 row sample workload
|
| 483 |
+
│ ├── weather_forecast.csv # 10 route forecast
|
| 484 |
+
│ └── historical_price_stats.csv # 19 commodity rolling stats
|
| 485 |
+
├── tests/ # 106 pytest test
|
| 486 |
+
│ ├── conftest.py # Fixtures (17 kab Jatim + factory)
|
| 487 |
+
│ ├── test_layer0_tier.py # 16 test (tier classification)
|
| 488 |
+
│ ├── test_layer1_constraints.py # 19 test (haversine + viability + Bulog)
|
| 489 |
+
│ ├── test_layer2_scoring.py # 23 test (5-dim scoring + weight schemes)
|
| 490 |
+
│ ├── test_layer3_allocation.py # 14 test (equity + stable + greedy)
|
| 491 |
+
│ ├── test_scenarios_volume.py # 4 test (A1-A4)
|
| 492 |
+
│ ├── test_scenarios_spatial.py # 6 test (B1-B3)
|
| 493 |
+
│ ├── test_scenarios_temporal.py # 7 test (C1-C3)
|
| 494 |
+
│ ├── test_scenarios_disruption.py # 9 test (D1-D5)
|
| 495 |
+
│ └── test_scenarios_political.py # 8 test (E1-E5)
|
| 496 |
+
├── examples/
|
| 497 |
+
│ └── run_demo.py # End-to-end demo dengan output formatted
|
| 498 |
+
├── benchmarks/
|
| 499 |
+
│ ├── latency.py # Multi-config provincial benchmark
|
| 500 |
+
│ └── national_scale.py # National scale stress test (514 kab)
|
| 501 |
+
├── docs/
|
| 502 |
+
│ ├── generate_v11_docx.py # Proposal v11 docx generator (claim-precision pass; current)
|
| 503 |
+
│ ├── generate_v10_docx.py # Proposal v10 docx generator (history)
|
| 504 |
+
│ └── AUDIT_v10.md # Audit lengkap (consistency + national scale analysis)
|
| 505 |
+
├── README.md # This file
|
| 506 |
+
├── requirements.txt # Python dependencies
|
| 507 |
+
└── venv/ # (gitignored) virtual env
|
| 508 |
+
```
|
| 509 |
+
|
| 510 |
+
---
|
| 511 |
+
|
| 512 |
+
## Development Guide
|
| 513 |
+
|
| 514 |
+
### Setup Development Environment
|
| 515 |
+
|
| 516 |
+
```bash
|
| 517 |
+
git clone https://github.com/masterA88/agriflow_engine.git
|
| 518 |
+
cd agriflow_engine
|
| 519 |
+
python -m venv venv
|
| 520 |
+
source venv/bin/activate # or venv\Scripts\activate on Windows
|
| 521 |
+
pip install -r requirements.txt
|
| 522 |
+
pip install python-docx # untuk regenerate proposal docx
|
| 523 |
+
```
|
| 524 |
+
|
| 525 |
+
### Workflow
|
| 526 |
+
|
| 527 |
+
1. **Edit code** di `matching_engine/` atau `data_sources/`
|
| 528 |
+
2. **Run test** sebelum commit: `pytest tests/ -v`
|
| 529 |
+
3. **Update sample data** kalau ubah threshold/komoditas: `python sample_data/generate_sample_data.py`
|
| 530 |
+
4. **Run demo** untuk smoke test: `python examples/run_demo.py`
|
| 531 |
+
5. **Run benchmark** kalau perubahan di hot path: `python benchmarks/latency.py`
|
| 532 |
+
6. **Regenerate proposal** kalau perubahan di logic: `python docs/generate_v10_docx.py`
|
| 533 |
+
|
| 534 |
+
### Add New Skenario
|
| 535 |
+
|
| 536 |
+
1. Tambah test class di file yang sesuai (mis. `tests/test_scenarios_volume.py`)
|
| 537 |
+
2. Tambah behavior di `matching_engine/engine.py` post-processing atau Layer yang relevan
|
| 538 |
+
3. Update `AgriFlow_v10.docx` Section 5.5.5 (regenerate via `docs/generate_v10_docx.py`)
|
| 539 |
+
4. Pastikan `pytest tests/ -v` masih PASS
|
| 540 |
+
|
| 541 |
+
### Add New Komoditas
|
| 542 |
+
|
| 543 |
+
1. Tambah row di [`sample_data/generate_sample_data.py:KOMODITAS_DATA`](sample_data/generate_sample_data.py)
|
| 544 |
+
2. Tambah row di [`matching_engine/constraints.py:COMMODITY_SPECS`](matching_engine/constraints.py) (samakan max_distance/min_viable/max_fresh_age)
|
| 545 |
+
3. Run `python sample_data/generate_sample_data.py` untuk regenerate CSV
|
| 546 |
+
4. Update assertion di test kalau komoditas count check
|
| 547 |
+
|
| 548 |
+
### Update IPM Tahunan (saat BPS publish data baru)
|
| 549 |
+
|
| 550 |
+
1. Edit [`sample_data/generate_sample_data.py:KABUPATEN_DATA`](sample_data/generate_sample_data.py) (source of truth)
|
| 551 |
+
2. Mirror ke [`data_sources/bps.py:IPM_2024_JATIM`](data_sources/bps.py)
|
| 552 |
+
3. Run `python sample_data/generate_sample_data.py` untuk regenerate CSV
|
| 553 |
+
4. Re-evaluate equity threshold di [`matching_engine/allocation.py:38`](matching_engine/allocation.py) — apakah masih meaningful trigger untuk distribusi baru?
|
| 554 |
+
5. Run `pytest tests/ -v` — beberapa test mungkin perlu update kalau IPM bergeser
|
| 555 |
+
|
| 556 |
+
---
|
| 557 |
+
|
| 558 |
+
## Status & Roadmap
|
| 559 |
+
|
| 560 |
+
### Current Status: ✅ Provincial-Ready (Jatim)
|
| 561 |
+
|
| 562 |
+
- [x] 4-layer architecture implemented
|
| 563 |
+
- [x] 19 skenario edge case handled
|
| 564 |
+
- [x] 106/106 tests passing
|
| 565 |
+
- [x] Latency p99 1.4ms (sample) - 55.5ms (stress)
|
| 566 |
+
- [x] Equity multiplier kalibrasi BPS 2024 — Sampang & Bangkalan menerima +30% boost
|
| 567 |
+
- [x] 8 data source connector dual-mode
|
| 568 |
+
- [x] Cross-platform demo (Windows/Linux/Mac)
|
| 569 |
+
- [x] Reproducible documentation generator
|
| 570 |
+
|
| 571 |
+
### Roadmap to National Scale (Y2-Y3)
|
| 572 |
+
|
| 573 |
+
⚠ **Honest disclosure:** Engine v11 (sama core dengan v10) saat ini BELUM siap untuk produksi nasional 514 kab. p99 untuk full Indonesia = 2.2 detik (4.4× over 500ms target).
|
| 574 |
+
|
| 575 |
+
**Optimization plan** (lihat [`docs/AUDIT_v10.md`](docs/AUDIT_v10.md) Section 6):
|
| 576 |
+
|
| 577 |
+
| Quick Win | Effort | Impact |
|
| 578 |
+
|---|---|---|
|
| 579 |
+
| Fix double-haversine bug di `generate_candidates` | 1 jam | 30-40% speedup Layer 1 |
|
| 580 |
+
| Add geohash precision-5 spatial pre-filter | 1-2 hari | 25-50× speedup Layer 1 |
|
| 581 |
+
| Multiprocessing per komoditas | 2-3 hari | Up to 19× speedup |
|
| 582 |
+
| Distance matrix precompute (Redis cache) | 4 jam | 5-10× speedup |
|
| 583 |
+
| Per-provinsi batching | 1 minggu | 5-15× speedup |
|
| 584 |
+
|
| 585 |
+
**Combined estimate:** ~100-200× speedup → bring p99 dari 2200ms ke ~10-20ms untuk 514 kab nasional.
|
| 586 |
+
|
| 587 |
+
### Data/Coverage Expansion (Y2)
|
| 588 |
+
|
| 589 |
+
- [ ] `TIER_1_KOTA_IHK`: 8 kota Jatim → ~90 kota IHK Indonesia
|
| 590 |
+
- [ ] `IPM_2024_JATIM` → `IPM_2024_INDONESIA` (514 kab/kota)
|
| 591 |
+
- [ ] Cluster definitions: Madura → 10-20 cluster nasional
|
| 592 |
+
- [ ] `GUNUNG_KABUPATEN_MAP`: 6 gunung Jatim → 130+ gunung api Indonesia
|
| 593 |
+
- [ ] Sample data: 38 kab → 514 kab synthetic + real
|
| 594 |
+
|
| 595 |
+
### Architectural Expansion (Y2-Y3)
|
| 596 |
+
|
| 597 |
+
- [ ] Inter-island logistics (transport mode: truck/ferry/cargo plane)
|
| 598 |
+
- [ ] Equity threshold recalibration nasional (range IPM 50-85)
|
| 599 |
+
- [ ] CI/CD pipeline (GitHub Actions: pytest + benchmark assertions)
|
| 600 |
+
- [ ] FastAPI wrapper untuk REST API production
|
| 601 |
+
- [ ] Hybrid stable+greedy untuk skenario campuran tier
|
| 602 |
+
- [ ] LLM integration (Gemini + Sahabat-AI Bahasa Daerah)
|
| 603 |
+
|
| 604 |
+
---
|
| 605 |
+
|
| 606 |
+
## Documentation
|
| 607 |
+
|
| 608 |
+
| Document | Lokasi | Deskripsi |
|
| 609 |
+
|---|---|---|
|
| 610 |
+
| **Proposal v11** (current) | Generate via `python docs/generate_v11_docx.py` | Proposal lengkap dengan claim-precision pass — setiap klaim diferensiator dirujuk ke `file:line` di `matching_engine/` (docx gitignored — internal team artifact) |
|
| 611 |
+
| **Proposal v10** (history) | Generate via `python docs/generate_v10_docx.py` | Versi sebelum claim-precision pass; tetap dipertahankan sebagai history |
|
| 612 |
+
| **Audit v10** | [`docs/AUDIT_v10.md`](docs/AUDIT_v10.md) | Consistency check + national scale analysis (engine code identik di v11) |
|
| 613 |
+
| **README** | This file | Quick reference + getting started |
|
| 614 |
+
| **Generator script** | [`docs/generate_v11_docx.py`](docs/generate_v11_docx.py) | Regenerate proposal v11 docx dari source |
|
| 615 |
+
| **Code comments** | All `.py` files | Inline docstrings dengan reference ke proposal section |
|
| 616 |
+
|
| 617 |
+
---
|
| 618 |
+
|
| 619 |
+
## Troubleshooting
|
| 620 |
+
|
| 621 |
+
### "ModuleNotFoundError: No module named 'matching_engine'"
|
| 622 |
+
|
| 623 |
+
Make sure di project root saat run script:
|
| 624 |
+
```bash
|
| 625 |
+
cd agriflow_engine
|
| 626 |
+
python examples/run_demo.py
|
| 627 |
+
```
|
| 628 |
+
|
| 629 |
+
Atau install sebagai package:
|
| 630 |
+
```bash
|
| 631 |
+
pip install -e . # editable install (kalau pyproject.toml ada)
|
| 632 |
+
```
|
| 633 |
+
|
| 634 |
+
### Demo crash di Windows: "UnicodeEncodeError: 'charmap' codec"
|
| 635 |
+
|
| 636 |
+
v10 sudah include UTF-8 fix. Pastikan pakai versi terbaru:
|
| 637 |
+
```bash
|
| 638 |
+
git pull
|
| 639 |
+
```
|
| 640 |
+
|
| 641 |
+
Atau set env variable manual:
|
| 642 |
+
```bash
|
| 643 |
+
set PYTHONIOENCODING=utf-8
|
| 644 |
+
python examples/run_demo.py
|
| 645 |
+
```
|
| 646 |
+
|
| 647 |
+
### "FileNotFoundError: sample_data/kabupaten_jatim.csv"
|
| 648 |
+
|
| 649 |
+
CSV belum di-generate. Run dulu:
|
| 650 |
+
```bash
|
| 651 |
+
python sample_data/generate_sample_data.py
|
| 652 |
+
```
|
| 653 |
+
|
| 654 |
+
### pytest collect 0 items
|
| 655 |
+
|
| 656 |
+
Pastikan run dari project root, bukan dari `tests/`:
|
| 657 |
+
```bash
|
| 658 |
+
cd agriflow_engine # not cd agriflow_engine/tests
|
| 659 |
+
pytest tests/
|
| 660 |
+
```
|
| 661 |
+
|
| 662 |
+
---
|
| 663 |
+
|
| 664 |
+
## License & Credits
|
| 665 |
+
|
| 666 |
+
**Lisensi:** Hackathon submission. Code internal AgriFlow team.
|
| 667 |
+
|
| 668 |
+
**Credits:**
|
| 669 |
+
- **Algoritma:** Modified Gale-Shapley Stable Matching (Nobel Prize Economics 2012, Roth & Shapley) — fires saat kedua kab Tier 1; Greedy multi-objective + equity priority untuk Tier 2 dan cross-tier
|
| 670 |
+
- **Inspirasi platform:** eNAM (India), MealConnect (Feeding America), FEWS NET (USAID), Uber matching pattern, Food Drop Indiana
|
| 671 |
+
- **Data sumber:** Bank Indonesia (PIHPS), Bapanas (Panel Harga), BPS (BRS Desember 2024 IPM), BMKG, PVMBG MAGMA, BNPB DIBI
|
| 672 |
+
|
| 673 |
+
**Tim AgriFlow:** Hackathon DIGDAYA × PIDI 2026.
|
| 674 |
+
|
| 675 |
+
**Citing:**
|
| 676 |
+
```
|
| 677 |
+
AgriFlow Team. (2026). AgriFlow Matching Engine v11.0:
|
| 678 |
+
Purpose-Built Sub-National Indonesian Food Matching Engine
|
| 679 |
+
dengan Stable Matching (Tier 1) + Greedy-Equity-Priority (Tier 2) + IPM-Based Equity Multiplier (BPS 2024).
|
| 680 |
+
PIDI DIGDAYA × Hackathon 2026, Bank Indonesia.
|
| 681 |
+
```
|
| 682 |
+
|
| 683 |
+
---
|
| 684 |
+
|
| 685 |
+
## Pertanyaan & Kontak
|
| 686 |
+
|
| 687 |
+
- Issue tracker: GitHub Issues (this repo)
|
| 688 |
+
- Proposal lengkap: regenerate via `python docs/generate_v10_docx.py` (output: `docs/AgriFlow_v10.docx`, gitignored)
|
| 689 |
+
- Audit teknis: [`docs/AUDIT_v10.md`](docs/AUDIT_v10.md)
|
| 690 |
+
|
| 691 |
+
**Deteksi. Prediksi. Distribusi. Untuk Semua.**
|
| 692 |
+
*AgriFlow — Purpose-Built Sub-National AI Matching Engine for Indonesian Food Distribution.*
|
README_v12.md
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AgriFlow API
|
| 3 |
+
emoji: "🌾"
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# AgriFlow Matching Engine
|
| 13 |
+
|
| 14 |
+
[](https://github.com/masterA88/agriflow_engine/actions/workflows/test.yml)
|
| 15 |
+
|
| 16 |
+
> **v12.0 release notes (Mei 2026)** — engine hardening + empirical equity validation di atas v11. README v11 diarsipkan di [`README_v11.md`](README_v11.md) (scoring/scenario history v11 disimpan di sana).
|
| 17 |
+
>
|
| 18 |
+
> **v12 = engine-hardening + empirical-validation pass vs v11:**
|
| 19 |
+
> 1. **OSRM road-distance precompute** — Layer 1 sekarang prefer OSRM road distance (`sample_data/road_distance_jatim.csv`, 38×38=1444 pairs) di atas haversine. Haversine over-permissive: **18.6% pair false-positive viable** untuk komoditas MAX_DISTANCE=200km (detour Selat Madura). Fallback haversine untuk pair tak dikenal.
|
| 20 |
+
> 2. **Concurrency hardening** — `BULOG_PROCUREMENT_KAB` tidak lagi race; `run_matching`/`apply_bulog_split` terima param eksplisit `bulog_procurement_kab` (regression test 100 parallel run × 4 worker).
|
| 21 |
+
> 3. **Layer 1 perf** — duplicate haversine dieliminasi; ~30% Layer 1 speedup, end-to-end mean −7–13%.
|
| 22 |
+
> 4. **CI** — GitHub Actions pytest matrix (Ubuntu+Windows × Python 3.11/3.12) + benchmark publish.
|
| 23 |
+
> 5. **Empirical equity validation** — baseline comparison vs pure-greedy / uniform / proportional, dua rezim pasokan (lihat [Validasi Empiris Equity](#validasi-empiris-equity-baseline-comparison)). **Temuan kunci (jujur):** di pasokan melimpah, equity nyaris tak terlihat (kab termiskin sudah terlayani; Gini AgriFlow ≈ greedy). Saat langka (shock La Nina), greedy menelantarkan Sampang (0%)/Bangkalan (20%) sedangkan **AgriFlow jamin keduanya 100% dengan biaya coverage agregat NOL** — inilah nilai equity yang sebenarnya, terukur. Klaim presisi: "memprioritaskan kab terlemah saat pasokan langka", bukan "menurunkan Gini secara umum".
|
| 24 |
+
> 6. **Claim reframe** — "Gale-Shapley stable matching (Nobel 2012)" → **centralized bipartite assignment dengan equity weighting** (preferences kedua sisi berasal dari shared welfare score; Gale-Shapley dipakai sebagai operational primitive, cf. Galichon 2021).
|
| 25 |
+
>
|
| 26 |
+
> **Test suite: 205/205 pytest pass** (naik dari v11's 134; +baseline-comparison +constrained-scenario +road-distance +concurrency). Engine backward-compatible: param baru `equity_fn` default ke fungsi shipped, `road_distance_km` fallback ke haversine.
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
> **Sub-national pangan matching engine pertama di Indonesia.**
|
| 31 |
+
> Algoritma 4-lapis purpose-built untuk konteks pangan sub-nasional Indonesia. Layer 3 menggabungkan **Gale-Shapley sebagai operational primitive** untuk centralized bipartite assignment dengan equity weighting (aktif saat kedua kabupaten Tier 1; cf. Galichon 2021) dengan **greedy multi-objective + equity priority** (aktif saat ada Tier 2 — produksi Jatim saat ini), multi-objective scoring 5 dimensi, dan equity multiplier untuk kabupaten tertinggal IPM <68 saat menjadi deficit — semua untuk komoditas pangan tingkat kabupaten.
|
| 32 |
+
|
| 33 |
+
[]()
|
| 34 |
+
[]()
|
| 35 |
+
[]()
|
| 36 |
+
[]()
|
| 37 |
+
|
| 38 |
+
Submisi **PIDI DIGDAYA × Hackathon 2026** — Bank Indonesia.
|
| 39 |
+
Problem Statement #2: Platform Matching Demand-Supply Antarwilayah.
|
| 40 |
+
|
| 41 |
+
---
|
| 42 |
+
|
| 43 |
+
## Daftar Isi
|
| 44 |
+
|
| 45 |
+
- [Apa Ini?](#apa-ini)
|
| 46 |
+
- [Quick Start (5 menit)](#quick-start-5-menit)
|
| 47 |
+
- [Arsitektur 4-Lapis](#arsitektur-4-lapis)
|
| 48 |
+
- [Equity Multiplier (Kalibrasi BPS 2024)](#equity-multiplier-kalibrasi-bps-2024)
|
| 49 |
+
- [19 Skenario Edge Case](#19-skenario-edge-case)
|
| 50 |
+
- [API Usage](#api-usage)
|
| 51 |
+
- [Performance & Validation](#performance--validation)
|
| 52 |
+
- [Validasi Empiris Equity](#validasi-empiris-equity-baseline-comparison)
|
| 53 |
+
- [Data Sources](#data-sources)
|
| 54 |
+
- [Project Structure](#project-structure)
|
| 55 |
+
- [Development Guide](#development-guide)
|
| 56 |
+
- [Status & Roadmap](#status--roadmap)
|
| 57 |
+
- [Documentation](#documentation)
|
| 58 |
+
- [License & Credits](#license--credits)
|
| 59 |
+
|
| 60 |
+
---
|
| 61 |
+
|
| 62 |
+
## Apa Ini?
|
| 63 |
+
|
| 64 |
+
**Bayangkan Uber, tapi untuk cabai dan bawang merah.**
|
| 65 |
+
|
| 66 |
+
Setiap hari, Indonesia kehilangan Rp 213-551 triliun pangan karena food loss & waste — 40% di distribusi, bukan produksi. Petani di Sampang membuang cabai karena harga jatuh, sementara pasar Surabaya melonjak 200% karena kelangkaan. Pemda baru tahu krisis 2-3 minggu kemudian.
|
| 67 |
+
|
| 68 |
+
AgriFlow Matching Engine memecahkan ini dengan 6 dimensi yang Uber tidak punya:
|
| 69 |
+
|
| 70 |
+
| Dimensi | Penjelasan |
|
| 71 |
+
|---|---|
|
| 72 |
+
| **Perishability** | Cabai busuk dalam 5 hari, beras tahan 180 hari — engine hitung shelf life |
|
| 73 |
+
| **Equity** | Kabupaten tertinggal IPM <68 dapat boost +30% **saat menjadi deficit/penerima** (allocation.py:38–65). Demo Jatim: Sampang (66.72) + Bangkalan (67.70) trigger `EQUITY_BOOST_30` di 2 dari 32 match (Ngawi→Bangkalan/Sampang beras). Rare-but-correct di Jatim; impact tumbuh dengan rollout nasional (Papua IPM ~50). |
|
| 74 |
+
| **Climate** | Banjir di rute = re-route otomatis |
|
| 75 |
+
| **Volume** | 1 surplus bisa di-split ke banyak deficit |
|
| 76 |
+
| **Centralized Assignment** | Gale-Shapley sebagai *operational primitive* untuk centralized bipartite assignment dengan equity weighting — fires saat kedua kabupaten Tier 1 (`allocation.py:341–347`). Preferences kedua sisi berasal dari shared welfare score, jadi ini bukan two-sided matching market dengan preferensi independen (cf. Galichon 2021). Untuk Jatim 8-IHK / 30-non-IHK saat ini, supply originate dari Tier 2 → production path = greedy-with-equity-priority. |
|
| 77 |
+
| **Two-tier Confidence** | Setiap match return label `HIGH` / `MEDIUM` / `LOW` (`allocation.py:68–77`). Di Jatim saat ini MEDIUM adalah label default (semua surplus dari non-IHK kab); HIGH require kedua kab Tier 1 (rare di Jatim, achievable nasional); LOW fires saat data >24h stale. Transparant ke user. |
|
| 78 |
+
| **Climate** | Score penalty saat BMKG/Open-Meteo forecast hujan deras di rute (`scoring.py:130–153`): >50mm/day → 0.3, >20mm → 0.6, ≤20mm → 1.0. Fallback neutral 0.7 untuk rute tanpa forecast data (demo: 10 rute weather seeded). Scoring penalty, bukan re-routing logic. |
|
| 79 |
+
|
| 80 |
+
**Status:** Production-ready untuk skala provinsial (38 kab Jatim) — **205/205 tests pass** dalam ~1.4s, latency p99 1.4ms (sample) - 55.5ms (stress 361×361). **v12.0 (Mei 2026)** adalah *engine-hardening + empirical-equity-validation update* di atas v11 (claim-precision + scoring-quality):
|
| 81 |
+
1. **OSRM road-distance** menggantikan haversine di Layer 1 (haversine over-permissive: 18.6% false-positive viable untuk komoditas 200km).
|
| 82 |
+
2. **Concurrency + perf + CI** — BULOG race fixed, double-haversine dieliminasi (~30% Layer 1 speedup), GitHub Actions matrix.
|
| 83 |
+
3. **Empirical equity validation** — baseline comparison membuktikan nilai equity AgriFlow muncul saat pasokan **langka** (greedy telantarkan Sampang 0%/Bangkalan 20%; AgriFlow jamin 100% biaya nol), bukan saat melimpah. Klaim Gale-Shapley di-reframe ke *centralized bipartite assignment* (cf. Galichon 2021). Lihat [Validasi Empiris Equity](#validasi-empiris-equity-baseline-comparison).
|
| 84 |
+
|
| 85 |
+
205/205 = 134 (v11) + baseline-comparison + constrained-scenario + road-distance + concurrency tests.
|
| 86 |
+
|
| 87 |
+
---
|
| 88 |
+
|
| 89 |
+
## Quick Start (5 menit)
|
| 90 |
+
|
| 91 |
+
### Prasyarat
|
| 92 |
+
|
| 93 |
+
- Python 3.10+
|
| 94 |
+
- pip
|
| 95 |
+
- ~50MB disk space
|
| 96 |
+
|
| 97 |
+
### Install
|
| 98 |
+
|
| 99 |
+
```bash
|
| 100 |
+
git clone https://github.com/masterA88/agriflow_engine.git
|
| 101 |
+
cd agriflow_engine
|
| 102 |
+
python -m venv venv
|
| 103 |
+
# Windows:
|
| 104 |
+
venv\Scripts\activate
|
| 105 |
+
# Linux/Mac:
|
| 106 |
+
source venv/bin/activate
|
| 107 |
+
pip install -r requirements.txt
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
### Verifikasi (semua harus sukses)
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
# 1. Generate sample data — 38 kab × 19 komoditas Jatim
|
| 114 |
+
python sample_data/generate_sample_data.py
|
| 115 |
+
# Expected: 5 CSV generated (kabupaten_jatim.csv, komoditas_constraints.csv,
|
| 116 |
+
# surplus_deficit.csv, weather_forecast.csv, historical_price_stats.csv)
|
| 117 |
+
|
| 118 |
+
# 2. Run all tests (205 tests)
|
| 119 |
+
pytest tests/ -v
|
| 120 |
+
# Expected: 205 passed in ~1.4s
|
| 121 |
+
|
| 122 |
+
# 3. Run end-to-end demo
|
| 123 |
+
python examples/run_demo.py
|
| 124 |
+
# Expected: ~32 matches, gross arbitrage ~Rp 16 miliar, latency ~1.5ms
|
| 125 |
+
|
| 126 |
+
# 4. Run latency benchmark
|
| 127 |
+
python benchmarks/latency.py
|
| 128 |
+
# Expected: highest p99 < 60ms (margin >88% vs 500ms target)
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
Kalau langkah 2 atau 3 gagal, lihat [Troubleshooting](#troubleshooting) di bawah.
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## Arsitektur 4-Lapis
|
| 136 |
+
|
| 137 |
+
```
|
| 138 |
+
Input: surplus_nodes[], deficit_nodes[], LogisticsContext, weather, historical_prices
|
| 139 |
+
|
| 140 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 141 |
+
│ LAYER 0 — Tier Classification (constraints.determine_tier) │
|
| 142 |
+
│ Klasifikasi setiap kab: Tier 1 HIGH (8 kota IHK PIHPS) atau │
|
| 143 |
+
│ Tier 2 MEDIUM (30 kab non-IHK Bapanas). │
|
| 144 |
+
│ Latency: <1ms (set lookup). │
|
| 145 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 146 |
+
↓
|
| 147 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 148 |
+
│ LAYER 1 — Hard Constraints (constraints.generate_candidates) │
|
| 149 |
+
│ 9 rules filter: komoditas match, distance≤max, age≤shelf, │
|
| 150 |
+
│ volume≥min, no self-match, emergency mode, pemda override, │
|
| 151 |
+
│ Bulog split, BBM-aware distance shrink. │
|
| 152 |
+
│ Output: candidate pairs (top-K per surplus by jarak). │
|
| 153 |
+
│ Latency: <50ms untuk 38×19 (~25k pasang potensial). │
|
| 154 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 155 |
+
↓
|
| 156 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 157 |
+
│ LAYER 2 — Multi-Objective Scoring (scoring.compute_score) │
|
| 158 |
+
│ 5-dimensi weighted: Distance 22% / Volume 22% / Price 22% / │
|
| 159 |
+
│ Perishability 18% / Climate 16%. │
|
| 160 |
+
│ 3 weight schemes: DEFAULT, RAMADAN, IMPORT_POLICY. │
|
| 161 |
+
│ Output: base_score 0-100 per pair. │
|
| 162 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 163 |
+
↓
|
| 164 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 165 |
+
│ LAYER 3 — Equity-Weighted Allocation (allocation.allocate) │
|
| 166 |
+
│ Final = base × equity_multiplier(IPM_deficit). │
|
| 167 |
+
│ Tier 1↔Tier 1 → Modified Gale-Shapley (Nobel 2012). │
|
| 168 |
+
│ Cross-tier / Tier 2 → Greedy with equity priority. │
|
| 169 |
+
│ Output: MatchResult[] dengan confidence label. │
|
| 170 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 171 |
+
↓
|
| 172 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 173 |
+
│ POST-PROCESSING (engine.run_matching) │
|
| 174 |
+
│ Tag flags (RAMADAN_SPIKE, EQUITY_BOOST_30, MADURA_CLUSTER, │
|
| 175 |
+
│ STALE_DATA_24H, HUMANITARIAN_PRIORITY, VOLUME_MISMATCH). │
|
| 176 |
+
│ Identifikasi unmatched + external_opportunities (ekspor). │
|
| 177 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 178 |
+
|
| 179 |
+
Output: MatchingReport(matches, unmatched_*, warnings, run_metadata)
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
**Why 4-layer?** Setiap layer bisa dioptimasi independent, testable secara isolated, dan early-exit di Layer 1 menghemat compute Layer 2/3 yang lebih mahal.
|
| 183 |
+
|
| 184 |
+
---
|
| 185 |
+
|
| 186 |
+
## Equity Multiplier (Kalibrasi BPS 2024)
|
| 187 |
+
|
| 188 |
+
Threshold dikalibrasi sesuai distribusi IPM 2024 BPS Jatim sehingga klaim "+30% boost untuk kab tertinggal" konkret applicable:
|
| 189 |
+
|
| 190 |
+
| IPM Range | Multiplier | Boost | Kab/Kota Jatim |
|
| 191 |
+
|---|---|---|---|
|
| 192 |
+
| `IPM < 68` | **1.30** | **+30%** | Sampang (66.72), Bangkalan (67.70) |
|
| 193 |
+
| `68 ≤ IPM < 72` | 1.15 | +15% | Sumenep, Probolinggo (kab), Bondowoso, Lumajang, Pamekasan, Pacitan, Pasuruan (kab), Situbondo, Jember, Madiun (kab) |
|
| 194 |
+
| `72 ≤ IPM < 78` | 1.05 | +5% | Bojonegoro, Banyuwangi, Tulungagung, Malang (kab), Magetan, Gresik, Mojokerto (kab), Lamongan, Tuban, Ngawi, Kediri (kab), dll |
|
| 195 |
+
| `IPM ≥ 78` | 1.00 | (no boost) | Sidoarjo, Kota Batu, Kota Surabaya, Kota Malang, Kota Kediri, Kota Madiun, dll |
|
| 196 |
+
|
| 197 |
+
**Mengapa kalibrasi:** Threshold v9 lama (`<65 → 1.30`) tidak pernah ter-trigger karena IPM terendah Jatim 2024 = Sampang 66.72. v10 menggeser threshold sehingga klaim "+30% boost" demonstrably valid.
|
| 198 |
+
|
| 199 |
+
**Catatan v11:** Boost +30% applies ke kab IPM <68 **saat menjadi deficit/penerima** (di-multiply dengan `base_score` dari Layer 2 untuk menghasilkan `final_score`). Demo run menunjukkan 2 dari 32 match trigger `EQUITY_BOOST_30` — keduanya Ngawi mengirim beras ke Madura (Bangkalan & Sampang). Saat Sampang sendiri jadi surplus (misal bawang), tidak ada boost karena bukan dia yang menjadi penerima. Honest framing: equity boost menggeser hasil saat low-IPM kab adalah sisi demand, bukan saat low-IPM kab disebutkan saja.
|
| 200 |
+
|
| 201 |
+
**Update IPM tahunan:** Saat BPS publish IPM baru (biasanya BRS Desember), edit di [`sample_data/generate_sample_data.py:KABUPATEN_DATA`](sample_data/generate_sample_data.py) sebagai source of truth, lalu mirror ke [`data_sources/bps.py:IPM_2024_JATIM`](data_sources/bps.py).
|
| 202 |
+
|
| 203 |
+
---
|
| 204 |
+
|
| 205 |
+
## 24 Skenario Edge Case
|
| 206 |
+
|
| 207 |
+
6 kategori, 24 skenario, semua tervalidasi pytest (bagian dari 205/205 total). Detail lengkap di [`docs/AUDIT_v10.md`](docs/AUDIT_v10.md) dan `AgriFlow_v11.docx` Section 5.5.5. **v11 menambahkan 5 commercial-reality scenarios** (C4 multi-holiday, D6 route blackout, E6 contract reserve, F1 grade substitution, F2 demand segmentation) di atas 19 skenario engineering edge case asli v10.
|
| 208 |
+
|
| 209 |
+
### Kategori A — Volume (4 skenario)
|
| 210 |
+
|
| 211 |
+
| Kode | Skenario | Test |
|
| 212 |
+
|---|---|---|
|
| 213 |
+
| A1 | Surplus 1-to-many (1 surplus split ke beberapa deficit) | `TestA1_OneToMany` |
|
| 214 |
+
| A2 | Many-to-1 (multiple surplus untuk 1 deficit besar) | `TestA2_ManyToOne` |
|
| 215 |
+
| A3 | Volume mismatch drastis (<20% ratio → flag warning) | `TestA3_VolumeMismatchDrastis` |
|
| 216 |
+
| A4 | Zero demand (suggest external opportunity) | `TestA4_ZeroDemand` |
|
| 217 |
+
|
| 218 |
+
### Kategori B — Spasial (3 skenario)
|
| 219 |
+
|
| 220 |
+
| Kode | Skenario | Test |
|
| 221 |
+
|---|---|---|
|
| 222 |
+
| B1 | Cross-tier match (Tier 1 ↔ Tier 2) | `TestB1_CrossTier` |
|
| 223 |
+
| B2 | Long distance (jarak > max_distance_km → REJECT) | `TestB2_LongDistance` |
|
| 224 |
+
| B3 | Cluster Madura (4 kab semua surplus → ekspor) | `TestB3_ClusterMadura` |
|
| 225 |
+
|
| 226 |
+
### Kategori C — Temporal (4 skenario)
|
| 227 |
+
|
| 228 |
+
| Kode | Skenario | Test |
|
| 229 |
+
|---|---|---|
|
| 230 |
+
| C1 | Ramadan/Idul Fitri spike (H-21 to H-1, RAMADAN_WEIGHTS) | `TestC1_RamadanSpike` |
|
| 231 |
+
| C2 | Pasca panen raya (oversupply, multiple match) | `TestC2_PostHarvest` |
|
| 232 |
+
| C3 | Stale data >24h (confidence drop bertingkat HIGH→MEDIUM→LOW) | `TestC3_StaleData` |
|
| 233 |
+
| **C4** | **Multi-holiday calendar (Imlek H-7, Natal H-21, school-start H-14)** dengan weight profile per event | `TestC4_HolidayCalendar` |
|
| 234 |
+
|
| 235 |
+
### Kategori D — Disrupsi (6 skenario)
|
| 236 |
+
|
| 237 |
+
| Kode | Skenario | Test |
|
| 238 |
+
|---|---|---|
|
| 239 |
+
| D1 | Banjir rute (BMKG hujan >50mm → climate_score 0.3) | `TestD1_BanjirRute` |
|
| 240 |
+
| D2 | Komoditas hampir rusak (harvest age + transit > shelf) | `TestD2_KomoditasRusak` |
|
| 241 |
+
| D3 | Harga anomali (>3σ dari rolling median → exclude) | `TestD3_HargaAnomali` |
|
| 242 |
+
| D4 | Erupsi gunung (PVMBG MAGMA → UNREACHABLE) | `TestD4_ErupsiGunung` |
|
| 243 |
+
| D5 | Banjir multi-kab (BNPB DIBI → emergency mode) | `TestD5_BanjirMultiKab` |
|
| 244 |
+
| **D6** | **Route blackout (mudik H+1 Idul Fitri, demo Trans-Jawa, Suramadu maintenance)** dengan wildcard support | `TestD6_RouteBlackout` |
|
| 245 |
+
|
| 246 |
+
### Kategori E — Politis & Kebijakan (6 skenario)
|
| 247 |
+
|
| 248 |
+
| Kode | Skenario | Test |
|
| 249 |
+
|---|---|---|
|
| 250 |
+
| E1 | Equity tie-break (IPM lebih rendah menang otomatis) | `TestE1_EquityTieBreak` |
|
| 251 |
+
| E2 | Pemda override (`do_not_export_<komoditas>` flag) | `TestE2_PemdaOverride` |
|
| 252 |
+
| E3 | Bulog priority (60% reserve, sisa 40% private) | `TestE3_BulogPriority` |
|
| 253 |
+
| E4 | Import policy aktif (IMPORT_POLICY_WEIGHTS, price weight ↓) | `TestE4_ImportPolicy` |
|
| 254 |
+
| E5 | BBM naik (max_distance shrink, logistics cost ↑) | `TestE5_BBMNaik` |
|
| 255 |
+
| **E6** | **Contract reserve generalisasi (Carrefour MoU 70%, Indofood gula 50%, dll)** — Bulog pattern di-generalisir | `TestE6_ContractReserve` |
|
| 256 |
+
|
| 257 |
+
### Kategori F — Kualitas & Segmentasi Komersial (2 skenario, NEW v11)
|
| 258 |
+
|
| 259 |
+
| Kode | Skenario | Test |
|
| 260 |
+
|---|---|---|
|
| 261 |
+
| **F1** | **Grade substitution** — surplus `beras_premium` dapat memenuhi demand `beras_medium` (opt-in `allow_grade_substitution=True`); reverse direction tetap REJECT | `TestF1_GradeSubstitution` |
|
| 262 |
+
| **F2** | **Demand segmentation** — `RETAIL` / `HORECA` / `GOVERNMENT` / `INDUSTRIAL` coexist untuk kab + komoditas yang sama; tiap segment di-match independen dengan flag `SEGMENT_<NAME>` | `TestF2_DemandSegmentation` |
|
| 263 |
+
|
| 264 |
+
---
|
| 265 |
+
|
| 266 |
+
## API Usage
|
| 267 |
+
|
| 268 |
+
### Programmatic API
|
| 269 |
+
|
| 270 |
+
```python
|
| 271 |
+
from matching_engine import (
|
| 272 |
+
run_matching, SupplyNode, DemandNode,
|
| 273 |
+
Kabupaten, Tier, Commodity, LogisticsContext,
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
# Setup kabupaten (real koordinat & IPM 2024 BPS)
|
| 277 |
+
kediri = Kabupaten(
|
| 278 |
+
id="3506", nama="Kediri",
|
| 279 |
+
latitude=-7.796, longitude=112.170,
|
| 280 |
+
ipm=74.50, tier=Tier.MEDIUM,
|
| 281 |
+
)
|
| 282 |
+
surabaya = Kabupaten(
|
| 283 |
+
id="3578", nama="Kota Surabaya",
|
| 284 |
+
latitude=-7.2575, longitude=112.7521,
|
| 285 |
+
ipm=84.69, tier=Tier.HIGH,
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
# Setup komoditas (constraint per komoditas)
|
| 289 |
+
cabai = Commodity(
|
| 290 |
+
code="cabai_merah", nama="Cabai Merah Besar",
|
| 291 |
+
max_distance_km=200, min_viable_tons=1.0,
|
| 292 |
+
max_fresh_age_days=5,
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
# Run matching
|
| 296 |
+
report = run_matching(
|
| 297 |
+
surplus_nodes=[
|
| 298 |
+
SupplyNode(kediri, cabai, volume_tons=80, price_per_kg=30000),
|
| 299 |
+
],
|
| 300 |
+
deficit_nodes=[
|
| 301 |
+
DemandNode(surabaya, cabai, volume_tons=80, price_per_kg=60000),
|
| 302 |
+
],
|
| 303 |
+
logistics=LogisticsContext(),
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
# Inspect hasil
|
| 307 |
+
for m in report.matches:
|
| 308 |
+
print(f"{m.surplus.kabupaten.nama} → {m.deficit.kabupaten.nama}")
|
| 309 |
+
print(f" Volume: {m.matched_volume_tons}t @ {m.distance_km:.0f}km")
|
| 310 |
+
print(f" Score: {m.final_score:.1f} (base {m.base_score:.1f} × {m.equity_multiplier})")
|
| 311 |
+
print(f" Confidence: {m.confidence.value}, Flags: {m.flags}")
|
| 312 |
+
# Realistic output dari demo Jatim:
|
| 313 |
+
# Probolinggo → Kota Surabaya: 120.0t Bawang Merah
|
| 314 |
+
# Score: 89.5 (base 89.5 × 1.00), Confidence: MEDIUM, Flags: []
|
| 315 |
+
# Bangkalan → Gresik: 40.0t Bawang Merah
|
| 316 |
+
# Score: 89.4 (base 85.1 × 1.05), Confidence: MEDIUM, Flags: ['EQUITY_BOOST_05', 'MADURA_CLUSTER']
|
| 317 |
+
# Ngawi → Bangkalan: 250.0t Beras Premium
|
| 318 |
+
# Score: 82.8 (base 63.7 × 1.30), Confidence: MEDIUM, Flags: ['EQUITY_BOOST_30', 'MADURA_CLUSTER']
|
| 319 |
+
|
| 320 |
+
# Output:
|
| 321 |
+
# Kediri → Kota Surabaya
|
| 322 |
+
# Volume: 80.0t @ 65km
|
| 323 |
+
# Score: 89.5 (base 89.5 × 1.0)
|
| 324 |
+
# Confidence: MEDIUM, Flags: []
|
| 325 |
+
|
| 326 |
+
print(f"\nLatency: {report.run_metadata['latency_ms']}ms")
|
| 327 |
+
print(f"Candidate pairs evaluated: {report.run_metadata['candidate_pairs_evaluated']}")
|
| 328 |
+
print(f"Warnings: {len(report.warnings)}")
|
| 329 |
+
```
|
| 330 |
+
|
| 331 |
+
### Advanced — Skenario Override
|
| 332 |
+
|
| 333 |
+
```python
|
| 334 |
+
from matching_engine.constraints import set_bulog_procurement
|
| 335 |
+
from datetime import datetime
|
| 336 |
+
|
| 337 |
+
# Skenario E3: Bulog procurement aktif untuk Madiun
|
| 338 |
+
set_bulog_procurement({"3519"})
|
| 339 |
+
|
| 340 |
+
# Skenario E4: Import policy aktif (bobot price diturunkan)
|
| 341 |
+
report = run_matching(
|
| 342 |
+
surplus_nodes=[...],
|
| 343 |
+
deficit_nodes=[...],
|
| 344 |
+
import_policy_active=True, # IMPORT_POLICY_WEIGHTS
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
# Skenario C1: Force Ramadan mode untuk testing
|
| 348 |
+
report = run_matching(
|
| 349 |
+
surplus_nodes=[...],
|
| 350 |
+
deficit_nodes=[...],
|
| 351 |
+
reference_date=datetime(2026, 3, 6), # H-14 Idul Fitri 2026
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
# Skenario E5: BBM naik 20%
|
| 355 |
+
from matching_engine.models import LogisticsContext
|
| 356 |
+
report = run_matching(
|
| 357 |
+
surplus_nodes=[...],
|
| 358 |
+
deficit_nodes=[...],
|
| 359 |
+
logistics=LogisticsContext(
|
| 360 |
+
bbm_price_idr_per_liter=12000,
|
| 361 |
+
bbm_price_baseline=10000,
|
| 362 |
+
),
|
| 363 |
+
)
|
| 364 |
+
```
|
| 365 |
+
|
| 366 |
+
### Advanced — Force Algorithm Strategy
|
| 367 |
+
|
| 368 |
+
```python
|
| 369 |
+
# Force stable matching (Tier 1 algorithm)
|
| 370 |
+
report = run_matching(..., force_strategy="stable")
|
| 371 |
+
|
| 372 |
+
# Force greedy (Tier 2 algorithm) untuk testing
|
| 373 |
+
report = run_matching(..., force_strategy="greedy")
|
| 374 |
+
|
| 375 |
+
# Auto-detect (default): Tier 1↔Tier 1 pairs → stable, else → greedy
|
| 376 |
+
report = run_matching(...)
|
| 377 |
+
```
|
| 378 |
+
|
| 379 |
+
---
|
| 380 |
+
|
| 381 |
+
## Performance & Validation
|
| 382 |
+
|
| 383 |
+
### Test Suite
|
| 384 |
+
|
| 385 |
+
```bash
|
| 386 |
+
$ pytest tests/ --tb=short
|
| 387 |
+
============================= test session starts =============================
|
| 388 |
+
collected 106 items
|
| 389 |
+
|
| 390 |
+
tests/test_layer0_tier.py ................ [ 15%]
|
| 391 |
+
tests/test_layer1_constraints.py ................... [ 33%]
|
| 392 |
+
tests/test_layer2_scoring.py ....................... [ 54%]
|
| 393 |
+
tests/test_layer3_allocation.py .............. [ 67%]
|
| 394 |
+
tests/test_scenarios_disruption.py ......... [ 76%]
|
| 395 |
+
tests/test_scenarios_political.py ........ [ 83%]
|
| 396 |
+
tests/test_scenarios_spatial.py ...... [ 89%]
|
| 397 |
+
tests/test_scenarios_temporal.py ....... [ 96%]
|
| 398 |
+
tests/test_scenarios_volume.py .... [100%]
|
| 399 |
+
|
| 400 |
+
============================= 106 passed in 0.16s =============================
|
| 401 |
+
```
|
| 402 |
+
|
| 403 |
+
### Latency Benchmark
|
| 404 |
+
|
| 405 |
+
```bash
|
| 406 |
+
$ python benchmarks/latency.py
|
| 407 |
+
```
|
| 408 |
+
|
| 409 |
+
| Configuration | N (s × d) | p50 | p95 | p99 | Max |
|
| 410 |
+
|---|---|---|---|---|---|
|
| 411 |
+
| Sample data CSV (realistic) | 40 × 33 | 0.99 ms | 1.26 ms | 1.38 ms | 1.42 ms |
|
| 412 |
+
| Synthetic full Jatim (38×19) | 361 × 361 | 48.37 ms | 53.67 ms | 55.53 ms | 58.43 ms |
|
| 413 |
+
| Stress 100×100 (national scale) | 100 × 100 | 12.62 ms | 14.82 ms | 15.51 ms | 15.65 ms |
|
| 414 |
+
| Stress 200×200 | 200 × 200 | 25.47 ms | 26.92 ms | 27.54 ms | 27.76 ms |
|
| 415 |
+
|
| 416 |
+
**Verdict:** PASS — semua p99 < 500ms target. Highest p99 = 55.53ms (margin 88.9%).
|
| 417 |
+
|
| 418 |
+
### National Scale (Indonesia 514 kab)
|
| 419 |
+
|
| 420 |
+
⚠ **HONEST DISCLOSURE:** Engine v11 (sama dengan v10 — claim-precision pass, bukan engine change) saat ini BELUM siap untuk produksi nasional 514 kab. Lihat [`docs/AUDIT_v10.md`](docs/AUDIT_v10.md) Section 3.2 untuk detail. Optimization roadmap (spatial indexing, per-provinsi batching, parallel) sudah ter-quantify untuk Y2-Y3.
|
| 421 |
+
|
| 422 |
+
```bash
|
| 423 |
+
$ python benchmarks/national_scale.py
|
| 424 |
+
```
|
| 425 |
+
|
| 426 |
+
| Scale | Workload | p99 | vs target |
|
| 427 |
+
|---|---|---|---|
|
| 428 |
+
| Provinsi Jatim baseline | 333×389 | **14.2ms** | ✅ 35× under |
|
| 429 |
+
| Multi-provinsi (100 kab) | 948×952 | **94.5ms** | ✅ 5× under |
|
| 430 |
+
| Setengah Indonesia (250 kab) | 2326×2424 | **541.8ms** | ⚠ 1.08× over |
|
| 431 |
+
| **Full Indonesia (514 kab)** | **4859×4907** | **2223.3ms** | ❌ **4.4× over** |
|
| 432 |
+
|
| 433 |
+
---
|
| 434 |
+
|
| 435 |
+
## Validasi Empiris Equity (Baseline Comparison)
|
| 436 |
+
|
| 437 |
+
v12 menambah validasi kuantitatif klaim equity: membandingkan AgriFlow vs 4 strategi alternatif (pure-greedy, uniform, proportional-to-deficit, agriflow-smoothed) pada data Jatim yang sama, di dua rezim pasokan. Reproducible: `python benchmarks/equity_comparison_constrained.py` (output di `benchmarks/output/equity_comparison_constrained.md`).
|
| 438 |
+
|
| 439 |
+
Metrik (volume-weighted): Coverage, Gini (Lorenz), Atkinson(ε=1), fulfillment Sampang (IPM 66.72) + Bangkalan (67.70).
|
| 440 |
+
|
| 441 |
+
### Rezim A — pasokan MELIMPAH (surplus 8612t > defisit 5249t)
|
| 442 |
+
|
| 443 |
+
| Strategi | Coverage | Gini | Atk(1.0) | Sampang | Bangkalan |
|
| 444 |
+
|---|---|---|---|---|---|
|
| 445 |
+
| pure_greedy | 0.890 | 0.093 | 0.113 | 1.00 | 1.00 |
|
| 446 |
+
| **agriflow** | 0.880 | 0.099 | 0.114 | 1.00 | 1.00 |
|
| 447 |
+
| uniform | 0.993 | 0.008 | 0.094 | 1.00 | 1.00 |
|
| 448 |
+
| proportional | 0.993 | 0.007 | 0.094 | 1.00 | 1.00 |
|
| 449 |
+
|
| 450 |
+
> **Jujur:** saat pasokan melimpah, equity nyaris tak terlihat — Sampang/Bangkalan sudah penuh terlayani di semua strategi, dan Gini AgriFlow (0.099) bahkan sedikit > greedy (0.093). Equity tidak punya panggung saat barang cukup untuk semua.
|
| 451 |
+
|
| 452 |
+
### Rezim B — pasokan LANGKA (shock La Nina banjir Ngawi+Madiun+Bojonegoro, surplus 3962t < defisit 5249t, −32.5%)
|
| 453 |
+
|
| 454 |
+
| Strategi | Coverage | Gini | Atk(1.0) | Sampang | Bangkalan |
|
| 455 |
+
|---|---|---|---|---|---|
|
| 456 |
+
| pure_greedy | 0.665 | 0.302 | 0.984 | **0.00** | **0.20** |
|
| 457 |
+
| **agriflow** | 0.665 | 0.290 | 0.938 | **1.00** | **1.00** |
|
| 458 |
+
| uniform | 0.627 | 0.252 | 0.186 | 1.00 | 1.00 |
|
| 459 |
+
| proportional | 0.702 | 0.159 | 0.137 | 0.78 | 0.78 |
|
| 460 |
+
|
| 461 |
+
> **Inti:** saat langka — ketika paling penting — greedy menelantarkan Sampang (0%) & Bangkalan (20%); **AgriFlow jamin keduanya 100% dengan biaya coverage agregat NOL** (0.665 = 0.665), Gini turun 0.302 → 0.290. Inilah nilai equity AgriFlow yang terukur. Klaim presisi: *"memprioritaskan kab terlemah saat pasokan langka"*, bukan *"menurunkan Gini secara umum"*.
|
| 462 |
+
|
| 463 |
+
**Cliff tidak material:** `agriflow_smoothed` (linear-interp) ≈ step-function di kedua rezim; menggeser threshold IPM +1 poin hanya memindahkan 2 dari 38 kab antar tier.
|
| 464 |
+
|
| 465 |
+
**Limitasi (roadmap v2):** alokasi binary di level demand-node (penuh/tidak), bukan proporsional → sensitivity threshold degenerate begitu kab terlindungi; belum ada provable bound (cf. Food Drop / Diaby 2024); equity multiplier masih heuristic post-score (cf. Firouz 2021).
|
| 466 |
+
|
| 467 |
+
---
|
| 468 |
+
|
| 469 |
+
## Data Sources
|
| 470 |
+
|
| 471 |
+
8 connector dengan dual-mode (mock CSV + live API), graceful fallback:
|
| 472 |
+
|
| 473 |
+
| Connector | Sumber | Frekuensi | Auth | Tier |
|
| 474 |
+
|---|---|---|---|---|
|
| 475 |
+
| [`pihps_bi.py`](data_sources/pihps_bi.py) | Bank Indonesia PIHPS | Harian (cut-off 13:00 WIB) | Tidak ada (scrape publik) | Tier 1 |
|
| 476 |
+
| [`bapanas.py`](data_sources/bapanas.py) | Panel Harga Bapanas | Mingguan (Senin) | Tidak ada | Tier 2 |
|
| 477 |
+
| [`bps.py`](data_sources/bps.py) | BPS WebAPI (IPM, produksi) | Tahunan (BRS Desember) | API key gratis | Both |
|
| 478 |
+
| [`bmkg.py`](data_sources/bmkg.py) | BMKG / Open-Meteo (cuaca) | 3-6 jam refresh | Tidak ada (Open-Meteo) | Both |
|
| 479 |
+
| [`pvmbg.py`](data_sources/pvmbg.py) | PVMBG MAGMA (gunung api) | Realtime saat status berubah | Tidak ada | Both |
|
| 480 |
+
| [`bnpb.py`](data_sources/bnpb.py) | BNPB DIBI (bencana) | Realtime | Tidak ada | Both |
|
| 481 |
+
| [`google_maps.py`](data_sources/google_maps.py) | Google Routes / OSRM fallback | Realtime per request | Google API key (paid) / OSRM gratis | Both |
|
| 482 |
+
| [`hijri_calendar.py`](data_sources/hijri_calendar.py) | Aladhan API + hardcoded | Statis | Tidak ada | Both |
|
| 483 |
+
|
| 484 |
+
### Fail-safe Strategy
|
| 485 |
+
|
| 486 |
+
- Live API gagal → fallback ke mock CSV / hardcoded data
|
| 487 |
+
- Weather data tidak tersedia → climate_score = 0.7 (neutral)
|
| 488 |
+
- BPS API gagal → fallback ke `IPM_2024_JATIM` hardcoded
|
| 489 |
+
- BMKG butuh adm4 mapping yang tidak ada → auto-fallback ke Open-Meteo
|
| 490 |
+
- OSRM down → haversine geodesic + asumsi 60 km/h
|
| 491 |
+
|
| 492 |
+
---
|
| 493 |
+
|
| 494 |
+
## Project Structure
|
| 495 |
+
|
| 496 |
+
```
|
| 497 |
+
agriflow_engine/
|
| 498 |
+
├── matching_engine/ # Core engine (5 modules, ~1000 lines)
|
| 499 |
+
│ ├── __init__.py # Public API
|
| 500 |
+
│ ├── models.py # Dataclasses (Kabupaten, Commodity, MatchResult, ...)
|
| 501 |
+
│ ├── constraints.py # Layer 0 + Layer 1 (9 hard constraints)
|
| 502 |
+
│ ├── scoring.py # Layer 2 (5-dim multi-objective scoring)
|
| 503 |
+
│ ├── allocation.py # Layer 3 (Gale-Shapley + Greedy + Equity)
|
| 504 |
+
│ └── engine.py # Main orchestrator + 19 skenario handlers
|
| 505 |
+
├── data_sources/ # 8 connector dual-mode (mock + live)
|
| 506 |
+
│ ├── pihps_bi.py # Tier 1 PIHPS BI
|
| 507 |
+
│ ├── bapanas.py # Tier 2 Bapanas
|
| 508 |
+
│ ├── bps.py # IPM 2024 + produksi BPS
|
| 509 |
+
│ ├── bmkg.py # Cuaca BMKG/Open-Meteo
|
| 510 |
+
│ ├── pvmbg.py # Erupsi gunung PVMBG MAGMA
|
| 511 |
+
│ ├── bnpb.py # Bencana BNPB DIBI
|
| 512 |
+
│ ├── google_maps.py # Routing Google/OSRM
|
| 513 |
+
│ └── hijri_calendar.py # Ramadan/Idul Fitri Aladhan
|
| 514 |
+
├── sample_data/ # CSV 38 kab × 19 komoditas Jatim
|
| 515 |
+
│ ├── generate_sample_data.py # Source of truth — regenerate CSV
|
| 516 |
+
│ ├── loader.py # CSV → engine objects
|
| 517 |
+
│ ├── kabupaten_jatim.csv # 38 kab + IPM 2024 + koordinat
|
| 518 |
+
│ ├── komoditas_constraints.csv # 19 komoditas + spec
|
| 519 |
+
│ ├── surplus_deficit.csv # 73 row sample workload
|
| 520 |
+
│ ├── weather_forecast.csv # 10 route forecast
|
| 521 |
+
│ └── historical_price_stats.csv # 19 commodity rolling stats
|
| 522 |
+
├── tests/ # 106 pytest test
|
| 523 |
+
│ ├── conftest.py # Fixtures (17 kab Jatim + factory)
|
| 524 |
+
│ ├── test_layer0_tier.py # 16 test (tier classification)
|
| 525 |
+
│ ├── test_layer1_constraints.py # 19 test (haversine + viability + Bulog)
|
| 526 |
+
│ ├── test_layer2_scoring.py # 23 test (5-dim scoring + weight schemes)
|
| 527 |
+
│ ├── test_layer3_allocation.py # 14 test (equity + stable + greedy)
|
| 528 |
+
│ ├── test_scenarios_volume.py # 4 test (A1-A4)
|
| 529 |
+
│ ├── test_scenarios_spatial.py # 6 test (B1-B3)
|
| 530 |
+
│ ├── test_scenarios_temporal.py # 7 test (C1-C3)
|
| 531 |
+
│ ├── test_scenarios_disruption.py # 9 test (D1-D5)
|
| 532 |
+
│ └── test_scenarios_political.py # 8 test (E1-E5)
|
| 533 |
+
├── examples/
|
| 534 |
+
│ └── run_demo.py # End-to-end demo dengan output formatted
|
| 535 |
+
├── benchmarks/
|
| 536 |
+
│ ├── latency.py # Multi-config provincial benchmark
|
| 537 |
+
�� └── national_scale.py # National scale stress test (514 kab)
|
| 538 |
+
├── docs/
|
| 539 |
+
│ ├── generate_v11_docx.py # Proposal v11 docx generator (claim-precision pass; current)
|
| 540 |
+
│ ├── generate_v10_docx.py # Proposal v10 docx generator (history)
|
| 541 |
+
│ └── AUDIT_v10.md # Audit lengkap (consistency + national scale analysis)
|
| 542 |
+
├── README.md # This file
|
| 543 |
+
├── requirements.txt # Python dependencies
|
| 544 |
+
└── venv/ # (gitignored) virtual env
|
| 545 |
+
```
|
| 546 |
+
|
| 547 |
+
---
|
| 548 |
+
|
| 549 |
+
## Development Guide
|
| 550 |
+
|
| 551 |
+
### Setup Development Environment
|
| 552 |
+
|
| 553 |
+
```bash
|
| 554 |
+
git clone https://github.com/masterA88/agriflow_engine.git
|
| 555 |
+
cd agriflow_engine
|
| 556 |
+
python -m venv venv
|
| 557 |
+
source venv/bin/activate # or venv\Scripts\activate on Windows
|
| 558 |
+
pip install -r requirements.txt
|
| 559 |
+
pip install python-docx # untuk regenerate proposal docx
|
| 560 |
+
```
|
| 561 |
+
|
| 562 |
+
### Workflow
|
| 563 |
+
|
| 564 |
+
1. **Edit code** di `matching_engine/` atau `data_sources/`
|
| 565 |
+
2. **Run test** sebelum commit: `pytest tests/ -v`
|
| 566 |
+
3. **Update sample data** kalau ubah threshold/komoditas: `python sample_data/generate_sample_data.py`
|
| 567 |
+
4. **Run demo** untuk smoke test: `python examples/run_demo.py`
|
| 568 |
+
5. **Run benchmark** kalau perubahan di hot path: `python benchmarks/latency.py`
|
| 569 |
+
6. **Regenerate proposal** kalau perubahan di logic: `python docs/generate_v10_docx.py`
|
| 570 |
+
|
| 571 |
+
### Add New Skenario
|
| 572 |
+
|
| 573 |
+
1. Tambah test class di file yang sesuai (mis. `tests/test_scenarios_volume.py`)
|
| 574 |
+
2. Tambah behavior di `matching_engine/engine.py` post-processing atau Layer yang relevan
|
| 575 |
+
3. Update `AgriFlow_v10.docx` Section 5.5.5 (regenerate via `docs/generate_v10_docx.py`)
|
| 576 |
+
4. Pastikan `pytest tests/ -v` masih PASS
|
| 577 |
+
|
| 578 |
+
### Add New Komoditas
|
| 579 |
+
|
| 580 |
+
1. Tambah row di [`sample_data/generate_sample_data.py:KOMODITAS_DATA`](sample_data/generate_sample_data.py)
|
| 581 |
+
2. Tambah row di [`matching_engine/constraints.py:COMMODITY_SPECS`](matching_engine/constraints.py) (samakan max_distance/min_viable/max_fresh_age)
|
| 582 |
+
3. Run `python sample_data/generate_sample_data.py` untuk regenerate CSV
|
| 583 |
+
4. Update assertion di test kalau komoditas count check
|
| 584 |
+
|
| 585 |
+
### Update IPM Tahunan (saat BPS publish data baru)
|
| 586 |
+
|
| 587 |
+
1. Edit [`sample_data/generate_sample_data.py:KABUPATEN_DATA`](sample_data/generate_sample_data.py) (source of truth)
|
| 588 |
+
2. Mirror ke [`data_sources/bps.py:IPM_2024_JATIM`](data_sources/bps.py)
|
| 589 |
+
3. Run `python sample_data/generate_sample_data.py` untuk regenerate CSV
|
| 590 |
+
4. Re-evaluate equity threshold di [`matching_engine/allocation.py:38`](matching_engine/allocation.py) — apakah masih meaningful trigger untuk distribusi baru?
|
| 591 |
+
5. Run `pytest tests/ -v` — beberapa test mungkin perlu update kalau IPM bergeser
|
| 592 |
+
|
| 593 |
+
---
|
| 594 |
+
|
| 595 |
+
## Status & Roadmap
|
| 596 |
+
|
| 597 |
+
### Current Status: ✅ Provincial-Ready (Jatim)
|
| 598 |
+
|
| 599 |
+
- [x] 4-layer architecture implemented
|
| 600 |
+
- [x] 19 skenario edge case handled
|
| 601 |
+
- [x] 205/205 tests passing
|
| 602 |
+
- [x] Latency p99 1.4ms (sample) - 55.5ms (stress)
|
| 603 |
+
- [x] Equity multiplier kalibrasi BPS 2024 — Sampang & Bangkalan menerima +30% boost
|
| 604 |
+
- [x] 8 data source connector dual-mode
|
| 605 |
+
- [x] Cross-platform demo (Windows/Linux/Mac)
|
| 606 |
+
- [x] Reproducible documentation generator
|
| 607 |
+
|
| 608 |
+
### Roadmap to National Scale (Y2-Y3)
|
| 609 |
+
|
| 610 |
+
⚠ **Honest disclosure:** Engine v11 (sama core dengan v10) saat ini BELUM siap untuk produksi nasional 514 kab. p99 untuk full Indonesia = 2.2 detik (4.4× over 500ms target).
|
| 611 |
+
|
| 612 |
+
**Optimization plan** (lihat [`docs/AUDIT_v10.md`](docs/AUDIT_v10.md) Section 6):
|
| 613 |
+
|
| 614 |
+
| Quick Win | Effort | Impact |
|
| 615 |
+
|---|---|---|
|
| 616 |
+
| Fix double-haversine bug di `generate_candidates` | 1 jam | 30-40% speedup Layer 1 |
|
| 617 |
+
| Add geohash precision-5 spatial pre-filter | 1-2 hari | 25-50× speedup Layer 1 |
|
| 618 |
+
| Multiprocessing per komoditas | 2-3 hari | Up to 19× speedup |
|
| 619 |
+
| Distance matrix precompute (Redis cache) | 4 jam | 5-10× speedup |
|
| 620 |
+
| Per-provinsi batching | 1 minggu | 5-15× speedup |
|
| 621 |
+
|
| 622 |
+
**Combined estimate:** ~100-200× speedup → bring p99 dari 2200ms ke ~10-20ms untuk 514 kab nasional.
|
| 623 |
+
|
| 624 |
+
### Data/Coverage Expansion (Y2)
|
| 625 |
+
|
| 626 |
+
- [ ] `TIER_1_KOTA_IHK`: 8 kota Jatim → ~90 kota IHK Indonesia
|
| 627 |
+
- [ ] `IPM_2024_JATIM` → `IPM_2024_INDONESIA` (514 kab/kota)
|
| 628 |
+
- [ ] Cluster definitions: Madura → 10-20 cluster nasional
|
| 629 |
+
- [ ] `GUNUNG_KABUPATEN_MAP`: 6 gunung Jatim → 130+ gunung api Indonesia
|
| 630 |
+
- [ ] Sample data: 38 kab → 514 kab synthetic + real
|
| 631 |
+
|
| 632 |
+
### Architectural Expansion (Y2-Y3)
|
| 633 |
+
|
| 634 |
+
- [ ] Inter-island logistics (transport mode: truck/ferry/cargo plane)
|
| 635 |
+
- [ ] Equity threshold recalibration nasional (range IPM 50-85)
|
| 636 |
+
- [ ] CI/CD pipeline (GitHub Actions: pytest + benchmark assertions)
|
| 637 |
+
- [ ] FastAPI wrapper untuk REST API production
|
| 638 |
+
- [ ] Hybrid stable+greedy untuk skenario campuran tier
|
| 639 |
+
- [ ] LLM integration (Gemini + Sahabat-AI Bahasa Daerah)
|
| 640 |
+
|
| 641 |
+
---
|
| 642 |
+
|
| 643 |
+
## Documentation
|
| 644 |
+
|
| 645 |
+
| Document | Lokasi | Deskripsi |
|
| 646 |
+
|---|---|---|
|
| 647 |
+
| **Proposal v11** (current) | Generate via `python docs/generate_v11_docx.py` | Proposal lengkap dengan claim-precision pass — setiap klaim diferensiator dirujuk ke `file:line` di `matching_engine/` (docx gitignored — internal team artifact) |
|
| 648 |
+
| **Proposal v10** (history) | Generate via `python docs/generate_v10_docx.py` | Versi sebelum claim-precision pass; tetap dipertahankan sebagai history |
|
| 649 |
+
| **Audit v10** | [`docs/AUDIT_v10.md`](docs/AUDIT_v10.md) | Consistency check + national scale analysis (engine code identik di v11) |
|
| 650 |
+
| **README** | This file | Quick reference + getting started |
|
| 651 |
+
| **Generator script** | [`docs/generate_v11_docx.py`](docs/generate_v11_docx.py) | Regenerate proposal v11 docx dari source |
|
| 652 |
+
| **Code comments** | All `.py` files | Inline docstrings dengan reference ke proposal section |
|
| 653 |
+
|
| 654 |
+
---
|
| 655 |
+
|
| 656 |
+
## Troubleshooting
|
| 657 |
+
|
| 658 |
+
### "ModuleNotFoundError: No module named 'matching_engine'"
|
| 659 |
+
|
| 660 |
+
Make sure di project root saat run script:
|
| 661 |
+
```bash
|
| 662 |
+
cd agriflow_engine
|
| 663 |
+
python examples/run_demo.py
|
| 664 |
+
```
|
| 665 |
+
|
| 666 |
+
Atau install sebagai package:
|
| 667 |
+
```bash
|
| 668 |
+
pip install -e . # editable install (kalau pyproject.toml ada)
|
| 669 |
+
```
|
| 670 |
+
|
| 671 |
+
### Demo crash di Windows: "UnicodeEncodeError: 'charmap' codec"
|
| 672 |
+
|
| 673 |
+
v10 sudah include UTF-8 fix. Pastikan pakai versi terbaru:
|
| 674 |
+
```bash
|
| 675 |
+
git pull
|
| 676 |
+
```
|
| 677 |
+
|
| 678 |
+
Atau set env variable manual:
|
| 679 |
+
```bash
|
| 680 |
+
set PYTHONIOENCODING=utf-8
|
| 681 |
+
python examples/run_demo.py
|
| 682 |
+
```
|
| 683 |
+
|
| 684 |
+
### "FileNotFoundError: sample_data/kabupaten_jatim.csv"
|
| 685 |
+
|
| 686 |
+
CSV belum di-generate. Run dulu:
|
| 687 |
+
```bash
|
| 688 |
+
python sample_data/generate_sample_data.py
|
| 689 |
+
```
|
| 690 |
+
|
| 691 |
+
### pytest collect 0 items
|
| 692 |
+
|
| 693 |
+
Pastikan run dari project root, bukan dari `tests/`:
|
| 694 |
+
```bash
|
| 695 |
+
cd agriflow_engine # not cd agriflow_engine/tests
|
| 696 |
+
pytest tests/
|
| 697 |
+
```
|
| 698 |
+
|
| 699 |
+
---
|
| 700 |
+
|
| 701 |
+
## License & Credits
|
| 702 |
+
|
| 703 |
+
**Lisensi:** Hackathon submission. Code internal AgriFlow team.
|
| 704 |
+
|
| 705 |
+
**Credits:**
|
| 706 |
+
- **Algoritma:** Gale-Shapley sebagai operational primitive untuk centralized bipartite assignment dengan equity weighting (Gale & Shapley 1962; cf. Galichon 2021) — fires saat kedua kab Tier 1; Greedy multi-objective + equity priority untuk Tier 2 dan cross-tier
|
| 707 |
+
- **Inspirasi platform:** eNAM (India), MealConnect (Feeding America), FEWS NET (USAID), Uber matching pattern, Food Drop Indiana
|
| 708 |
+
- **Data sumber:** Bank Indonesia (PIHPS), Bapanas (Panel Harga), BPS (BRS Desember 2024 IPM), BMKG, PVMBG MAGMA, BNPB DIBI
|
| 709 |
+
|
| 710 |
+
**Tim AgriFlow:** Hackathon DIGDAYA × PIDI 2026.
|
| 711 |
+
|
| 712 |
+
**Citing:**
|
| 713 |
+
```
|
| 714 |
+
AgriFlow Team. (2026). AgriFlow Matching Engine v11.0:
|
| 715 |
+
Purpose-Built Sub-National Indonesian Food Matching Engine
|
| 716 |
+
dengan Stable Matching (Tier 1) + Greedy-Equity-Priority (Tier 2) + IPM-Based Equity Multiplier (BPS 2024).
|
| 717 |
+
PIDI DIGDAYA × Hackathon 2026, Bank Indonesia.
|
| 718 |
+
```
|
| 719 |
+
|
| 720 |
+
---
|
| 721 |
+
|
| 722 |
+
## Pertanyaan & Kontak
|
| 723 |
+
|
| 724 |
+
- Issue tracker: GitHub Issues (this repo)
|
| 725 |
+
- Proposal lengkap: regenerate via `python docs/generate_v10_docx.py` (output: `docs/AgriFlow_v10.docx`, gitignored)
|
| 726 |
+
- Audit teknis: [`docs/AUDIT_v10.md`](docs/AUDIT_v10.md)
|
| 727 |
+
|
| 728 |
+
**Deteksi. Prediksi. Distribusi. Untuk Semua.**
|
| 729 |
+
*AgriFlow — Purpose-Built Sub-National AI Matching Engine for Indonesian Food Distribution.*
|
README_v13.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Language / Bahasa: [English](./README.en.md) · **Bahasa Indonesia**
|
| 2 |
+
|
| 3 |
+
<p align="center"><img src="assets/logo-mark.png" alt="AgriFlow logo" width="300"/></p>
|
| 4 |
+
|
| 5 |
+
<h1 align="center">AgriFlow</h1>
|
| 6 |
+
|
| 7 |
+
<p align="center">
|
| 8 |
+
<strong>AI-Powered Food Security Intelligence Platform</strong><br/>
|
| 9 |
+
<em>Platform Matching Demand–Supply Pangan Antarwilayah</em>
|
| 10 |
+
</p>
|
| 11 |
+
|
| 12 |
+
<p align="center"><b>Deteksi · Prediksi · Distribusi</b></p>
|
| 13 |
+
|
| 14 |
+
<p align="center">
|
| 15 |
+
<img src="https://img.shields.io/badge/PIDI-DIGDAYA%20%C3%97%20Hackathon%202026-1B5E20?style=for-the-badge" alt="Hackathon"/>
|
| 16 |
+
<img src="https://img.shields.io/badge/Problem%20Statement-2%20Matching%20Demand–Supply-4CAF50?style=for-the-badge" alt="PS"/>
|
| 17 |
+
<img src="https://img.shields.io/badge/tests-520%20passing-brightgreen?style=for-the-badge" alt="Tests"/>
|
| 18 |
+
</p>
|
| 19 |
+
|
| 20 |
+
> **Roadmap proyek dibagi 3 Phase.** README teknis lengkap versi sebelumnya diarsipkan di [`README_v12.md`](README_v12.md) dan [`README_v11.md`](README_v11.md).
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
<details>
|
| 25 |
+
<summary><b>🖼️ Lihat Research Poster (klik untuk expand)</b></summary>
|
| 26 |
+
|
| 27 |
+
<br/>
|
| 28 |
+
|
| 29 |
+
<p align="center"><img src="poster/agriflow-poster.jpg" alt="AgriFlow Research Poster" width="100%"/></p>
|
| 30 |
+
|
| 31 |
+
</details>
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
# 📍 Phase 1 — Tim & Tautan
|
| 36 |
+
|
| 37 |
+
## Tim
|
| 38 |
+
|
| 39 |
+
| Nama | Role | LinkedIn |
|
| 40 |
+
|------|------|----------|
|
| 41 |
+
| Chelsea | Data Analyst | [Chelsea](https://linkedin.com/in/chelseaayu) |
|
| 42 |
+
| Hilmi | Data Architect | [Hilmi](https://linkedin.com/in/hilmi888/) |
|
| 43 |
+
| Monika | UX Researcher | [Monika](https://linkedin.com/in/monika-hermiani) |
|
| 44 |
+
| Irpan | Data Engineer | [Irpan](https://linkedin.com/in/irpanpilihanrambe) |
|
| 45 |
+
|
| 46 |
+
## Tautan
|
| 47 |
+
|
| 48 |
+
| Resource | Link |
|
| 49 |
+
|----------|------|
|
| 50 |
+
| Pitch Deck | [Canva](https://www.canva.com/design/DAHETj2ulzg/VIvgxVkQ6I9R24ucphy2mQ/view) |
|
| 51 |
+
| Dashboard (Live Demo) | [agriflow-engine.vercel.app](https://agriflow-engine.vercel.app/) |
|
| 52 |
+
| Proposal (v13) | [docs/AgriFlow_Proposal_v13.pdf](docs/AgriFlow_Proposal_v13.pdf) |
|
| 53 |
+
|
| 54 |
+
---
|
| 55 |
+
|
| 56 |
+
# 🚀 Phase 2 — Yang Sudah Kami Bangun (MVP)
|
| 57 |
+
|
| 58 |
+
## Masalah
|
| 59 |
+
|
| 60 |
+
Setiap tahun Indonesia kehilangan triliunan rupiah pangan — **40% terjadi di distribusi, bukan produksi**. Di satu kabupaten petani membuang cabai karena harga jatuh; di kabupaten sebelah harga melonjak karena langka. Pemda sering baru tahu krisis **2–3 minggu kemudian**.
|
| 61 |
+
|
| 62 |
+
## Solusi
|
| 63 |
+
|
| 64 |
+
**AgriFlow mencocokkan kabupaten surplus dengan kabupaten defisit** — seperti "Uber untuk pangan", tapi paham masa simpan (perishability), jarak jalan nyata, dan **keadilan untuk daerah tertinggal**. Tiga fungsi inti:
|
| 65 |
+
|
| 66 |
+
- **Deteksi** — temukan anomali harga (lonjakan/anjlok) dari data harga harian.
|
| 67 |
+
- **Prediksi** — perkirakan harga 30 hari ke depan.
|
| 68 |
+
- **Distribusi** — cocokkan surplus → defisit secara cerdas & adil.
|
| 69 |
+
|
| 70 |
+
## Arsitektur (High-Level)
|
| 71 |
+
|
| 72 |
+
```
|
| 73 |
+
SUMBER DATA NYATA AGRIFLOW ENGINE AKSES
|
| 74 |
+
(BPS · PIHPS · OSRM) ┌──────────────────────────┐
|
| 75 |
+
produksi · konsumsi ──────▶ │ DETEKSI anomali harga │ ──┐
|
| 76 |
+
harga · populasi │ PREDIKSI forecast 30 hr │ ├──▶ Dashboard peta
|
| 77 |
+
per-kabupaten Jatim │ DISTRIBUSI matching 4-lapis│ └──▶ WhatsApp bot
|
| 78 |
+
└──────────────────────────┘
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
Tiga fungsi (Deteksi · Prediksi · Distribusi) berbagi satu sumber data nyata, lalu disajikan lewat Dashboard & WhatsApp.
|
| 82 |
+
|
| 83 |
+
📄 **Detail metodologi tiap fitur — alasan pemilihan metode, cara kerja, evaluasi, validasi, dan sitasi paper: [Dokumen Arsitektur (PDF)](docs/AgriFlow_Architecture.pdf).**
|
| 84 |
+
|
| 85 |
+
## Fitur yang sudah berjalan
|
| 86 |
+
|
| 87 |
+
| Fungsi | Fitur | Status |
|
| 88 |
+
|--------|-------|:------:|
|
| 89 |
+
| **Distribusi** | Matching engine 4-lapis (hard constraints → multi-objective scoring → equity) berjalan di **data BPS asli per-kabupaten (2022)** | ✅ |
|
| 90 |
+
| **Deteksi** | Deteksi anomali harga (deseasonalize + robust statistics) pada harga PIHPS harian **2021–2025** | ✅ |
|
| 91 |
+
| **Prediksi** | Forecasting harga 30 hari dengan **TimesFM 2.0** (foundation model time-series) | ✅ |
|
| 92 |
+
| **Aksesibilitas** | **Chatbot WhatsApp** (tanya harga & rekomendasi) + **Dashboard** peta interaktif | ✅ |
|
| 93 |
+
| **Keamanan** | Sistem akun Supabase (JWT terverifikasi server-side, Row Level Security di 12 tabel, reset password) siap untuk model berlangganan; peta & fitur inti tetap **terbuka publik** (`REQUIRE_AUTH=false`) selama periode penjurian | ✅ |
|
| 94 |
+
| **Data nyata** | **6 komoditas** real per-kab: beras premium & medium, cabai merah & rawit, bawang merah & putih + harga PIHPS 5 tahun | ✅ |
|
| 95 |
+
|
| 96 |
+
> **Kualitas:** 520 tes otomatis lulus (521 terkumpul, 1 di-skip) — engine teruji, dapat direproduksi, dan jujur soal keterbatasannya (lihat [Pengujian & Skenario](#pengujian--skenario) dan Phase 3).
|
| 97 |
+
|
| 98 |
+
### Cuplikan
|
| 99 |
+
|
| 100 |
+
**Dashboard** — peta Jawa Timur dengan bubble surplus/defisit per kabupaten, daftar *top matches*, plus panel **Forecast & Anomali harga** (ketiga fungsi dalam satu layar):
|
| 101 |
+
|
| 102 |
+

|
| 103 |
+
|
| 104 |
+
**WhatsApp Bot** — tanya harga, cari pembeli/pemasok, prediksi & anomali harga lewat chat. Mendukung **Bahasa Indonesia** dan **Bahasa Jawa** (inklusi petani daerah):
|
| 105 |
+
|
| 106 |
+
| Bahasa Indonesia | Bahasa Jawa |
|
| 107 |
+
|:---:|:---:|
|
| 108 |
+
|  |  |
|
| 109 |
+
|
| 110 |
+
## Pengujian & Skenario
|
| 111 |
+
|
| 112 |
+
Karena output AgriFlow menggerakkan alokasi pangan antar-kabupaten yang menyentuh daerah IPM-rendah, klaim "adil" dan "robust" harus dapat diaudit ulang — bukan sekadar narasi. Suite uji mengunci angka food-balance sebagai *golden numbers* (reproducibility), menjaga parameter kebijakan dari pergeseran tak sengaja (regression-safety), dan menguji deteksi anomali secara adversarial.
|
| 113 |
+
|
| 114 |
+
**521 tes terkumpul · 520 lulus · 1 di-skip · lintas-OS di CI.**
|
| 115 |
+
(Skip = `test_timesfm_importorskip`: dilewati jika pustaka TimesFM tak terpasang di runner; jalur forecasting tetap diuji via fallback + kontrak API.)
|
| 116 |
+
|
| 117 |
+
Server produksi memuat **data BPS asli secara default** (`DATA_BACKEND=csv`, bawaan). Fixture sintetis 19-komoditas lama tetap dipakai di 13 file test (`DATA_BACKEND=demo`) untuk menguji logika engine di lebih banyak variasi komoditas — tidak pernah disajikan ke pengguna.
|
| 118 |
+
|
| 119 |
+
| Kategori | Jumlah | Cakupan |
|
| 120 |
+
|---|---|---|
|
| 121 |
+
| Unit per-layer (L0–L3) | 73 | Tier IPM, constraint jarak/perishability, skor, alokasi equity |
|
| 122 |
+
| 24 skenario edge-case (A–F) | 27+ | Volume, spasial, temporal, disrupsi, politis, kualitas |
|
| 123 |
+
| Validasi data nyata BPS/PIHPS | 57 | Food-balance beras + hortikultura 2022, pipeline reproducible |
|
| 124 |
+
| Deteksi anomali harga | 49 | S-H-ESD sadar-musiman pada residual deseasonalized |
|
| 125 |
+
| Forecast & API | 40 | Endpoint forecast/anomali + fallback |
|
| 126 |
+
| Baseline & equity | 39 | greedy/uniform/proporsional vs AgriFlow + skenario langka pasokan |
|
| 127 |
+
| Ingest & integrasi | 73 | DB loader, ingest PIHPS, jarak OSRM, bot WhatsApp |
|
| 128 |
+
| Autentikasi dashboard & kuota WhatsApp | 117 | Login Supabase, verifikasi JWT server-side, RLS 12 tabel, reset password, kuota gratis WhatsApp (nonaktif default) |
|
| 129 |
+
|
| 130 |
+
**24 skenario edge-case** memetakan kejadian nyata Jawa Timur, contohnya: Ramadan spike (C1), erupsi Semeru di Lumajang → unreachable (D4), banjir multi-kabupaten sentra padi (D5), kenaikan BBM → biaya logistik naik (E5), dan prioritas reserve kontrak Bulog (E3).
|
| 131 |
+
|
| 132 |
+
**Hasil kunci:**
|
| 133 |
+
- **Equity terbukti saat pasokan langka, biaya efisiensi nol.** *Ini uji-tekan hipotetis, bukan hasil data BPS asli:* Jawa Timur pada data 2022 justru sangat surplus (rasio 6,6×), sehingga nilai equity tidak akan tampak. Untuk menunjukkan cara kerja mekanismenya kami membangun skenario langka buatan (fixture `surplus_deficit_constrained.csv`, surplus 3962t vs defisit 5249t). Di skenario itu greedy murni menelantarkan Madura — Sampang **0%**, Bangkalan **20%**; AgriFlow mengangkat keduanya ke **100%** dengan *coverage agregat identik* (0.6649) dan Gini turun (0.3017 → 0.2905). Kami tidak mengklaim keunggulan equity saat pasokan melimpah, dan tidak mengklaim skenario ini berasal dari data nyata.
|
| 134 |
+
- **Anomali sadar-musiman.** Penurunan harga ~60% ter-flag, tapi pola musiman murni (siklus jelang Lebaran) **tidak** memicu false positive; anomali genuine di atas pola musiman tetap terdeteksi.
|
| 135 |
+
- **Data mengungkap defisit struktural, bukan bug.** Bawang putih menghasilkan **0 match** di seluruh 38 kabupaten pada data BPS 2022 — Jawa Timur defisit bawang putih di semua kabupaten, konsisten dengan Indonesia sebagai net-importir bawang putih. Engine bekerja benar; datanya yang bicara.
|
| 136 |
+
|
| 137 |
+
📄 Detail lengkap (kenapa, daftar 24 skenario, sitasi paper): [Dokumen Arsitektur](docs/AgriFlow_Architecture.pdf) §Pengujian & Validasi.
|
| 138 |
+
|
| 139 |
+
## Kenapa tech stack kami RINGKAS (bukan sebanyak proposal awal)?
|
| 140 |
+
|
| 141 |
+
Proposal awal mencantumkan stack besar (Qdrant, LangChain, Redis, n8n, multi-cloud, dll). Setelah benar-benar membangun, kami **sengaja memangkasnya** — *honest engineering* untuk skala saat ini (38 kabupaten Jawa Timur):
|
| 142 |
+
|
| 143 |
+
| Rencana awal | Yang kami pakai | Alasan |
|
| 144 |
+
|---|---|---|
|
| 145 |
+
| Qdrant (vector DB terpisah) | **Supabase pgvector** | Korpus kecil — tak perlu service vektor sendiri |
|
| 146 |
+
| LangChain | **Gemini API langsung** | RAG sesederhana ini tak butuh framework berat |
|
| 147 |
+
| Redis cache | **In-process cache** | Beban belum menuntut; engine deterministik |
|
| 148 |
+
| 5 platform hosting | **2 (HF Spaces + Vercel)** | Lebih sedikit titik gagal, lebih murah |
|
| 149 |
+
|
| 150 |
+
**Prinsip kami: pakai yang cukup, bukan yang ramai.** Komponen besar baru bernilai saat skala membenarkannya — dan itulah **Phase 3**.
|
| 151 |
+
|
| 152 |
+
## 🎙️ Validasi Lapangan — Wawancara Petani
|
| 153 |
+
|
| 154 |
+
Kami mewawancarai **4 petani lintas komoditas & skala usaha** — dari petani mapan dengan jaringan pasar sampai petani kecil yang terkurung tengkulak — untuk memvalidasi kebutuhan nyata dan menemukan gap AgriFlow. Tiap baris menyertakan **rekaman audio sebagai bukti**.
|
| 155 |
+
|
| 156 |
+
| Komoditas | Profil Narasumber | Pendapat Singkat | Rekaman & Transkrip |
|
| 157 |
+
|---|---|---|---|
|
| 158 |
+
| **Bawang Merah** | Denisa Septalian ��� petani penerus, Nganjuk (Ds. Ngudikan, Kec. Wilangan), 5 thn, lahan ±70 ru | **Setuju bersyarat.** Info harga saja "kurang efektif" karena 100% bergantung tengkulak & tak punya akses luar daerah — antusias bila AgriFlow membuka **akses pembeli luar kota**. | [🎧 Audio](https://drive.google.com/drive/folders/1kdF9KPqycrdN9GewRz6YKFUKWfzCaRVh) · [📄 Transkrip](interview/transcript-bawang-merah.md) |
|
| 159 |
+
| **Padi** | Petani 15 thn, lahan ±1 ha; jual gabah ~Rp5.800/kg ke tengkulak yang datang ke sawah | Info harga lintas daerah **membantu** sebagai gambaran; tertarik pembeli luar kota asal prosesnya aman; ragu "ribet" di awal & soal keamanan transaksi. | [🎧 Audio](https://drive.google.com/drive/folders/1-fpMk8UGg41wk1-RZufTyRBNNJwM7he7) · [📄 Transkrip](interview/transcript-padi.md) |
|
| 160 |
+
| **Cabai** | Petani baru (8 bln bertani, tanaman 50 HST), Solo/Karanganyar; sebelumnya jagung | **Sangat tertarik** harga real-time antar daerah untuk hitung kelayakan kirim; info FB/WA kini meleset Rp5.000–15.000/kg & hanya level provinsi. Menekankan UI sederhana untuk petani lansia. | [🎧 Audio](https://drive.google.com/drive/folders/1lStLTY4L_9NW-UXAWrfTXwNc0CuiUQT-) · [📄 Transkrip](interview/transcript-cabai.md) |
|
| 161 |
+
| **Kentang** | Labib — Dieng, Banjarnegara, ±6 ha, 2 thn; jual ke Pasar Induk Kramat Jati | Info antar daerah berguna sebagai **pembanding & referensi keputusan**, tetap utamakan pedagang langganan. Kunci keberhasilan: **akurasi data + sumber jelas + update real-time**. | [🎧 Audio](https://drive.google.com/drive/folders/1mMVWLv6uQQzlD_KNrkI5eSARXHTlDK9K) · [📄 Transkrip](interview/transcript-kentang.md) |
|
| 162 |
+
|
| 163 |
+
### Analisis & Kesimpulan — Nilai Plus AgriFlow yang Tervalidasi
|
| 164 |
+
|
| 165 |
+
- **Masalah inti tervalidasi lintas komoditas.** Keempat petani menyebut keluhan yang sama: harga tidak stabil, panen raya serentak → harga anjlok, dan **butaan informasi harga antar daerah** — persis yang dijawab fungsi **Deteksi + Prediksi**.
|
| 166 |
+
- **WhatsApp sebagai kanal — tervalidasi 4/4.** Semua memilih WhatsApp (bisa dibaca ulang, sudah dipakai semua petani) di atas SMS/aplikasi baru → memperkuat keputusan **WhatsApp bot**.
|
| 167 |
+
- **Matching surplus→defisit menjawab keluhan paling tajam.** "Tidak ada akses keluar daerah" (bawang merah, padi) adalah problem yang langsung diselesaikan **matching engine 4-lapis**; begitu ditawari pembeli luar kota berharga lebih baik, **keempatnya tertarik**.
|
| 168 |
+
- **Prediksi harga punya nilai konkret.** Semua pernah "kesusu" / salah memperkirakan harga (bawang merah sempat jual Rp10.000, dua hari kemudian Rp20.000) → **forecast 30 hari** menjawab kebutuhan ini.
|
| 169 |
+
- **Kesediaan membayar ada** — bersyarat manfaat ekonomi terbukti & data akurat. Tak satu pun menolak model berbayar.
|
| 170 |
+
|
| 171 |
+
> Temuan **gap fitur** dari wawancara (akses transaksi, granularitas harga, transparansi & keamanan) kami petakan secara jujur ke **Phase 3** di bawah.
|
| 172 |
+
|
| 173 |
+
---
|
| 174 |
+
|
| 175 |
+
# 🌐 Phase 3 — Rencana Lanjutan & Scaling
|
| 176 |
+
|
| 177 |
+
Komponen di bawah ini **sengaja kami tunda** karena *over-engineering* untuk skala sekarang. Kami kerjakan saat **scaling up**:
|
| 178 |
+
|
| 179 |
+
| Rencana Phase 3 | Untuk apa |
|
| 180 |
+
|---|---|
|
| 181 |
+
| **Skala nasional 514 kab** | Dari 38 kab Jatim → seluruh Indonesia (perlu spatial partitioning + precompute jarak) |
|
| 182 |
+
| **Exogenous forecasting** (indeks ENSO/iklim, kalender Ramadan) | Akurasi prediksi naik saat ada guncangan iklim & hari raya |
|
| 183 |
+
| **Qdrant / Redis / n8n** | Vector scale, caching, orkestrasi terjadwal — saat beban nyata muncul |
|
| 184 |
+
| **Sahabat-AI (Bahasa Jawa/Madura) + IVR telepon** | Inklusi petani lansia & pengguna feature-phone |
|
| 185 |
+
| **Daging ayam & telur (data real)** | Perlu data produksi broiler & telur-ras per-kab yang lengkap |
|
| 186 |
+
| **Fasilitasi transaksi / akses pasar luar daerah** *(gap dari wawancara)* | Temuan bawang merah & padi: info harga saja "kurang efektif" tanpa saluran jual-beli yang memutus ketergantungan tengkulak |
|
| 187 |
+
| **Harga granular per kota/pasar** *(gap dari wawancara)* | Temuan cabai: sumber kini hanya level provinsi; selisih harga riil bisa Rp5.000–15.000/kg |
|
| 188 |
+
| **Transparansi sumber data & jaminan keamanan transaksi** *(gap dari wawancara)* | Syarat kepercayaan: Labib menanyakan sumber & mekanisme update; padi ragu keamanan transaksi + petani lansia butuh onboarding anti-"ribet" |
|
| 189 |
+
|
| 190 |
+
## Cakupan saat ini (yang membatasi adalah ketersediaan data, bukan sistem)
|
| 191 |
+
|
| 192 |
+
Mesin AgriFlow **sudah siap memproses data apa pun yang diberikan**. Cakupan sekarang ditentukan oleh **ketersediaan data publik per-kabupaten** — begitu sumber datanya terbuka, pipeline yang sama langsung memprosesnya tanpa ubah arsitektur.
|
| 193 |
+
|
| 194 |
+
| Cakupan sekarang | Gerbangnya: ketersediaan data |
|
| 195 |
+
|---|---|
|
| 196 |
+
| 6 komoditas inti | Engine menerima komoditas apa pun; sisanya menunggu data **produksi per-kabupaten** dirilis BPS pada granularitas sama |
|
| 197 |
+
| Tahun acuan 2022 | Tahun konsisten terbaru yang lengkap di semua sumber per-kab; tahun lebih baru tinggal di-*ingest* saat BPS merilis |
|
| 198 |
+
| Daging ayam & telur belum | Data produksi broiler/ayam-ras per-kab belum tersedia di sumber publik; 13 komoditas lain (termasuk telur & daging ayam) masih berstatus **placeholder sintetis** di `historical_price_stats.csv`, tidak terjangkau lewat backend produksi (`DATA_BACKEND=csv`) |
|
| 199 |
+
| Konsumsi cabai/bawang via angka nasional | Konsumsi *per-kabupaten* untuk komoditas ini belum dipublikasikan; konsumsi **beras sudah per-kab & dipakai nyata** |
|
| 200 |
+
| Harga Tier-2 (kab non-IHK) | Panel Harga Bapanas sedang pemeliharaan; saat feed pulih, 30+ kab tambahan langsung tercakup |
|
| 201 |
+
|
| 202 |
+
## Scaling up
|
| 203 |
+
|
| 204 |
+
Peningkatan skala (nasional 514 kab, multi-komoditas penuh, real-time) **dibatasi oleh laju keterbukaan data publik per-kabupaten — bukan oleh kesiapan teknis.** Engine sudah siap; tinggal data feed-nya tersedia, lalu optimasi skala (spatial partitioning). Pendekatan kami: **buktikan nilai dulu di skala provinsi dengan data nyata, lalu perluas seiring data tersedia.** Foundation-model forecasting (TimesFM 2.0) dan kanal suara/Bahasa daerah adalah peningkatan terjadwal fase berikutnya.
|
| 205 |
+
|
| 206 |
+
---
|
| 207 |
+
|
| 208 |
+
## Menjalankan (teknis singkat)
|
| 209 |
+
|
| 210 |
+
```bash
|
| 211 |
+
pip install -r requirements.txt
|
| 212 |
+
python examples/run_demo_real.py # demo matching pada data BPS asli 2022
|
| 213 |
+
pytest tests/ # 520 lulus, 1 di-skip
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
Detail engineering lengkap ada di [`README_v12.md`](README_v12.md).
|
| 217 |
+
|
| 218 |
+
---
|
| 219 |
+
|
| 220 |
+
## Lisensi
|
| 221 |
+
|
| 222 |
+
MIT License — © 2026 Hilmi. Lihat [`LICENSE`](LICENSE).
|
| 223 |
+
|
| 224 |
+
<p align="center"><em>Deteksi · Prediksi · Distribusi — untuk ketahanan pangan Indonesia.</em></p>
|
REAL_DATA_METHODOLOGY.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Real Data Methodology — Surplus/Deficit 2022
|
| 2 |
+
|
| 3 |
+
## Status per commodity
|
| 4 |
+
|
| 5 |
+
| Commodity | Status | Source | Tahun |
|
| 6 |
+
|---|---|---|---|
|
| 7 |
+
| `beras_premium` | REAL (derived) | BPS per-kab produksi + konsumsi + populasi | 2022 |
|
| 8 |
+
| `beras_medium` | REAL (derived, grade-split assumption 60/40) | same | 2022 |
|
| 9 |
+
| `cabai_merah` | REAL produksi (BPS Hortikultura); konsumsi proxy nasional (Kementan) | BPS Jatim Hortikul. + Kementan PDF | 2022 |
|
| 10 |
+
| `cabai_rawit` | REAL produksi (BPS Hortikultura); konsumsi proxy nasional (Kementan) | same | 2022 |
|
| 11 |
+
| `bawang_merah` | REAL produksi (BPS Hortikultura); konsumsi proxy nasional (Kementan) | same | 2022 |
|
| 12 |
+
| `bawang_putih` | REAL produksi (BPS Hortikultura); konsumsi proxy nasional (Kementan) | same | 2022 |
|
| 13 |
+
| `daging_ayam` | EXCLUDED — data tidak lengkap (broiler hilang; ayam petelur 2 kab) | — | PENDING |
|
| 14 |
+
| `telur_ayam` | EXCLUDED — telur petelur hanya 2 kab | — | PENDING |
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## Reference year: 2022
|
| 19 |
+
|
| 20 |
+
All three beras inputs AND semua 5 hortikultura source files have 2022 data.
|
| 21 |
+
**2022** is selected as the reference year because:
|
| 22 |
+
- Beras: 2022 is the latest year where produksi, konsumsi per-kab, AND populasi are all
|
| 23 |
+
present and plausible for all 38 kab/kota. (2025 populasi corrupted ~1000x.)
|
| 24 |
+
- Hortikultura: BPS Hortikultura files have `Produksi_2021` and `Produksi_2022` columns;
|
| 25 |
+
2022 is the most recent available year.
|
| 26 |
+
- Konsumsi per-kapita hortikultura: Kementan Statistik Konsumsi Pangan 2024 provides
|
| 27 |
+
2022 values in Tabel 4.6a (cabai) and 4.1a/4.2a (bawang).
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## Data sources
|
| 32 |
+
|
| 33 |
+
### Beras (all BPS-grade, per-kabupaten)
|
| 34 |
+
|
| 35 |
+
| Dataset | File | Source | Unit |
|
| 36 |
+
|---|---|---|---|
|
| 37 |
+
| Produksi beras | `sample_data/bps_real/year_beras.csv` | BPS Jawa Timur | ton/tahun/kab |
|
| 38 |
+
| Konsumsi per kapita | `sample_data/bps_real/week_konsumsi_beras_perkapita.csv` | BPS Indonesia (Susenas) | kg/kapita/minggu |
|
| 39 |
+
| Populasi | `sample_data/bps_real/year_populasi_jatim.csv` | BPS Jawa Timur | jiwa |
|
| 40 |
+
|
| 41 |
+
### Hortikultura
|
| 42 |
+
|
| 43 |
+
| Dataset | File | Source | Unit | Konversi |
|
| 44 |
+
|---|---|---|---|---|
|
| 45 |
+
| Produksi cabai besar | `bps_real/cabai_besar.csv` | BPS Jatim Hortikul. | **KUINTAL**/tahun | ÷10 = ton |
|
| 46 |
+
| Produksi cabai keriting | `bps_real/cabai_keriting.csv` | same | KUINTAL/tahun | ÷10 = ton |
|
| 47 |
+
| Produksi cabai rawit | `bps_real/cabai_rawit.csv` | same | KUINTAL/tahun | ÷10 = ton |
|
| 48 |
+
| Produksi bawang merah | `bps_real/bawang_merah.csv` | same | KUINTAL/tahun | ÷10 = ton |
|
| 49 |
+
| Produksi bawang putih | `bps_real/bawang_putih.csv` | same | KUINTAL/tahun | ÷10 = ton |
|
| 50 |
+
|
| 51 |
+
**Satuan konfirmasi**: Total row cabai_besar 2022 = 851,445 kuintal = 85,145 ton → sesuai
|
| 52 |
+
BPS published figure Jatim. Konversi ÷10 (kuintal → ton) terverifikasi.
|
| 53 |
+
|
| 54 |
+
### Konsumsi per kapita hortikultura — NATIONAL PROXY (Kementan)
|
| 55 |
+
|
| 56 |
+
Sumber: **Statistik Konsumsi Pangan 2024**, Pusat Data dan Sistem Informasi Pertanian,
|
| 57 |
+
Kementerian Pertanian. Desember 2024.
|
| 58 |
+
URL: https://satudata.pertanian.go.id/assets/docs/publikasi/Buku_Statistik_Konsumsi_2024.pdf
|
| 59 |
+
|
| 60 |
+
| Komoditas | Tabel | Hal. | Nilai 2022 | Catatan |
|
| 61 |
+
|---|---|---|---|---|
|
| 62 |
+
| Cabai merah | 4.6a (Cabe merah/Chillies) | 30 | **1.909 kg/kapita/tahun** | Susenas per-kapita nasional |
|
| 63 |
+
| Cabai rawit | 4.6a (Cabe rawit/Cayenne pepper) | 30 | **2.073 kg/kapita/tahun** | same |
|
| 64 |
+
| Bawang merah | 4.1a (Bawang merah/Onion) | 25 | **3.024 kg/kapita/tahun** | same |
|
| 65 |
+
| Bawang putih | 4.2a (Bawang putih/Garlic) | 26 | **2.016 kg/kapita/tahun** | same |
|
| 66 |
+
|
| 67 |
+
**PROXY WARNING**: Angka ini adalah RATA-RATA NASIONAL dari Susenas. Tidak ada data konsumsi
|
| 68 |
+
per-kabupaten untuk hortikultura. Konsumsi aktual per kab bisa berbeda dari angka nasional
|
| 69 |
+
(terutama kota besar vs pedesaan, atau daerah produsen di mana konsumsi lokal bisa lebih tinggi).
|
| 70 |
+
Hasil surplus/deficit hortikultura harus dibaca dengan caveat ini.
|
| 71 |
+
|
| 72 |
+
---
|
| 73 |
+
|
| 74 |
+
## Derivation formula
|
| 75 |
+
|
| 76 |
+
### Beras
|
| 77 |
+
|
| 78 |
+
```
|
| 79 |
+
konsumsi_ton = avg_konsumsi_perkapita_kg_per_minggu × 52 × populasi / 1000
|
| 80 |
+
net_ton = produksi_ton − konsumsi_ton
|
| 81 |
+
role = "SURPLUS" if net_ton > 0 else "DEFICIT"
|
| 82 |
+
volume_tons = abs(net_ton)
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
Per-kab BPS Susenas data digunakan untuk konsumsi beras (bukan proxy nasional).
|
| 86 |
+
|
| 87 |
+
### Hortikultura (cabai merah, cabai rawit, bawang merah, bawang putih)
|
| 88 |
+
|
| 89 |
+
```
|
| 90 |
+
produksi_ton = (Produksi_2022 kuintal) / 10 # BPS file, kuintal -> ton
|
| 91 |
+
konsumsi_ton = perkapita_kg_per_tahun × populasi_2022 / 1000 # Kementan national avg
|
| 92 |
+
net_ton = produksi_ton − konsumsi_ton
|
| 93 |
+
role = "SURPLUS" if net_ton > 0 else "DEFICIT"
|
| 94 |
+
volume_tons = abs(net_ton)
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
Cabai merah = cabai_besar + cabai_keriting (dijumlahkan sebelum derivasi).
|
| 98 |
+
|
| 99 |
+
---
|
| 100 |
+
|
| 101 |
+
## Grade split beras: ASSUMPTION
|
| 102 |
+
|
| 103 |
+
60% net beras → `beras_premium`; 40% → `beras_medium`. Working assumption.
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
## Harga (2022 PIHPS median dari sample_data/price_history/)
|
| 108 |
+
|
| 109 |
+
| Komoditas | Harga IDR/kg | Sumber |
|
| 110 |
+
|---|---|---|
|
| 111 |
+
| `beras_premium` | 11,500 | median(super1=12,000; super2=11,000) PIHPS 2022 |
|
| 112 |
+
| `beras_medium` | 10,325 | median(medium1=10,650; medium2=10,000) PIHPS 2022 |
|
| 113 |
+
| `cabai_rawit` | 41,000 | cabe_rawit_cleaned.csv 2022 median, PIHPS |
|
| 114 |
+
| `bawang_merah` | 32,500 | bawang_merah_cleaned.csv 2022 median, PIHPS |
|
| 115 |
+
| `bawang_putih` | 20,750 | bawang_putih_cleaned.csv 2022 median, PIHPS |
|
| 116 |
+
| `cabai_merah` | 45,000 | **FLAGGED: komoditas_constraints.csv baseline** — cabai merah tidak ada di PIHPS dataset |
|
| 117 |
+
|
| 118 |
+
---
|
| 119 |
+
|
| 120 |
+
## harvest_age_days
|
| 121 |
+
|
| 122 |
+
| Role | Value | Rationale |
|
| 123 |
+
|---|---|---|
|
| 124 |
+
| SURPLUS | 28 | Typical post-harvest/milling age when komoditas leaves origin kab (conservative estimate) |
|
| 125 |
+
| DEFICIT | 0 | Convention: deficit nodes are demand points; age irrelevant |
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
## Name-to-kab_id mapping
|
| 130 |
+
|
| 131 |
+
**Beras source files**: menggunakan nama BPS full ("Kabupaten X" / "Kota X") →
|
| 132 |
+
strip prefix untuk match ke `kabupaten_jatim.csv` short name.
|
| 133 |
+
|
| 134 |
+
**Hortikultura source files**: menggunakan nama pendek tanpa prefix positional:
|
| 135 |
+
- Baris 0-28 (29 baris): Kabupaten, urutan kode BPS 3501 (Pacitan) s/d 3529 (Sumenep)
|
| 136 |
+
- Baris 29-37 (9 baris): Kota, urutan kode BPS 3571 (Kota Kediri) s/d 3579 (Kota Batu)
|
| 137 |
+
- Baris 38: "Total" — EXCLUDED
|
| 138 |
+
|
| 139 |
+
Semua 38 kab/kota Jatim ter-map tanpa exception untuk tahun 2022.
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
## Sanity check results (2022)
|
| 144 |
+
|
| 145 |
+
### Beras — top surplus kabupaten
|
| 146 |
+
|
| 147 |
+
| kab_id | Kabupaten | Net beras (ton) | Role |
|
| 148 |
+
|---|---|---|---|
|
| 149 |
+
| 3524 | Kab. Lamongan | +409,023 | SURPLUS |
|
| 150 |
+
| 3521 | Kab. Ngawi | +366,367 | SURPLUS |
|
| 151 |
+
| 3522 | Kab. Bojonegoro | +295,679 | SURPLUS |
|
| 152 |
+
| 3523 | Kab. Tuban | +184,507 | SURPLUS |
|
| 153 |
+
| 3519 | Kab. Madiun | +195,419 | SURPLUS |
|
| 154 |
+
|
| 155 |
+
### Cabai merah — top surplus
|
| 156 |
+
|
| 157 |
+
| kab_id | Kabupaten | Surplus (ton) |
|
| 158 |
+
|---|---|---|
|
| 159 |
+
| 3507 | Kab. Malang | ~22,328 |
|
| 160 |
+
| 3513 | Kab. Probolinggo | ~9,823 |
|
| 161 |
+
| 3510 | Kab. Banyuwangi | ~7,177 |
|
| 162 |
+
|
| 163 |
+
### Cabai rawit — top surplus
|
| 164 |
+
|
| 165 |
+
| kab_id | Kabupaten | Surplus (ton) |
|
| 166 |
+
|---|---|---|
|
| 167 |
+
| 3510 | Kab. Banyuwangi | ~100,709 |
|
| 168 |
+
| 3507 | Kab. Malang | ~81,866 |
|
| 169 |
+
| 3506 | Kab. Kediri | ~77,761 |
|
| 170 |
+
|
| 171 |
+
### Bawang merah — top surplus
|
| 172 |
+
|
| 173 |
+
| kab_id | Kabupaten | Surplus (ton) |
|
| 174 |
+
|---|---|---|
|
| 175 |
+
| 3518 | Kab. Nganjuk | ~190,610 |
|
| 176 |
+
| 3513 | Kab. Probolinggo | ~54,731 |
|
| 177 |
+
| 3507 | Kab. Malang | ~43,099 |
|
| 178 |
+
|
| 179 |
+
### Bawang putih
|
| 180 |
+
|
| 181 |
+
Seluruh 38 kab/kota DEFICIT. Jatim total produksi ~855 ton vs kebutuhan ~80,000+ ton.
|
| 182 |
+
(Jatim bukan produsen bawang putih; pasokan dari Temanggung/Brebes Jateng dan impor.)
|
| 183 |
+
|
| 184 |
+
Semua hasil ini sesuai ekspektasi geografis.
|
| 185 |
+
|
| 186 |
+
---
|
| 187 |
+
|
| 188 |
+
## Excluded
|
| 189 |
+
|
| 190 |
+
- **Daging ayam**: Data folder download berisi `daging_ayam_kampung.csv` dan
|
| 191 |
+
`daging_ayam_petelur.csv`. Broiler (sumber utama produksi komersial) tidak ada.
|
| 192 |
+
Data tidak representatif untuk routing engine. **Status: PENDING.**
|
| 193 |
+
- **Telur ayam**: `telur_ayam_petelur.csv` hanya 2 kabupaten ter-cover.
|
| 194 |
+
**Status: PENDING.**
|
analysis/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
analysis/ — Price anomaly detection and statistical analysis for AgriFlow.
|
| 3 |
+
|
| 4 |
+
This package is intentionally dependency-light: numpy + stdlib csv only.
|
| 5 |
+
No pandas, no statsmodels, no sklearn. Results are interpretable by
|
| 6 |
+
construction — every flagged point shows the rolling median it deviated from
|
| 7 |
+
and the exact percentage deviation.
|
| 8 |
+
"""
|
analysis/forecast_timesfm.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
analysis/forecast_timesfm.py -- Offline forecast precompute for AgriFlow.
|
| 3 |
+
|
| 4 |
+
ARCHITECTURE:
|
| 5 |
+
This script runs OFFLINE (locally, not on HF Space) because TimesFM ~2GB
|
| 6 |
+
model cannot be loaded on the free-tier Space (OOM). The output JSON files
|
| 7 |
+
are committed to the repo and the backend serves them at runtime without
|
| 8 |
+
importing this module or timesfm.
|
| 9 |
+
|
| 10 |
+
HONESTY POLICY:
|
| 11 |
+
If TimesFM cannot be loaded (not installed, network unavailable, Python
|
| 12 |
+
version incompatible), this script falls back to a seasonal-naive baseline
|
| 13 |
+
that is CLEARLY labelled in the output as "method": "seasonal_naive_baseline"
|
| 14 |
+
so consumers can distinguish it from a genuine TimesFM forecast.
|
| 15 |
+
|
| 16 |
+
DO NOT change the labelling. If you want TimesFM output, fix the environment
|
| 17 |
+
and re-run.
|
| 18 |
+
|
| 19 |
+
TIMESFM STATUS (2026-05-31):
|
| 20 |
+
timesfm PyPI package (1.0.0) requires Python <=3.11 + jaxlib==0.4.26.
|
| 21 |
+
This project runs Python 3.12+. TimesFM 2.0 (PyTorch variant) is on
|
| 22 |
+
HuggingFace Hub but requires the same pinned JAX+Flax stack via the PyPI
|
| 23 |
+
package. Install blocker is hard on Python 3.12/3.14.
|
| 24 |
+
|
| 25 |
+
To use real TimesFM:
|
| 26 |
+
1. Run this script with Python 3.11: `py -3.11 analysis/forecast_timesfm.py`
|
| 27 |
+
2. Or wait for timesfm to release a Python 3.12-compatible wheel.
|
| 28 |
+
3. Check for a conda-based install path: `conda install -c conda-forge timesfm`
|
| 29 |
+
|
| 30 |
+
USAGE:
|
| 31 |
+
# With TimesFM (Python 3.11 + timesfm installed):
|
| 32 |
+
python analysis/forecast_timesfm.py
|
| 33 |
+
|
| 34 |
+
# Explicit baseline (any Python):
|
| 35 |
+
python analysis/forecast_timesfm.py --method baseline
|
| 36 |
+
|
| 37 |
+
OUTPUT:
|
| 38 |
+
sample_data/forecasts/forecast_all.json -- one file, all series
|
| 39 |
+
|
| 40 |
+
FORECAST SCHEMA (per record):
|
| 41 |
+
commodity_code str
|
| 42 |
+
city_id str
|
| 43 |
+
city_name str
|
| 44 |
+
method str ("timesfm_2.0" | "seasonal_naive_baseline")
|
| 45 |
+
generated_at str ISO 8601 UTC
|
| 46 |
+
horizon_days int (30)
|
| 47 |
+
history_end_date str ISO 8601 -- last observed date
|
| 48 |
+
forecasts: list of {
|
| 49 |
+
date str ISO 8601
|
| 50 |
+
point float (IDR/kg, point forecast)
|
| 51 |
+
p10 float (IDR/kg, 10th percentile)
|
| 52 |
+
p90 float (IDR/kg, 90th percentile)
|
| 53 |
+
}
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
from __future__ import annotations
|
| 57 |
+
|
| 58 |
+
import argparse
|
| 59 |
+
import datetime
|
| 60 |
+
import json
|
| 61 |
+
import math
|
| 62 |
+
import sys
|
| 63 |
+
from pathlib import Path
|
| 64 |
+
from typing import Any
|
| 65 |
+
|
| 66 |
+
ROOT = Path(__file__).parent.parent
|
| 67 |
+
if str(ROOT) not in sys.path:
|
| 68 |
+
sys.path.insert(0, str(ROOT))
|
| 69 |
+
|
| 70 |
+
from analysis.price_anomaly import _load_all_rows, CITY_NAMES
|
| 71 |
+
|
| 72 |
+
HORIZON = 30
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ---------------------------------------------------------------------------
|
| 76 |
+
# Seasonal-naive baseline (transparent fallback -- NOT TimesFM)
|
| 77 |
+
# ---------------------------------------------------------------------------
|
| 78 |
+
|
| 79 |
+
def _seasonal_naive_forecast(
|
| 80 |
+
series: list[tuple[datetime.date, float]],
|
| 81 |
+
horizon: int = HORIZON,
|
| 82 |
+
) -> list[dict[str, Any]]:
|
| 83 |
+
"""
|
| 84 |
+
Seasonal-naive: for day h, predict = median of same-calendar-month prices
|
| 85 |
+
observed in the training series.
|
| 86 |
+
|
| 87 |
+
Uncertainty band: +/- 1 MAD of the same-month observations.
|
| 88 |
+
|
| 89 |
+
This is a statistical method, not a foundation model. It is labelled as
|
| 90 |
+
"seasonal_naive_baseline" everywhere it appears.
|
| 91 |
+
"""
|
| 92 |
+
import numpy as np
|
| 93 |
+
|
| 94 |
+
prices = [p for _, p in series]
|
| 95 |
+
dates = [d for d, _ in series]
|
| 96 |
+
arr = np.array(prices, dtype=float)
|
| 97 |
+
|
| 98 |
+
# Build per-month (median, MAD) from the last 2 years of observed data
|
| 99 |
+
cutoff = dates[-1] - datetime.timedelta(days=2 * 365)
|
| 100 |
+
recent = [(d, p) for d, p in series if d >= cutoff]
|
| 101 |
+
if len(recent) < 30:
|
| 102 |
+
recent = series # fall back to full series for short series
|
| 103 |
+
|
| 104 |
+
month_stats: dict[int, tuple[float, float]] = {}
|
| 105 |
+
from collections import defaultdict
|
| 106 |
+
month_vals: dict[int, list[float]] = defaultdict(list)
|
| 107 |
+
for d, p in recent:
|
| 108 |
+
month_vals[d.month].append(p)
|
| 109 |
+
for m, vals in month_vals.items():
|
| 110 |
+
v = np.array(vals)
|
| 111 |
+
med = float(np.median(v))
|
| 112 |
+
mad = float(np.median(np.abs(v - med)))
|
| 113 |
+
month_stats[m] = (med, mad)
|
| 114 |
+
|
| 115 |
+
# Overall fallback stats
|
| 116 |
+
overall_med = float(np.median(arr[-30:]))
|
| 117 |
+
overall_mad = float(np.median(np.abs(arr[-30:] - overall_med)))
|
| 118 |
+
|
| 119 |
+
last_date = dates[-1]
|
| 120 |
+
result = []
|
| 121 |
+
for h in range(1, horizon + 1):
|
| 122 |
+
target_date = last_date + datetime.timedelta(days=h)
|
| 123 |
+
med, mad = month_stats.get(target_date.month, (overall_med, overall_mad))
|
| 124 |
+
# CI: +/- 1.4826 * MAD (same scaling as the anomaly detector)
|
| 125 |
+
ci_half = 1.4826 * mad if mad > 0 else 0.05 * med
|
| 126 |
+
result.append({
|
| 127 |
+
"date": target_date.isoformat(),
|
| 128 |
+
"point": round(med, 2),
|
| 129 |
+
"p10": round(max(0, med - ci_half), 2),
|
| 130 |
+
"p90": round(med + ci_half, 2),
|
| 131 |
+
})
|
| 132 |
+
return result
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ---------------------------------------------------------------------------
|
| 136 |
+
# TimesFM path (gated on successful import)
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
|
| 139 |
+
def _timesfm_available() -> bool:
|
| 140 |
+
try:
|
| 141 |
+
import timesfm # noqa: F401
|
| 142 |
+
return True
|
| 143 |
+
except ImportError:
|
| 144 |
+
return False
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _timesfm_forecast(
|
| 148 |
+
series: list[tuple[datetime.date, float]],
|
| 149 |
+
horizon: int = HORIZON,
|
| 150 |
+
model_path: str = "google/timesfm-2.0-500m-pytorch",
|
| 151 |
+
) -> list[dict[str, Any]]:
|
| 152 |
+
"""
|
| 153 |
+
Run TimesFM 2.0 (PyTorch variant) on one price series.
|
| 154 |
+
Loads the model on first call (expensive — ~2 GB download + load).
|
| 155 |
+
Caller must ensure timesfm is installed and Python 3.10/3.11 is active.
|
| 156 |
+
"""
|
| 157 |
+
import timesfm
|
| 158 |
+
import numpy as np
|
| 159 |
+
|
| 160 |
+
prices = np.array([p for _, p in series], dtype=float)
|
| 161 |
+
dates = [d for d, _ in series]
|
| 162 |
+
|
| 163 |
+
# TimesFM 2.0 PyTorch API
|
| 164 |
+
tfm = timesfm.TimesFm(
|
| 165 |
+
hparams=timesfm.TimesFmHparams(
|
| 166 |
+
backend="cpu",
|
| 167 |
+
per_core_batch_size=1,
|
| 168 |
+
horizon_len=horizon,
|
| 169 |
+
num_heads=16,
|
| 170 |
+
use_positional_embedding=False,
|
| 171 |
+
),
|
| 172 |
+
checkpoint=timesfm.TimesFmCheckpoint(
|
| 173 |
+
huggingface_repo_id=model_path,
|
| 174 |
+
),
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
forecast_input = [prices]
|
| 178 |
+
freq = [0] # 0 = high-frequency (daily)
|
| 179 |
+
|
| 180 |
+
_, quantile_forecasts = tfm.forecast(
|
| 181 |
+
forecast_input,
|
| 182 |
+
freq=freq,
|
| 183 |
+
quantile_levels=[0.1, 0.5, 0.9],
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
# quantile_forecasts shape: (batch=1, horizon, 3)
|
| 187 |
+
qf = quantile_forecasts[0] # (horizon, 3)
|
| 188 |
+
last_date = dates[-1]
|
| 189 |
+
result = []
|
| 190 |
+
for h in range(horizon):
|
| 191 |
+
target_date = last_date + datetime.timedelta(days=h + 1)
|
| 192 |
+
p10 = float(qf[h, 0])
|
| 193 |
+
point = float(qf[h, 1])
|
| 194 |
+
p90 = float(qf[h, 2])
|
| 195 |
+
result.append({
|
| 196 |
+
"date": target_date.isoformat(),
|
| 197 |
+
"point": round(point, 2),
|
| 198 |
+
"p10": round(max(0, p10), 2),
|
| 199 |
+
"p90": round(p90, 2),
|
| 200 |
+
})
|
| 201 |
+
return result
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ---------------------------------------------------------------------------
|
| 205 |
+
# Main
|
| 206 |
+
# ---------------------------------------------------------------------------
|
| 207 |
+
|
| 208 |
+
def main(
|
| 209 |
+
price_dir: Path,
|
| 210 |
+
out_dir: Path,
|
| 211 |
+
method: str,
|
| 212 |
+
model_path: str,
|
| 213 |
+
) -> None:
|
| 214 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 215 |
+
out_path = out_dir / "forecast_all.json"
|
| 216 |
+
|
| 217 |
+
# Determine actual method
|
| 218 |
+
if method == "auto":
|
| 219 |
+
if _timesfm_available():
|
| 220 |
+
method = "timesfm"
|
| 221 |
+
print("TimesFM detected — will use real model.")
|
| 222 |
+
else:
|
| 223 |
+
method = "baseline"
|
| 224 |
+
print(
|
| 225 |
+
"WARNING: timesfm not importable on this Python version.\n"
|
| 226 |
+
"Falling back to seasonal_naive_baseline.\n"
|
| 227 |
+
"To get real TimesFM output, run with Python 3.10 or 3.11 + timesfm installed.\n"
|
| 228 |
+
"The output JSON will be labelled method=seasonal_naive_baseline."
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
generated_at = datetime.datetime.utcnow().isoformat() + "Z"
|
| 232 |
+
series_map = _load_all_rows(price_dir)
|
| 233 |
+
|
| 234 |
+
print(f"Forecasting {len(series_map)} series ...")
|
| 235 |
+
all_records: list[dict[str, Any]] = []
|
| 236 |
+
|
| 237 |
+
for (commodity, city), series in sorted(series_map.items()):
|
| 238 |
+
if len(series) < 30:
|
| 239 |
+
print(f" Skipping {commodity}/{city}: too short ({len(series)} obs)")
|
| 240 |
+
continue
|
| 241 |
+
|
| 242 |
+
if method == "timesfm":
|
| 243 |
+
try:
|
| 244 |
+
fc_points = _timesfm_forecast(series, horizon=HORIZON, model_path=model_path)
|
| 245 |
+
method_label = "timesfm_2.0"
|
| 246 |
+
except Exception as exc:
|
| 247 |
+
print(f" TimesFM failed for {commodity}/{city}: {exc} — using baseline")
|
| 248 |
+
fc_points = _seasonal_naive_forecast(series, horizon=HORIZON)
|
| 249 |
+
method_label = "seasonal_naive_baseline"
|
| 250 |
+
else:
|
| 251 |
+
fc_points = _seasonal_naive_forecast(series, horizon=HORIZON)
|
| 252 |
+
method_label = "seasonal_naive_baseline"
|
| 253 |
+
|
| 254 |
+
all_records.append({
|
| 255 |
+
"commodity_code": commodity,
|
| 256 |
+
"city_id": city,
|
| 257 |
+
"city_name": CITY_NAMES.get(city, city),
|
| 258 |
+
"method": method_label,
|
| 259 |
+
"generated_at": generated_at,
|
| 260 |
+
"horizon_days": HORIZON,
|
| 261 |
+
"history_end_date": series[-1][0].isoformat(),
|
| 262 |
+
"forecasts": fc_points,
|
| 263 |
+
})
|
| 264 |
+
print(f" {commodity}/{city}: {method_label} — last obs {series[-1][0]}")
|
| 265 |
+
|
| 266 |
+
with out_path.open("w", encoding="utf-8") as fh:
|
| 267 |
+
json.dump(all_records, fh, ensure_ascii=False, separators=(",", ":"))
|
| 268 |
+
|
| 269 |
+
size_kb = out_path.stat().st_size / 1024
|
| 270 |
+
print(f"\nWrote {len(all_records)} series forecasts to {out_path} ({size_kb:.1f} KB)")
|
| 271 |
+
if any(r["method"] == "seasonal_naive_baseline" for r in all_records):
|
| 272 |
+
print(
|
| 273 |
+
"\nNOTE: Output labelled 'seasonal_naive_baseline'. "
|
| 274 |
+
"This is a transparent statistical baseline, NOT TimesFM. "
|
| 275 |
+
"Re-run with Python 3.10/3.11 + timesfm installed for real forecasts."
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
if __name__ == "__main__":
|
| 280 |
+
parser = argparse.ArgumentParser(
|
| 281 |
+
description="Precompute 30-day forecasts (TimesFM or seasonal baseline)."
|
| 282 |
+
)
|
| 283 |
+
parser.add_argument(
|
| 284 |
+
"--price-dir",
|
| 285 |
+
type=Path,
|
| 286 |
+
default=ROOT / "sample_data" / "price_history",
|
| 287 |
+
)
|
| 288 |
+
parser.add_argument(
|
| 289 |
+
"--out-dir",
|
| 290 |
+
type=Path,
|
| 291 |
+
default=ROOT / "sample_data" / "forecasts",
|
| 292 |
+
)
|
| 293 |
+
parser.add_argument(
|
| 294 |
+
"--method",
|
| 295 |
+
choices=["auto", "timesfm", "baseline"],
|
| 296 |
+
default="auto",
|
| 297 |
+
help=(
|
| 298 |
+
"auto: use TimesFM if available, else baseline. "
|
| 299 |
+
"baseline: force seasonal_naive_baseline (honest fallback). "
|
| 300 |
+
"timesfm: force TimesFM (will fail if not installed)."
|
| 301 |
+
),
|
| 302 |
+
)
|
| 303 |
+
parser.add_argument(
|
| 304 |
+
"--model",
|
| 305 |
+
default="google/timesfm-2.0-500m-pytorch",
|
| 306 |
+
help="HuggingFace model ID for TimesFM 2.0 PyTorch variant.",
|
| 307 |
+
)
|
| 308 |
+
args = parser.parse_args()
|
| 309 |
+
main(args.price_dir, args.out_dir, args.method, args.model)
|
analysis/precompute_anomalies.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
analysis/precompute_anomalies.py -- Precompute all S-H-ESD anomalies to JSON.
|
| 3 |
+
|
| 4 |
+
Run offline (locally or in CI) to produce sample_data/anomalies/anomalies_all.json.
|
| 5 |
+
The backend serves from this file at runtime -- zero runtime computation on HF Space.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python analysis/precompute_anomalies.py
|
| 9 |
+
python analysis/precompute_anomalies.py --price-dir sample_data/price_history
|
| 10 |
+
--out-dir sample_data/anomalies
|
| 11 |
+
|
| 12 |
+
Output schema per record:
|
| 13 |
+
date str (ISO 8601, YYYY-MM-DD)
|
| 14 |
+
price float
|
| 15 |
+
rolling_median float
|
| 16 |
+
deviation_pct float
|
| 17 |
+
type str ("SPIKE" | "DROP")
|
| 18 |
+
score float
|
| 19 |
+
commodity_code str
|
| 20 |
+
city_id str
|
| 21 |
+
city_name str
|
| 22 |
+
persistent bool
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import argparse
|
| 28 |
+
import json
|
| 29 |
+
import sys
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
|
| 32 |
+
ROOT = Path(__file__).parent.parent
|
| 33 |
+
if str(ROOT) not in sys.path:
|
| 34 |
+
sys.path.insert(0, str(ROOT))
|
| 35 |
+
|
| 36 |
+
from analysis.price_anomaly import scan_all, CITY_NAMES
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main(price_dir: Path, out_dir: Path) -> None:
|
| 40 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 41 |
+
out_path = out_dir / "anomalies_all.json"
|
| 42 |
+
|
| 43 |
+
print(f"Scanning {price_dir} ...")
|
| 44 |
+
anomalies = scan_all(price_dir, window=30, k=3.0, trend_window=30, persist=2)
|
| 45 |
+
print(f" Found {len(anomalies)} anomalies across all series.")
|
| 46 |
+
|
| 47 |
+
# Serialise: convert datetime.date -> str, numpy floats -> float
|
| 48 |
+
records = []
|
| 49 |
+
for a in anomalies:
|
| 50 |
+
records.append({
|
| 51 |
+
"date": a["date"].isoformat(),
|
| 52 |
+
"price": float(a["price"]),
|
| 53 |
+
"rolling_median": float(a["rolling_median"]),
|
| 54 |
+
"deviation_pct": float(a["deviation_pct"]),
|
| 55 |
+
"type": a["type"],
|
| 56 |
+
"score": float(a["score"]),
|
| 57 |
+
"commodity_code": a["commodity_code"],
|
| 58 |
+
"city_id": a["city_id"],
|
| 59 |
+
"city_name": CITY_NAMES.get(a["city_id"], a["city_id"]),
|
| 60 |
+
"persistent": bool(a["persistent"]),
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
with out_path.open("w", encoding="utf-8") as fh:
|
| 64 |
+
json.dump(records, fh, ensure_ascii=False, separators=(",", ":"))
|
| 65 |
+
|
| 66 |
+
size_kb = out_path.stat().st_size / 1024
|
| 67 |
+
print(f" Wrote {len(records)} records to {out_path} ({size_kb:.1f} KB)")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
if __name__ == "__main__":
|
| 71 |
+
parser = argparse.ArgumentParser(description="Precompute S-H-ESD anomaly scan to JSON.")
|
| 72 |
+
parser.add_argument(
|
| 73 |
+
"--price-dir",
|
| 74 |
+
type=Path,
|
| 75 |
+
default=ROOT / "sample_data" / "price_history",
|
| 76 |
+
)
|
| 77 |
+
parser.add_argument(
|
| 78 |
+
"--out-dir",
|
| 79 |
+
type=Path,
|
| 80 |
+
default=ROOT / "sample_data" / "anomalies",
|
| 81 |
+
)
|
| 82 |
+
args = parser.parse_args()
|
| 83 |
+
main(args.price_dir, args.out_dir)
|
analysis/price_anomaly.py
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
analysis/price_anomaly.py -- S-H-ESD price anomaly detector for AgriFlow.
|
| 3 |
+
|
| 4 |
+
METHOD: Seasonal-Hybrid ESD (S-H-ESD style)
|
| 5 |
+
Based on: Hochenbaum, Vallis, Kejariwal (2017), "Automatic Anomaly Detection in
|
| 6 |
+
the Cloud Via Statistical Learning", arXiv:1704.07706.
|
| 7 |
+
Validated against: Liu & Paparrizos, NeurIPS 2024, "Elephant in the Room" --
|
| 8 |
+
which confirms that robust statistical TSAD remains preferred over transformer-based
|
| 9 |
+
methods when interpretability and policy transparency are required.
|
| 10 |
+
|
| 11 |
+
PIPELINE (per city x commodity series):
|
| 12 |
+
1. Decompose: price = trend + seasonal + residual
|
| 13 |
+
- trend : rolling median over `trend_window` observations (robust to level
|
| 14 |
+
shifts; same justification as original MAD detector).
|
| 15 |
+
- seasonal : median of (price - trend) grouped by *calendar month*. Month
|
| 16 |
+
granularity is correct for Indonesian agricultural seasonality
|
| 17 |
+
(Ramadan, harvest cycles, year-end). day-of-year would be noisier
|
| 18 |
+
given the 5-year series length.
|
| 19 |
+
- residual : price - trend - seasonal
|
| 20 |
+
2. MAD on residual:
|
| 21 |
+
flag when |residual_t - rolling_median(residual)| > k * 1.4826 * MAD(residual)
|
| 22 |
+
using a rolling window of `window` observations over the residuals.
|
| 23 |
+
3. Persistence threshold: flag only if the breach persists for >= `persist` days
|
| 24 |
+
(default 2). One-day noise (telur spike 16 Nov) is filtered.
|
| 25 |
+
4. MAD floor: if MAD(residual window) < `mad_floor_pct` * rolling_median(price),
|
| 26 |
+
skip flagging for that window. Prevents over-sensitivity on low-volatility
|
| 27 |
+
commodities (beras_medium, beras_premium).
|
| 28 |
+
5. Min relative-change gate: flag only if |deviation_pct| >= `min_dev_pct`
|
| 29 |
+
(default 3 %). Nominal IDR fluctuations in flat series are noise.
|
| 30 |
+
|
| 31 |
+
WHY numpy-first (no statsmodels):
|
| 32 |
+
STL (statsmodels) would be cleaner for non-integer period series, but adds a
|
| 33 |
+
heavy dependency the project doesn't already carry. Monthly medians over 5 years
|
| 34 |
+
of daily observations capture the dominant Indonesian agricultural seasonality
|
| 35 |
+
pattern with zero extra deps. This is documented as a known simplification.
|
| 36 |
+
|
| 37 |
+
HONEST LIMITATIONS:
|
| 38 |
+
1. Monthly seasonal component is estimated from only 4-5 years of data per month.
|
| 39 |
+
For commodities with irregular seasonality (Hijri calendar shifts, e.g. Ramadan
|
| 40 |
+
drifts ~11 days/year), the seasonal estimate will lag by up to 2 weeks. This
|
| 41 |
+
means a Ramadan spike at an unusual calendar date may still be partially flagged.
|
| 42 |
+
2. Trend window is rolling median -- it will lag a step-change by up to trend_window/2
|
| 43 |
+
observations. Residuals during a rapid price-regime shift will be elevated until
|
| 44 |
+
the trend catches up, producing a cluster of alerts at the breakpoint. This is
|
| 45 |
+
intentional for the policy-alert use case.
|
| 46 |
+
3. Persistence filter is count-of-consecutive-flagged-observations, not calendar days.
|
| 47 |
+
For weekly-sampled data, N=2 means "two consecutive weeks", not "two days".
|
| 48 |
+
4. This is a robust statistical detector, not AI or ML. It does not learn from
|
| 49 |
+
labelled anomaly data. False negatives (missed true anomalies) and false positives
|
| 50 |
+
(flagged non-events) both exist. The k and persist parameters must be tuned for
|
| 51 |
+
each deployment context.
|
| 52 |
+
5. beras_medium/premium are averages of two PIHPS sub-grades; single-grade commodities
|
| 53 |
+
will have somewhat sharper MAD estimates.
|
| 54 |
+
|
| 55 |
+
Public API (backward-compatible with v1):
|
| 56 |
+
load_series(commodity_code, city_id, price_dir) -> list[(date, price)]
|
| 57 |
+
detect_anomalies(series, window=30, k=3.0, trend_window=30,
|
| 58 |
+
persist=2, mad_floor_pct=0.005, min_dev_pct=3.0) -> list[dict]
|
| 59 |
+
scan_all(price_dir, window=30, k=3.0, trend_window=30,
|
| 60 |
+
persist=2, mad_floor_pct=0.005, min_dev_pct=3.0) -> list[dict]
|
| 61 |
+
|
| 62 |
+
Output schema (each dict):
|
| 63 |
+
date datetime.date
|
| 64 |
+
price float observed price (IDR/kg)
|
| 65 |
+
rolling_median float rolling median of residuals at this point
|
| 66 |
+
deviation_pct float (price - trend) / trend * 100, signed [v2: vs trend not raw median]
|
| 67 |
+
type str 'SPIKE' or 'DROP'
|
| 68 |
+
score float |residual dev| / (1.4826 * MAD(residual)), higher = more anomalous
|
| 69 |
+
commodity_code str populated by scan_all; empty str from detect_anomalies
|
| 70 |
+
city_id str populated by scan_all; empty str from detect_anomalies
|
| 71 |
+
persistent bool True if breach lasted >= persist days
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
+
from __future__ import annotations
|
| 75 |
+
|
| 76 |
+
import csv
|
| 77 |
+
import datetime
|
| 78 |
+
from collections import defaultdict
|
| 79 |
+
from pathlib import Path
|
| 80 |
+
from typing import List, Tuple, Dict, Any
|
| 81 |
+
|
| 82 |
+
import numpy as np
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# Commodity code normalisation (mirrors db/price_ingest.py)
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
|
| 89 |
+
_COMMODITY_MAP: Dict[str, str] = {
|
| 90 |
+
"bawang_merah": "bawang_merah",
|
| 91 |
+
"bawang_putih": "bawang_putih",
|
| 92 |
+
"daging_ayam": "daging_ayam",
|
| 93 |
+
"telur_ayam": "telur_ayam",
|
| 94 |
+
"cabe_rawit": "cabai_rawit",
|
| 95 |
+
"beras_medium_1": "beras_medium",
|
| 96 |
+
"beras_medium_2": "beras_medium",
|
| 97 |
+
"beras_super_1": "beras_premium",
|
| 98 |
+
"beras_super_2": "beras_premium",
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
# Human-readable city names for reporting (IHK Jatim cities)
|
| 102 |
+
CITY_NAMES: Dict[str, str] = {
|
| 103 |
+
"3509": "Jember",
|
| 104 |
+
"3510": "Banyuwangi",
|
| 105 |
+
"3529": "Sumenep",
|
| 106 |
+
"3571": "Kota Kediri",
|
| 107 |
+
"3573": "Kota Malang",
|
| 108 |
+
"3574": "Kota Probolinggo",
|
| 109 |
+
"3577": "Kota Madiun",
|
| 110 |
+
"3578": "Kota Surabaya",
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
# Internal: load raw CSV
|
| 116 |
+
# ---------------------------------------------------------------------------
|
| 117 |
+
|
| 118 |
+
def _read_csv_rows(csv_path: Path) -> List[Dict[str, Any]]:
|
| 119 |
+
"""
|
| 120 |
+
Read one *_cleaned.csv; return list of dicts with canonical commodity codes.
|
| 121 |
+
Silently skips rows with unrecognised commodity codes.
|
| 122 |
+
"""
|
| 123 |
+
rows = []
|
| 124 |
+
with csv_path.open(newline="", encoding="utf-8") as fh:
|
| 125 |
+
for row in csv.DictReader(fh):
|
| 126 |
+
raw_code = row["commodity_code"].strip()
|
| 127 |
+
canonical = _COMMODITY_MAP.get(raw_code)
|
| 128 |
+
if canonical is None:
|
| 129 |
+
continue
|
| 130 |
+
rows.append({
|
| 131 |
+
"date": datetime.date.fromisoformat(row["date"].strip()),
|
| 132 |
+
"city_id": row["city_id"].strip(),
|
| 133 |
+
"commodity_code": canonical,
|
| 134 |
+
"price": float(row["price_per_kg"].strip()),
|
| 135 |
+
})
|
| 136 |
+
return rows
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _load_all_rows(price_dir: Path) -> Dict[Tuple[str, str], List[Tuple[datetime.date, float]]]:
|
| 140 |
+
"""
|
| 141 |
+
Load all *_cleaned.csv; return (commodity_code, city_id) -> sorted [(date, price)].
|
| 142 |
+
Multiple sub-grades on the same (date, city) are averaged.
|
| 143 |
+
"""
|
| 144 |
+
price_dir = Path(price_dir)
|
| 145 |
+
if not price_dir.is_dir():
|
| 146 |
+
raise FileNotFoundError(f"Price directory not found: {price_dir}")
|
| 147 |
+
|
| 148 |
+
accumulated: Dict[Tuple[str, str, datetime.date], List[float]] = {}
|
| 149 |
+
for csv_path in sorted(price_dir.glob("*_cleaned.csv")):
|
| 150 |
+
for row in _read_csv_rows(csv_path):
|
| 151 |
+
key = (row["commodity_code"], row["city_id"], row["date"])
|
| 152 |
+
accumulated.setdefault(key, []).append(row["price"])
|
| 153 |
+
|
| 154 |
+
series_map: Dict[Tuple[str, str], List[Tuple[datetime.date, float]]] = {}
|
| 155 |
+
for (commodity, city, date), prices in accumulated.items():
|
| 156 |
+
avg_price = sum(prices) / len(prices)
|
| 157 |
+
series_map.setdefault((commodity, city), []).append((date, avg_price))
|
| 158 |
+
|
| 159 |
+
for key in series_map:
|
| 160 |
+
series_map[key].sort(key=lambda x: x[0])
|
| 161 |
+
|
| 162 |
+
return series_map
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ---------------------------------------------------------------------------
|
| 166 |
+
# Decomposition: trend + seasonal via numpy (no external dep)
|
| 167 |
+
# ---------------------------------------------------------------------------
|
| 168 |
+
|
| 169 |
+
def _rolling_median(arr: np.ndarray, window: int) -> np.ndarray:
|
| 170 |
+
"""
|
| 171 |
+
Rolling median, left-aligned (uses the `window` most recent values up to t).
|
| 172 |
+
Positions with fewer than `window` values use the available prefix (min_periods=1).
|
| 173 |
+
Returns array same length as arr.
|
| 174 |
+
"""
|
| 175 |
+
n = len(arr)
|
| 176 |
+
result = np.empty(n, dtype=float)
|
| 177 |
+
for t in range(n):
|
| 178 |
+
lo = max(0, t - window + 1)
|
| 179 |
+
result[t] = float(np.median(arr[lo : t + 1]))
|
| 180 |
+
return result
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _decompose(
|
| 184 |
+
prices: np.ndarray,
|
| 185 |
+
dates: List[datetime.date],
|
| 186 |
+
trend_window: int,
|
| 187 |
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 188 |
+
"""
|
| 189 |
+
Decompose a price series into (trend, seasonal, residual).
|
| 190 |
+
|
| 191 |
+
trend = rolling median over trend_window observations
|
| 192 |
+
seasonal = per-month median of (price - trend), applied back to each observation
|
| 193 |
+
by month. This captures the dominant Indonesian agricultural calendar
|
| 194 |
+
(Ramadan, harvest, year-end) without requiring statsmodels.
|
| 195 |
+
residual = price - trend - seasonal
|
| 196 |
+
|
| 197 |
+
Design notes:
|
| 198 |
+
- Month-level seasonality (12 bins) is appropriate here: the dataset spans 5 years
|
| 199 |
+
of daily observations. Day-of-year (365 bins) would produce 5 samples/bin on
|
| 200 |
+
average -- too noisy.
|
| 201 |
+
- Seasonal is estimated on detrended prices (price - trend), not raw prices, to
|
| 202 |
+
avoid trend contamination in the seasonal component.
|
| 203 |
+
- Seasonal is set to 0 for any month with fewer than 2 detrended observations
|
| 204 |
+
(edge case for very short or gappy series).
|
| 205 |
+
"""
|
| 206 |
+
n = len(prices)
|
| 207 |
+
trend = _rolling_median(prices, trend_window)
|
| 208 |
+
detrended = prices - trend
|
| 209 |
+
|
| 210 |
+
# Build month -> median of detrended prices
|
| 211 |
+
month_vals: Dict[int, List[float]] = defaultdict(list)
|
| 212 |
+
for i, d in enumerate(dates):
|
| 213 |
+
month_vals[d.month].append(detrended[i])
|
| 214 |
+
|
| 215 |
+
monthly_median: Dict[int, float] = {}
|
| 216 |
+
for m, vals in month_vals.items():
|
| 217 |
+
monthly_median[m] = float(np.median(vals)) if len(vals) >= 2 else 0.0
|
| 218 |
+
|
| 219 |
+
seasonal = np.array(
|
| 220 |
+
[monthly_median.get(d.month, 0.0) for d in dates],
|
| 221 |
+
dtype=float,
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
residual = prices - trend - seasonal
|
| 225 |
+
return trend, seasonal, residual
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# ---------------------------------------------------------------------------
|
| 229 |
+
# Public API
|
| 230 |
+
# ---------------------------------------------------------------------------
|
| 231 |
+
|
| 232 |
+
def load_series(
|
| 233 |
+
commodity_code: str,
|
| 234 |
+
city_id: str,
|
| 235 |
+
price_dir: str | Path,
|
| 236 |
+
) -> List[Tuple[datetime.date, float]]:
|
| 237 |
+
"""
|
| 238 |
+
Load the price time series for one (commodity_code, city_id) pair.
|
| 239 |
+
|
| 240 |
+
Parameters
|
| 241 |
+
----------
|
| 242 |
+
commodity_code : str
|
| 243 |
+
AgriFlow canonical code (e.g. "cabai_rawit", "bawang_merah").
|
| 244 |
+
Also accepts raw PIHPS codes (e.g. "cabe_rawit") -- normalised automatically.
|
| 245 |
+
city_id : str
|
| 246 |
+
IHK city identifier (e.g. "3578" for Surabaya).
|
| 247 |
+
price_dir : str or Path
|
| 248 |
+
Directory containing *_cleaned.csv files.
|
| 249 |
+
|
| 250 |
+
Returns
|
| 251 |
+
-------
|
| 252 |
+
list of (datetime.date, float)
|
| 253 |
+
Sorted by date ascending. Empty list if no data found.
|
| 254 |
+
|
| 255 |
+
Raises
|
| 256 |
+
------
|
| 257 |
+
FileNotFoundError
|
| 258 |
+
If price_dir does not exist.
|
| 259 |
+
"""
|
| 260 |
+
canonical = _COMMODITY_MAP.get(commodity_code, commodity_code)
|
| 261 |
+
series_map = _load_all_rows(Path(price_dir))
|
| 262 |
+
return series_map.get((canonical, city_id), [])
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def detect_anomalies(
|
| 266 |
+
series: List[Tuple[datetime.date, float]],
|
| 267 |
+
window: int = 30,
|
| 268 |
+
k: float = 3.0,
|
| 269 |
+
trend_window: int = 30,
|
| 270 |
+
persist: int = 2,
|
| 271 |
+
mad_floor_pct: float = 0.005,
|
| 272 |
+
min_dev_pct: float = 3.0,
|
| 273 |
+
) -> List[Dict[str, Any]]:
|
| 274 |
+
"""
|
| 275 |
+
Detect price anomalies using S-H-ESD: deseasonalize then apply rolling MAD
|
| 276 |
+
on the residuals.
|
| 277 |
+
|
| 278 |
+
Steps
|
| 279 |
+
-----
|
| 280 |
+
1. Decompose series: trend (rolling median) + seasonal (monthly median of
|
| 281 |
+
detrended) + residual.
|
| 282 |
+
2. Apply rolling MAD on residuals with threshold k * 1.4826 * MAD.
|
| 283 |
+
3. Persistence filter: retain only flags that appear in a streak of >= persist
|
| 284 |
+
consecutive observations.
|
| 285 |
+
4. MAD floor: skip windows where MAD(residual) < mad_floor_pct * rolling median
|
| 286 |
+
of raw prices (protects beras_medium / beras_premium from over-sensitivity).
|
| 287 |
+
5. Min relative change gate: skip flags where |price - trend| / trend * 100
|
| 288 |
+
< min_dev_pct (filters nominal IDR noise).
|
| 289 |
+
|
| 290 |
+
Parameters
|
| 291 |
+
----------
|
| 292 |
+
series : list of (datetime.date, float)
|
| 293 |
+
Price observations, sorted ascending.
|
| 294 |
+
window : int, default 30
|
| 295 |
+
Rolling window for MAD on residuals. Minimum to flag is window (same as v1).
|
| 296 |
+
k : float, default 3.0
|
| 297 |
+
Sensitivity multiplier. k=3 corresponds to ~0.3 % tail on Gaussian data;
|
| 298 |
+
effectively ~1-2 % on fat-tailed commodity residuals after deseasonalisation.
|
| 299 |
+
trend_window : int, default 30
|
| 300 |
+
Rolling window for trend estimation. Larger = smoother trend but more lag.
|
| 301 |
+
Set equal to `window` by default so the two rolling calculations are aligned.
|
| 302 |
+
persist : int, default 2
|
| 303 |
+
Minimum consecutive flagged observations for a flag to be reported.
|
| 304 |
+
Setting persist=1 disables the persistence filter (reverts to v1 behaviour).
|
| 305 |
+
mad_floor_pct : float, default 0.005
|
| 306 |
+
MAD floor as a fraction of the rolling median of raw prices. E.g. 0.005 means
|
| 307 |
+
the MAD must be at least 0.5 % of the current price level. Prevents
|
| 308 |
+
over-sensitivity on low-volatility series (beras).
|
| 309 |
+
min_dev_pct : float, default 3.0
|
| 310 |
+
Minimum absolute deviation from trend (as % of trend) to flag.
|
| 311 |
+
Filters nominal fluctuations that pass MAD test only due to very small MAD.
|
| 312 |
+
|
| 313 |
+
Returns
|
| 314 |
+
-------
|
| 315 |
+
list of dict, each with keys:
|
| 316 |
+
date datetime.date
|
| 317 |
+
price float observed price (IDR/kg)
|
| 318 |
+
rolling_median float rolling median of RESIDUALS at this point
|
| 319 |
+
deviation_pct float (price - trend) / trend * 100, signed
|
| 320 |
+
type str 'SPIKE' or 'DROP'
|
| 321 |
+
score float |residual dev| in MAD units
|
| 322 |
+
commodity_code str populated by scan_all; empty str here
|
| 323 |
+
city_id str populated by scan_all; empty str here
|
| 324 |
+
persistent bool True if streak >= persist
|
| 325 |
+
|
| 326 |
+
Notes
|
| 327 |
+
-----
|
| 328 |
+
- Series shorter than window returns empty list (same as v1).
|
| 329 |
+
- MAD == 0 on residual window: no flag (perfectly flat residuals mean no
|
| 330 |
+
anomalous structure).
|
| 331 |
+
- Result sorted by score descending.
|
| 332 |
+
"""
|
| 333 |
+
if len(series) < window:
|
| 334 |
+
return []
|
| 335 |
+
|
| 336 |
+
dates = [d for d, _ in series]
|
| 337 |
+
prices = np.array([p for _, p in series], dtype=float)
|
| 338 |
+
n = len(prices)
|
| 339 |
+
|
| 340 |
+
# Step 1: decompose
|
| 341 |
+
trend, seasonal, residual = _decompose(prices, dates, trend_window)
|
| 342 |
+
|
| 343 |
+
# Step 2: rolling MAD on residuals — same left-aligned logic as v1
|
| 344 |
+
raw_flags: List[Dict[str, Any]] = []
|
| 345 |
+
|
| 346 |
+
for t in range(window - 1, n):
|
| 347 |
+
res_window = residual[t - window + 1 : t + 1]
|
| 348 |
+
roll_med_res = float(np.median(res_window))
|
| 349 |
+
abs_devs = np.abs(res_window - roll_med_res)
|
| 350 |
+
mad = float(np.median(abs_devs))
|
| 351 |
+
|
| 352 |
+
raw_dev_res = residual[t] - roll_med_res
|
| 353 |
+
|
| 354 |
+
# --- gate logic ---
|
| 355 |
+
if mad == 0.0:
|
| 356 |
+
# Perfectly flat residual window: any non-zero deviation is a
|
| 357 |
+
# step-change (e.g. spike into a perfectly flat series). We skip
|
| 358 |
+
# the MAD floor (which would fire trivially on mad==0) and score the
|
| 359 |
+
# deviation as percentage of the current price level instead.
|
| 360 |
+
if raw_dev_res == 0.0:
|
| 361 |
+
continue # truly flat: nothing to flag
|
| 362 |
+
price_level = float(np.median(prices[t - window + 1 : t + 1]))
|
| 363 |
+
score = float(abs(raw_dev_res) / price_level) * 100.0 if price_level > 0 else 0.0
|
| 364 |
+
# still apply min relative-change gate
|
| 365 |
+
raw_price_dev = prices[t] - trend[t]
|
| 366 |
+
dev_pct = (raw_price_dev / trend[t]) * 100.0 if trend[t] > 0 else 0.0
|
| 367 |
+
if abs(dev_pct) < min_dev_pct:
|
| 368 |
+
continue
|
| 369 |
+
raw_flags.append({
|
| 370 |
+
"idx": t,
|
| 371 |
+
"date": dates[t],
|
| 372 |
+
"price": float(prices[t]),
|
| 373 |
+
"rolling_median": round(roll_med_res, 2),
|
| 374 |
+
"deviation_pct": round(dev_pct, 2),
|
| 375 |
+
"type": "SPIKE" if raw_price_dev > 0 else "DROP",
|
| 376 |
+
"score": round(score, 3),
|
| 377 |
+
"commodity_code": "",
|
| 378 |
+
"city_id": "",
|
| 379 |
+
"persistent": False,
|
| 380 |
+
})
|
| 381 |
+
continue
|
| 382 |
+
|
| 383 |
+
# Step 4: MAD floor -- skip if both (a) MAD is tiny relative to price
|
| 384 |
+
# level AND (b) the current residual deviation is also tiny.
|
| 385 |
+
# This protects against over-sensitivity on low-volatility series
|
| 386 |
+
# (beras_medium / beras_premium) where the residual MAD and the
|
| 387 |
+
# deviation itself are both small IDR amounts.
|
| 388 |
+
# We guard condition (b) so that a genuine large anomaly (large |dev|)
|
| 389 |
+
# is never blocked even if the window MAD happens to be below the floor.
|
| 390 |
+
price_level = float(np.median(prices[t - window + 1 : t + 1]))
|
| 391 |
+
if price_level > 0 and mad < mad_floor_pct * price_level:
|
| 392 |
+
# Only skip if the deviation is also small (< 2x the floor threshold).
|
| 393 |
+
# A 100k deviation on a 54k series must NOT be blocked by the floor.
|
| 394 |
+
dev_abs = abs(raw_dev_res)
|
| 395 |
+
floor_val = mad_floor_pct * price_level
|
| 396 |
+
if dev_abs < 2.0 * floor_val:
|
| 397 |
+
continue
|
| 398 |
+
|
| 399 |
+
threshold = k * 1.4826 * mad
|
| 400 |
+
score = float(abs(raw_dev_res) / (1.4826 * mad))
|
| 401 |
+
|
| 402 |
+
if abs(raw_dev_res) > threshold:
|
| 403 |
+
# Step 5: min relative-change gate (vs trend, not vs raw median)
|
| 404 |
+
raw_price_dev = prices[t] - trend[t]
|
| 405 |
+
dev_pct = (raw_price_dev / trend[t]) * 100.0 if trend[t] > 0 else 0.0
|
| 406 |
+
if abs(dev_pct) < min_dev_pct:
|
| 407 |
+
continue
|
| 408 |
+
|
| 409 |
+
raw_flags.append({
|
| 410 |
+
"idx": t,
|
| 411 |
+
"date": dates[t],
|
| 412 |
+
"price": float(prices[t]),
|
| 413 |
+
"rolling_median": round(roll_med_res, 2),
|
| 414 |
+
"deviation_pct": round(dev_pct, 2),
|
| 415 |
+
"type": "SPIKE" if raw_price_dev > 0 else "DROP",
|
| 416 |
+
"score": round(score, 3),
|
| 417 |
+
"commodity_code": "",
|
| 418 |
+
"city_id": "",
|
| 419 |
+
"persistent": False, # filled in step 3
|
| 420 |
+
})
|
| 421 |
+
|
| 422 |
+
# Step 3: persistence filter
|
| 423 |
+
# A flag is "persistent" if the consecutive run of flagged indices that
|
| 424 |
+
# contains it has total length >= persist.
|
| 425 |
+
# Algorithm: for each flagged index, walk backward to the run start, then
|
| 426 |
+
# measure the run forward from there. Cache run-start -> run-length to
|
| 427 |
+
# avoid O(n^2) re-computation for long streaks.
|
| 428 |
+
flagged_indices = {f["idx"] for f in raw_flags}
|
| 429 |
+
run_length_cache: Dict[int, int] = {}
|
| 430 |
+
|
| 431 |
+
def _run_length(idx: int) -> int:
|
| 432 |
+
# Walk to run start
|
| 433 |
+
start = idx
|
| 434 |
+
while (start - 1) in flagged_indices:
|
| 435 |
+
start -= 1
|
| 436 |
+
if start in run_length_cache:
|
| 437 |
+
return run_length_cache[start]
|
| 438 |
+
# Measure run from start
|
| 439 |
+
length = 0
|
| 440 |
+
cur = start
|
| 441 |
+
while cur in flagged_indices:
|
| 442 |
+
length += 1
|
| 443 |
+
cur += 1
|
| 444 |
+
run_length_cache[start] = length
|
| 445 |
+
return length
|
| 446 |
+
|
| 447 |
+
anomalies: List[Dict[str, Any]] = []
|
| 448 |
+
|
| 449 |
+
for flag in raw_flags:
|
| 450 |
+
is_persistent = _run_length(flag["idx"]) >= persist
|
| 451 |
+
flag["persistent"] = is_persistent
|
| 452 |
+
if is_persistent:
|
| 453 |
+
anomalies.append(flag)
|
| 454 |
+
|
| 455 |
+
# Remove internal idx key
|
| 456 |
+
for a in anomalies:
|
| 457 |
+
del a["idx"]
|
| 458 |
+
|
| 459 |
+
anomalies.sort(key=lambda x: x["score"], reverse=True)
|
| 460 |
+
return anomalies
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def scan_all(
|
| 464 |
+
price_dir: str | Path,
|
| 465 |
+
window: int = 30,
|
| 466 |
+
k: float = 3.0,
|
| 467 |
+
trend_window: int = 30,
|
| 468 |
+
persist: int = 2,
|
| 469 |
+
mad_floor_pct: float = 0.005,
|
| 470 |
+
min_dev_pct: float = 3.0,
|
| 471 |
+
) -> List[Dict[str, Any]]:
|
| 472 |
+
"""
|
| 473 |
+
Scan all (commodity_code, city_id) combinations and return all detected
|
| 474 |
+
anomalies, sorted by score descending.
|
| 475 |
+
|
| 476 |
+
Parameters
|
| 477 |
+
----------
|
| 478 |
+
price_dir : str or Path
|
| 479 |
+
Directory containing *_cleaned.csv files.
|
| 480 |
+
window, k, trend_window, persist, mad_floor_pct, min_dev_pct
|
| 481 |
+
Passed directly to detect_anomalies().
|
| 482 |
+
|
| 483 |
+
Returns
|
| 484 |
+
-------
|
| 485 |
+
list of dict
|
| 486 |
+
Same schema as detect_anomalies() output, with commodity_code and
|
| 487 |
+
city_id populated. Sorted by score descending.
|
| 488 |
+
|
| 489 |
+
Raises
|
| 490 |
+
------
|
| 491 |
+
FileNotFoundError
|
| 492 |
+
If price_dir does not exist.
|
| 493 |
+
"""
|
| 494 |
+
series_map = _load_all_rows(Path(price_dir))
|
| 495 |
+
all_anomalies: List[Dict[str, Any]] = []
|
| 496 |
+
|
| 497 |
+
for (commodity, city), series in series_map.items():
|
| 498 |
+
anomalies = detect_anomalies(
|
| 499 |
+
series,
|
| 500 |
+
window=window,
|
| 501 |
+
k=k,
|
| 502 |
+
trend_window=trend_window,
|
| 503 |
+
persist=persist,
|
| 504 |
+
mad_floor_pct=mad_floor_pct,
|
| 505 |
+
min_dev_pct=min_dev_pct,
|
| 506 |
+
)
|
| 507 |
+
for a in anomalies:
|
| 508 |
+
a["commodity_code"] = commodity
|
| 509 |
+
a["city_id"] = city
|
| 510 |
+
all_anomalies.extend(anomalies)
|
| 511 |
+
|
| 512 |
+
all_anomalies.sort(key=lambda x: x["score"], reverse=True)
|
| 513 |
+
return all_anomalies
|
analysis/run_anomaly_report.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
analysis/run_anomaly_report.py -- Price anomaly report for AgriFlow.
|
| 3 |
+
|
| 4 |
+
Usage (from project root):
|
| 5 |
+
python analysis/run_anomaly_report.py
|
| 6 |
+
python analysis/run_anomaly_report.py --top 20 --k 2.5
|
| 7 |
+
python analysis/run_anomaly_report.py --window 14 --k 3.0 --commodity cabai_rawit
|
| 8 |
+
python analysis/run_anomaly_report.py --compare # show BEFORE vs AFTER flag counts
|
| 9 |
+
|
| 10 |
+
Method v2: S-H-ESD (Seasonal-Hybrid ESD).
|
| 11 |
+
v1 was rolling-median + MAD on raw prices; this is deseasonalise first,
|
| 12 |
+
then MAD on residuals, with persistence and low-vol gates.
|
| 13 |
+
Result: ~70 % reduction in flag count (14,261 -> 4,192 on 2021-2025 Jatim data).
|
| 14 |
+
|
| 15 |
+
No external API calls; fully offline.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import sys
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
# Force UTF-8 stdout/stderr on Windows (default cp1252 breaks "Rp", arrows, etc.)
|
| 25 |
+
if sys.platform == "win32":
|
| 26 |
+
try:
|
| 27 |
+
sys.stdout.reconfigure(encoding="utf-8")
|
| 28 |
+
sys.stderr.reconfigure(encoding="utf-8")
|
| 29 |
+
except (AttributeError, OSError):
|
| 30 |
+
pass
|
| 31 |
+
|
| 32 |
+
# Allow running from project root without pip install
|
| 33 |
+
_ROOT = Path(__file__).parent.parent
|
| 34 |
+
if str(_ROOT) not in sys.path:
|
| 35 |
+
sys.path.insert(0, str(_ROOT))
|
| 36 |
+
|
| 37 |
+
from analysis.price_anomaly import scan_all, CITY_NAMES
|
| 38 |
+
|
| 39 |
+
PRICE_DIR = _ROOT / "sample_data" / "price_history"
|
| 40 |
+
|
| 41 |
+
COMMODITY_LABELS = {
|
| 42 |
+
"cabai_rawit": "Cabai Rawit",
|
| 43 |
+
"bawang_merah": "Bawang Merah",
|
| 44 |
+
"bawang_putih": "Bawang Putih",
|
| 45 |
+
"daging_ayam": "Daging Ayam",
|
| 46 |
+
"telur_ayam": "Telur Ayam",
|
| 47 |
+
"beras_medium": "Beras Medium",
|
| 48 |
+
"beras_premium":"Beras Premium",
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _fmt_price(p: float) -> str:
|
| 53 |
+
return f"Rp {p:,.0f}/kg"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _run_v1_count() -> int:
|
| 57 |
+
"""
|
| 58 |
+
Reproduce the v1 (raw-price rolling-MAD) flag count for BEFORE/AFTER comparison.
|
| 59 |
+
Uses the same k=3.0, window=30 as the default.
|
| 60 |
+
"""
|
| 61 |
+
from analysis.price_anomaly import _load_all_rows
|
| 62 |
+
import numpy as np
|
| 63 |
+
|
| 64 |
+
series_map = _load_all_rows(PRICE_DIR)
|
| 65 |
+
total = 0
|
| 66 |
+
window = 30
|
| 67 |
+
k = 3.0
|
| 68 |
+
|
| 69 |
+
for (commodity, city), series in series_map.items():
|
| 70 |
+
if len(series) < window:
|
| 71 |
+
continue
|
| 72 |
+
prices = np.array([p for _, p in series], dtype=float)
|
| 73 |
+
n = len(prices)
|
| 74 |
+
for t in range(window - 1, n):
|
| 75 |
+
w = prices[t - window + 1 : t + 1]
|
| 76 |
+
roll_med = float(np.median(w))
|
| 77 |
+
mad = float(np.median(np.abs(w - roll_med)))
|
| 78 |
+
if mad == 0.0:
|
| 79 |
+
if prices[t] != roll_med:
|
| 80 |
+
total += 1
|
| 81 |
+
continue
|
| 82 |
+
if abs(prices[t] - roll_med) > k * 1.4826 * mad:
|
| 83 |
+
total += 1
|
| 84 |
+
|
| 85 |
+
return total
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def main() -> None:
|
| 89 |
+
parser = argparse.ArgumentParser(description="AgriFlow price anomaly report (S-H-ESD v2)")
|
| 90 |
+
parser.add_argument("--top", type=int, default=15, help="Top N to show (default 15)")
|
| 91 |
+
parser.add_argument("--window", type=int, default=30, help="Rolling window size (default 30)")
|
| 92 |
+
parser.add_argument("--k", type=float, default=3.0, help="MAD sensitivity k (default 3.0)")
|
| 93 |
+
parser.add_argument("--persist", type=int, default=2, help="Persistence threshold (default 2)")
|
| 94 |
+
parser.add_argument("--commodity", type=str, default=None, help="Filter to one commodity code")
|
| 95 |
+
parser.add_argument("--compare", action="store_true",
|
| 96 |
+
help="Show BEFORE (v1 raw-price MAD) vs AFTER (S-H-ESD v2) counts")
|
| 97 |
+
args = parser.parse_args()
|
| 98 |
+
|
| 99 |
+
print()
|
| 100 |
+
print("=" * 72)
|
| 101 |
+
print(" AgriFlow -- Deteksi Anomali Harga (S-H-ESD v2, PIHPS Jatim 2021-2025)")
|
| 102 |
+
print("=" * 72)
|
| 103 |
+
print(f" Data : {PRICE_DIR}")
|
| 104 |
+
print(f" Window : {args.window} observations")
|
| 105 |
+
print(f" Threshold : k={args.k} (|dev| > {args.k} * 1.4826 * MAD on RESIDUAL)")
|
| 106 |
+
print(f" Persist : >= {args.persist} consecutive flagged observations")
|
| 107 |
+
print(f" Method : S-H-ESD -- deseasonalise, then robust MAD on residual")
|
| 108 |
+
print(f" (Hochenbaum/Vallis/Kejariwal arXiv:1704.07706)")
|
| 109 |
+
print(f" NOT 'AI'; interpretable statistical detector")
|
| 110 |
+
print()
|
| 111 |
+
|
| 112 |
+
# BEFORE/AFTER comparison
|
| 113 |
+
if args.compare:
|
| 114 |
+
print(" Computing BEFORE count (v1 rolling-MAD on raw prices) ...", end=" ", flush=True)
|
| 115 |
+
before_count = _run_v1_count()
|
| 116 |
+
print(f"done. {before_count:,} flags.")
|
| 117 |
+
|
| 118 |
+
print(" Running S-H-ESD v2 ...", end=" ", flush=True)
|
| 119 |
+
anomalies = scan_all(
|
| 120 |
+
PRICE_DIR,
|
| 121 |
+
window=args.window,
|
| 122 |
+
k=args.k,
|
| 123 |
+
persist=args.persist,
|
| 124 |
+
)
|
| 125 |
+
after_count = len(anomalies)
|
| 126 |
+
print(f"done. {after_count:,} anomaly points detected.")
|
| 127 |
+
|
| 128 |
+
if args.compare:
|
| 129 |
+
reduction = (before_count - after_count) / before_count * 100
|
| 130 |
+
print()
|
| 131 |
+
print(" BEFORE vs AFTER:")
|
| 132 |
+
print(f" v1 (raw-price rolling-MAD) : {before_count:>7,} flags")
|
| 133 |
+
print(f" v2 (S-H-ESD, deseasonalised): {after_count:>7,} flags")
|
| 134 |
+
print(f" Reduction : {reduction:>6.1f} %")
|
| 135 |
+
|
| 136 |
+
print()
|
| 137 |
+
|
| 138 |
+
if args.commodity:
|
| 139 |
+
anomalies = [a for a in anomalies if a["commodity_code"] == args.commodity]
|
| 140 |
+
print(f" Filtered to '{args.commodity}': {len(anomalies):,} anomalies.")
|
| 141 |
+
print()
|
| 142 |
+
|
| 143 |
+
if not anomalies:
|
| 144 |
+
print(" No anomalies found for the given filters.")
|
| 145 |
+
return
|
| 146 |
+
|
| 147 |
+
top = anomalies[: args.top]
|
| 148 |
+
|
| 149 |
+
print(f" Top {len(top)} anomalies ranked by score (highest first):")
|
| 150 |
+
print()
|
| 151 |
+
print(f" {'#':>3} {'Date':<12} {'Type':<6} {'Commodity':<15} {'Kota':<20}"
|
| 152 |
+
f" {'Price':>14} {'Dev%':>8} {'Score':>7} {'Persist':<8}")
|
| 153 |
+
print(" " + "-" * 106)
|
| 154 |
+
|
| 155 |
+
for i, a in enumerate(top, 1):
|
| 156 |
+
city_name = CITY_NAMES.get(a["city_id"], a["city_id"])
|
| 157 |
+
comm_label = COMMODITY_LABELS.get(a["commodity_code"], a["commodity_code"])
|
| 158 |
+
sign = "+" if a["deviation_pct"] > 0 else ""
|
| 159 |
+
persist_marker = "YES" if a["persistent"] else "no"
|
| 160 |
+
print(
|
| 161 |
+
f" {i:>3} {str(a['date']):<12} {a['type']:<6} {comm_label:<15} "
|
| 162 |
+
f"{city_name:<20} {_fmt_price(a['price']):>14} "
|
| 163 |
+
f"{sign}{a['deviation_pct']:>6.1f}% {a['score']:>7.2f} {persist_marker:<8}"
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
print()
|
| 167 |
+
print(" Catatan keterbatasan (S-H-ESD v2):")
|
| 168 |
+
print(" - Seasonal komponen: monthly median -- Ramadan (Hijri) drifts ~11 hr/thn;")
|
| 169 |
+
print(" spike Ramadan di tanggal masehi tak biasa masih bisa muncul parsial.")
|
| 170 |
+
print(" - Trend window = rolling median; lag 15-obs saat price-regime shift cepat.")
|
| 171 |
+
print(" - Persist filter = consecutive observations, bukan hari kalender.")
|
| 172 |
+
print(" Untuk data mingguan, persist=2 = '2 minggu berturut-turut'.")
|
| 173 |
+
print(" - beras_medium/premium: rata-rata 2 sub-grade PIHPS; anomali sedikit")
|
| 174 |
+
print(" konservatif dibanding single-grade.")
|
| 175 |
+
print()
|
| 176 |
+
|
| 177 |
+
# Summary by commodity
|
| 178 |
+
from collections import Counter
|
| 179 |
+
by_comm = Counter(a["commodity_code"] for a in anomalies)
|
| 180 |
+
print(" Total anomalies per commodity (semua, bukan hanya top N):")
|
| 181 |
+
for comm, count in by_comm.most_common():
|
| 182 |
+
label = COMMODITY_LABELS.get(comm, comm)
|
| 183 |
+
persistent_count = sum(1 for a in anomalies if a["commodity_code"] == comm and a["persistent"])
|
| 184 |
+
print(f" {label:<20} : {count:>5} points ({persistent_count} persistent)")
|
| 185 |
+
print()
|
| 186 |
+
print("=" * 72)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
if __name__ == "__main__":
|
| 190 |
+
main()
|
benchmarks/_metrics.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
benchmarks/_metrics.py — Pure equity metrics helpers.
|
| 3 |
+
|
| 4 |
+
No engine imports. All functions are pure (no side effects, no I/O).
|
| 5 |
+
These are the five metrics columns in the baseline comparison table.
|
| 6 |
+
|
| 7 |
+
Metric definitions
|
| 8 |
+
------------------
|
| 9 |
+
total_deficit_covered
|
| 10 |
+
Volume-weighted coverage: sum of min(matched, demanded) across all
|
| 11 |
+
(kab, commodity, segment) keys divided by total demand volume.
|
| 12 |
+
This is tons fulfilled / tons demanded, NOT count of kab covered.
|
| 13 |
+
A 200-ton deficit that is 50% filled counts more than a 10-ton
|
| 14 |
+
deficit that is 100% filled.
|
| 15 |
+
|
| 16 |
+
gini
|
| 17 |
+
Weighted Gini coefficient from the Lorenz curve formulation:
|
| 18 |
+
|
| 19 |
+
G = Σᵢ Σⱼ wᵢ wⱼ |rᵢ − rⱼ| / (2 (Σwᵢ)² r̄)
|
| 20 |
+
|
| 21 |
+
where:
|
| 22 |
+
rᵢ = fulfillment ratio for node i (matched_tons / demand_tons)
|
| 23 |
+
wᵢ = demand volume weight (demand_tons for node i)
|
| 24 |
+
r̄ = weighted mean fulfillment ratio
|
| 25 |
+
|
| 26 |
+
Verification invariants:
|
| 27 |
+
gini_weighted([1,1,1,1], equal weights) ≈ 0.0 (perfectly equal)
|
| 28 |
+
gini_weighted([1,0,0,0], equal weights) > 0.6 (maximum inequality)
|
| 29 |
+
uniform allocation → Gini ≈ 0 (everyone gets same ratio)
|
| 30 |
+
pure greedy → Gini highest (best nodes get everything first)
|
| 31 |
+
|
| 32 |
+
atkinson
|
| 33 |
+
Atkinson index A(ε) = 1 − (1/μ) * (Σᵢ wᵢ rᵢ^(1−ε) / Σᵢ wᵢ)^(1/(1−ε))
|
| 34 |
+
for ε ≠ 1. For ε = 1: A(1) = 1 − exp(Σᵢ wᵢ ln(rᵢ) / Σᵢ wᵢ) / μ.
|
| 35 |
+
Reported at ε=0.5 (moderate inequality aversion) and ε=1.0 (strong).
|
| 36 |
+
wᵢ = demand_tons weight. rᵢ values of 0 handled by clamping to 1e-9
|
| 37 |
+
before log, consistent with standard practice.
|
| 38 |
+
|
| 39 |
+
min_fulfillment
|
| 40 |
+
Leximin gap = the single worst fulfillment ratio across all nodes.
|
| 41 |
+
One number per strategy; higher is better.
|
| 42 |
+
|
| 43 |
+
kab_fulfillment
|
| 44 |
+
Volume-weighted fulfillment ratio for a single kabupaten across all
|
| 45 |
+
its (commodity, segment) demand nodes.
|
| 46 |
+
Used for Sampang (3527) and Bangkalan (3526) headline numbers.
|
| 47 |
+
"""
|
| 48 |
+
from __future__ import annotations
|
| 49 |
+
|
| 50 |
+
import math
|
| 51 |
+
from typing import Dict, Tuple
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# Key type used throughout: (kab_id, commodity_code, segment_value) -> float
|
| 55 |
+
_Key = Tuple[str, str, str]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def fulfillment_by_node(
|
| 59 |
+
matched_tons: Dict[_Key, float],
|
| 60 |
+
demand_tons: Dict[_Key, float],
|
| 61 |
+
) -> Dict[_Key, float]:
|
| 62 |
+
"""
|
| 63 |
+
Per-node fulfillment ratio capped at 1.0.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
matched_tons: dict key -> tons matched (may be missing for unmatched nodes)
|
| 67 |
+
demand_tons: dict key -> tons demanded (positive values only)
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
dict key -> ratio in [0.0, 1.0] for every key with demand_tons > 0.
|
| 71 |
+
"""
|
| 72 |
+
return {
|
| 73 |
+
k: min(1.0, matched_tons.get(k, 0.0) / v)
|
| 74 |
+
for k, v in demand_tons.items()
|
| 75 |
+
if v > 0
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def total_deficit_covered(
|
| 80 |
+
matched_tons: Dict[_Key, float],
|
| 81 |
+
demand_tons: Dict[_Key, float],
|
| 82 |
+
) -> float:
|
| 83 |
+
"""
|
| 84 |
+
Volume-weighted coverage ratio: tons fulfilled / tons demanded.
|
| 85 |
+
|
| 86 |
+
This is NOT a count of kabupaten covered — it weights each node by
|
| 87 |
+
its demand volume so a 200-ton deficit that is half-filled (100t)
|
| 88 |
+
contributes more than a 10-ton deficit that is fully filled (10t).
|
| 89 |
+
|
| 90 |
+
Returns a float in [0.0, 1.0].
|
| 91 |
+
"""
|
| 92 |
+
total = sum(demand_tons.values())
|
| 93 |
+
if total == 0:
|
| 94 |
+
return 0.0
|
| 95 |
+
covered = sum(
|
| 96 |
+
min(matched_tons.get(k, 0.0), v) for k, v in demand_tons.items()
|
| 97 |
+
)
|
| 98 |
+
return covered / total
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def gini(
|
| 102 |
+
matched_tons: Dict[_Key, float],
|
| 103 |
+
demand_tons: Dict[_Key, float],
|
| 104 |
+
) -> float:
|
| 105 |
+
"""
|
| 106 |
+
Weighted Gini from the Lorenz curve mean-absolute-difference formula.
|
| 107 |
+
|
| 108 |
+
G = Σᵢ Σⱼ wᵢ wⱼ |rᵢ − rⱼ| / (2 (Σwᵢ)² r̄)
|
| 109 |
+
|
| 110 |
+
wᵢ = demand_tons[i] (volume weight)
|
| 111 |
+
rᵢ = fulfillment ratio for node i, capped at 1.0
|
| 112 |
+
|
| 113 |
+
Returns 0.0 when all fulfillment ratios are identical (uniform allocation).
|
| 114 |
+
Returns near-maximum when one node gets everything and others get nothing.
|
| 115 |
+
|
| 116 |
+
Verification:
|
| 117 |
+
gini({k: 1 for all k}, equal weights) == 0.0
|
| 118 |
+
gini({k: 0 for all k except one}, equal weights) > 0.6
|
| 119 |
+
uniform allocation produces Gini ≈ 0 (all ratios equal)
|
| 120 |
+
"""
|
| 121 |
+
nodes = [(k, v) for k, v in demand_tons.items() if v > 0]
|
| 122 |
+
if not nodes:
|
| 123 |
+
return 0.0
|
| 124 |
+
|
| 125 |
+
ratios = [min(1.0, matched_tons.get(k, 0.0) / v) for k, v in nodes]
|
| 126 |
+
weights = [v for _, v in nodes]
|
| 127 |
+
|
| 128 |
+
w_sum = sum(weights)
|
| 129 |
+
if w_sum == 0:
|
| 130 |
+
return 0.0
|
| 131 |
+
|
| 132 |
+
# Weighted mean fulfillment
|
| 133 |
+
r_bar = sum(w * r for w, r in zip(weights, ratios)) / w_sum
|
| 134 |
+
if r_bar == 0:
|
| 135 |
+
return 0.0
|
| 136 |
+
|
| 137 |
+
# Double-sum formulation: O(n²) but n is at most ~38*19*4 ≈ 2888 for Jatim.
|
| 138 |
+
# Formula: G = Σ_all_ij wᵢwⱼ|rᵢ−rⱼ| / (2 w_sum² r̄)
|
| 139 |
+
# Σ_all_ij = 2 * Σ_{i<j} (since |rᵢ−rⱼ| is symmetric and diagonal is 0).
|
| 140 |
+
# Therefore: G = 2*Σ_{i<j} wᵢwⱼ|rᵢ−rⱼ| / (2 w_sum² r̄) = Σ_{i<j} / (w_sum² r̄).
|
| 141 |
+
total = 0.0
|
| 142 |
+
n = len(ratios)
|
| 143 |
+
for i in range(n):
|
| 144 |
+
for j in range(i + 1, n):
|
| 145 |
+
total += weights[i] * weights[j] * abs(ratios[i] - ratios[j])
|
| 146 |
+
return total / (w_sum ** 2 * r_bar)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def atkinson(
|
| 150 |
+
matched_tons: Dict[_Key, float],
|
| 151 |
+
demand_tons: Dict[_Key, float],
|
| 152 |
+
epsilon: float = 0.5,
|
| 153 |
+
) -> float:
|
| 154 |
+
"""
|
| 155 |
+
Atkinson inequality index A(ε).
|
| 156 |
+
|
| 157 |
+
For ε ≠ 1:
|
| 158 |
+
A(ε) = 1 − (1/μ) * (Σᵢ wᵢ rᵢ^(1−ε) / Σᵢ wᵢ)^(1/(1−ε))
|
| 159 |
+
|
| 160 |
+
For ε = 1 (limiting case):
|
| 161 |
+
A(1) = 1 − exp(Σᵢ wᵢ ln(rᵢ) / Σᵢ wᵢ) / μ
|
| 162 |
+
|
| 163 |
+
wᵢ = demand_tons weight.
|
| 164 |
+
rᵢ = fulfillment ratio capped at 1.0.
|
| 165 |
+
rᵢ = 0 clamped to 1e-9 before log (standard handling).
|
| 166 |
+
|
| 167 |
+
Returns value in [0.0, 1.0]; higher means more inequality.
|
| 168 |
+
"""
|
| 169 |
+
nodes = [(k, v) for k, v in demand_tons.items() if v > 0]
|
| 170 |
+
if not nodes:
|
| 171 |
+
return 0.0
|
| 172 |
+
|
| 173 |
+
ratios = [min(1.0, matched_tons.get(k, 0.0) / v) for k, v in nodes]
|
| 174 |
+
weights = [v for _, v in nodes]
|
| 175 |
+
|
| 176 |
+
w_sum = sum(weights)
|
| 177 |
+
if w_sum == 0:
|
| 178 |
+
return 0.0
|
| 179 |
+
|
| 180 |
+
mu = sum(w * r for w, r in zip(weights, ratios)) / w_sum
|
| 181 |
+
if mu == 0:
|
| 182 |
+
return 1.0 # everyone gets nothing → maximum inequality by convention
|
| 183 |
+
|
| 184 |
+
if abs(epsilon - 1.0) < 1e-9:
|
| 185 |
+
# Geometric mean formulation
|
| 186 |
+
log_sum = sum(
|
| 187 |
+
w * math.log(max(r, 1e-9)) for w, r in zip(weights, ratios)
|
| 188 |
+
)
|
| 189 |
+
geom_mean = math.exp(log_sum / w_sum)
|
| 190 |
+
return 1.0 - geom_mean / mu
|
| 191 |
+
else:
|
| 192 |
+
power = 1.0 - epsilon
|
| 193 |
+
moment = sum(
|
| 194 |
+
w * (max(r, 1e-9) ** power) for w, r in zip(weights, ratios)
|
| 195 |
+
) / w_sum
|
| 196 |
+
ede = moment ** (1.0 / power)
|
| 197 |
+
return 1.0 - ede / mu
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def min_fulfillment(
|
| 201 |
+
matched_tons: Dict[_Key, float],
|
| 202 |
+
demand_tons: Dict[_Key, float],
|
| 203 |
+
) -> float:
|
| 204 |
+
"""
|
| 205 |
+
Leximin gap: fulfillment ratio of the single worst-served demand node.
|
| 206 |
+
|
| 207 |
+
A strategy that sacrifices one kab entirely will score 0.0 here.
|
| 208 |
+
Higher is better.
|
| 209 |
+
|
| 210 |
+
Returns 0.0 if there are no demand nodes.
|
| 211 |
+
"""
|
| 212 |
+
nodes = [(k, v) for k, v in demand_tons.items() if v > 0]
|
| 213 |
+
if not nodes:
|
| 214 |
+
return 0.0
|
| 215 |
+
ratios = [min(1.0, matched_tons.get(k, 0.0) / v) for k, v in nodes]
|
| 216 |
+
return min(ratios)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def kab_fulfillment(
|
| 220 |
+
matched_tons: Dict[_Key, float],
|
| 221 |
+
demand_tons: Dict[_Key, float],
|
| 222 |
+
kab_id: str,
|
| 223 |
+
) -> float:
|
| 224 |
+
"""
|
| 225 |
+
Volume-weighted fulfillment ratio for a single kabupaten across all
|
| 226 |
+
its (commodity, segment) demand nodes.
|
| 227 |
+
|
| 228 |
+
Used for Sampang (3527) and Bangkalan (3526) headline pitch numbers.
|
| 229 |
+
|
| 230 |
+
Returns 0.0 if kab_id has no demand entries.
|
| 231 |
+
"""
|
| 232 |
+
kab_demand = {k: v for k, v in demand_tons.items() if k[0] == kab_id}
|
| 233 |
+
if not kab_demand:
|
| 234 |
+
return 0.0
|
| 235 |
+
total_demand = sum(kab_demand.values())
|
| 236 |
+
if total_demand == 0:
|
| 237 |
+
return 0.0
|
| 238 |
+
total_matched = sum(
|
| 239 |
+
min(matched_tons.get(k, 0.0), v) for k, v in kab_demand.items()
|
| 240 |
+
)
|
| 241 |
+
return total_matched / total_demand
|
benchmarks/anomaly_detector_gap.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
benchmarks/anomaly_detector_gap.py — Quantifies the gap between AgriFlow's TWO
|
| 3 |
+
price-anomaly detectors on the project's own real data.
|
| 4 |
+
|
| 5 |
+
BACKGROUND
|
| 6 |
+
----------
|
| 7 |
+
Two detectors exist in the codebase, both in the production path:
|
| 8 |
+
|
| 9 |
+
matching_engine/engine.py:62 detect_price_anomaly(node, historical_median,
|
| 10 |
+
historical_std) -- z_score = |price - median| / std, flag if > 3.0
|
| 11 |
+
(PRICE_ANOMALY_SIGMA). This is what actually gates matching: called at
|
| 12 |
+
engine.py:416 and :429 inside run_matching()'s D3 preprocessing, and a
|
| 13 |
+
flagged node is DROPPED from the surplus/deficit pool entirely (not just
|
| 14 |
+
down-weighted).
|
| 15 |
+
|
| 16 |
+
analysis/price_anomaly.py detect_anomalies() -- S-H-ESD: deseasonalise
|
| 17 |
+
(rolling-median trend + monthly seasonal), then robust MAD on the
|
| 18 |
+
residual, with a persistence filter. This is validated against the
|
| 19 |
+
literature (Hochenbaum/Vallis/Kejariwal 2017) and is what feeds the
|
| 20 |
+
dashboard anomaly panel / API -- NOT the matching engine.
|
| 21 |
+
|
| 22 |
+
detect_price_anomaly's std is non-robust: a real outlier inflates std, which
|
| 23 |
+
shrinks the z-score of ALL points (including the outlier itself and future
|
| 24 |
+
genuine anomalies) -- the classic "masking effect" in robust statistics. This
|
| 25 |
+
script measures whether that mechanism is real on THIS project's actual price
|
| 26 |
+
data, and quantifies it two ways:
|
| 27 |
+
|
| 28 |
+
PART 1 -- "As shipped" gap on real data.
|
| 29 |
+
The production historical_prices dict is NOT a rolling window -- it is a
|
| 30 |
+
single STATIC (median, std) constant per commodity, hand-set in
|
| 31 |
+
sample_data/historical_price_stats.csv (mirrored into db/schema.sql's
|
| 32 |
+
historical_prices table). This part runs the ACTUAL shipped function,
|
| 33 |
+
detect_price_anomaly(), against every real observed price in
|
| 34 |
+
sample_data/price_history/*.csv using the ACTUAL shipped static
|
| 35 |
+
constants, and compares the flag set against the already-validated
|
| 36 |
+
S-H-ESD persistent-anomaly set (ground truth for "this was a real
|
| 37 |
+
event"). Reports recall (of genuine persistent anomalies, how many does
|
| 38 |
+
the static 3-sigma catch) and precision-adjacent flag volume.
|
| 39 |
+
|
| 40 |
+
PART 2 -- Masking mechanism, isolated, on real price levels.
|
| 41 |
+
Simulates the scenario the code's OWN docstrings claim ("historical_prices:
|
| 42 |
+
dict commodity_code -> (median, std) 30-day rolling", engine.py:352,370)
|
| 43 |
+
but the shipped loaders never actually implement: a rolling 30-day window
|
| 44 |
+
recomputed from real data. Calls the ACTUAL detect_price_anomaly()
|
| 45 |
+
function (not a re-implementation) with median/std computed from a
|
| 46 |
+
window seeded with real cabai_rawit/beras_medium price levels, sweeping
|
| 47 |
+
the number of contaminating outliers already present in that window
|
| 48 |
+
(0..~40% of the window), and reports at what contamination fraction the
|
| 49 |
+
REAL genuine spike stops being flagged. Cross-checked against the
|
| 50 |
+
MAD-based rule (median +/- k*1.4826*MAD, same k=3.0) at every
|
| 51 |
+
contamination level.
|
| 52 |
+
|
| 53 |
+
Both parts are seeded (--seed, default 2026) for reproducibility.
|
| 54 |
+
|
| 55 |
+
Usage:
|
| 56 |
+
python benchmarks/anomaly_detector_gap.py
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
from __future__ import annotations
|
| 60 |
+
|
| 61 |
+
import argparse
|
| 62 |
+
import os
|
| 63 |
+
import random
|
| 64 |
+
import statistics
|
| 65 |
+
import sys
|
| 66 |
+
from datetime import datetime
|
| 67 |
+
from typing import Dict, List, Tuple
|
| 68 |
+
|
| 69 |
+
if sys.platform == "win32":
|
| 70 |
+
try:
|
| 71 |
+
sys.stdout.reconfigure(encoding="utf-8")
|
| 72 |
+
sys.stderr.reconfigure(encoding="utf-8")
|
| 73 |
+
except (AttributeError, OSError):
|
| 74 |
+
pass
|
| 75 |
+
|
| 76 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 77 |
+
if ROOT not in sys.path:
|
| 78 |
+
sys.path.insert(0, ROOT)
|
| 79 |
+
|
| 80 |
+
from matching_engine.engine import PRICE_ANOMALY_SIGMA, detect_price_anomaly
|
| 81 |
+
from matching_engine.models import Commodity, DemandNode, Kabupaten, Tier
|
| 82 |
+
from analysis.price_anomaly import CITY_NAMES, load_series, scan_all
|
| 83 |
+
|
| 84 |
+
DEFAULT_SEED = 2026
|
| 85 |
+
PRICE_HISTORY_DIR = os.path.join(ROOT, "sample_data", "price_history")
|
| 86 |
+
HISTORICAL_STATS_CSV = os.path.join(ROOT, "sample_data", "historical_price_stats.csv")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def load_static_stats() -> Dict[str, Tuple[float, float, str]]:
|
| 90 |
+
"""Read the ACTUAL shipped static (median, std, source) per commodity."""
|
| 91 |
+
out = {}
|
| 92 |
+
with open(HISTORICAL_STATS_CSV, encoding="utf-8") as fh:
|
| 93 |
+
import csv
|
| 94 |
+
for row in csv.DictReader(fh):
|
| 95 |
+
out[row["commodity_code"]] = (
|
| 96 |
+
float(row["median_idr_per_kg"]),
|
| 97 |
+
float(row["std_idr_per_kg"]),
|
| 98 |
+
row["source"],
|
| 99 |
+
)
|
| 100 |
+
return out
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _dummy_node(price: float):
|
| 104 |
+
"""
|
| 105 |
+
Minimal SupplyNode-like stand-in: detect_price_anomaly only reads
|
| 106 |
+
node.price_per_kg, so a lightweight namespace avoids constructing full
|
| 107 |
+
Kabupaten/Commodity graphs for a million-point scan.
|
| 108 |
+
"""
|
| 109 |
+
class _N:
|
| 110 |
+
pass
|
| 111 |
+
n = _N()
|
| 112 |
+
n.price_per_kg = price
|
| 113 |
+
return n
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# =============================================================================
|
| 117 |
+
# PART 1 — "AS SHIPPED": static constants vs real observed prices
|
| 118 |
+
# =============================================================================
|
| 119 |
+
|
| 120 |
+
def run_as_shipped_gap(seed: int) -> None:
|
| 121 |
+
print("=" * 82)
|
| 122 |
+
print(" PART 1 — 'As shipped': static (median, std) vs real PIHPS Jatim prices")
|
| 123 |
+
print("=" * 82)
|
| 124 |
+
print(f" historical_price_stats.csv is a STATIC constant per commodity "
|
| 125 |
+
f"(not a rolling window,\n despite engine.py's docstring claiming "
|
| 126 |
+
f"'30-day rolling') -- this reproduces exactly what\n production does "
|
| 127 |
+
f"today: detect_price_anomaly() called with the SAME fixed (median, std)\n"
|
| 128 |
+
f" against every real observed price.\n")
|
| 129 |
+
|
| 130 |
+
static_stats = load_static_stats()
|
| 131 |
+
ground_truth = scan_all(PRICE_HISTORY_DIR, window=30, k=3.0, persist=2)
|
| 132 |
+
ground_truth_persistent = [a for a in ground_truth if a["persistent"]]
|
| 133 |
+
|
| 134 |
+
header = (f" {'commodity':<15}{'source':<38}{'n_obs':>8}{'static_3σ_flags':>17}"
|
| 135 |
+
f"{'MAD_persistent':>16}{'overlap':>9}{'recall':>9}")
|
| 136 |
+
print(header)
|
| 137 |
+
print(" " + "-" * (len(header) - 2))
|
| 138 |
+
|
| 139 |
+
total_obs = 0
|
| 140 |
+
total_static_flags = 0
|
| 141 |
+
total_mad_persistent = 0
|
| 142 |
+
total_overlap = 0
|
| 143 |
+
|
| 144 |
+
for commodity, (median, std, source) in sorted(static_stats.items()):
|
| 145 |
+
gt_comm = [a for a in ground_truth_persistent if a["commodity_code"] == commodity]
|
| 146 |
+
if not gt_comm and not any(True for _ in []):
|
| 147 |
+
pass # commodity may still have price series even with 0 persistent anomalies
|
| 148 |
+
|
| 149 |
+
n_obs = 0
|
| 150 |
+
static_flag_dates = set() # (city_id, date)
|
| 151 |
+
for city_id in CITY_NAMES:
|
| 152 |
+
series = load_series(commodity, city_id, PRICE_HISTORY_DIR)
|
| 153 |
+
if not series:
|
| 154 |
+
continue
|
| 155 |
+
for date, price in series:
|
| 156 |
+
n_obs += 1
|
| 157 |
+
if detect_price_anomaly(_dummy_node(price), median, std):
|
| 158 |
+
static_flag_dates.add((city_id, date))
|
| 159 |
+
|
| 160 |
+
if n_obs == 0:
|
| 161 |
+
continue # commodity has no real price series (e.g. cabai_merah, tomat, ...)
|
| 162 |
+
|
| 163 |
+
gt_dates = {(a["city_id"], a["date"]) for a in gt_comm}
|
| 164 |
+
overlap = static_flag_dates & gt_dates
|
| 165 |
+
recall = (len(overlap) / len(gt_dates) * 100.0) if gt_dates else float("nan")
|
| 166 |
+
|
| 167 |
+
total_obs += n_obs
|
| 168 |
+
total_static_flags += len(static_flag_dates)
|
| 169 |
+
total_mad_persistent += len(gt_dates)
|
| 170 |
+
total_overlap += len(overlap)
|
| 171 |
+
|
| 172 |
+
recall_str = f"{recall:5.1f}%" if gt_dates else "n/a"
|
| 173 |
+
print(f" {commodity:<15}{source:<38}{n_obs:>8}{len(static_flag_dates):>17}"
|
| 174 |
+
f"{len(gt_dates):>16}{len(overlap):>9}{recall_str:>9}")
|
| 175 |
+
|
| 176 |
+
print()
|
| 177 |
+
overall_recall = (total_overlap / total_mad_persistent * 100.0) if total_mad_persistent else 0.0
|
| 178 |
+
print(f" TOTAL: {total_obs} real observations across 7 commodities x 8 IHK cities.")
|
| 179 |
+
print(f" Static 3σ flagged {total_static_flags} points total.")
|
| 180 |
+
print(f" S-H-ESD (robust, validated) flagged {total_mad_persistent} PERSISTENT genuine events.")
|
| 181 |
+
print(f" Static 3σ caught {total_overlap}/{total_mad_persistent} of those "
|
| 182 |
+
f"({overall_recall:.1f}% recall) on the SAME date+city.")
|
| 183 |
+
print(f"\n Caveat: telur_ayam and daging_ayam static constants are labelled "
|
| 184 |
+
f"'SYNTHETIC' in\n historical_price_stats.csv (never calibrated against "
|
| 185 |
+
f"the real PIHPS series at all) --\n their recall numbers reflect a "
|
| 186 |
+
f"made-up threshold, not a stale-but-real one.\n")
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# =============================================================================
|
| 190 |
+
# PART 2 — Masking mechanism, isolated, on real price levels
|
| 191 |
+
# =============================================================================
|
| 192 |
+
|
| 193 |
+
def run_masking_mechanism(seed: int, window: int = 30, trials: int = 200) -> None:
|
| 194 |
+
print("=" * 82)
|
| 195 |
+
print(" PART 2 — Masking mechanism (rolling window, real price levels)")
|
| 196 |
+
print("=" * 82)
|
| 197 |
+
print(f" Simulates the 'rolling {window}-day' design the docstring promises "
|
| 198 |
+
f"(engine.py:352)\n but the shipped loader never implements. Calls the "
|
| 199 |
+
f"ACTUAL detect_price_anomaly()\n function with median/std computed "
|
| 200 |
+
f"from a window built on REAL median price levels\n (not an arbitrary "
|
| 201 |
+
f"Rp30,000 placeholder), sweeping how many contaminating outliers\n"
|
| 202 |
+
f" are already sitting in that window when a genuine 3x spike arrives.\n")
|
| 203 |
+
|
| 204 |
+
# Real median price levels, PIHPS Jatim (Surabaya, 3578), for grounding.
|
| 205 |
+
scenarios = [
|
| 206 |
+
("cabai_rawit (volatile, MAPE 23.2% per forecasting validation)", 41_000.0),
|
| 207 |
+
("beras_medium (low-volatility staple)", 14_000.0),
|
| 208 |
+
]
|
| 209 |
+
|
| 210 |
+
for label, base_price in scenarios:
|
| 211 |
+
print(f" --- {label}: base price Rp{base_price:,.0f}/kg ---")
|
| 212 |
+
header = (f" {'contam_n':>9}{'contam_%':>10}{'mean_std':>12}"
|
| 213 |
+
f"{'3σ_flags_spike':>16}{'MAD_flags_spike':>17}")
|
| 214 |
+
print(header)
|
| 215 |
+
|
| 216 |
+
for contam_n in range(0, int(window * 0.5) + 1, 2):
|
| 217 |
+
contam_frac = contam_n / window * 100.0
|
| 218 |
+
rng = random.Random(seed + contam_n)
|
| 219 |
+
spike3sigma_hits = 0
|
| 220 |
+
mad_hits = 0
|
| 221 |
+
stds = []
|
| 222 |
+
|
| 223 |
+
for _ in range(trials):
|
| 224 |
+
# Build a window of `window` observations: base price with mild
|
| 225 |
+
# noise, `contam_n` of them replaced by contaminating spikes
|
| 226 |
+
# (2-3x base, random sign/magnitude), and ONE genuine test spike
|
| 227 |
+
# (3x base) at a fixed slot -- this is the point we ask both
|
| 228 |
+
# detectors to catch.
|
| 229 |
+
win = [base_price * rng.gauss(1.0, 0.03) for _ in range(window)]
|
| 230 |
+
contam_positions = rng.sample(range(window - 1), k=min(contam_n, window - 1))
|
| 231 |
+
for p in contam_positions:
|
| 232 |
+
mult = rng.uniform(2.0, 3.0) * rng.choice([1, -0.4])
|
| 233 |
+
win[p] = max(1.0, base_price * abs(mult))
|
| 234 |
+
test_spike_price = base_price * 3.0
|
| 235 |
+
win[-1] = test_spike_price # genuine anomaly under test
|
| 236 |
+
|
| 237 |
+
median = statistics.median(win)
|
| 238 |
+
std = statistics.pstdev(win)
|
| 239 |
+
stds.append(std)
|
| 240 |
+
|
| 241 |
+
# ACTUAL shipped function — not a re-implementation.
|
| 242 |
+
if detect_price_anomaly(_dummy_node(test_spike_price), median, std):
|
| 243 |
+
spike3sigma_hits += 1
|
| 244 |
+
|
| 245 |
+
# MAD-equivalent at the SAME k, matching analysis/price_anomaly's
|
| 246 |
+
# spread estimator (robust) instead of engine.py's std (non-robust).
|
| 247 |
+
abs_devs = [abs(x - median) for x in win]
|
| 248 |
+
mad = statistics.median(abs_devs)
|
| 249 |
+
if mad > 0:
|
| 250 |
+
mad_z = abs(test_spike_price - median) / (1.4826 * mad)
|
| 251 |
+
if mad_z > PRICE_ANOMALY_SIGMA:
|
| 252 |
+
mad_hits += 1
|
| 253 |
+
else:
|
| 254 |
+
mad_hits += 1 # flat window + spike -> trivially anomalous
|
| 255 |
+
|
| 256 |
+
mean_std = statistics.mean(stds)
|
| 257 |
+
print(f" {contam_n:>9}{contam_frac:>9.1f}%{mean_std:>12,.0f}"
|
| 258 |
+
f"{spike3sigma_hits:>10}/{trials:<5}{mad_hits:>10}/{trials:<5}")
|
| 259 |
+
print()
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def main() -> None:
|
| 263 |
+
parser = argparse.ArgumentParser(
|
| 264 |
+
description="Quantify the gap between engine.py's z-score detector and "
|
| 265 |
+
"analysis/price_anomaly's MAD detector, on AgriFlow's own real data.",
|
| 266 |
+
)
|
| 267 |
+
parser.add_argument("--seed", type=int, default=DEFAULT_SEED)
|
| 268 |
+
parser.add_argument("--trials", type=int, default=200)
|
| 269 |
+
args = parser.parse_args()
|
| 270 |
+
|
| 271 |
+
print(f"AgriFlow — Anomaly Detector Gap Analysis (seed={args.seed})")
|
| 272 |
+
print(f"Run: {datetime.now().isoformat(timespec='seconds')}\n")
|
| 273 |
+
|
| 274 |
+
run_as_shipped_gap(args.seed)
|
| 275 |
+
run_masking_mechanism(args.seed, trials=args.trials)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
if __name__ == "__main__":
|
| 279 |
+
main()
|
benchmarks/dashboard_load.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Concurrency load test for the authenticated dashboard API.
|
| 3 |
+
|
| 4 |
+
Answers one question with numbers rather than adjectives: how many simultaneous
|
| 5 |
+
signed-in dashboard users can one API worker serve?
|
| 6 |
+
|
| 7 |
+
Simulates N distinct users, each with their own signed JWT, hitting the
|
| 8 |
+
endpoints the dashboard actually calls on page load. Reports throughput and
|
| 9 |
+
latency percentiles, and verifies every response was correct — a fast server
|
| 10 |
+
that returns 500s is not a passing result.
|
| 11 |
+
|
| 12 |
+
Run:
|
| 13 |
+
python benchmarks/dashboard_load.py # default 1000 users
|
| 14 |
+
python benchmarks/dashboard_load.py --users 5000 --workers 64
|
| 15 |
+
|
| 16 |
+
Runs fully in-process against the ASGI app via TestClient, so it measures
|
| 17 |
+
application cost with no network or TLS in the way. Real-world numbers will be
|
| 18 |
+
lower; this isolates whether *our code* is the bottleneck.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import argparse
|
| 24 |
+
import os
|
| 25 |
+
import statistics
|
| 26 |
+
import sys
|
| 27 |
+
import time
|
| 28 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 29 |
+
from datetime import datetime, timedelta, timezone
|
| 30 |
+
|
| 31 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 32 |
+
|
| 33 |
+
SECRET = "load-test-secret-not-used-in-production"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def make_token(user_index: int) -> str:
|
| 37 |
+
import jwt
|
| 38 |
+
now = datetime.now(timezone.utc)
|
| 39 |
+
return jwt.encode(
|
| 40 |
+
{
|
| 41 |
+
"sub": f"00000000-0000-0000-0000-{user_index:012d}",
|
| 42 |
+
"email": f"user{user_index}@dinas.example.go.id",
|
| 43 |
+
"aud": "authenticated", "role": "authenticated",
|
| 44 |
+
"iat": now, "exp": now + timedelta(hours=1),
|
| 45 |
+
},
|
| 46 |
+
SECRET, algorithm="HS256",
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# The requests a dashboard makes when a signed-in user opens it.
|
| 51 |
+
PAGE_LOAD = [
|
| 52 |
+
("/api/v1/commodities", {}),
|
| 53 |
+
("/api/v1/kabupaten", {}),
|
| 54 |
+
("/api/v1/surplus-deficit", {"commodity": "beras_premium"}),
|
| 55 |
+
("/api/v1/matches", {"commodity": "beras_premium", "limit": 50}),
|
| 56 |
+
("/api/v1/anomalies", {"limit": 20}),
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def main() -> int:
|
| 61 |
+
ap = argparse.ArgumentParser()
|
| 62 |
+
ap.add_argument("--users", type=int, default=1000)
|
| 63 |
+
ap.add_argument("--workers", type=int, default=32)
|
| 64 |
+
args = ap.parse_args()
|
| 65 |
+
|
| 66 |
+
os.environ["SUPABASE_JWT_SECRET"] = SECRET
|
| 67 |
+
os.environ.pop("SUPABASE_URL", None)
|
| 68 |
+
os.environ["REQUIRE_AUTH"] = "true" # every request must verify a token
|
| 69 |
+
os.environ["PHONE_HASH_SALT"] = "load-test"
|
| 70 |
+
|
| 71 |
+
from fastapi.testclient import TestClient
|
| 72 |
+
from whatsapp_bot import server
|
| 73 |
+
|
| 74 |
+
print(f"Minting {args.users:,} distinct user tokens…")
|
| 75 |
+
tokens = [make_token(i) for i in range(args.users)]
|
| 76 |
+
|
| 77 |
+
latencies: list[float] = []
|
| 78 |
+
failures: list[str] = []
|
| 79 |
+
|
| 80 |
+
with TestClient(server.app) as client:
|
| 81 |
+
# Warm the caches so we measure steady state, not cold start.
|
| 82 |
+
for path, params in PAGE_LOAD:
|
| 83 |
+
client.get(path, params=params, headers={"Authorization": f"Bearer {tokens[0]}"})
|
| 84 |
+
|
| 85 |
+
def one_user(token: str) -> float:
|
| 86 |
+
t0 = time.perf_counter()
|
| 87 |
+
for path, params in PAGE_LOAD:
|
| 88 |
+
r = client.get(path, params=params,
|
| 89 |
+
headers={"Authorization": f"Bearer {token}"})
|
| 90 |
+
if r.status_code != 200:
|
| 91 |
+
failures.append(f"{path} -> {r.status_code}")
|
| 92 |
+
return (time.perf_counter() - t0) * 1000
|
| 93 |
+
|
| 94 |
+
print(f"Running {args.users:,} user sessions across {args.workers} workers…")
|
| 95 |
+
t_start = time.perf_counter()
|
| 96 |
+
with ThreadPoolExecutor(max_workers=args.workers) as pool:
|
| 97 |
+
latencies = list(pool.map(one_user, tokens))
|
| 98 |
+
elapsed = time.perf_counter() - t_start
|
| 99 |
+
|
| 100 |
+
latencies.sort()
|
| 101 |
+
|
| 102 |
+
def pct(p: float) -> float:
|
| 103 |
+
return latencies[min(int(len(latencies) * p), len(latencies) - 1)]
|
| 104 |
+
|
| 105 |
+
requests = args.users * len(PAGE_LOAD)
|
| 106 |
+
print()
|
| 107 |
+
print("=" * 62)
|
| 108 |
+
print(f" users simulated {args.users:,}")
|
| 109 |
+
print(f" requests issued {requests:,} ({len(PAGE_LOAD)} per user)")
|
| 110 |
+
print(f" wall clock {elapsed:.2f} s")
|
| 111 |
+
print(f" throughput {requests / elapsed:,.0f} req/s")
|
| 112 |
+
print(f" user sessions/sec {args.users / elapsed:,.0f}")
|
| 113 |
+
print("-" * 62)
|
| 114 |
+
print(f" full page load mean {statistics.mean(latencies):7.1f} ms")
|
| 115 |
+
print(f" p50 {pct(0.50):7.1f} ms")
|
| 116 |
+
print(f" p95 {pct(0.95):7.1f} ms")
|
| 117 |
+
print(f" p99 {pct(0.99):7.1f} ms")
|
| 118 |
+
print(f" max {latencies[-1]:7.1f} ms")
|
| 119 |
+
print("-" * 62)
|
| 120 |
+
print(f" failed requests {len(failures)}")
|
| 121 |
+
if failures:
|
| 122 |
+
for f in sorted(set(failures))[:5]:
|
| 123 |
+
print(f" {f} (x{failures.count(f)})")
|
| 124 |
+
print("=" * 62)
|
| 125 |
+
return 1 if failures else 0
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
if __name__ == "__main__":
|
| 129 |
+
sys.exit(main())
|
benchmarks/equity_comparison.py
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
benchmarks/equity_comparison.py — Baseline Comparison + Equity Sensitivity Harness.
|
| 3 |
+
|
| 4 |
+
Action 2 (baseline comparison) + Action 6 (sensitivity) from recommendation.md.
|
| 5 |
+
Generates two pitch tables and writes them to benchmarks/output/equity_comparison.md.
|
| 6 |
+
|
| 7 |
+
Five strategies:
|
| 8 |
+
1. pure_greedy — equity_fn=lambda _: 1.0, force_strategy="greedy"
|
| 9 |
+
Efficiency anchor (honest frontier: no equity).
|
| 10 |
+
2. agriflow — default equity_fn + default dispatch
|
| 11 |
+
Production behavior.
|
| 12 |
+
3. uniform — equal split per surplus across viable deficits
|
| 13 |
+
Equity anchor (ignores score entirely).
|
| 14 |
+
4. proportional — allocation proportional to deficit magnitude per kab
|
| 15 |
+
Status-quo Bapanas foil (attack #7 answer).
|
| 16 |
+
5. agriflow_smoothed — linear-interpolation closure over existing tier knots
|
| 17 |
+
(1.30/1.15/1.05/1.00 at IPM 68/72/78), no new params.
|
| 18 |
+
Demonstrates smoothed ≈ step (attack #2 answer).
|
| 19 |
+
|
| 20 |
+
All five strategies receive IDENTICAL inputs: same supply/deficit pool, same
|
| 21 |
+
logistics context, same hard constraints (generate_candidates). Only
|
| 22 |
+
scoring/prioritisation differs. This is the apple-to-apple requirement.
|
| 23 |
+
|
| 24 |
+
Run:
|
| 25 |
+
python benchmarks/equity_comparison.py
|
| 26 |
+
"""
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
import os
|
| 29 |
+
import sys
|
| 30 |
+
from collections import defaultdict
|
| 31 |
+
from typing import Dict, List, Tuple
|
| 32 |
+
|
| 33 |
+
if sys.platform == "win32":
|
| 34 |
+
try:
|
| 35 |
+
sys.stdout.reconfigure(encoding="utf-8")
|
| 36 |
+
except (AttributeError, OSError):
|
| 37 |
+
pass
|
| 38 |
+
|
| 39 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 40 |
+
|
| 41 |
+
from matching_engine import run_matching
|
| 42 |
+
from matching_engine.allocation import equity_multiplier_value
|
| 43 |
+
from matching_engine.constraints import generate_candidates
|
| 44 |
+
from matching_engine.models import LogisticsContext
|
| 45 |
+
from sample_data.loader import load_all_sample_data
|
| 46 |
+
|
| 47 |
+
from benchmarks._metrics import (
|
| 48 |
+
atkinson,
|
| 49 |
+
fulfillment_by_node,
|
| 50 |
+
gini,
|
| 51 |
+
kab_fulfillment,
|
| 52 |
+
min_fulfillment,
|
| 53 |
+
total_deficit_covered,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# ---------------------------------------------------------------------------
|
| 57 |
+
# Key type: (kab_id, commodity_code, segment_value) -> float
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
_Key = Tuple[str, str, str]
|
| 60 |
+
|
| 61 |
+
SAMPANG_ID = "3527"
|
| 62 |
+
BANGKALAN_ID = "3526"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
# HELPER: extract demand_tons dict from loaded deficit nodes
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
def _build_demand_tons(deficit_nodes) -> Dict[_Key, float]:
|
| 70 |
+
"""Build demand_tons dict from a list of DemandNode objects."""
|
| 71 |
+
out: Dict[_Key, float] = {}
|
| 72 |
+
for d in deficit_nodes:
|
| 73 |
+
key = (d.kabupaten.id, d.commodity.code, d.segment.value)
|
| 74 |
+
out[key] = out.get(key, 0.0) + d.volume_tons
|
| 75 |
+
return out
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
# HELPER: extract matched_tons dict from a MatchingReport
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
|
| 82 |
+
def _report_to_matched_tons(report) -> Dict[_Key, float]:
|
| 83 |
+
"""Adapt MatchingReport.matches -> matched_tons dict."""
|
| 84 |
+
out: Dict[_Key, float] = {}
|
| 85 |
+
for m in report.matches:
|
| 86 |
+
key = (m.deficit.kabupaten.id, m.deficit.commodity.code, m.deficit.segment.value)
|
| 87 |
+
out[key] = out.get(key, 0.0) + m.matched_volume_tons
|
| 88 |
+
return out
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ---------------------------------------------------------------------------
|
| 92 |
+
# STRATEGY 3: UNIFORM ALLOCATION
|
| 93 |
+
# Uses only Layer 1 candidate generation; ignores score entirely.
|
| 94 |
+
# Each surplus splits its volume equally across its viable deficits,
|
| 95 |
+
# capping at each deficit's actual need.
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
|
| 98 |
+
def uniform_allocate(surplus, deficit, logistics) -> Dict[_Key, float]:
|
| 99 |
+
"""
|
| 100 |
+
Equity baseline: each surplus splits volume equally across its viable
|
| 101 |
+
deficit partners (post hard-constraint). Score is ignored entirely.
|
| 102 |
+
|
| 103 |
+
Returns matched_tons dict: (kab_id, commodity_code, segment) -> tons matched.
|
| 104 |
+
This is a foil only — not a product feature, implemented in benchmarks/.
|
| 105 |
+
"""
|
| 106 |
+
candidates = generate_candidates(surplus, deficit, logistics=logistics)
|
| 107 |
+
|
| 108 |
+
# Group: surplus_key -> list of deficit nodes that can receive it
|
| 109 |
+
surplus_to_deficits: Dict[str, List] = defaultdict(list)
|
| 110 |
+
for s, d in candidates:
|
| 111 |
+
s_key = s.kabupaten.id + "_" + s.commodity.code
|
| 112 |
+
surplus_to_deficits[s_key].append((s, d))
|
| 113 |
+
|
| 114 |
+
# Build demand lookup: (kab_id, commodity, segment) -> total_demand
|
| 115 |
+
demand_lookup: Dict[_Key, float] = _build_demand_tons(deficit)
|
| 116 |
+
|
| 117 |
+
# remaining demand per key
|
| 118 |
+
remaining_demand: Dict[_Key, float] = dict(demand_lookup)
|
| 119 |
+
matched: Dict[_Key, float] = defaultdict(float)
|
| 120 |
+
|
| 121 |
+
# For each surplus, distribute its volume equally across eligible deficits
|
| 122 |
+
# Iterate multiple passes to handle partial fills (demand may already be
|
| 123 |
+
# partially filled from another surplus). Simple single-pass equal split:
|
| 124 |
+
# divide surplus volume by number of eligible deficit keys.
|
| 125 |
+
for s_key, pairs in surplus_to_deficits.items():
|
| 126 |
+
s = pairs[0][0]
|
| 127 |
+
remaining_supply = s.volume_tons
|
| 128 |
+
eligible = [
|
| 129 |
+
(s_, d) for s_, d in pairs
|
| 130 |
+
if remaining_demand.get(
|
| 131 |
+
(d.kabupaten.id, d.commodity.code, d.segment.value), 0.0
|
| 132 |
+
) > 0
|
| 133 |
+
]
|
| 134 |
+
if not eligible:
|
| 135 |
+
continue
|
| 136 |
+
n = len(eligible)
|
| 137 |
+
share = remaining_supply / n # equal share per eligible deficit
|
| 138 |
+
for _, d in eligible:
|
| 139 |
+
d_key = (d.kabupaten.id, d.commodity.code, d.segment.value)
|
| 140 |
+
allocation = min(share, remaining_demand.get(d_key, 0.0))
|
| 141 |
+
matched[d_key] += allocation
|
| 142 |
+
remaining_demand[d_key] = max(0.0, remaining_demand[d_key] - allocation)
|
| 143 |
+
|
| 144 |
+
return dict(matched)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
# STRATEGY 4: PROPORTIONAL-TO-DEFICIT (Bapanas status-quo foil)
|
| 149 |
+
# Allocates each surplus proportionally to the deficit magnitude of each
|
| 150 |
+
# eligible kab. Larger deficit gets larger share.
|
| 151 |
+
# ---------------------------------------------------------------------------
|
| 152 |
+
|
| 153 |
+
def proportional_allocate(surplus, deficit, logistics) -> Dict[_Key, float]:
|
| 154 |
+
"""
|
| 155 |
+
Status-quo Bapanas foil: allocation proportional to deficit magnitude.
|
| 156 |
+
|
| 157 |
+
Each surplus distributes volume to eligible deficits weighted by
|
| 158 |
+
their remaining demand volume. Ignores score, distance ranking, equity.
|
| 159 |
+
Represents a simple pro-rata rule: 'give more to those who need more'.
|
| 160 |
+
|
| 161 |
+
Returns matched_tons dict: (kab_id, commodity_code, segment) -> tons.
|
| 162 |
+
"""
|
| 163 |
+
candidates = generate_candidates(surplus, deficit, logistics=logistics)
|
| 164 |
+
|
| 165 |
+
surplus_to_deficits: Dict[str, List] = defaultdict(list)
|
| 166 |
+
for s, d in candidates:
|
| 167 |
+
s_key = s.kabupaten.id + "_" + s.commodity.code
|
| 168 |
+
surplus_to_deficits[s_key].append((s, d))
|
| 169 |
+
|
| 170 |
+
demand_lookup: Dict[_Key, float] = _build_demand_tons(deficit)
|
| 171 |
+
remaining_demand: Dict[_Key, float] = dict(demand_lookup)
|
| 172 |
+
matched: Dict[_Key, float] = defaultdict(float)
|
| 173 |
+
|
| 174 |
+
for s_key, pairs in surplus_to_deficits.items():
|
| 175 |
+
s = pairs[0][0]
|
| 176 |
+
remaining_supply = s.volume_tons
|
| 177 |
+
eligible = [
|
| 178 |
+
(s_, d) for s_, d in pairs
|
| 179 |
+
if remaining_demand.get(
|
| 180 |
+
(d.kabupaten.id, d.commodity.code, d.segment.value), 0.0
|
| 181 |
+
) > 0
|
| 182 |
+
]
|
| 183 |
+
if not eligible:
|
| 184 |
+
continue
|
| 185 |
+
# Weight = remaining demand magnitude
|
| 186 |
+
d_keys = [
|
| 187 |
+
(d.kabupaten.id, d.commodity.code, d.segment.value)
|
| 188 |
+
for _, d in eligible
|
| 189 |
+
]
|
| 190 |
+
weights = [remaining_demand[k] for k in d_keys]
|
| 191 |
+
total_weight = sum(weights)
|
| 192 |
+
if total_weight == 0:
|
| 193 |
+
continue
|
| 194 |
+
for d_key, w in zip(d_keys, weights):
|
| 195 |
+
share = remaining_supply * (w / total_weight)
|
| 196 |
+
allocation = min(share, remaining_demand[d_key])
|
| 197 |
+
matched[d_key] += allocation
|
| 198 |
+
remaining_demand[d_key] = max(0.0, remaining_demand[d_key] - allocation)
|
| 199 |
+
|
| 200 |
+
return dict(matched)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
# ---------------------------------------------------------------------------
|
| 204 |
+
# STRATEGY 5: AGRIFLOW-SMOOTHED (linear interpolation over existing knots)
|
| 205 |
+
# This is a closure ONLY — do not modify equity_multiplier_value production.
|
| 206 |
+
# Knots: (68, 1.30), (72, 1.15), (78, 1.05), (inf, 1.00)
|
| 207 |
+
# Linear interpolation between knots; outside range uses boundary values.
|
| 208 |
+
# ---------------------------------------------------------------------------
|
| 209 |
+
|
| 210 |
+
def _equity_smoothed(ipm: float) -> float:
|
| 211 |
+
"""
|
| 212 |
+
Linear interpolation over the existing tier knots.
|
| 213 |
+
Knot values match equity_multiplier_value exactly at knot boundaries:
|
| 214 |
+
IPM=68 -> 1.30, IPM=72 -> 1.15, IPM=78 -> 1.05, IPM>=85 -> 1.00
|
| 215 |
+
|
| 216 |
+
Below 68: returns 1.30 (same as step-function).
|
| 217 |
+
Between knots: linear interpolation.
|
| 218 |
+
Above 85: returns 1.00.
|
| 219 |
+
|
| 220 |
+
This demonstrates that smoothed behaviour approximates the step-function
|
| 221 |
+
(attack #2 answer) without changing any production code.
|
| 222 |
+
"""
|
| 223 |
+
# Knot points: (ipm, multiplier)
|
| 224 |
+
knots = [(68.0, 1.30), (72.0, 1.15), (78.0, 1.05), (85.0, 1.00)]
|
| 225 |
+
if ipm <= knots[0][0]:
|
| 226 |
+
return knots[0][1]
|
| 227 |
+
if ipm >= knots[-1][0]:
|
| 228 |
+
return knots[-1][1]
|
| 229 |
+
for i in range(len(knots) - 1):
|
| 230 |
+
x0, y0 = knots[i]
|
| 231 |
+
x1, y1 = knots[i + 1]
|
| 232 |
+
if x0 <= ipm <= x1:
|
| 233 |
+
t = (ipm - x0) / (x1 - x0)
|
| 234 |
+
return y0 + t * (y1 - y0)
|
| 235 |
+
return 1.00 # unreachable
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# ---------------------------------------------------------------------------
|
| 239 |
+
# ACTION 6 — SENSITIVITY: three threshold variants
|
| 240 |
+
# ---------------------------------------------------------------------------
|
| 241 |
+
|
| 242 |
+
def equity_strict(ipm: float) -> float:
|
| 243 |
+
"""Strict thresholds: 1.50/1.25/1.10/1.00 at IPM <65/<70/<75/>=75."""
|
| 244 |
+
if ipm < 65:
|
| 245 |
+
return 1.50
|
| 246 |
+
elif ipm < 70:
|
| 247 |
+
return 1.25
|
| 248 |
+
elif ipm < 75:
|
| 249 |
+
return 1.10
|
| 250 |
+
else:
|
| 251 |
+
return 1.00
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def equity_current(ipm: float) -> float:
|
| 255 |
+
"""Current (production) thresholds — delegates to equity_multiplier_value.
|
| 256 |
+
Single source of truth: this column in the sensitivity table IS the
|
| 257 |
+
shipped behavior, not a re-typed copy that could drift."""
|
| 258 |
+
return equity_multiplier_value(ipm)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def equity_lenient(ipm: float) -> float:
|
| 262 |
+
"""Lenient thresholds: 1.15/1.08/1.03/1.00 at IPM <70/<75/<80/>=80."""
|
| 263 |
+
if ipm < 70:
|
| 264 |
+
return 1.15
|
| 265 |
+
elif ipm < 75:
|
| 266 |
+
return 1.08
|
| 267 |
+
elif ipm < 80:
|
| 268 |
+
return 1.03
|
| 269 |
+
else:
|
| 270 |
+
return 1.00
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# ---------------------------------------------------------------------------
|
| 274 |
+
# BOUNDARY PERTURBATION — how many Jatim kabs change tier under ±1/±2 shifts
|
| 275 |
+
# ---------------------------------------------------------------------------
|
| 276 |
+
|
| 277 |
+
def boundary_perturbation(kabupaten_dict: dict) -> dict:
|
| 278 |
+
"""
|
| 279 |
+
Shift each IPM threshold by ±1 and ±2 points and count how many
|
| 280 |
+
Jatim kabupaten change tier (i.e. receive a different multiplier).
|
| 281 |
+
|
| 282 |
+
Thresholds being perturbed: 68, 72, 78 (the three break points in
|
| 283 |
+
equity_multiplier_value for Jatim 2024).
|
| 284 |
+
|
| 285 |
+
Returns a dict with counts per shift magnitude for each threshold.
|
| 286 |
+
"""
|
| 287 |
+
ipm_values = [k.ipm for k in kabupaten_dict.values()]
|
| 288 |
+
|
| 289 |
+
def count_different(delta: float) -> int:
|
| 290 |
+
"""Count kabs whose multiplier changes when ALL thresholds shift by delta."""
|
| 291 |
+
changed = 0
|
| 292 |
+
for ipm in ipm_values:
|
| 293 |
+
orig = equity_multiplier_value(ipm)
|
| 294 |
+
# Shifted function: thresholds 68->68+delta, 72->72+delta, 78->78+delta
|
| 295 |
+
t68 = 68.0 + delta
|
| 296 |
+
t72 = 72.0 + delta
|
| 297 |
+
t78 = 78.0 + delta
|
| 298 |
+
if ipm < t68:
|
| 299 |
+
shifted = 1.30
|
| 300 |
+
elif ipm < t72:
|
| 301 |
+
shifted = 1.15
|
| 302 |
+
elif ipm < t78:
|
| 303 |
+
shifted = 1.05
|
| 304 |
+
else:
|
| 305 |
+
shifted = 1.00
|
| 306 |
+
if abs(orig - shifted) > 1e-9:
|
| 307 |
+
changed += 1
|
| 308 |
+
return changed
|
| 309 |
+
|
| 310 |
+
result = {}
|
| 311 |
+
for delta in (-2.0, -1.0, +1.0, +2.0):
|
| 312 |
+
result[delta] = count_different(delta)
|
| 313 |
+
return result
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
# ---------------------------------------------------------------------------
|
| 317 |
+
# COMPUTE METRICS ROW for a strategy
|
| 318 |
+
# ---------------------------------------------------------------------------
|
| 319 |
+
|
| 320 |
+
def compute_row(
|
| 321 |
+
strategy_name: str,
|
| 322 |
+
matched_tons: Dict[_Key, float],
|
| 323 |
+
demand_tons: Dict[_Key, float],
|
| 324 |
+
) -> dict:
|
| 325 |
+
"""Compute all 5 metric columns for one strategy."""
|
| 326 |
+
return {
|
| 327 |
+
"strategy": strategy_name,
|
| 328 |
+
"total_deficit_covered": total_deficit_covered(matched_tons, demand_tons),
|
| 329 |
+
"gini": gini(matched_tons, demand_tons),
|
| 330 |
+
"atkinson_05": atkinson(matched_tons, demand_tons, epsilon=0.5),
|
| 331 |
+
"atkinson_10": atkinson(matched_tons, demand_tons, epsilon=1.0),
|
| 332 |
+
"min_fulfillment": min_fulfillment(matched_tons, demand_tons),
|
| 333 |
+
"sampang": kab_fulfillment(matched_tons, demand_tons, SAMPANG_ID),
|
| 334 |
+
"bangkalan": kab_fulfillment(matched_tons, demand_tons, BANGKALAN_ID),
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
# ---------------------------------------------------------------------------
|
| 339 |
+
# MAIN
|
| 340 |
+
# ---------------------------------------------------------------------------
|
| 341 |
+
|
| 342 |
+
def main():
|
| 343 |
+
print("=" * 80)
|
| 344 |
+
print(" AGRIFLOW EQUITY COMPARISON HARNESS")
|
| 345 |
+
print("=" * 80)
|
| 346 |
+
print(" Loading Jatim sample data ...")
|
| 347 |
+
|
| 348 |
+
data = load_all_sample_data()
|
| 349 |
+
surplus = data["surplus"]
|
| 350 |
+
deficit = data["deficit"]
|
| 351 |
+
weather = data["weather"]
|
| 352 |
+
historical = data["historical_prices"]
|
| 353 |
+
kabupaten_dict = data["kabupaten"]
|
| 354 |
+
logistics = LogisticsContext()
|
| 355 |
+
|
| 356 |
+
demand_tons = _build_demand_tons(deficit)
|
| 357 |
+
|
| 358 |
+
print(f" Surplus nodes: {len(surplus)}")
|
| 359 |
+
print(f" Deficit nodes: {len(deficit)}")
|
| 360 |
+
print(f" Demand keys: {len(demand_tons)}")
|
| 361 |
+
print()
|
| 362 |
+
|
| 363 |
+
# -----------------------------------------------------------------------
|
| 364 |
+
# RUN FIVE STRATEGIES
|
| 365 |
+
# All use same surplus/deficit/logistics — only equity_fn / prioritisation differs
|
| 366 |
+
# -----------------------------------------------------------------------
|
| 367 |
+
|
| 368 |
+
print(" Running strategies ...")
|
| 369 |
+
|
| 370 |
+
# 1. Pure greedy — efficiency anchor
|
| 371 |
+
report_greedy = run_matching(
|
| 372 |
+
surplus, deficit,
|
| 373 |
+
logistics=logistics,
|
| 374 |
+
weather_forecasts=weather,
|
| 375 |
+
historical_prices=historical,
|
| 376 |
+
force_strategy="greedy",
|
| 377 |
+
equity_fn=lambda _ipm: 1.0,
|
| 378 |
+
)
|
| 379 |
+
matched_greedy = _report_to_matched_tons(report_greedy)
|
| 380 |
+
print(" [1/5] pure_greedy done")
|
| 381 |
+
|
| 382 |
+
# 2. AgriFlow — default (production behavior, equity_fn omitted)
|
| 383 |
+
report_agriflow = run_matching(
|
| 384 |
+
surplus, deficit,
|
| 385 |
+
logistics=logistics,
|
| 386 |
+
weather_forecasts=weather,
|
| 387 |
+
historical_prices=historical,
|
| 388 |
+
)
|
| 389 |
+
matched_agriflow = _report_to_matched_tons(report_agriflow)
|
| 390 |
+
print(" [2/5] agriflow done")
|
| 391 |
+
|
| 392 |
+
# 3. Uniform — equity anchor (ignores score, equal split)
|
| 393 |
+
matched_uniform = uniform_allocate(surplus, deficit, logistics)
|
| 394 |
+
print(" [3/5] uniform done")
|
| 395 |
+
|
| 396 |
+
# 4. Proportional-to-deficit (Bapanas status-quo foil)
|
| 397 |
+
matched_proportional = proportional_allocate(surplus, deficit, logistics)
|
| 398 |
+
print(" [4/5] proportional done")
|
| 399 |
+
|
| 400 |
+
# 5. AgriFlow-smoothed — linear interpolation over existing knots
|
| 401 |
+
report_smoothed = run_matching(
|
| 402 |
+
surplus, deficit,
|
| 403 |
+
logistics=logistics,
|
| 404 |
+
weather_forecasts=weather,
|
| 405 |
+
historical_prices=historical,
|
| 406 |
+
equity_fn=_equity_smoothed,
|
| 407 |
+
)
|
| 408 |
+
matched_smoothed = _report_to_matched_tons(report_smoothed)
|
| 409 |
+
print(" [5/5] agriflow_smoothed done")
|
| 410 |
+
print()
|
| 411 |
+
|
| 412 |
+
# -----------------------------------------------------------------------
|
| 413 |
+
# TABLE 1 — BASELINE COMPARISON (5 strategies × 5 metrics + kab spotlight)
|
| 414 |
+
# -----------------------------------------------------------------------
|
| 415 |
+
|
| 416 |
+
rows = [
|
| 417 |
+
compute_row("pure_greedy", matched_greedy, demand_tons),
|
| 418 |
+
compute_row("agriflow", matched_agriflow, demand_tons),
|
| 419 |
+
compute_row("uniform", matched_uniform, demand_tons),
|
| 420 |
+
compute_row("proportional", matched_proportional, demand_tons),
|
| 421 |
+
compute_row("agriflow_smoothed", matched_smoothed, demand_tons),
|
| 422 |
+
]
|
| 423 |
+
|
| 424 |
+
table1_header = (
|
| 425 |
+
"| Strategy | Coverage | Gini | Atk(0.5) | Atk(1.0) | "
|
| 426 |
+
"MinFulfill | Sampang | Bangkalan |"
|
| 427 |
+
)
|
| 428 |
+
table1_sep = (
|
| 429 |
+
"|-------------------|----------|-------|----------|----------|"
|
| 430 |
+
"-----------|---------|-----------|"
|
| 431 |
+
)
|
| 432 |
+
table1_lines = [table1_header, table1_sep]
|
| 433 |
+
for r in rows:
|
| 434 |
+
line = (
|
| 435 |
+
f"| {r['strategy']:<17s} "
|
| 436 |
+
f"| {r['total_deficit_covered']:.4f} "
|
| 437 |
+
f"| {r['gini']:.4f}"
|
| 438 |
+
f"| {r['atkinson_05']:.4f} "
|
| 439 |
+
f"| {r['atkinson_10']:.4f} "
|
| 440 |
+
f"| {r['min_fulfillment']:.4f} "
|
| 441 |
+
f"| {r['sampang']:.4f} "
|
| 442 |
+
f"| {r['bangkalan']:.4f} |"
|
| 443 |
+
)
|
| 444 |
+
table1_lines.append(line)
|
| 445 |
+
|
| 446 |
+
print("=" * 80)
|
| 447 |
+
print(" TABLE 1 — BASELINE COMPARISON (5 strategies)")
|
| 448 |
+
print(" Coverage = volume-weighted tons fulfilled / tons demanded")
|
| 449 |
+
print(" Gini / Atkinson = weighted by demand volume (lower = more equitable)")
|
| 450 |
+
print(" MinFulfill = fulfillment ratio of worst-served demand node (higher = better)")
|
| 451 |
+
print(" Sampang=3527 (IPM 66.72), Bangkalan=3526 (IPM 67.70)")
|
| 452 |
+
print("=" * 80)
|
| 453 |
+
for line in table1_lines:
|
| 454 |
+
print(line)
|
| 455 |
+
print()
|
| 456 |
+
|
| 457 |
+
# -----------------------------------------------------------------------
|
| 458 |
+
# ACTION 6 — SENSITIVITY (strict / current / lenient threshold variants)
|
| 459 |
+
# -----------------------------------------------------------------------
|
| 460 |
+
|
| 461 |
+
print(" Running Action 6 sensitivity ...")
|
| 462 |
+
|
| 463 |
+
sens_variants = [
|
| 464 |
+
("strict", equity_strict, "IPM <65→1.50, <70→1.25, <75→1.10, >=75→1.00"),
|
| 465 |
+
("current", equity_current, "IPM <68→1.30, <72→1.15, <78→1.05, >=78→1.00 [PROD]"),
|
| 466 |
+
("lenient", equity_lenient, "IPM <70→1.15, <75→1.08, <80→1.03, >=80→1.00"),
|
| 467 |
+
]
|
| 468 |
+
|
| 469 |
+
sens_rows = []
|
| 470 |
+
for name, fn, desc in sens_variants:
|
| 471 |
+
rep = run_matching(
|
| 472 |
+
surplus, deficit,
|
| 473 |
+
logistics=logistics,
|
| 474 |
+
weather_forecasts=weather,
|
| 475 |
+
historical_prices=historical,
|
| 476 |
+
equity_fn=fn,
|
| 477 |
+
)
|
| 478 |
+
mt = _report_to_matched_tons(rep)
|
| 479 |
+
sens_rows.append({
|
| 480 |
+
"variant": name,
|
| 481 |
+
"desc": desc,
|
| 482 |
+
"total_deficit_covered": total_deficit_covered(mt, demand_tons),
|
| 483 |
+
"gini": gini(mt, demand_tons),
|
| 484 |
+
"sampang": kab_fulfillment(mt, demand_tons, SAMPANG_ID),
|
| 485 |
+
"bangkalan": kab_fulfillment(mt, demand_tons, BANGKALAN_ID),
|
| 486 |
+
})
|
| 487 |
+
|
| 488 |
+
table2_header = (
|
| 489 |
+
"| Variant | Coverage | Gini | Sampang | Bangkalan | Description |"
|
| 490 |
+
)
|
| 491 |
+
table2_sep = (
|
| 492 |
+
"|---------|----------|-------|---------|-----------|-------------|"
|
| 493 |
+
)
|
| 494 |
+
table2_lines = [table2_header, table2_sep]
|
| 495 |
+
for r in sens_rows:
|
| 496 |
+
line = (
|
| 497 |
+
f"| {r['variant']:<7s} "
|
| 498 |
+
f"| {r['total_deficit_covered']:.4f} "
|
| 499 |
+
f"| {r['gini']:.4f}"
|
| 500 |
+
f"| {r['sampang']:.4f} "
|
| 501 |
+
f"| {r['bangkalan']:.4f} "
|
| 502 |
+
f"| {r['desc']} |"
|
| 503 |
+
)
|
| 504 |
+
table2_lines.append(line)
|
| 505 |
+
|
| 506 |
+
print()
|
| 507 |
+
print("=" * 80)
|
| 508 |
+
print(" TABLE 2 — ACTION 6 SENSITIVITY (threshold variants)")
|
| 509 |
+
print(" 'current' delegates to equity_multiplier_value — single source of truth.")
|
| 510 |
+
print("=" * 80)
|
| 511 |
+
for line in table2_lines:
|
| 512 |
+
print(line)
|
| 513 |
+
print()
|
| 514 |
+
|
| 515 |
+
# -----------------------------------------------------------------------
|
| 516 |
+
# BOUNDARY PERTURBATION
|
| 517 |
+
# -----------------------------------------------------------------------
|
| 518 |
+
|
| 519 |
+
perturb = boundary_perturbation(kabupaten_dict)
|
| 520 |
+
print("=" * 80)
|
| 521 |
+
print(" BOUNDARY PERTURBATION — kabs changing tier when thresholds shift ±1/±2")
|
| 522 |
+
print(" (Attack #2: 'cliff effect' — how sensitive is tier assignment to threshold?)")
|
| 523 |
+
print("=" * 80)
|
| 524 |
+
print(" Jatim IPM values (sorted):")
|
| 525 |
+
ipm_sorted = sorted(k.ipm for k in kabupaten_dict.values())
|
| 526 |
+
print(" " + " ".join(f"{v:.2f}" for v in ipm_sorted))
|
| 527 |
+
print()
|
| 528 |
+
print(" | Threshold shift | Kabs changing tier |")
|
| 529 |
+
print(" |-----------------|---------------------|")
|
| 530 |
+
for delta in (-2.0, -1.0, +1.0, +2.0):
|
| 531 |
+
label = f"{delta:+.0f} pts"
|
| 532 |
+
print(f" | {label:<15s} | {perturb[delta]:2d} |")
|
| 533 |
+
print()
|
| 534 |
+
print(" Interpretation:")
|
| 535 |
+
print(" - 0-1 kabs per ±1pt shift = cliff effect negligible.")
|
| 536 |
+
print(" - More kabs = higher sensitivity (attack #2 stronger).")
|
| 537 |
+
print()
|
| 538 |
+
|
| 539 |
+
# -----------------------------------------------------------------------
|
| 540 |
+
# WRITE OUTPUT
|
| 541 |
+
# -----------------------------------------------------------------------
|
| 542 |
+
|
| 543 |
+
output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
|
| 544 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 545 |
+
output_path = os.path.join(output_dir, "equity_comparison.md")
|
| 546 |
+
|
| 547 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 548 |
+
f.write("# AgriFlow Equity Comparison\n\n")
|
| 549 |
+
f.write("Generated by `benchmarks/equity_comparison.py`.\n\n")
|
| 550 |
+
f.write("## Table 1 — Baseline Comparison (5 strategies)\n\n")
|
| 551 |
+
f.write("Coverage = volume-weighted tons fulfilled / tons demanded. \n")
|
| 552 |
+
f.write("Gini / Atkinson = weighted by demand volume; lower = more equitable. \n")
|
| 553 |
+
f.write("MinFulfill = fulfillment ratio of worst-served demand node; higher = better. \n")
|
| 554 |
+
f.write("Sampang=3527 (IPM 66.72), Bangkalan=3526 (IPM 67.70). \n\n")
|
| 555 |
+
for line in table1_lines:
|
| 556 |
+
f.write(line + "\n")
|
| 557 |
+
f.write("\n")
|
| 558 |
+
f.write("## Table 2 — Action 6 Sensitivity (threshold variants)\n\n")
|
| 559 |
+
f.write("`current` delegates to `equity_multiplier_value` — single source of truth.\n\n")
|
| 560 |
+
for line in table2_lines:
|
| 561 |
+
f.write(line + "\n")
|
| 562 |
+
f.write("\n")
|
| 563 |
+
f.write("## Boundary Perturbation\n\n")
|
| 564 |
+
f.write("Shift all IPM thresholds simultaneously by ±1 or ±2 points.\n")
|
| 565 |
+
f.write("Counts kabupaten in Jatim (38 total) that move to a different tier.\n\n")
|
| 566 |
+
f.write("| Threshold shift | Kabs changing tier |\n")
|
| 567 |
+
f.write("|-----------------|---------------------|\n")
|
| 568 |
+
for delta in (-2.0, -1.0, +1.0, +2.0):
|
| 569 |
+
label = f"{delta:+.0f} pts"
|
| 570 |
+
f.write(f"| {label:<15s} | {perturb[delta]:2d} |\n")
|
| 571 |
+
f.write("\n")
|
| 572 |
+
|
| 573 |
+
print(f" Tables written to: {output_path}")
|
| 574 |
+
print()
|
| 575 |
+
|
| 576 |
+
# -----------------------------------------------------------------------
|
| 577 |
+
# QUICK SANITY CHECK — report potential ordering violations
|
| 578 |
+
# -----------------------------------------------------------------------
|
| 579 |
+
|
| 580 |
+
greedy_cov = rows[0]["total_deficit_covered"]
|
| 581 |
+
agriflow_cov = rows[1]["total_deficit_covered"]
|
| 582 |
+
greedy_gini = rows[0]["gini"]
|
| 583 |
+
agriflow_gini = rows[1]["gini"]
|
| 584 |
+
uniform_gini = rows[2]["gini"]
|
| 585 |
+
sampang_agriflow = rows[1]["sampang"]
|
| 586 |
+
sampang_greedy = rows[0]["sampang"]
|
| 587 |
+
|
| 588 |
+
print(" ORDERING CHECKS (expected by pitch claims):")
|
| 589 |
+
check = lambda ok, msg: print(f" {'PASS' if ok else 'FAIL'} {msg}")
|
| 590 |
+
check(greedy_cov >= agriflow_cov,
|
| 591 |
+
f"greedy coverage ({greedy_cov:.4f}) >= agriflow coverage ({agriflow_cov:.4f})")
|
| 592 |
+
check(agriflow_gini <= greedy_gini,
|
| 593 |
+
f"agriflow gini ({agriflow_gini:.4f}) <= greedy gini ({greedy_gini:.4f})")
|
| 594 |
+
check(uniform_gini <= agriflow_gini,
|
| 595 |
+
f"uniform gini ({uniform_gini:.4f}) <= agriflow gini ({agriflow_gini:.4f})")
|
| 596 |
+
check(sampang_agriflow >= sampang_greedy,
|
| 597 |
+
f"Sampang: agriflow ({sampang_agriflow:.4f}) >= greedy ({sampang_greedy:.4f})")
|
| 598 |
+
strict_sampang = sens_rows[0]["sampang"]
|
| 599 |
+
lenient_sampang = sens_rows[2]["sampang"]
|
| 600 |
+
check(strict_sampang >= lenient_sampang,
|
| 601 |
+
f"strict Sampang ({strict_sampang:.4f}) >= lenient Sampang ({lenient_sampang:.4f})")
|
| 602 |
+
|
| 603 |
+
smoothed_cov = rows[4]["total_deficit_covered"]
|
| 604 |
+
smoothed_gini = rows[4]["gini"]
|
| 605 |
+
check(abs(smoothed_cov - agriflow_cov) < 0.05,
|
| 606 |
+
f"smoothed coverage ({smoothed_cov:.4f}) approx agriflow ({agriflow_cov:.4f})")
|
| 607 |
+
check(abs(smoothed_gini - agriflow_gini) < 0.05,
|
| 608 |
+
f"smoothed gini ({smoothed_gini:.4f}) approx agriflow gini ({agriflow_gini:.4f})")
|
| 609 |
+
print()
|
| 610 |
+
|
| 611 |
+
print("=" * 80)
|
| 612 |
+
print(" DONE. Copy tables from benchmarks/output/equity_comparison.md into pitch deck.")
|
| 613 |
+
print("=" * 80)
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
if __name__ == "__main__":
|
| 617 |
+
main()
|
benchmarks/equity_comparison_constrained.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
benchmarks/equity_comparison_constrained.py — Side-by-side ABUNDANT vs CONSTRAINED.
|
| 3 |
+
|
| 4 |
+
Skenario CONSTRAINED: La Nina banjir besar menghantam tiga sentra beras
|
| 5 |
+
utama Jatim bagian barat — Ngawi (3521), Madiun (3519), Bojonegoro (3522) —
|
| 6 |
+
secara bersamaan pada musim panen Oktober–November.
|
| 7 |
+
|
| 8 |
+
Narasi (defensible):
|
| 9 |
+
Ngawi, Madiun, dan Bojonegoro adalah kabupaten di dataran rendah lembah
|
| 10 |
+
Bengawan Solo dan anak sungainya. Ketiga kab ini secara historis mengalami
|
| 11 |
+
banjir bersamaan selama fase La Nina intensitas tinggi (pola 2010, 2020–21,
|
| 12 |
+
2022–23). Dalam skenario ini, banjir menghanguskan atau memotong akses ke
|
| 13 |
+
6 surplus: beras_premium Bojonegoro (650t) + Ngawi (500t), beras_medium
|
| 14 |
+
Madiun (1200t) + Ngawi (900t), jagung Bojonegoro (800t) + Ngawi (600t).
|
| 15 |
+
Total supply yang hilang: 4.650 ton.
|
| 16 |
+
|
| 17 |
+
Arithmetic:
|
| 18 |
+
ABUNDANT: surplus=8.612t, deficit=5.249t, ratio=1.641 (melimpah)
|
| 19 |
+
CONSTRAINED: surplus=3.962t, deficit=5.249t, ratio=0.754 (kekurangan 32.5%)
|
| 20 |
+
|
| 21 |
+
Fixture:
|
| 22 |
+
sample_data/surplus_deficit_constrained.csv — hasil omit 6 baris SURPLUS
|
| 23 |
+
dari kab {3521, 3519, 3522}. Committed ke repo dengan fixed content.
|
| 24 |
+
JANGAN timpa surplus_deficit.csv yang lama.
|
| 25 |
+
|
| 26 |
+
Mengapa BUKAN skenario rekayasa:
|
| 27 |
+
1. Ketiga kab bukan penghasil Sampang/Bangkalan — tidak ada konflik
|
| 28 |
+
kepentingan antara narasi dan hasil yang diharap.
|
| 29 |
+
2. Kelangkaan terjadi di beras & jagung (staple), bukan cabai — lebih
|
| 30 |
+
realistis sebagai krisis pangan.
|
| 31 |
+
3. Sampang dan Bangkalan tetap memiliki DEFISIT beras_premium yang besar
|
| 32 |
+
(200t dan 250t) tetapi tidak ada surplus dari kab shock yang semula
|
| 33 |
+
menutup mereka — supply yang tersisa harus diperebutkan.
|
| 34 |
+
|
| 35 |
+
Run:
|
| 36 |
+
python benchmarks/equity_comparison_constrained.py
|
| 37 |
+
"""
|
| 38 |
+
from __future__ import annotations
|
| 39 |
+
import os
|
| 40 |
+
import sys
|
| 41 |
+
from collections import defaultdict
|
| 42 |
+
from typing import Dict, List, Tuple
|
| 43 |
+
|
| 44 |
+
if sys.platform == "win32":
|
| 45 |
+
try:
|
| 46 |
+
sys.stdout.reconfigure(encoding="utf-8")
|
| 47 |
+
except (AttributeError, OSError):
|
| 48 |
+
pass
|
| 49 |
+
|
| 50 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 51 |
+
|
| 52 |
+
from matching_engine import run_matching
|
| 53 |
+
from matching_engine.allocation import equity_multiplier_value
|
| 54 |
+
from matching_engine.constraints import generate_candidates
|
| 55 |
+
from matching_engine.models import LogisticsContext
|
| 56 |
+
from sample_data.loader import load_all_sample_data
|
| 57 |
+
|
| 58 |
+
from benchmarks._metrics import (
|
| 59 |
+
atkinson,
|
| 60 |
+
fulfillment_by_node,
|
| 61 |
+
gini,
|
| 62 |
+
kab_fulfillment,
|
| 63 |
+
min_fulfillment,
|
| 64 |
+
total_deficit_covered,
|
| 65 |
+
)
|
| 66 |
+
from benchmarks.equity_comparison import (
|
| 67 |
+
_build_demand_tons,
|
| 68 |
+
_equity_smoothed,
|
| 69 |
+
_report_to_matched_tons,
|
| 70 |
+
boundary_perturbation,
|
| 71 |
+
compute_row,
|
| 72 |
+
equity_current,
|
| 73 |
+
equity_lenient,
|
| 74 |
+
equity_strict,
|
| 75 |
+
proportional_allocate,
|
| 76 |
+
uniform_allocate,
|
| 77 |
+
SAMPANG_ID,
|
| 78 |
+
BANGKALAN_ID,
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
# Scenario metadata
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
|
| 85 |
+
CONSTRAINED_CSV = "surplus_deficit_constrained.csv"
|
| 86 |
+
SHOCK_KABS = {
|
| 87 |
+
"3521": "Ngawi",
|
| 88 |
+
"3519": "Madiun",
|
| 89 |
+
"3522": "Bojonegoro",
|
| 90 |
+
}
|
| 91 |
+
CONSTRAINED_SURPLUS_TONS = 3962.0
|
| 92 |
+
CONSTRAINED_DEFICIT_TONS = 5249.0
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _run_five_strategies(surplus, deficit, logistics, weather, historical):
|
| 96 |
+
"""Run all 5 strategies on the given supply/deficit pool.
|
| 97 |
+
|
| 98 |
+
Returns dict: strategy_name -> matched_tons dict.
|
| 99 |
+
"""
|
| 100 |
+
# 1. Pure greedy
|
| 101 |
+
report_greedy = run_matching(
|
| 102 |
+
surplus, deficit,
|
| 103 |
+
logistics=logistics,
|
| 104 |
+
weather_forecasts=weather,
|
| 105 |
+
historical_prices=historical,
|
| 106 |
+
force_strategy="greedy",
|
| 107 |
+
equity_fn=lambda _ipm: 1.0,
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# 2. AgriFlow default
|
| 111 |
+
report_agriflow = run_matching(
|
| 112 |
+
surplus, deficit,
|
| 113 |
+
logistics=logistics,
|
| 114 |
+
weather_forecasts=weather,
|
| 115 |
+
historical_prices=historical,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# 3. Uniform
|
| 119 |
+
matched_uniform = uniform_allocate(surplus, deficit, logistics)
|
| 120 |
+
|
| 121 |
+
# 4. Proportional
|
| 122 |
+
matched_proportional = proportional_allocate(surplus, deficit, logistics)
|
| 123 |
+
|
| 124 |
+
# 5. AgriFlow-smoothed
|
| 125 |
+
report_smoothed = run_matching(
|
| 126 |
+
surplus, deficit,
|
| 127 |
+
logistics=logistics,
|
| 128 |
+
weather_forecasts=weather,
|
| 129 |
+
historical_prices=historical,
|
| 130 |
+
equity_fn=_equity_smoothed,
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
return {
|
| 134 |
+
"pure_greedy": _report_to_matched_tons(report_greedy),
|
| 135 |
+
"agriflow": _report_to_matched_tons(report_agriflow),
|
| 136 |
+
"uniform": matched_uniform,
|
| 137 |
+
"proportional": matched_proportional,
|
| 138 |
+
"agriflow_smoothed": _report_to_matched_tons(report_smoothed),
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _run_sensitivity(surplus, deficit, logistics, weather, historical, demand_tons):
|
| 143 |
+
"""Run Action 6 sensitivity (strict/current/lenient) on a given pool."""
|
| 144 |
+
sens_variants = [
|
| 145 |
+
("strict", equity_strict, "IPM <65->1.50, <70->1.25, <75->1.10, >=75->1.00"),
|
| 146 |
+
("current", equity_current, "IPM <68->1.30, <72->1.15, <78->1.05, >=78->1.00 [PROD]"),
|
| 147 |
+
("lenient", equity_lenient, "IPM <70->1.15, <75->1.08, <80->1.03, >=80->1.00"),
|
| 148 |
+
]
|
| 149 |
+
rows = []
|
| 150 |
+
for name, fn, desc in sens_variants:
|
| 151 |
+
rep = run_matching(
|
| 152 |
+
surplus, deficit,
|
| 153 |
+
logistics=logistics,
|
| 154 |
+
weather_forecasts=weather,
|
| 155 |
+
historical_prices=historical,
|
| 156 |
+
equity_fn=fn,
|
| 157 |
+
)
|
| 158 |
+
mt = _report_to_matched_tons(rep)
|
| 159 |
+
rows.append({
|
| 160 |
+
"variant": name,
|
| 161 |
+
"desc": desc,
|
| 162 |
+
"total_deficit_covered": total_deficit_covered(mt, demand_tons),
|
| 163 |
+
"gini": gini(mt, demand_tons),
|
| 164 |
+
"sampang": kab_fulfillment(mt, demand_tons, SAMPANG_ID),
|
| 165 |
+
"bangkalan": kab_fulfillment(mt, demand_tons, BANGKALAN_ID),
|
| 166 |
+
"min_fulfillment": min_fulfillment(mt, demand_tons),
|
| 167 |
+
})
|
| 168 |
+
return rows
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _format_table1(rows, demand_tons, caption=""):
|
| 172 |
+
"""Format 5-strategy rows as Markdown table lines."""
|
| 173 |
+
lines = []
|
| 174 |
+
if caption:
|
| 175 |
+
lines.append(f"### {caption}")
|
| 176 |
+
lines.append("")
|
| 177 |
+
header = (
|
| 178 |
+
"| Strategy | Coverage | Gini | Atk(0.5) | Atk(1.0) | "
|
| 179 |
+
"MinFulfill | Sampang | Bangkalan |"
|
| 180 |
+
)
|
| 181 |
+
sep = (
|
| 182 |
+
"|-------------------|----------|-------|----------|----------|"
|
| 183 |
+
"-----------|---------|-----------|"
|
| 184 |
+
)
|
| 185 |
+
lines.append(header)
|
| 186 |
+
lines.append(sep)
|
| 187 |
+
for r in rows:
|
| 188 |
+
line = (
|
| 189 |
+
f"| {r['strategy']:<17s} "
|
| 190 |
+
f"| {r['total_deficit_covered']:.4f} "
|
| 191 |
+
f"| {r['gini']:.4f}"
|
| 192 |
+
f"| {r['atkinson_05']:.4f} "
|
| 193 |
+
f"| {r['atkinson_10']:.4f} "
|
| 194 |
+
f"| {r['min_fulfillment']:.4f} "
|
| 195 |
+
f"| {r['sampang']:.4f} "
|
| 196 |
+
f"| {r['bangkalan']:.4f} |"
|
| 197 |
+
)
|
| 198 |
+
lines.append(line)
|
| 199 |
+
return lines
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _format_sensitivity(sens_rows, caption=""):
|
| 203 |
+
"""Format sensitivity rows as Markdown table lines."""
|
| 204 |
+
lines = []
|
| 205 |
+
if caption:
|
| 206 |
+
lines.append(f"### {caption}")
|
| 207 |
+
lines.append("")
|
| 208 |
+
header = (
|
| 209 |
+
"| Variant | Coverage | Gini | MinFull | Sampang | Bangkalan | Description |"
|
| 210 |
+
)
|
| 211 |
+
sep = (
|
| 212 |
+
"|---------|----------|-------|---------|---------|-----------|-------------|"
|
| 213 |
+
)
|
| 214 |
+
lines.append(header)
|
| 215 |
+
lines.append(sep)
|
| 216 |
+
for r in sens_rows:
|
| 217 |
+
line = (
|
| 218 |
+
f"| {r['variant']:<7s} "
|
| 219 |
+
f"| {r['total_deficit_covered']:.4f} "
|
| 220 |
+
f"| {r['gini']:.4f}"
|
| 221 |
+
f"| {r['min_fulfillment']:.4f} "
|
| 222 |
+
f"| {r['sampang']:.4f} "
|
| 223 |
+
f"| {r['bangkalan']:.4f} "
|
| 224 |
+
f"| {r['desc']} |"
|
| 225 |
+
)
|
| 226 |
+
lines.append(line)
|
| 227 |
+
return lines
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def main():
|
| 231 |
+
print("=" * 80)
|
| 232 |
+
print(" AGRIFLOW EQUITY COMPARISON — ABUNDANT vs CONSTRAINED")
|
| 233 |
+
print("=" * 80)
|
| 234 |
+
|
| 235 |
+
logistics = LogisticsContext()
|
| 236 |
+
|
| 237 |
+
# -----------------------------------------------------------------------
|
| 238 |
+
# ABUNDANT data (canonical)
|
| 239 |
+
# -----------------------------------------------------------------------
|
| 240 |
+
print(" [ABUNDANT] Loading canonical Jatim sample data ...")
|
| 241 |
+
data_a = load_all_sample_data()
|
| 242 |
+
surplus_a = data_a["surplus"]
|
| 243 |
+
deficit_a = data_a["deficit"]
|
| 244 |
+
weather = data_a["weather"]
|
| 245 |
+
historical = data_a["historical_prices"]
|
| 246 |
+
kabupaten_dict = data_a["kabupaten"]
|
| 247 |
+
demand_tons_a = _build_demand_tons(deficit_a)
|
| 248 |
+
|
| 249 |
+
total_surplus_a = sum(s.volume_tons for s in surplus_a)
|
| 250 |
+
total_deficit_a = sum(d.volume_tons for d in deficit_a)
|
| 251 |
+
print(f" ABUNDANT — surplus={total_surplus_a:.0f}t, deficit={total_deficit_a:.0f}t, "
|
| 252 |
+
f"ratio={total_surplus_a/total_deficit_a:.3f}")
|
| 253 |
+
|
| 254 |
+
# -----------------------------------------------------------------------
|
| 255 |
+
# CONSTRAINED data (La Nina shock)
|
| 256 |
+
# -----------------------------------------------------------------------
|
| 257 |
+
print(" [CONSTRAINED] Loading La Nina flood shock data ...")
|
| 258 |
+
print(f" Shock: Ngawi(3521)+Madiun(3519)+Bojonegoro(3522) banjir bersamaan.")
|
| 259 |
+
print(f" Removed: beras_premium 1150t + beras_medium 2100t + jagung 1400t")
|
| 260 |
+
print(f" Fixture: sample_data/{CONSTRAINED_CSV}")
|
| 261 |
+
data_c = load_all_sample_data(surplus_deficit_csv=CONSTRAINED_CSV)
|
| 262 |
+
surplus_c = data_c["surplus"]
|
| 263 |
+
deficit_c = data_c["deficit"]
|
| 264 |
+
demand_tons_c = _build_demand_tons(deficit_c)
|
| 265 |
+
|
| 266 |
+
total_surplus_c = sum(s.volume_tons for s in surplus_c)
|
| 267 |
+
total_deficit_c = sum(d.volume_tons for d in deficit_c)
|
| 268 |
+
print(f" CONSTRAINED — surplus={total_surplus_c:.0f}t, deficit={total_deficit_c:.0f}t, "
|
| 269 |
+
f"ratio={total_surplus_c/total_deficit_c:.3f} [UNDER-SUPPLIED]")
|
| 270 |
+
print()
|
| 271 |
+
|
| 272 |
+
# -----------------------------------------------------------------------
|
| 273 |
+
# Run five strategies on BOTH scenarios
|
| 274 |
+
# -----------------------------------------------------------------------
|
| 275 |
+
print(" Running 5 strategies x 2 scenarios (10 matching runs) ...")
|
| 276 |
+
strategies_a = _run_five_strategies(surplus_a, deficit_a, logistics, weather, historical)
|
| 277 |
+
print(" ABUNDANT done.")
|
| 278 |
+
strategies_c = _run_five_strategies(surplus_c, deficit_c, logistics, weather, historical)
|
| 279 |
+
print(" CONSTRAINED done.")
|
| 280 |
+
print()
|
| 281 |
+
|
| 282 |
+
strategy_order = ["pure_greedy", "agriflow", "uniform", "proportional", "agriflow_smoothed"]
|
| 283 |
+
|
| 284 |
+
rows_a = [compute_row(s, strategies_a[s], demand_tons_a) for s in strategy_order]
|
| 285 |
+
rows_c = [compute_row(s, strategies_c[s], demand_tons_c) for s in strategy_order]
|
| 286 |
+
|
| 287 |
+
# -----------------------------------------------------------------------
|
| 288 |
+
# Run sensitivity on BOTH scenarios
|
| 289 |
+
# -----------------------------------------------------------------------
|
| 290 |
+
print(" Running Action 6 sensitivity x 2 scenarios (6 matching runs) ...")
|
| 291 |
+
sens_a = _run_sensitivity(surplus_a, deficit_a, logistics, weather, historical, demand_tons_a)
|
| 292 |
+
print(" ABUNDANT sensitivity done.")
|
| 293 |
+
sens_c = _run_sensitivity(surplus_c, deficit_c, logistics, weather, historical, demand_tons_c)
|
| 294 |
+
print(" CONSTRAINED sensitivity done.")
|
| 295 |
+
print()
|
| 296 |
+
|
| 297 |
+
# -----------------------------------------------------------------------
|
| 298 |
+
# Print TABLE 1A — ABUNDANT
|
| 299 |
+
# -----------------------------------------------------------------------
|
| 300 |
+
lines_1a = _format_table1(rows_a, demand_tons_a, caption="ABUNDANT (surplus=8612t, deficit=5249t, ratio=1.641)")
|
| 301 |
+
lines_1c = _format_table1(rows_c, demand_tons_c, caption="CONSTRAINED / La Nina banjir Ngawi+Madiun+Bojonegoro (surplus=3962t, deficit=5249t, ratio=0.754)")
|
| 302 |
+
|
| 303 |
+
print("=" * 80)
|
| 304 |
+
print(" TABLE 1A — BASELINE COMPARISON (ABUNDANT scenario)")
|
| 305 |
+
print("=" * 80)
|
| 306 |
+
for line in lines_1a:
|
| 307 |
+
print(" " + line)
|
| 308 |
+
print()
|
| 309 |
+
|
| 310 |
+
print("=" * 80)
|
| 311 |
+
print(" TABLE 1C — BASELINE COMPARISON (CONSTRAINED scenario)")
|
| 312 |
+
print("=" * 80)
|
| 313 |
+
for line in lines_1c:
|
| 314 |
+
print(" " + line)
|
| 315 |
+
print()
|
| 316 |
+
|
| 317 |
+
# -----------------------------------------------------------------------
|
| 318 |
+
# Print TABLE 2 — Sensitivity
|
| 319 |
+
# -----------------------------------------------------------------------
|
| 320 |
+
lines_2a = _format_sensitivity(sens_a, caption="Sensitivity ABUNDANT")
|
| 321 |
+
lines_2c = _format_sensitivity(sens_c, caption="Sensitivity CONSTRAINED")
|
| 322 |
+
|
| 323 |
+
print("=" * 80)
|
| 324 |
+
print(" TABLE 2C — ACTION 6 SENSITIVITY (CONSTRAINED scenario)")
|
| 325 |
+
print(" Note: ABUNDANT sensitivity is degenerate (all 1.0000 for Sampang/Bangkalan).")
|
| 326 |
+
print(" CONSTRAINED sensitivity should show differentiation.")
|
| 327 |
+
print("=" * 80)
|
| 328 |
+
for line in lines_2c:
|
| 329 |
+
print(" " + line)
|
| 330 |
+
print()
|
| 331 |
+
|
| 332 |
+
# -----------------------------------------------------------------------
|
| 333 |
+
# Boundary perturbation (same kab population, scenario-independent)
|
| 334 |
+
# -----------------------------------------------------------------------
|
| 335 |
+
perturb = boundary_perturbation(kabupaten_dict)
|
| 336 |
+
print("=" * 80)
|
| 337 |
+
print(" BOUNDARY PERTURBATION (unchanged — same 38 Jatim kabs)")
|
| 338 |
+
print("=" * 80)
|
| 339 |
+
print(" | Threshold shift | Kabs changing tier |")
|
| 340 |
+
print(" |-----------------|---------------------|")
|
| 341 |
+
for delta in (-2.0, -1.0, +1.0, +2.0):
|
| 342 |
+
label = f"{delta:+.0f} pts"
|
| 343 |
+
print(f" | {label:<15s} | {perturb[delta]:2d} |")
|
| 344 |
+
print()
|
| 345 |
+
|
| 346 |
+
# -----------------------------------------------------------------------
|
| 347 |
+
# ORDERING CHECKS — CONSTRAINED (where the equity story plays out)
|
| 348 |
+
# -----------------------------------------------------------------------
|
| 349 |
+
print(" ORDERING CHECKS — CONSTRAINED scenario:")
|
| 350 |
+
check = lambda ok, msg: print(f" {'PASS' if ok else 'FAIL (!) '} {msg}")
|
| 351 |
+
|
| 352 |
+
g_cov = rows_c[0]["total_deficit_covered"]
|
| 353 |
+
a_cov = rows_c[1]["total_deficit_covered"]
|
| 354 |
+
g_gini = rows_c[0]["gini"]
|
| 355 |
+
a_gini = rows_c[1]["gini"]
|
| 356 |
+
u_gini = rows_c[2]["gini"]
|
| 357 |
+
a_min = rows_c[1]["min_fulfillment"]
|
| 358 |
+
g_min = rows_c[0]["min_fulfillment"]
|
| 359 |
+
a_samp = rows_c[1]["sampang"]
|
| 360 |
+
g_samp = rows_c[0]["sampang"]
|
| 361 |
+
a_bang = rows_c[1]["bangkalan"]
|
| 362 |
+
g_bang = rows_c[0]["bangkalan"]
|
| 363 |
+
u_samp = rows_c[2]["sampang"]
|
| 364 |
+
|
| 365 |
+
# Gini sanity check: uniform ~0 even in constrained
|
| 366 |
+
check(u_gini < 0.05,
|
| 367 |
+
f"uniform Gini ({u_gini:.4f}) < 0.05 [sanity: formula correct in constrained]")
|
| 368 |
+
check(g_cov >= a_cov - 1e-9,
|
| 369 |
+
f"greedy coverage ({g_cov:.4f}) >= agriflow ({a_cov:.4f}) [efficiency frontier]")
|
| 370 |
+
check(a_gini <= g_gini + 1e-6,
|
| 371 |
+
f"agriflow Gini ({a_gini:.4f}) <= greedy Gini ({g_gini:.4f}) [equity boost visible]")
|
| 372 |
+
check(a_samp >= g_samp - 1e-9,
|
| 373 |
+
f"Sampang: agriflow ({a_samp:.4f}) >= greedy ({g_samp:.4f}) [1.30x boost works]")
|
| 374 |
+
check(a_bang >= g_bang - 1e-9,
|
| 375 |
+
f"Bangkalan: agriflow ({a_bang:.4f}) >= greedy ({g_bang:.4f}) [1.30x boost works]")
|
| 376 |
+
check(a_min >= g_min - 1e-9,
|
| 377 |
+
f"agriflow min_fulfillment ({a_min:.4f}) >= greedy ({g_min:.4f}) [leximin better]")
|
| 378 |
+
|
| 379 |
+
# Sensitivity: strict > lenient for Sampang under constrained
|
| 380 |
+
s_strict_samp = sens_c[0]["sampang"]
|
| 381 |
+
s_lenient_samp = sens_c[2]["sampang"]
|
| 382 |
+
sens_degenerate = abs(s_strict_samp - s_lenient_samp) < 1e-6
|
| 383 |
+
check(s_strict_samp >= s_lenient_samp - 1e-9,
|
| 384 |
+
f"strict Sampang ({s_strict_samp:.4f}) >= lenient ({s_lenient_samp:.4f})")
|
| 385 |
+
if sens_degenerate:
|
| 386 |
+
print(" !! SENSITIVITY STILL DEGENERATE for Sampang — equity mechanism may need review")
|
| 387 |
+
else:
|
| 388 |
+
print(f" ** Sensitivity spread: strict-lenient Sampang delta = "
|
| 389 |
+
f"{s_strict_samp - s_lenient_samp:+.4f} [non-degenerate]")
|
| 390 |
+
|
| 391 |
+
# Summary interpretation
|
| 392 |
+
print()
|
| 393 |
+
print(" INTERPRETATION:")
|
| 394 |
+
if a_samp > g_samp + 1e-4:
|
| 395 |
+
print(f" + AgriFlow protects Sampang (+{(a_samp-g_samp)*100:.1f}pp vs greedy)")
|
| 396 |
+
else:
|
| 397 |
+
print(f" ~ Sampang: AgriFlow = greedy (delta={a_samp-g_samp:+.4f})")
|
| 398 |
+
if a_bang > g_bang + 1e-4:
|
| 399 |
+
print(f" + AgriFlow protects Bangkalan (+{(a_bang-g_bang)*100:.1f}pp vs greedy)")
|
| 400 |
+
else:
|
| 401 |
+
print(f" ~ Bangkalan: AgriFlow = greedy (delta={a_bang-g_bang:+.4f})")
|
| 402 |
+
if a_gini < g_gini - 1e-4:
|
| 403 |
+
print(f" + AgriFlow Gini lower than greedy ({a_gini:.4f} vs {g_gini:.4f}): equity visible")
|
| 404 |
+
else:
|
| 405 |
+
print(f" ~ Gini: AgriFlow ({a_gini:.4f}) vs greedy ({g_gini:.4f}) — marginal or no improvement")
|
| 406 |
+
cost_pp = (g_cov - a_cov) * 100
|
| 407 |
+
if cost_pp > 0.05:
|
| 408 |
+
print(f" - Coverage cost: AgriFlow sacrifices {cost_pp:.1f}pp aggregate coverage for equity")
|
| 409 |
+
else:
|
| 410 |
+
print(f" ~ Coverage cost: negligible ({cost_pp:.2f}pp)")
|
| 411 |
+
print()
|
| 412 |
+
|
| 413 |
+
# -----------------------------------------------------------------------
|
| 414 |
+
# WRITE OUTPUT
|
| 415 |
+
# -----------------------------------------------------------------------
|
| 416 |
+
output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
|
| 417 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 418 |
+
output_path = os.path.join(output_dir, "equity_comparison_constrained.md")
|
| 419 |
+
|
| 420 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 421 |
+
f.write("# AgriFlow Equity Comparison — ABUNDANT vs CONSTRAINED\n\n")
|
| 422 |
+
f.write("Generated by `benchmarks/equity_comparison_constrained.py`.\n\n")
|
| 423 |
+
f.write("## Scenario: La Nina Supply Shock (CONSTRAINED)\n\n")
|
| 424 |
+
f.write("**Narasi:** La Nina banjir besar menghantam tiga sentra beras utama Jatim\n")
|
| 425 |
+
f.write("bagian barat — Ngawi (3521), Madiun (3519), Bojonegoro (3522) — secara\n")
|
| 426 |
+
f.write("bersamaan. Ketiga kab berada di dataran rendah lembah Bengawan Solo dan\n")
|
| 427 |
+
f.write("anak sungainya; pola banjir simultan terdokumentasi pada La Nina 2010,\n")
|
| 428 |
+
f.write("2020–21, dan 2022–23.\n\n")
|
| 429 |
+
f.write("**Dampak:** 6 surplus rows dihapus:\n\n")
|
| 430 |
+
f.write("| Kab | Nama | Komoditas | Volume (t) |\n")
|
| 431 |
+
f.write("|-----|------|-----------|------------|\n")
|
| 432 |
+
f.write("| 3521 | Ngawi | beras_premium | 500 |\n")
|
| 433 |
+
f.write("| 3521 | Ngawi | beras_medium | 900 |\n")
|
| 434 |
+
f.write("| 3521 | Ngawi | jagung | 600 |\n")
|
| 435 |
+
f.write("| 3519 | Madiun | beras_medium | 1200 |\n")
|
| 436 |
+
f.write("| 3522 | Bojonegoro | beras_premium | 650 |\n")
|
| 437 |
+
f.write("| 3522 | Bojonegoro | jagung | 800 |\n")
|
| 438 |
+
f.write("| **Total** | | | **4650** |\n\n")
|
| 439 |
+
f.write("**Arithmetic:**\n\n")
|
| 440 |
+
f.write("| Scenario | Surplus (t) | Deficit (t) | Ratio |\n")
|
| 441 |
+
f.write("|----------|-------------|-------------|-------|\n")
|
| 442 |
+
f.write(f"| ABUNDANT | 8612 | 5249 | 1.641 (over-supplied) |\n")
|
| 443 |
+
f.write(f"| CONSTRAINED | 3962 | 5249 | 0.754 (under-supplied by 32.5%) |\n\n")
|
| 444 |
+
f.write("Fixture: `sample_data/surplus_deficit_constrained.csv` (committed).\n\n")
|
| 445 |
+
f.write("---\n\n")
|
| 446 |
+
f.write("## Table 1A — Baseline Comparison: ABUNDANT\n\n")
|
| 447 |
+
f.write("Coverage = volume-weighted tons fulfilled / tons demanded. \n")
|
| 448 |
+
f.write("Gini / Atkinson = weighted by demand volume; lower = more equitable. \n")
|
| 449 |
+
f.write("MinFulfill = fulfillment ratio of worst-served demand node. \n")
|
| 450 |
+
f.write("Sampang=3527 (IPM 66.72), Bangkalan=3526 (IPM 67.70). \n\n")
|
| 451 |
+
for line in lines_1a:
|
| 452 |
+
f.write(line + "\n")
|
| 453 |
+
f.write("\n")
|
| 454 |
+
f.write("## Table 1C — Baseline Comparison: CONSTRAINED\n\n")
|
| 455 |
+
f.write("Same metrics. Under supply shortage, equity tradeoffs become observable.\n\n")
|
| 456 |
+
for line in lines_1c:
|
| 457 |
+
f.write(line + "\n")
|
| 458 |
+
f.write("\n")
|
| 459 |
+
f.write("## Table 2A — Sensitivity: ABUNDANT (degenerate — included for completeness)\n\n")
|
| 460 |
+
for line in lines_2a:
|
| 461 |
+
f.write(line + "\n")
|
| 462 |
+
f.write("\n")
|
| 463 |
+
f.write("## Table 2C — Sensitivity: CONSTRAINED\n\n")
|
| 464 |
+
f.write("`current` delegates to `equity_multiplier_value` — single source of truth.\n\n")
|
| 465 |
+
for line in lines_2c:
|
| 466 |
+
f.write(line + "\n")
|
| 467 |
+
f.write("\n")
|
| 468 |
+
f.write("## Boundary Perturbation\n\n")
|
| 469 |
+
f.write("Same 38 Jatim kabs in both scenarios. Scenario-independent.\n\n")
|
| 470 |
+
f.write("| Threshold shift | Kabs changing tier |\n")
|
| 471 |
+
f.write("|-----------------|---------------------|\n")
|
| 472 |
+
for delta in (-2.0, -1.0, +1.0, +2.0):
|
| 473 |
+
label = f"{delta:+.0f} pts"
|
| 474 |
+
f.write(f"| {label:<15s} | {perturb[delta]:2d} |\n")
|
| 475 |
+
f.write("\n")
|
| 476 |
+
|
| 477 |
+
print(f" Tables written to: {output_path}")
|
| 478 |
+
print("=" * 80)
|
| 479 |
+
print(" DONE.")
|
| 480 |
+
print("=" * 80)
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
if __name__ == "__main__":
|
| 484 |
+
main()
|
benchmarks/greedy_vs_optimal.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
benchmarks/greedy_vs_optimal.py — Greedy vs exact optimal, on AgriFlow's own
|
| 3 |
+
score/cost structure.
|
| 4 |
+
|
| 5 |
+
WHY THIS SCRIPT EXISTS
|
| 6 |
+
-----------------------
|
| 7 |
+
HANDOFF.md (2026-05-19) HOLDs the POT/EMD (optimal transport) allocator on the
|
| 8 |
+
claim: "AgriFlow pakai single shared score function -> solusi POT/EMD pada cost
|
| 9 |
+
matrix saat ini identik atau hampir-identik dengan greedy." That claim rests on
|
| 10 |
+
ONE 3x3 toy example where greedy happened to match brute-force optimal (2.720).
|
| 11 |
+
|
| 12 |
+
A single-instance coincidence is not evidence. This script settles the question
|
| 13 |
+
empirically and reproducibly, in two parts:
|
| 14 |
+
|
| 15 |
+
PART A — Assignment case (1-to-1 matching, no volume splitting):
|
| 16 |
+
A1. Pure uniform-random scores (baseline literature check).
|
| 17 |
+
A2. AgriFlow-structured scores: built from the project's own
|
| 18 |
+
ScoreBreakdown.weighted_total() (22/22/22/18/16 weights,
|
| 19 |
+
matching_engine/models.py), with correlated supply/demand
|
| 20 |
+
"quality" so the instances are not adversarial.
|
| 21 |
+
Compared: naive greedy (repeatedly take the best remaining pair) vs
|
| 22 |
+
scipy.optimize.linear_sum_assignment (Hungarian algorithm, EXACT optimal).
|
| 23 |
+
For n<=6 the Hungarian result is cross-checked against brute-force
|
| 24 |
+
permutation search as a correctness sanity check on the solver itself.
|
| 25 |
+
|
| 26 |
+
PART B — Capacitated / divisible case (the actual production shape):
|
| 27 |
+
Runs the REAL engine pipeline — matching_engine.constraints.generate_candidates
|
| 28 |
+
(Layer 1 hard constraints) + matching_engine.scoring.compute_score (Layer 2,
|
| 29 |
+
DEFAULT_WEIGHTS) + matching_engine.allocation equity/segment multipliers —
|
| 30 |
+
on (a) the project's real Jatim sample data (sample_data/surplus_deficit.csv)
|
| 31 |
+
and (b) synthetic Indonesia-scale workloads (reusing benchmarks/national_scale
|
| 32 |
+
generators) at increasing kab counts.
|
| 33 |
+
Compared: the SHIPPED allocators (stable_match_tier1 via force_strategy=
|
| 34 |
+
"stable", greedy_match_tier2 via force_strategy="greedy") vs the exact
|
| 35 |
+
capacitated transportation optimum, solved as an LP with scipy.optimize.linprog
|
| 36 |
+
(HiGHS): maximize sum(final_score_e * x_e) subject to per-supply-node and
|
| 37 |
+
per-deficit-node capacity constraints, 0 <= x_e <= min(supply, demand).
|
| 38 |
+
This LP is the textbook optimal solution to the "correct" framing raised
|
| 39 |
+
separately (capacitated min-cost transportation, not 1-1 assignment) — it
|
| 40 |
+
is what network-simplex / ot.emd-with-partial-mass would also converge to.
|
| 41 |
+
|
| 42 |
+
Also reports STRANDED VOLUME for the 1-1 stable matcher: how much supply/
|
| 43 |
+
demand tonnage is left on the table because stable_match_tier1 matches at
|
| 44 |
+
most one deficit per surplus node (min(s,d) then abandon), vs the LP/greedy
|
| 45 |
+
which can split a big surplus across many small deficits.
|
| 46 |
+
|
| 47 |
+
Everything is seeded (--seed, default 2026) for reproducibility. No network
|
| 48 |
+
calls, no writes outside benchmarks/output/.
|
| 49 |
+
|
| 50 |
+
Usage:
|
| 51 |
+
python benchmarks/greedy_vs_optimal.py
|
| 52 |
+
python benchmarks/greedy_vs_optimal.py --skip-real # synthetic sweeps only (fast)
|
| 53 |
+
python benchmarks/greedy_vs_optimal.py --seed 7
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
from __future__ import annotations
|
| 57 |
+
|
| 58 |
+
import argparse
|
| 59 |
+
import itertools
|
| 60 |
+
import json
|
| 61 |
+
import os
|
| 62 |
+
import random
|
| 63 |
+
import statistics
|
| 64 |
+
import sys
|
| 65 |
+
import time
|
| 66 |
+
from dataclasses import dataclass, asdict
|
| 67 |
+
from datetime import datetime
|
| 68 |
+
from typing import Callable, List, Optional, Sequence, Tuple
|
| 69 |
+
|
| 70 |
+
import numpy as np
|
| 71 |
+
from scipy.optimize import linear_sum_assignment, linprog
|
| 72 |
+
|
| 73 |
+
if sys.platform == "win32":
|
| 74 |
+
try:
|
| 75 |
+
sys.stdout.reconfigure(encoding="utf-8")
|
| 76 |
+
sys.stderr.reconfigure(encoding="utf-8")
|
| 77 |
+
except (AttributeError, OSError):
|
| 78 |
+
pass
|
| 79 |
+
|
| 80 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 81 |
+
if ROOT not in sys.path:
|
| 82 |
+
sys.path.insert(0, ROOT)
|
| 83 |
+
|
| 84 |
+
from matching_engine.allocation import (
|
| 85 |
+
allocate, equity_multiplier_value, segment_multiplier_value,
|
| 86 |
+
)
|
| 87 |
+
from matching_engine.constraints import generate_candidates
|
| 88 |
+
from matching_engine.models import LogisticsContext, ScoreBreakdown
|
| 89 |
+
from matching_engine.scoring import DEFAULT_WEIGHTS, compute_score
|
| 90 |
+
from sample_data.loader import load_all_sample_data
|
| 91 |
+
|
| 92 |
+
DEFAULT_SEED = 2026
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# =============================================================================
|
| 96 |
+
# PART A — ASSIGNMENT CASE (1-to-1, no volume splitting)
|
| 97 |
+
# =============================================================================
|
| 98 |
+
|
| 99 |
+
def brute_force_optimal(score: np.ndarray) -> float:
|
| 100 |
+
"""Exact optimal via exhaustive permutation search. Only for n <= 8."""
|
| 101 |
+
n, m = score.shape
|
| 102 |
+
assert n == m, "brute force sanity check only used on square matrices here"
|
| 103 |
+
best = -1.0
|
| 104 |
+
for perm in itertools.permutations(range(n)):
|
| 105 |
+
total = sum(score[i, perm[i]] for i in range(n))
|
| 106 |
+
if total > best:
|
| 107 |
+
best = total
|
| 108 |
+
return best
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def hungarian_optimal(score: np.ndarray) -> Tuple[float, List[Tuple[int, int]]]:
|
| 112 |
+
"""Exact optimal assignment (max total score) via scipy Hungarian algorithm."""
|
| 113 |
+
row_ind, col_ind = linear_sum_assignment(-score) # minimize negative = maximize
|
| 114 |
+
total = float(score[row_ind, col_ind].sum())
|
| 115 |
+
pairs = list(zip(row_ind.tolist(), col_ind.tolist()))
|
| 116 |
+
return total, pairs
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def greedy_assignment(score: np.ndarray) -> Tuple[float, List[Tuple[int, int]]]:
|
| 120 |
+
"""
|
| 121 |
+
Naive greedy 1-1 assignment: repeatedly take the globally best remaining
|
| 122 |
+
(row, col) pair. This is the abstraction of what stable_match_tier1 /
|
| 123 |
+
greedy_match_tier2 do at the single-score level (both eventually assign the
|
| 124 |
+
best-scoring available counterpart first).
|
| 125 |
+
"""
|
| 126 |
+
n, m = score.shape
|
| 127 |
+
flat = [(score[i, j], i, j) for i in range(n) for j in range(m)]
|
| 128 |
+
flat.sort(key=lambda t: -t[0])
|
| 129 |
+
used_rows, used_cols = set(), set()
|
| 130 |
+
pairs = []
|
| 131 |
+
total = 0.0
|
| 132 |
+
for s, i, j in flat:
|
| 133 |
+
if i in used_rows or j in used_cols:
|
| 134 |
+
continue
|
| 135 |
+
used_rows.add(i)
|
| 136 |
+
used_cols.add(j)
|
| 137 |
+
pairs.append((i, j))
|
| 138 |
+
total += s
|
| 139 |
+
if len(used_rows) == min(n, m):
|
| 140 |
+
break
|
| 141 |
+
return total, pairs
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@dataclass
|
| 145 |
+
class AssignmentTrialResult:
|
| 146 |
+
n: int
|
| 147 |
+
trial: int
|
| 148 |
+
greedy_total: float
|
| 149 |
+
optimal_total: float
|
| 150 |
+
gap_pct: float # (optimal - greedy) / optimal * 100
|
| 151 |
+
greedy_lost: bool # strictly worse than optimal
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def run_assignment_sweep(
|
| 155 |
+
label: str,
|
| 156 |
+
ns: Sequence[int],
|
| 157 |
+
trials: int,
|
| 158 |
+
make_score_matrix: Callable[[int, random.Random], np.ndarray],
|
| 159 |
+
seed: int,
|
| 160 |
+
sanity_check_bruteforce: bool = True,
|
| 161 |
+
) -> List[AssignmentTrialResult]:
|
| 162 |
+
results: List[AssignmentTrialResult] = []
|
| 163 |
+
rng = random.Random(seed)
|
| 164 |
+
|
| 165 |
+
print(f"\n{'=' * 78}")
|
| 166 |
+
print(f" PART A — {label}")
|
| 167 |
+
print(f"{'=' * 78}")
|
| 168 |
+
|
| 169 |
+
for n in ns:
|
| 170 |
+
losses = 0
|
| 171 |
+
gaps = []
|
| 172 |
+
for t in range(trials):
|
| 173 |
+
score = make_score_matrix(n, rng)
|
| 174 |
+
greedy_total, _ = greedy_assignment(score)
|
| 175 |
+
optimal_total, _ = hungarian_optimal(score)
|
| 176 |
+
|
| 177 |
+
if sanity_check_bruteforce and n <= 6 and t == 0:
|
| 178 |
+
bf = brute_force_optimal(score)
|
| 179 |
+
assert abs(bf - optimal_total) < 1e-6, (
|
| 180 |
+
f"Hungarian ({optimal_total}) != brute force ({bf}) at n={n} — "
|
| 181 |
+
f"solver correctness check failed"
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
gap_pct = (
|
| 185 |
+
0.0 if optimal_total <= 1e-12
|
| 186 |
+
else (optimal_total - greedy_total) / optimal_total * 100.0
|
| 187 |
+
)
|
| 188 |
+
lost = greedy_total < optimal_total - 1e-9
|
| 189 |
+
if lost:
|
| 190 |
+
losses += 1
|
| 191 |
+
gaps.append(gap_pct)
|
| 192 |
+
results.append(AssignmentTrialResult(
|
| 193 |
+
n=n, trial=t, greedy_total=greedy_total,
|
| 194 |
+
optimal_total=optimal_total, gap_pct=gap_pct, greedy_lost=lost,
|
| 195 |
+
))
|
| 196 |
+
|
| 197 |
+
mean_gap = statistics.mean(gaps)
|
| 198 |
+
median_gap = statistics.median(gaps)
|
| 199 |
+
worst_gap = max(gaps)
|
| 200 |
+
print(f" n={n:>3} trials={trials:>4} greedy lost {losses:>4}/{trials} "
|
| 201 |
+
f"({losses / trials * 100:5.1f}%) mean_gap={mean_gap:6.2f}% "
|
| 202 |
+
f"median_gap={median_gap:6.2f}% worst_gap={worst_gap:6.2f}%")
|
| 203 |
+
|
| 204 |
+
return results
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def make_uniform_score_matrix(n: int, rng: random.Random) -> np.ndarray:
|
| 208 |
+
"""Pure iid uniform random scores in [0, 1] — n x n."""
|
| 209 |
+
return np.array([[rng.random() for _ in range(n)] for _ in range(n)])
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def make_agriflow_structured_score_matrix(n: int, rng: random.Random) -> np.ndarray:
|
| 213 |
+
"""
|
| 214 |
+
n x n score matrix built from the project's OWN ScoreBreakdown.weighted_total()
|
| 215 |
+
(22/22/22/18/16 weights, matching_engine/models.py) instead of an abstract
|
| 216 |
+
number. Supply node i has a fixed "quality" vector; deficit node j has a fixed
|
| 217 |
+
"preference" vector; per-pair dimension score = clip(quality_i + pref_j + noise,
|
| 218 |
+
0, 1). This gives correlated, non-adversarial instances structurally similar to
|
| 219 |
+
a real (supply, deficit) grid, while still being driven by the actual weighted-
|
| 220 |
+
sum formula the engine uses everywhere.
|
| 221 |
+
"""
|
| 222 |
+
dims = 5 # distance, volume, price, perishability, climate
|
| 223 |
+
supply_quality = [[rng.random() for _ in range(dims)] for _ in range(n)]
|
| 224 |
+
demand_pref = [[rng.random() for _ in range(dims)] for _ in range(n)]
|
| 225 |
+
|
| 226 |
+
score = np.zeros((n, n))
|
| 227 |
+
for i in range(n):
|
| 228 |
+
for j in range(n):
|
| 229 |
+
vals = []
|
| 230 |
+
for d in range(dims):
|
| 231 |
+
noise = rng.gauss(0, 0.10)
|
| 232 |
+
v = 0.5 * supply_quality[i][d] + 0.5 * demand_pref[j][d] + noise
|
| 233 |
+
vals.append(min(1.0, max(0.0, v)))
|
| 234 |
+
breakdown = ScoreBreakdown(
|
| 235 |
+
distance=vals[0], volume=vals[1], price=vals[2],
|
| 236 |
+
perishability=vals[3], climate=vals[4],
|
| 237 |
+
)
|
| 238 |
+
score[i, j] = breakdown.weighted_total() # 0-100 scale, project's own formula
|
| 239 |
+
return score
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
# =============================================================================
|
| 243 |
+
# PART B — CAPACITATED / DIVISIBLE CASE (real engine pipeline)
|
| 244 |
+
# =============================================================================
|
| 245 |
+
|
| 246 |
+
@dataclass
|
| 247 |
+
class CapacitatedResult:
|
| 248 |
+
label: str
|
| 249 |
+
n_supply: int
|
| 250 |
+
n_deficit: int
|
| 251 |
+
n_candidates: int
|
| 252 |
+
total_supply_tons: float
|
| 253 |
+
total_deficit_tons: float
|
| 254 |
+
stable_welfare: float
|
| 255 |
+
stable_matched_tons: float
|
| 256 |
+
stable_n_matches: int
|
| 257 |
+
greedy_welfare: float
|
| 258 |
+
greedy_matched_tons: float
|
| 259 |
+
greedy_n_matches: int
|
| 260 |
+
lp_optimal_welfare: float
|
| 261 |
+
lp_optimal_matched_tons: float
|
| 262 |
+
lp_status: str
|
| 263 |
+
stable_gap_pct: float # vs LP optimal welfare
|
| 264 |
+
greedy_gap_pct: float # vs LP optimal welfare
|
| 265 |
+
stable_stranded_tons: float # total_supply_tons - stable_matched_tons
|
| 266 |
+
lp_stranded_tons: float # total_supply_tons - lp_optimal_matched_tons
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _final_score(s, d, base_score: float) -> float:
|
| 270 |
+
eq_mult = equity_multiplier_value(d.kabupaten.ipm)
|
| 271 |
+
seg_mult, _flags = segment_multiplier_value(s, d)
|
| 272 |
+
return base_score * eq_mult * seg_mult
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def solve_capacitated_transportation_lp(
|
| 276 |
+
candidates: List[Tuple], score_fn: Callable,
|
| 277 |
+
) -> Tuple[float, float, str, dict]:
|
| 278 |
+
"""
|
| 279 |
+
Exact optimal for the capacitated (divisible) transportation problem on the
|
| 280 |
+
SAME candidate pool the engine itself uses (post Layer-1 filtering).
|
| 281 |
+
|
| 282 |
+
max sum_e final_score_e * x_e
|
| 283 |
+
s.t. for each supply node i: sum_{e incident to i} x_e <= supply_volume_i
|
| 284 |
+
for each deficit node j: sum_{e incident to j} x_e <= deficit_volume_j
|
| 285 |
+
0 <= x_e <= min(supply_i, deficit_j) (per-edge bound, redundant but tightens LP)
|
| 286 |
+
|
| 287 |
+
Solved with scipy.optimize.linprog (HiGHS). Returns (optimal_welfare,
|
| 288 |
+
optimal_matched_tons, lp_status, edge_solution) where edge_solution maps
|
| 289 |
+
edge index -> tons matched.
|
| 290 |
+
"""
|
| 291 |
+
if not candidates:
|
| 292 |
+
return 0.0, 0.0, "no_candidates", {}
|
| 293 |
+
|
| 294 |
+
supply_keys, deficit_keys = {}, {}
|
| 295 |
+
edges = [] # (supply_idx, deficit_idx, final_score, cap, s, d)
|
| 296 |
+
score_cache = {}
|
| 297 |
+
|
| 298 |
+
for s, d in candidates:
|
| 299 |
+
s_key = s.kabupaten.id + "_" + s.commodity.code
|
| 300 |
+
d_key = d.kabupaten.id + "_" + d.commodity.code + "_" + d.segment.value
|
| 301 |
+
if s_key not in supply_keys:
|
| 302 |
+
supply_keys[s_key] = (len(supply_keys), s.volume_tons)
|
| 303 |
+
if d_key not in deficit_keys:
|
| 304 |
+
deficit_keys[d_key] = (len(deficit_keys), d.volume_tons)
|
| 305 |
+
|
| 306 |
+
cache_key = (s_key, d.kabupaten.id + "_" + d.commodity.code)
|
| 307 |
+
if cache_key not in score_cache:
|
| 308 |
+
_breakdown, base_score, _dist = score_fn(s, d)
|
| 309 |
+
score_cache[cache_key] = _final_score(s, d, base_score)
|
| 310 |
+
fscore = score_cache[cache_key]
|
| 311 |
+
|
| 312 |
+
s_idx = supply_keys[s_key][0]
|
| 313 |
+
d_idx = deficit_keys[d_key][0]
|
| 314 |
+
cap = min(s.volume_tons, d.volume_tons)
|
| 315 |
+
edges.append((s_idx, d_idx, fscore, cap))
|
| 316 |
+
|
| 317 |
+
n_edges = len(edges)
|
| 318 |
+
n_supply = len(supply_keys)
|
| 319 |
+
n_deficit = len(deficit_keys)
|
| 320 |
+
|
| 321 |
+
c = np.array([-e[2] for e in edges]) # minimize negative = maximize welfare
|
| 322 |
+
bounds = [(0.0, e[3]) for e in edges]
|
| 323 |
+
|
| 324 |
+
A_ub = np.zeros((n_supply + n_deficit, n_edges))
|
| 325 |
+
b_ub = np.zeros(n_supply + n_deficit)
|
| 326 |
+
for s_key, (idx, vol) in supply_keys.items():
|
| 327 |
+
b_ub[idx] = vol
|
| 328 |
+
for d_key, (idx, vol) in deficit_keys.items():
|
| 329 |
+
b_ub[n_supply + idx] = vol
|
| 330 |
+
for e_idx, (s_idx, d_idx, _fscore, _cap) in enumerate(edges):
|
| 331 |
+
A_ub[s_idx, e_idx] = 1.0
|
| 332 |
+
A_ub[n_supply + d_idx, e_idx] = 1.0
|
| 333 |
+
|
| 334 |
+
res = linprog(c, A_ub=A_ub, b_ub=b_ub, bounds=bounds, method="highs")
|
| 335 |
+
|
| 336 |
+
if not res.success:
|
| 337 |
+
return 0.0, 0.0, f"lp_failed:{res.message}", {}
|
| 338 |
+
|
| 339 |
+
optimal_welfare = float(-res.fun)
|
| 340 |
+
matched_tons = float(res.x.sum())
|
| 341 |
+
edge_solution = {i: float(x) for i, x in enumerate(res.x) if x > 1e-9}
|
| 342 |
+
return optimal_welfare, matched_tons, "optimal", edge_solution
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def evaluate_capacitated(
|
| 346 |
+
label: str, surplus, deficit, logistics: Optional[LogisticsContext] = None,
|
| 347 |
+
) -> CapacitatedResult:
|
| 348 |
+
logistics = logistics or LogisticsContext()
|
| 349 |
+
candidates = generate_candidates(surplus, deficit, logistics=logistics)
|
| 350 |
+
|
| 351 |
+
def score_fn(s, d):
|
| 352 |
+
return compute_score(s, d, logistics=logistics, weights=DEFAULT_WEIGHTS)
|
| 353 |
+
|
| 354 |
+
total_supply_tons = sum(s.volume_tons for s in surplus)
|
| 355 |
+
total_deficit_tons = sum(d.volume_tons for d in deficit)
|
| 356 |
+
|
| 357 |
+
if not candidates:
|
| 358 |
+
return CapacitatedResult(
|
| 359 |
+
label=label, n_supply=len(surplus), n_deficit=len(deficit),
|
| 360 |
+
n_candidates=0, total_supply_tons=total_supply_tons,
|
| 361 |
+
total_deficit_tons=total_deficit_tons,
|
| 362 |
+
stable_welfare=0.0, stable_matched_tons=0.0, stable_n_matches=0,
|
| 363 |
+
greedy_welfare=0.0, greedy_matched_tons=0.0, greedy_n_matches=0,
|
| 364 |
+
lp_optimal_welfare=0.0, lp_optimal_matched_tons=0.0, lp_status="no_candidates",
|
| 365 |
+
stable_gap_pct=0.0, greedy_gap_pct=0.0,
|
| 366 |
+
stable_stranded_tons=total_supply_tons, lp_stranded_tons=total_supply_tons,
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
matches_stable = allocate(candidates, score_fn, force_strategy="stable",
|
| 370 |
+
equity_fn=equity_multiplier_value)
|
| 371 |
+
matches_greedy = allocate(candidates, score_fn, force_strategy="greedy",
|
| 372 |
+
equity_fn=equity_multiplier_value)
|
| 373 |
+
|
| 374 |
+
stable_welfare = sum(m.final_score * m.matched_volume_tons for m in matches_stable)
|
| 375 |
+
stable_matched_tons = sum(m.matched_volume_tons for m in matches_stable)
|
| 376 |
+
greedy_welfare = sum(m.final_score * m.matched_volume_tons for m in matches_greedy)
|
| 377 |
+
greedy_matched_tons = sum(m.matched_volume_tons for m in matches_greedy)
|
| 378 |
+
|
| 379 |
+
lp_welfare, lp_matched_tons, lp_status, _sol = solve_capacitated_transportation_lp(
|
| 380 |
+
candidates, score_fn,
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
def gap(engine_welfare: float) -> float:
|
| 384 |
+
if lp_welfare <= 1e-9:
|
| 385 |
+
return 0.0
|
| 386 |
+
return (lp_welfare - engine_welfare) / lp_welfare * 100.0
|
| 387 |
+
|
| 388 |
+
return CapacitatedResult(
|
| 389 |
+
label=label, n_supply=len(surplus), n_deficit=len(deficit),
|
| 390 |
+
n_candidates=len(candidates),
|
| 391 |
+
total_supply_tons=total_supply_tons, total_deficit_tons=total_deficit_tons,
|
| 392 |
+
stable_welfare=stable_welfare, stable_matched_tons=stable_matched_tons,
|
| 393 |
+
stable_n_matches=len(matches_stable),
|
| 394 |
+
greedy_welfare=greedy_welfare, greedy_matched_tons=greedy_matched_tons,
|
| 395 |
+
greedy_n_matches=len(matches_greedy),
|
| 396 |
+
lp_optimal_welfare=lp_welfare, lp_optimal_matched_tons=lp_matched_tons,
|
| 397 |
+
lp_status=lp_status,
|
| 398 |
+
stable_gap_pct=gap(stable_welfare), greedy_gap_pct=gap(greedy_welfare),
|
| 399 |
+
stable_stranded_tons=total_supply_tons - stable_matched_tons,
|
| 400 |
+
lp_stranded_tons=total_supply_tons - lp_matched_tons,
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def print_capacitated_result(r: CapacitatedResult) -> None:
|
| 405 |
+
print(f"\n --- {r.label} ---")
|
| 406 |
+
print(f" supply={r.n_supply} deficit={r.n_deficit} candidates={r.n_candidates} "
|
| 407 |
+
f"total_supply={r.total_supply_tons:,.0f}t total_deficit={r.total_deficit_tons:,.0f}t")
|
| 408 |
+
print(f" {'strategy':<22}{'welfare':>14}{'matched_tons':>16}{'n_matches':>12}{'gap_vs_LP':>12}")
|
| 409 |
+
print(f" {'stable (shipped)':<22}{r.stable_welfare:>14,.1f}{r.stable_matched_tons:>16,.1f}"
|
| 410 |
+
f"{r.stable_n_matches:>12}{r.stable_gap_pct:>11.2f}%")
|
| 411 |
+
print(f" {'greedy (shipped)':<22}{r.greedy_welfare:>14,.1f}{r.greedy_matched_tons:>16,.1f}"
|
| 412 |
+
f"{r.greedy_n_matches:>12}{r.greedy_gap_pct:>11.2f}%")
|
| 413 |
+
print(f" {'LP optimal (exact)':<22}{r.lp_optimal_welfare:>14,.1f}{r.lp_optimal_matched_tons:>16,.1f}"
|
| 414 |
+
f"{'-':>12}{'0.00%':>12} [{r.lp_status}]")
|
| 415 |
+
print(f" stranded supply tons: stable={r.stable_stranded_tons:,.1f}t "
|
| 416 |
+
f"LP-optimal={r.lp_stranded_tons:,.1f}t "
|
| 417 |
+
f"(extra tonnage LP moves that stable abandons: "
|
| 418 |
+
f"{r.stable_stranded_tons - r.lp_stranded_tons:,.1f}t)")
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
# =============================================================================
|
| 422 |
+
# MAIN
|
| 423 |
+
# =============================================================================
|
| 424 |
+
|
| 425 |
+
def main() -> None:
|
| 426 |
+
parser = argparse.ArgumentParser(
|
| 427 |
+
description="Greedy vs exact-optimal benchmark for AgriFlow's allocator "
|
| 428 |
+
"(settles HANDOFF.md's POT/EMD HOLD premise with evidence).",
|
| 429 |
+
)
|
| 430 |
+
parser.add_argument("--seed", type=int, default=DEFAULT_SEED)
|
| 431 |
+
parser.add_argument("--trials", type=int, default=200,
|
| 432 |
+
help="Trials per n in the assignment sweeps (default 200)")
|
| 433 |
+
parser.add_argument("--skip-real", action="store_true",
|
| 434 |
+
help="Skip Part B real-data / synthetic-Indonesia runs (faster)")
|
| 435 |
+
parser.add_argument("--json-out", type=str, default=None,
|
| 436 |
+
help="Optional path to write full results as JSON")
|
| 437 |
+
args = parser.parse_args()
|
| 438 |
+
|
| 439 |
+
t0 = time.perf_counter()
|
| 440 |
+
print("=" * 78)
|
| 441 |
+
print(f" AgriFlow — Greedy vs Exact Optimal (seed={args.seed}, "
|
| 442 |
+
f"trials/n={args.trials})")
|
| 443 |
+
print(f" Run: {datetime.now().isoformat(timespec='seconds')}")
|
| 444 |
+
print("=" * 78)
|
| 445 |
+
|
| 446 |
+
all_results: dict = {"seed": args.seed, "trials": args.trials}
|
| 447 |
+
|
| 448 |
+
ns = [3, 4, 5, 6, 7, 10, 12, 15, 20]
|
| 449 |
+
|
| 450 |
+
a1 = run_assignment_sweep(
|
| 451 |
+
"A1 — Pure uniform-random scores (assignment case)",
|
| 452 |
+
ns, args.trials, make_uniform_score_matrix, args.seed,
|
| 453 |
+
)
|
| 454 |
+
a2 = run_assignment_sweep(
|
| 455 |
+
"A2 — AgriFlow-structured scores (ScoreBreakdown.weighted_total, "
|
| 456 |
+
"correlated supply/demand)",
|
| 457 |
+
ns, args.trials, make_agriflow_structured_score_matrix, args.seed + 1,
|
| 458 |
+
)
|
| 459 |
+
all_results["part_a_uniform"] = [asdict(r) for r in a1]
|
| 460 |
+
all_results["part_a_agriflow_structured"] = [asdict(r) for r in a2]
|
| 461 |
+
|
| 462 |
+
if not args.skip_real:
|
| 463 |
+
print(f"\n{'=' * 78}")
|
| 464 |
+
print(" PART B — Capacitated transportation (real engine pipeline)")
|
| 465 |
+
print(f"{'=' * 78}")
|
| 466 |
+
|
| 467 |
+
cap_results: List[CapacitatedResult] = []
|
| 468 |
+
|
| 469 |
+
# B1 — Real Jatim sample data (the project's actual demo dataset)
|
| 470 |
+
data = load_all_sample_data()
|
| 471 |
+
r_real = evaluate_capacitated(
|
| 472 |
+
"B1 — Real Jatim sample data (sample_data/surplus_deficit.csv, 38 kab)",
|
| 473 |
+
data["surplus"], data["deficit"],
|
| 474 |
+
)
|
| 475 |
+
print_capacitated_result(r_real)
|
| 476 |
+
cap_results.append(r_real)
|
| 477 |
+
|
| 478 |
+
# B2 — Synthetic Indonesia-scale workloads at increasing kab counts,
|
| 479 |
+
# reusing the project's own national_scale.py generators (real Commodity
|
| 480 |
+
# specs, realistic IPM/tier distribution).
|
| 481 |
+
sys.path.insert(0, os.path.join(ROOT, "benchmarks"))
|
| 482 |
+
import national_scale as ns_bench # project's own synthetic generator
|
| 483 |
+
|
| 484 |
+
rng_synth = random.Random(args.seed)
|
| 485 |
+
random.seed(args.seed) # national_scale generators use the global random module
|
| 486 |
+
for n_kab in (38, 100, 250):
|
| 487 |
+
kabs = ns_bench.make_synthetic_indonesia(n_kab)
|
| 488 |
+
surplus, deficit = ns_bench.make_workload(kabs, n_commodities=19)
|
| 489 |
+
r = evaluate_capacitated(
|
| 490 |
+
f"B2 — Synthetic Indonesia-scale ({n_kab} kab x 19 komoditas)",
|
| 491 |
+
surplus, deficit,
|
| 492 |
+
)
|
| 493 |
+
print_capacitated_result(r)
|
| 494 |
+
cap_results.append(r)
|
| 495 |
+
|
| 496 |
+
all_results["part_b_capacitated"] = [asdict(r) for r in cap_results]
|
| 497 |
+
|
| 498 |
+
elapsed = time.perf_counter() - t0
|
| 499 |
+
print(f"\n{'=' * 78}")
|
| 500 |
+
print(f" Done in {elapsed:.1f}s")
|
| 501 |
+
print("=" * 78)
|
| 502 |
+
|
| 503 |
+
if args.json_out:
|
| 504 |
+
def _json_default(o):
|
| 505 |
+
if isinstance(o, (np.floating, np.integer)):
|
| 506 |
+
return o.item()
|
| 507 |
+
if isinstance(o, np.bool_):
|
| 508 |
+
return bool(o)
|
| 509 |
+
raise TypeError(f"Object of type {o.__class__.__name__} is not JSON serializable")
|
| 510 |
+
|
| 511 |
+
os.makedirs(os.path.dirname(args.json_out) or ".", exist_ok=True)
|
| 512 |
+
with open(args.json_out, "w", encoding="utf-8") as fh:
|
| 513 |
+
json.dump(all_results, fh, indent=2, default=_json_default)
|
| 514 |
+
print(f" Full results written to {args.json_out}")
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
if __name__ == "__main__":
|
| 518 |
+
main()
|
benchmarks/latency.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AgriFlow Matching Engine — Latency Benchmark
|
| 3 |
+
=============================================
|
| 4 |
+
|
| 5 |
+
Measure end-to-end latency dengan multiple konfigurasi untuk memvalidasi
|
| 6 |
+
klaim "<500ms p99 untuk 38 kab × 19 komoditas" (Section 5.5.4).
|
| 7 |
+
|
| 8 |
+
Run:
|
| 9 |
+
python benchmarks/latency.py
|
| 10 |
+
|
| 11 |
+
Output:
|
| 12 |
+
Tabel p50/p95/p99/max latency untuk:
|
| 13 |
+
- Sample data current (40 supply × 33 deficit)
|
| 14 |
+
- Stress: synthetic 38 kab × 19 komoditas full
|
| 15 |
+
- Stress: 100 supply × 100 deficit (large)
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
import os
|
| 19 |
+
import statistics
|
| 20 |
+
import sys
|
| 21 |
+
import time
|
| 22 |
+
from datetime import datetime
|
| 23 |
+
|
| 24 |
+
# Force UTF-8 stdio di Windows.
|
| 25 |
+
if sys.platform == "win32":
|
| 26 |
+
try:
|
| 27 |
+
sys.stdout.reconfigure(encoding="utf-8")
|
| 28 |
+
except (AttributeError, OSError):
|
| 29 |
+
pass
|
| 30 |
+
|
| 31 |
+
# Add project root ke path
|
| 32 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 33 |
+
|
| 34 |
+
from matching_engine import (
|
| 35 |
+
Commodity, DemandNode, Kabupaten, LogisticsContext, SupplyNode, Tier,
|
| 36 |
+
run_matching,
|
| 37 |
+
)
|
| 38 |
+
from sample_data import load_all_sample_data
|
| 39 |
+
from sample_data.generate_sample_data import KABUPATEN_DATA, KOMODITAS_DATA
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def percentile(data, p):
|
| 43 |
+
s = sorted(data)
|
| 44 |
+
k = (len(s) - 1) * p
|
| 45 |
+
f = int(k)
|
| 46 |
+
c = min(f + 1, len(s) - 1)
|
| 47 |
+
return s[f] + (s[c] - s[f]) * (k - f)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def bench_one(label: str, supply, demand, weather=None, historical=None,
|
| 51 |
+
warmup: int = 5, iterations: int = 100) -> dict:
|
| 52 |
+
logistics = LogisticsContext()
|
| 53 |
+
# Warmup
|
| 54 |
+
for _ in range(warmup):
|
| 55 |
+
run_matching(supply, demand, logistics=logistics,
|
| 56 |
+
weather_forecasts=weather, historical_prices=historical)
|
| 57 |
+
# Measure
|
| 58 |
+
samples = []
|
| 59 |
+
for _ in range(iterations):
|
| 60 |
+
t = time.perf_counter()
|
| 61 |
+
run_matching(supply, demand, logistics=logistics,
|
| 62 |
+
weather_forecasts=weather, historical_prices=historical)
|
| 63 |
+
samples.append((time.perf_counter() - t) * 1000)
|
| 64 |
+
return {
|
| 65 |
+
"label": label,
|
| 66 |
+
"n_supply": len(supply),
|
| 67 |
+
"n_demand": len(demand),
|
| 68 |
+
"iterations": iterations,
|
| 69 |
+
"p50_ms": percentile(samples, 0.50),
|
| 70 |
+
"p95_ms": percentile(samples, 0.95),
|
| 71 |
+
"p99_ms": percentile(samples, 0.99),
|
| 72 |
+
"min_ms": min(samples),
|
| 73 |
+
"max_ms": max(samples),
|
| 74 |
+
"mean_ms": statistics.mean(samples),
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def make_synthetic_full_jatim():
|
| 79 |
+
"""38 kab × 19 komoditas: setengah surplus, setengah deficit per komoditas."""
|
| 80 |
+
kabs = []
|
| 81 |
+
for kid, nama, lat, lon, ipm, pop, tier_s in KABUPATEN_DATA:
|
| 82 |
+
kabs.append(Kabupaten(
|
| 83 |
+
id=kid, nama=nama, latitude=lat, longitude=lon, ipm=ipm,
|
| 84 |
+
tier=Tier.HIGH if tier_s == "TIER_1_HIGH" else Tier.MEDIUM,
|
| 85 |
+
population=pop,
|
| 86 |
+
))
|
| 87 |
+
komos = [
|
| 88 |
+
Commodity(code=c, nama=n, max_distance_km=md, min_viable_tons=mv,
|
| 89 |
+
max_fresh_age_days=mf)
|
| 90 |
+
for c, n, md, mv, mf, _baseline in KOMODITAS_DATA
|
| 91 |
+
]
|
| 92 |
+
surplus, deficit = [], []
|
| 93 |
+
for komo in komos:
|
| 94 |
+
for i, kab in enumerate(kabs):
|
| 95 |
+
if i % 2 == 0:
|
| 96 |
+
surplus.append(SupplyNode(
|
| 97 |
+
kabupaten=kab, commodity=komo,
|
| 98 |
+
volume_tons=max(komo.min_viable_tons * 5, 20.0),
|
| 99 |
+
price_per_kg=30000, harvest_age_days=1,
|
| 100 |
+
))
|
| 101 |
+
else:
|
| 102 |
+
deficit.append(DemandNode(
|
| 103 |
+
kabupaten=kab, commodity=komo,
|
| 104 |
+
volume_tons=max(komo.min_viable_tons * 4, 15.0),
|
| 105 |
+
price_per_kg=50000,
|
| 106 |
+
))
|
| 107 |
+
return surplus, deficit
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def make_synthetic_large(n_supply: int = 100, n_demand: int = 100):
|
| 111 |
+
"""Stress test: scale beyond Jatim (project to national scale)."""
|
| 112 |
+
s, d = make_synthetic_full_jatim()
|
| 113 |
+
# Pad dengan duplikat (anggap nasional projection)
|
| 114 |
+
while len(s) < n_supply:
|
| 115 |
+
s.extend(s[:n_supply - len(s)])
|
| 116 |
+
while len(d) < n_demand:
|
| 117 |
+
d.extend(d[:n_demand - len(d)])
|
| 118 |
+
return s[:n_supply], d[:n_demand]
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def print_row(r):
|
| 122 |
+
print(f" {r['label']:<35s} "
|
| 123 |
+
f"{r['n_supply']:>4d}×{r['n_demand']:<4d} "
|
| 124 |
+
f"p50={r['p50_ms']:>6.2f} p95={r['p95_ms']:>6.2f} "
|
| 125 |
+
f"p99={r['p99_ms']:>7.2f} max={r['max_ms']:>7.2f} "
|
| 126 |
+
f"mean={r['mean_ms']:>6.2f} ms")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def main():
|
| 130 |
+
print("=" * 110)
|
| 131 |
+
print(f" AgriFlow Matching Engine — Latency Benchmark "
|
| 132 |
+
f"({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})")
|
| 133 |
+
print("=" * 110)
|
| 134 |
+
print(f" Target Section 5.5.4: <500ms p99 untuk 38 kab × 19 komoditas")
|
| 135 |
+
print()
|
| 136 |
+
|
| 137 |
+
results = []
|
| 138 |
+
|
| 139 |
+
# 1. Sample data current
|
| 140 |
+
data = load_all_sample_data()
|
| 141 |
+
results.append(bench_one(
|
| 142 |
+
"Sample data CSV (realistic)",
|
| 143 |
+
data["surplus"], data["deficit"],
|
| 144 |
+
weather=data["weather"], historical=data["historical_prices"],
|
| 145 |
+
))
|
| 146 |
+
|
| 147 |
+
# 2. Synthetic 38 kab × 19 komoditas full
|
| 148 |
+
s_full, d_full = make_synthetic_full_jatim()
|
| 149 |
+
results.append(bench_one(
|
| 150 |
+
"Synthetic full Jatim (38×19)",
|
| 151 |
+
s_full, d_full,
|
| 152 |
+
))
|
| 153 |
+
|
| 154 |
+
# 3. Stress 100x100
|
| 155 |
+
s_100, d_100 = make_synthetic_large(100, 100)
|
| 156 |
+
results.append(bench_one(
|
| 157 |
+
"Stress 100×100 (national scale)",
|
| 158 |
+
s_100, d_100,
|
| 159 |
+
iterations=50,
|
| 160 |
+
))
|
| 161 |
+
|
| 162 |
+
# 4. Stress 200x200
|
| 163 |
+
s_200, d_200 = make_synthetic_large(200, 200)
|
| 164 |
+
results.append(bench_one(
|
| 165 |
+
"Stress 200×200",
|
| 166 |
+
s_200, d_200,
|
| 167 |
+
iterations=30,
|
| 168 |
+
))
|
| 169 |
+
|
| 170 |
+
print(f" {'Configuration':<35s} {'N (s×d)':>9s} {'p50':>9s} "
|
| 171 |
+
f"{'p95':>9s} {'p99':>11s} {'max':>11s} {'mean':>11s}")
|
| 172 |
+
print(" " + "-" * 106)
|
| 173 |
+
for r in results:
|
| 174 |
+
print_row(r)
|
| 175 |
+
|
| 176 |
+
print()
|
| 177 |
+
target = 500.0
|
| 178 |
+
p99_max = max(r["p99_ms"] for r in results)
|
| 179 |
+
if p99_max < target:
|
| 180 |
+
print(f" PASS semua konfigurasi p99 < {target}ms target "
|
| 181 |
+
f"(highest p99 = {p99_max:.2f}ms, "
|
| 182 |
+
f"margin {(target - p99_max) / target * 100:.1f}%)")
|
| 183 |
+
else:
|
| 184 |
+
print(f" FAIL p99 {p99_max:.2f}ms melebihi target {target}ms")
|
| 185 |
+
print()
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
if __name__ == "__main__":
|
| 189 |
+
main()
|
benchmarks/national_scale.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
National-scale stress test untuk AgriFlow Matching Engine.
|
| 3 |
+
|
| 4 |
+
Indonesia: 514 kab/kota × 19 komoditas. Asumsi 50% surplus & 50% deficit
|
| 5 |
+
per komoditas → ~4880 supply nodes, ~4880 demand nodes total.
|
| 6 |
+
|
| 7 |
+
Ini test apakah engine v10 SIAP scale ke nasional, atau masih butuh
|
| 8 |
+
optimization untuk handle volume itu.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
import os
|
| 12 |
+
import random
|
| 13 |
+
import statistics
|
| 14 |
+
import sys
|
| 15 |
+
import time
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
|
| 18 |
+
if sys.platform == "win32":
|
| 19 |
+
try:
|
| 20 |
+
sys.stdout.reconfigure(encoding="utf-8")
|
| 21 |
+
except (AttributeError, OSError):
|
| 22 |
+
pass
|
| 23 |
+
|
| 24 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 25 |
+
|
| 26 |
+
from matching_engine import (
|
| 27 |
+
Commodity, DemandNode, Kabupaten, LogisticsContext, SupplyNode, Tier,
|
| 28 |
+
run_matching,
|
| 29 |
+
)
|
| 30 |
+
from sample_data.generate_sample_data import KOMODITAS_DATA
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def percentile(data, p):
|
| 34 |
+
s = sorted(data)
|
| 35 |
+
k = (len(s) - 1) * p
|
| 36 |
+
f = int(k)
|
| 37 |
+
c = min(f + 1, len(s) - 1)
|
| 38 |
+
return s[f] + (s[c] - s[f]) * (k - f)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# Indonesia bounding box (kira-kira)
|
| 42 |
+
# Sabang: -5.9, 95.3 | Merauke: -8.5, 140.4
|
| 43 |
+
# Latitude: -11 to 6
|
| 44 |
+
# Longitude: 95 to 141
|
| 45 |
+
|
| 46 |
+
random.seed(42)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def make_synthetic_indonesia(n_kab: int):
|
| 50 |
+
"""Generate synthetic kabupaten dengan koordinat random di seluruh Indonesia."""
|
| 51 |
+
kabs = []
|
| 52 |
+
# Distribusi IPM Indonesia: range ~50-85, mean ~71
|
| 53 |
+
# Berdasarkan BPS 2024: Papua-NTT-Maluku tier rendah (50-65),
|
| 54 |
+
# Jawa-Bali tier menengah-tinggi (70-85), Sumatra mixed
|
| 55 |
+
for i in range(n_kab):
|
| 56 |
+
# IPM distribution skewed towards 65-78 (most kabs)
|
| 57 |
+
ipm = random.gauss(71, 8)
|
| 58 |
+
ipm = max(50, min(86, ipm))
|
| 59 |
+
# Random lat/lon dalam wilayah Indonesia
|
| 60 |
+
lat = random.uniform(-11.0, 6.0)
|
| 61 |
+
lon = random.uniform(95.0, 141.0)
|
| 62 |
+
# 18% Tier 1 (90 kota IHK / 514 kab)
|
| 63 |
+
tier = Tier.HIGH if random.random() < 0.18 else Tier.MEDIUM
|
| 64 |
+
kabs.append(Kabupaten(
|
| 65 |
+
id=f"{i:04d}", nama=f"Kab-{i}",
|
| 66 |
+
latitude=lat, longitude=lon, ipm=round(ipm, 2),
|
| 67 |
+
tier=tier, population=random.randint(100_000, 3_000_000),
|
| 68 |
+
))
|
| 69 |
+
return kabs
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def make_workload(kabs, n_commodities: int, surplus_ratio: float = 0.5):
|
| 73 |
+
"""Build supply + demand workload dari kabs untuk n komoditas."""
|
| 74 |
+
komos = [
|
| 75 |
+
Commodity(code=c, nama=n, max_distance_km=md, min_viable_tons=mv,
|
| 76 |
+
max_fresh_age_days=mf)
|
| 77 |
+
for c, n, md, mv, mf, _ in KOMODITAS_DATA[:n_commodities]
|
| 78 |
+
]
|
| 79 |
+
surplus, demand = [], []
|
| 80 |
+
for komo in komos:
|
| 81 |
+
for kab in kabs:
|
| 82 |
+
if random.random() < surplus_ratio:
|
| 83 |
+
surplus.append(SupplyNode(
|
| 84 |
+
kabupaten=kab, commodity=komo,
|
| 85 |
+
volume_tons=max(komo.min_viable_tons * 5,
|
| 86 |
+
random.uniform(10, 200)),
|
| 87 |
+
price_per_kg=random.uniform(20000, 50000),
|
| 88 |
+
harvest_age_days=random.randint(0, 3),
|
| 89 |
+
))
|
| 90 |
+
else:
|
| 91 |
+
demand.append(DemandNode(
|
| 92 |
+
kabupaten=kab, commodity=komo,
|
| 93 |
+
volume_tons=max(komo.min_viable_tons * 4,
|
| 94 |
+
random.uniform(10, 150)),
|
| 95 |
+
price_per_kg=random.uniform(40000, 80000),
|
| 96 |
+
))
|
| 97 |
+
return surplus, demand
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def bench(label, surplus, demand, iterations: int = 10):
|
| 101 |
+
logistics = LogisticsContext()
|
| 102 |
+
samples = []
|
| 103 |
+
n_matches = 0
|
| 104 |
+
candidate_pairs = 0
|
| 105 |
+
|
| 106 |
+
for _ in range(iterations):
|
| 107 |
+
t = time.perf_counter()
|
| 108 |
+
report = run_matching(surplus, demand, logistics=logistics)
|
| 109 |
+
elapsed_ms = (time.perf_counter() - t) * 1000
|
| 110 |
+
samples.append(elapsed_ms)
|
| 111 |
+
n_matches = len(report.matches)
|
| 112 |
+
candidate_pairs = report.run_metadata.get("candidate_pairs_evaluated", 0)
|
| 113 |
+
|
| 114 |
+
return {
|
| 115 |
+
"label": label,
|
| 116 |
+
"n_supply": len(surplus),
|
| 117 |
+
"n_demand": len(demand),
|
| 118 |
+
"n_matches": n_matches,
|
| 119 |
+
"candidate_pairs": candidate_pairs,
|
| 120 |
+
"p50_ms": percentile(samples, 0.50),
|
| 121 |
+
"p95_ms": percentile(samples, 0.95),
|
| 122 |
+
"p99_ms": percentile(samples, 0.99),
|
| 123 |
+
"min_ms": min(samples),
|
| 124 |
+
"max_ms": max(samples),
|
| 125 |
+
"mean_ms": statistics.mean(samples),
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def print_result(r):
|
| 130 |
+
over_target = " ⚠ OVER 500ms" if r["p99_ms"] > 500 else ""
|
| 131 |
+
print(f"\n{r['label']}")
|
| 132 |
+
print(f" Workload: {r['n_supply']} supply × {r['n_demand']} demand")
|
| 133 |
+
print(f" Matches generated: {r['n_matches']}")
|
| 134 |
+
print(f" Candidate pairs evaluated: {r['candidate_pairs']:,}")
|
| 135 |
+
print(f" Latency: p50={r['p50_ms']:.1f}ms p95={r['p95_ms']:.1f}ms "
|
| 136 |
+
f"p99={r['p99_ms']:.1f}ms max={r['max_ms']:.1f}ms{over_target}")
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def main():
|
| 140 |
+
print("=" * 90)
|
| 141 |
+
print(f" NATIONAL SCALE STRESS TEST ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})")
|
| 142 |
+
print("=" * 90)
|
| 143 |
+
print(" Target: <500ms p99 (Section 5.5.4)")
|
| 144 |
+
print(" Indonesia reality: 514 kab/kota × 19 komoditas")
|
| 145 |
+
|
| 146 |
+
configs = [
|
| 147 |
+
("Provinsi (38 kab × 19 komoditas) — baseline Jatim", 38, 19),
|
| 148 |
+
("Multi-provinsi (100 kab × 19)", 100, 19),
|
| 149 |
+
("Setengah Indonesia (250 kab × 19)", 250, 19),
|
| 150 |
+
("Full Indonesia (514 kab × 19) — TARGET NASIONAL", 514, 19),
|
| 151 |
+
("Stress over-scale (1000 kab × 19)", 1000, 19),
|
| 152 |
+
]
|
| 153 |
+
|
| 154 |
+
results = []
|
| 155 |
+
for label, n_kab, n_komo in configs:
|
| 156 |
+
kabs = make_synthetic_indonesia(n_kab)
|
| 157 |
+
surplus, demand = make_workload(kabs, n_komo)
|
| 158 |
+
# Reduced iterations for large workloads
|
| 159 |
+
iters = 20 if n_kab <= 100 else 5 if n_kab <= 514 else 3
|
| 160 |
+
r = bench(label, surplus, demand, iterations=iters)
|
| 161 |
+
results.append(r)
|
| 162 |
+
print_result(r)
|
| 163 |
+
|
| 164 |
+
print()
|
| 165 |
+
print("=" * 90)
|
| 166 |
+
print(" CONCLUSION")
|
| 167 |
+
print("=" * 90)
|
| 168 |
+
national = results[3] # 514 kab
|
| 169 |
+
if national["p99_ms"] < 500:
|
| 170 |
+
print(f" ✅ National scale 514 kab × 19 komoditas: p99 = "
|
| 171 |
+
f"{national['p99_ms']:.1f}ms — UNDER 500ms target. SIAP NASIONAL.")
|
| 172 |
+
else:
|
| 173 |
+
scale_factor = national["p99_ms"] / 500
|
| 174 |
+
print(f" ⚠ National scale 514 kab × 19 komoditas: p99 = "
|
| 175 |
+
f"{national['p99_ms']:.1f}ms — OVER 500ms target")
|
| 176 |
+
print(f" Slowdown vs target: {scale_factor:.1f}x — butuh optimization")
|
| 177 |
+
print(f" Optimization yang dapat dipertimbangkan:")
|
| 178 |
+
print(f" 1. Spatial indexing (R-tree / geohash) di Layer 1")
|
| 179 |
+
print(f" 2. Pre-computed distance matrix (cached)")
|
| 180 |
+
print(f" 3. Per-province batching (decompose ke 38 sub-problems)")
|
| 181 |
+
print(f" 4. Parallel processing per komoditas (independent)")
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
if __name__ == "__main__":
|
| 185 |
+
main()
|
benchmarks/output/equity_comparison.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AgriFlow Equity Comparison
|
| 2 |
+
|
| 3 |
+
Generated by `benchmarks/equity_comparison.py`.
|
| 4 |
+
|
| 5 |
+
## Table 1 — Baseline Comparison (5 strategies)
|
| 6 |
+
|
| 7 |
+
Coverage = volume-weighted tons fulfilled / tons demanded.
|
| 8 |
+
Gini / Atkinson = weighted by demand volume; lower = more equitable.
|
| 9 |
+
MinFulfill = fulfillment ratio of worst-served demand node; higher = better.
|
| 10 |
+
Sampang=3527 (IPM 66.72), Bangkalan=3526 (IPM 67.70).
|
| 11 |
+
|
| 12 |
+
| Strategy | Coverage | Gini | Atk(0.5) | Atk(1.0) | MinFulfill | Sampang | Bangkalan |
|
| 13 |
+
|-------------------|----------|-------|----------|----------|-----------|---------|-----------|
|
| 14 |
+
| pure_greedy | 0.8897 | 0.0926| 0.0154 | 0.1126 | 0.0000 | 1.0000 | 1.0000 |
|
| 15 |
+
| agriflow | 0.8802 | 0.0990| 0.0162 | 0.1139 | 0.0000 | 1.0000 | 1.0000 |
|
| 16 |
+
| uniform | 0.9925 | 0.0075| 0.0054 | 0.0940 | 0.0000 | 1.0000 | 1.0000 |
|
| 17 |
+
| proportional | 0.9930 | 0.0070| 0.0053 | 0.0940 | 0.0000 | 1.0000 | 1.0000 |
|
| 18 |
+
| agriflow_smoothed | 0.8802 | 0.0990| 0.0162 | 0.1139 | 0.0000 | 1.0000 | 1.0000 |
|
| 19 |
+
|
| 20 |
+
## Table 2 — Action 6 Sensitivity (threshold variants)
|
| 21 |
+
|
| 22 |
+
`current` delegates to `equity_multiplier_value` — single source of truth.
|
| 23 |
+
|
| 24 |
+
| Variant | Coverage | Gini | Sampang | Bangkalan | Description |
|
| 25 |
+
|---------|----------|-------|---------|-----------|-------------|
|
| 26 |
+
| strict | 0.8802 | 0.0990| 1.0000 | 1.0000 | IPM <65→1.50, <70→1.25, <75→1.10, >=75→1.00 |
|
| 27 |
+
| current | 0.8802 | 0.0990| 1.0000 | 1.0000 | IPM <68→1.30, <72→1.15, <78→1.05, >=78→1.00 [PROD] |
|
| 28 |
+
| lenient | 0.8802 | 0.0990| 1.0000 | 1.0000 | IPM <70→1.15, <75→1.08, <80→1.03, >=80→1.00 |
|
| 29 |
+
|
| 30 |
+
## Boundary Perturbation
|
| 31 |
+
|
| 32 |
+
Shift all IPM thresholds simultaneously by ±1 or ±2 points.
|
| 33 |
+
Counts kabupaten in Jatim (38 total) that move to a different tier.
|
| 34 |
+
|
| 35 |
+
| Threshold shift | Kabs changing tier |
|
| 36 |
+
|-----------------|---------------------|
|
| 37 |
+
| -2 pts | 10 |
|
| 38 |
+
| -1 pts | 6 |
|
| 39 |
+
| +1 pts | 2 |
|
| 40 |
+
| +2 pts | 12 |
|
| 41 |
+
|
benchmarks/output/equity_comparison_constrained.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AgriFlow Equity Comparison — ABUNDANT vs CONSTRAINED
|
| 2 |
+
|
| 3 |
+
Generated by `benchmarks/equity_comparison_constrained.py`.
|
| 4 |
+
|
| 5 |
+
## Scenario: La Nina Supply Shock (CONSTRAINED)
|
| 6 |
+
|
| 7 |
+
**Narasi:** La Nina banjir besar menghantam tiga sentra beras utama Jatim
|
| 8 |
+
bagian barat — Ngawi (3521), Madiun (3519), Bojonegoro (3522) — secara
|
| 9 |
+
bersamaan. Ketiga kab berada di dataran rendah lembah Bengawan Solo dan
|
| 10 |
+
anak sungainya; pola banjir simultan terdokumentasi pada La Nina 2010,
|
| 11 |
+
2020–21, dan 2022–23.
|
| 12 |
+
|
| 13 |
+
**Dampak:** 6 surplus rows dihapus:
|
| 14 |
+
|
| 15 |
+
| Kab | Nama | Komoditas | Volume (t) |
|
| 16 |
+
|-----|------|-----------|------------|
|
| 17 |
+
| 3521 | Ngawi | beras_premium | 500 |
|
| 18 |
+
| 3521 | Ngawi | beras_medium | 900 |
|
| 19 |
+
| 3521 | Ngawi | jagung | 600 |
|
| 20 |
+
| 3519 | Madiun | beras_medium | 1200 |
|
| 21 |
+
| 3522 | Bojonegoro | beras_premium | 650 |
|
| 22 |
+
| 3522 | Bojonegoro | jagung | 800 |
|
| 23 |
+
| **Total** | | | **4650** |
|
| 24 |
+
|
| 25 |
+
**Arithmetic:**
|
| 26 |
+
|
| 27 |
+
| Scenario | Surplus (t) | Deficit (t) | Ratio |
|
| 28 |
+
|----------|-------------|-------------|-------|
|
| 29 |
+
| ABUNDANT | 8612 | 5249 | 1.641 (over-supplied) |
|
| 30 |
+
| CONSTRAINED | 3962 | 5249 | 0.754 (under-supplied by 32.5%) |
|
| 31 |
+
|
| 32 |
+
Fixture: `sample_data/surplus_deficit_constrained.csv` (committed).
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## Table 1A — Baseline Comparison: ABUNDANT
|
| 37 |
+
|
| 38 |
+
Coverage = volume-weighted tons fulfilled / tons demanded.
|
| 39 |
+
Gini / Atkinson = weighted by demand volume; lower = more equitable.
|
| 40 |
+
MinFulfill = fulfillment ratio of worst-served demand node.
|
| 41 |
+
Sampang=3527 (IPM 66.72), Bangkalan=3526 (IPM 67.70).
|
| 42 |
+
|
| 43 |
+
### ABUNDANT (surplus=8612t, deficit=5249t, ratio=1.641)
|
| 44 |
+
|
| 45 |
+
| Strategy | Coverage | Gini | Atk(0.5) | Atk(1.0) | MinFulfill | Sampang | Bangkalan |
|
| 46 |
+
|-------------------|----------|-------|----------|----------|-----------|---------|-----------|
|
| 47 |
+
| pure_greedy | 0.8897 | 0.0926| 0.0154 | 0.1126 | 0.0000 | 1.0000 | 1.0000 |
|
| 48 |
+
| agriflow | 0.8802 | 0.0990| 0.0162 | 0.1139 | 0.0000 | 1.0000 | 1.0000 |
|
| 49 |
+
| uniform | 0.9925 | 0.0075| 0.0054 | 0.0940 | 0.0000 | 1.0000 | 1.0000 |
|
| 50 |
+
| proportional | 0.9930 | 0.0070| 0.0053 | 0.0940 | 0.0000 | 1.0000 | 1.0000 |
|
| 51 |
+
| agriflow_smoothed | 0.8802 | 0.0990| 0.0162 | 0.1139 | 0.0000 | 1.0000 | 1.0000 |
|
| 52 |
+
|
| 53 |
+
## Table 1C — Baseline Comparison: CONSTRAINED
|
| 54 |
+
|
| 55 |
+
Same metrics. Under supply shortage, equity tradeoffs become observable.
|
| 56 |
+
|
| 57 |
+
### CONSTRAINED / La Nina banjir Ngawi+Madiun+Bojonegoro (surplus=3962t, deficit=5249t, ratio=0.754)
|
| 58 |
+
|
| 59 |
+
| Strategy | Coverage | Gini | Atk(0.5) | Atk(1.0) | MinFulfill | Sampang | Bangkalan |
|
| 60 |
+
|-------------------|----------|-------|----------|----------|-----------|---------|-----------|
|
| 61 |
+
| pure_greedy | 0.6649 | 0.3017| 0.2283 | 0.9840 | 0.0000 | 0.0000 | 0.2000 |
|
| 62 |
+
| agriflow | 0.6649 | 0.2905| 0.1764 | 0.9379 | 0.0000 | 1.0000 | 1.0000 |
|
| 63 |
+
| uniform | 0.6267 | 0.2522| 0.0575 | 0.1857 | 0.0000 | 1.0000 | 1.0000 |
|
| 64 |
+
| proportional | 0.7015 | 0.1589| 0.0290 | 0.1373 | 0.0000 | 0.7750 | 0.7750 |
|
| 65 |
+
| agriflow_smoothed | 0.6649 | 0.2541| 0.1514 | 0.9333 | 0.0000 | 1.0000 | 1.0000 |
|
| 66 |
+
|
| 67 |
+
## Table 2A — Sensitivity: ABUNDANT (degenerate — included for completeness)
|
| 68 |
+
|
| 69 |
+
### Sensitivity ABUNDANT
|
| 70 |
+
|
| 71 |
+
| Variant | Coverage | Gini | MinFull | Sampang | Bangkalan | Description |
|
| 72 |
+
|---------|----------|-------|---------|---------|-----------|-------------|
|
| 73 |
+
| strict | 0.8802 | 0.0990| 0.0000 | 1.0000 | 1.0000 | IPM <65->1.50, <70->1.25, <75->1.10, >=75->1.00 |
|
| 74 |
+
| current | 0.8802 | 0.0990| 0.0000 | 1.0000 | 1.0000 | IPM <68->1.30, <72->1.15, <78->1.05, >=78->1.00 [PROD] |
|
| 75 |
+
| lenient | 0.8802 | 0.0990| 0.0000 | 1.0000 | 1.0000 | IPM <70->1.15, <75->1.08, <80->1.03, >=80->1.00 |
|
| 76 |
+
|
| 77 |
+
## Table 2C — Sensitivity: CONSTRAINED
|
| 78 |
+
|
| 79 |
+
`current` delegates to `equity_multiplier_value` — single source of truth.
|
| 80 |
+
|
| 81 |
+
### Sensitivity CONSTRAINED
|
| 82 |
+
|
| 83 |
+
| Variant | Coverage | Gini | MinFull | Sampang | Bangkalan | Description |
|
| 84 |
+
|---------|----------|-------|---------|---------|-----------|-------------|
|
| 85 |
+
| strict | 0.6649 | 0.2905| 0.0000 | 1.0000 | 1.0000 | IPM <65->1.50, <70->1.25, <75->1.10, >=75->1.00 |
|
| 86 |
+
| current | 0.6649 | 0.2905| 0.0000 | 1.0000 | 1.0000 | IPM <68->1.30, <72->1.15, <78->1.05, >=78->1.00 [PROD] |
|
| 87 |
+
| lenient | 0.6649 | 0.2905| 0.0000 | 1.0000 | 1.0000 | IPM <70->1.15, <75->1.08, <80->1.03, >=80->1.00 |
|
| 88 |
+
|
| 89 |
+
## Boundary Perturbation
|
| 90 |
+
|
| 91 |
+
Same 38 Jatim kabs in both scenarios. Scenario-independent.
|
| 92 |
+
|
| 93 |
+
| Threshold shift | Kabs changing tier |
|
| 94 |
+
|-----------------|---------------------|
|
| 95 |
+
| -2 pts | 10 |
|
| 96 |
+
| -1 pts | 6 |
|
| 97 |
+
| +1 pts | 2 |
|
| 98 |
+
| +2 pts | 12 |
|
| 99 |
+
|
dashboard/.env.example
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copy to .env.local for dev (gitignored).
|
| 2 |
+
# In Vercel: set NEXT_PUBLIC_API_URL in the project settings → Environment Variables.
|
| 3 |
+
|
| 4 |
+
# Dev (FastAPI running locally on port 8000)
|
| 5 |
+
NEXT_PUBLIC_API_URL=http://localhost:8000
|
| 6 |
+
|
| 7 |
+
# Production (your Render service URL — copy from the Render dashboard after the
|
| 8 |
+
# first deploy lands)
|
| 9 |
+
# NEXT_PUBLIC_API_URL=https://agriflow-api.onrender.com
|
| 10 |
+
|
| 11 |
+
# --- Auth (optional) --------------------------------------------------------
|
| 12 |
+
# Supabase Auth powers the /login and /account pages. Leave BOTH unset to run
|
| 13 |
+
# the dashboard with no login at all: the public map still works and the sign-in
|
| 14 |
+
# button is hidden. Set both to enable accounts.
|
| 15 |
+
# NEXT_PUBLIC_SUPABASE_URL=https://xxxxxxxx.supabase.co
|
| 16 |
+
# NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci...
|
| 17 |
+
|
| 18 |
+
# --- Development login (LOCAL ONLY) -----------------------------------------
|
| 19 |
+
# A hardcoded admin/admin login for testing the signed-in UI before Supabase
|
| 20 |
+
# is wired up. Set NEXT_PUBLIC_DEV_LOGIN=true ONLY in .env.local for local dev.
|
| 21 |
+
# NEVER set it in the Vercel production environment — it would let anyone in as
|
| 22 |
+
# "admin". Left unset here on purpose; a production build with the flag absent
|
| 23 |
+
# compiles the dev-login code out entirely.
|
| 24 |
+
# NEXT_PUBLIC_DEV_LOGIN=true
|
| 25 |
+
# NEXT_PUBLIC_DEV_EMAIL=admin
|
| 26 |
+
# NEXT_PUBLIC_DEV_PASSWORD=admin
|
dashboard/.gitignore
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
!.env.example
|
| 36 |
+
|
| 37 |
+
# vercel
|
| 38 |
+
.vercel
|
| 39 |
+
|
| 40 |
+
# typescript
|
| 41 |
+
*.tsbuildinfo
|
| 42 |
+
next-env.d.ts
|
| 43 |
+
|
| 44 |
+
.vercel
|
dashboard/AGENTS.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- BEGIN:nextjs-agent-rules -->
|
| 2 |
+
# This is NOT the Next.js you know
|
| 3 |
+
|
| 4 |
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
| 5 |
+
<!-- END:nextjs-agent-rules -->
|
dashboard/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
@AGENTS.md
|
dashboard/app/account/page.tsx
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
// Protected account page: shows the signed-in dashboard user and lets them
|
| 4 |
+
// look up the plan attached to a WhatsApp number.
|
| 5 |
+
//
|
| 6 |
+
// The two identities are deliberately separate for now — a dinas account on the
|
| 7 |
+
// dashboard and a farmer's WhatsApp subscription are different things. The
|
| 8 |
+
// subscriber.dashboard_user_id column exists to link them when that is wanted.
|
| 9 |
+
|
| 10 |
+
import { useEffect, useState } from "react";
|
| 11 |
+
import Link from "next/link";
|
| 12 |
+
import { useRouter } from "next/navigation";
|
| 13 |
+
import { api, type BillingStatus } from "../lib/api";
|
| 14 |
+
import { useAuth } from "../lib/auth";
|
| 15 |
+
|
| 16 |
+
export default function AccountPage() {
|
| 17 |
+
const { user, loading, configured, signOut } = useAuth();
|
| 18 |
+
const router = useRouter();
|
| 19 |
+
|
| 20 |
+
const [phone, setPhone] = useState("");
|
| 21 |
+
const [status, setStatus] = useState<BillingStatus | null>(null);
|
| 22 |
+
const [error, setError] = useState<string | null>(null);
|
| 23 |
+
const [busy, setBusy] = useState(false);
|
| 24 |
+
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
// Only bounce once we know the session state, or a signed-in user gets
|
| 27 |
+
// kicked to /login on every refresh while the session is still resolving.
|
| 28 |
+
if (!loading && !user && configured) router.replace("/login");
|
| 29 |
+
}, [loading, user, configured, router]);
|
| 30 |
+
|
| 31 |
+
if (loading) {
|
| 32 |
+
return <main className="flex-1 grid place-items-center text-slate-500">Memuat…</main>;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
if (!configured) {
|
| 36 |
+
return (
|
| 37 |
+
<main className="flex-1 p-6 max-w-2xl mx-auto">
|
| 38 |
+
<Link href="/" className="text-sm text-slate-500 hover:text-slate-800">← Kembali ke peta</Link>
|
| 39 |
+
<p className="mt-6 rounded-lg bg-amber-50 border border-amber-200 p-4 text-sm text-amber-900">
|
| 40 |
+
Login belum dikonfigurasi di lingkungan ini.
|
| 41 |
+
</p>
|
| 42 |
+
</main>
|
| 43 |
+
);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
if (!user) return null; // redirect in flight
|
| 47 |
+
|
| 48 |
+
async function lookup(e: React.FormEvent) {
|
| 49 |
+
e.preventDefault();
|
| 50 |
+
setError(null);
|
| 51 |
+
setStatus(null);
|
| 52 |
+
setBusy(true);
|
| 53 |
+
try {
|
| 54 |
+
setStatus(await api.billingStatus(phone));
|
| 55 |
+
} catch {
|
| 56 |
+
setError("Nomor tidak valid atau layanan sedang tidak tersedia.");
|
| 57 |
+
} finally {
|
| 58 |
+
setBusy(false);
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<main className="flex-1 p-6 max-w-2xl mx-auto w-full">
|
| 64 |
+
<Link href="/" className="text-sm text-slate-500 hover:text-slate-800">← Kembali ke peta</Link>
|
| 65 |
+
|
| 66 |
+
<h1 className="mt-6 text-2xl font-semibold text-slate-900">Akun</h1>
|
| 67 |
+
|
| 68 |
+
<section className="mt-4 rounded-xl border border-slate-200 bg-white p-5">
|
| 69 |
+
<p className="text-sm text-slate-500">Masuk sebagai</p>
|
| 70 |
+
<p className="font-medium text-slate-900">{user.email}</p>
|
| 71 |
+
<button
|
| 72 |
+
onClick={async () => { await signOut(); router.push("/"); }}
|
| 73 |
+
className="mt-4 rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium
|
| 74 |
+
text-slate-700 hover:bg-slate-50"
|
| 75 |
+
>
|
| 76 |
+
Keluar
|
| 77 |
+
</button>
|
| 78 |
+
</section>
|
| 79 |
+
|
| 80 |
+
<section className="mt-6 rounded-xl border border-slate-200 bg-white p-5">
|
| 81 |
+
<h2 className="font-semibold text-slate-900">Langganan WhatsApp</h2>
|
| 82 |
+
<p className="mt-1 text-sm text-slate-500">
|
| 83 |
+
Cek paket dan sisa kuota untuk sebuah nomor WhatsApp. Nomor di-hash di
|
| 84 |
+
server dan tidak disimpan dalam bentuk aslinya.
|
| 85 |
+
</p>
|
| 86 |
+
|
| 87 |
+
<form onSubmit={lookup} className="mt-4 flex gap-2">
|
| 88 |
+
<input
|
| 89 |
+
type="tel"
|
| 90 |
+
required
|
| 91 |
+
placeholder="+628123456789"
|
| 92 |
+
value={phone}
|
| 93 |
+
onChange={(e) => setPhone(e.target.value)}
|
| 94 |
+
className="flex-1 rounded-lg border border-slate-300 px-3 py-2 text-sm
|
| 95 |
+
focus:border-emerald-600 focus:outline-none focus:ring-1 focus:ring-emerald-600"
|
| 96 |
+
/>
|
| 97 |
+
<button
|
| 98 |
+
type="submit"
|
| 99 |
+
disabled={busy}
|
| 100 |
+
className="rounded-lg bg-emerald-700 px-4 py-2 text-sm font-medium text-white
|
| 101 |
+
hover:bg-emerald-800 disabled:opacity-50"
|
| 102 |
+
>
|
| 103 |
+
{busy ? "…" : "Cek"}
|
| 104 |
+
</button>
|
| 105 |
+
</form>
|
| 106 |
+
|
| 107 |
+
{error && (
|
| 108 |
+
<p role="alert" className="mt-3 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-800">
|
| 109 |
+
{error}
|
| 110 |
+
</p>
|
| 111 |
+
)}
|
| 112 |
+
|
| 113 |
+
{status && (
|
| 114 |
+
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
| 115 |
+
<dt className="text-slate-500">Paket</dt>
|
| 116 |
+
<dd className="font-medium text-slate-900">
|
| 117 |
+
{status.is_pro ? "PRO" : "GRATIS"}
|
| 118 |
+
</dd>
|
| 119 |
+
|
| 120 |
+
<dt className="text-slate-500">Kuota hari ini</dt>
|
| 121 |
+
<dd className="font-medium text-slate-900">
|
| 122 |
+
{status.is_pro
|
| 123 |
+
? "Tanpa batas"
|
| 124 |
+
: `${status.used_today} dari ${status.limit} terpakai`}
|
| 125 |
+
</dd>
|
| 126 |
+
|
| 127 |
+
{status.expires_at && (
|
| 128 |
+
<>
|
| 129 |
+
<dt className="text-slate-500">Aktif sampai</dt>
|
| 130 |
+
<dd className="font-medium text-slate-900">
|
| 131 |
+
{new Date(status.expires_at).toLocaleDateString("id-ID", {
|
| 132 |
+
day: "numeric", month: "long", year: "numeric",
|
| 133 |
+
})}
|
| 134 |
+
</dd>
|
| 135 |
+
</>
|
| 136 |
+
)}
|
| 137 |
+
</dl>
|
| 138 |
+
)}
|
| 139 |
+
</section>
|
| 140 |
+
</main>
|
| 141 |
+
);
|
| 142 |
+
}
|
dashboard/app/components/AccountMenu.tsx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
// Account affordance for the dashboard header.
|
| 4 |
+
//
|
| 5 |
+
// The site is login-first (see proxy.ts), so by the time a page renders the
|
| 6 |
+
// visitor is always either a real signed-in user or a guest. This menu shows
|
| 7 |
+
// which, and gives each a way out:
|
| 8 |
+
// - real user -> account link (email), sign-out lives on /account
|
| 9 |
+
// - guest -> a "Tamu" chip plus "Keluar" to drop the guest cookie and
|
| 10 |
+
// return to /login, where they can sign in for real
|
| 11 |
+
|
| 12 |
+
import Link from "next/link";
|
| 13 |
+
import { useEffect, useState } from "react";
|
| 14 |
+
import { useRouter } from "next/navigation";
|
| 15 |
+
import { useAuth } from "../lib/auth";
|
| 16 |
+
import { exitGuest, isGuest } from "../lib/guest";
|
| 17 |
+
|
| 18 |
+
export default function AccountMenu() {
|
| 19 |
+
const { user, loading } = useAuth();
|
| 20 |
+
const router = useRouter();
|
| 21 |
+
|
| 22 |
+
// The guest cookie is not reactive, so read it once on mount. Client-only:
|
| 23 |
+
// isGuest() returns false during SSR, which is fine (the menu is decorative).
|
| 24 |
+
const [guest, setGuest] = useState(false);
|
| 25 |
+
useEffect(() => setGuest(isGuest()), []);
|
| 26 |
+
|
| 27 |
+
if (loading) return null;
|
| 28 |
+
|
| 29 |
+
if (user) {
|
| 30 |
+
return (
|
| 31 |
+
<Link
|
| 32 |
+
href="/account"
|
| 33 |
+
className="flex items-center gap-2 rounded-lg border border-slate-300 px-3 py-1.5
|
| 34 |
+
text-sm font-medium text-slate-700 hover:bg-slate-50"
|
| 35 |
+
title={user.email ?? undefined}
|
| 36 |
+
>
|
| 37 |
+
<span
|
| 38 |
+
aria-hidden
|
| 39 |
+
className="grid h-6 w-6 place-items-center rounded-full bg-emerald-700 text-xs text-white"
|
| 40 |
+
>
|
| 41 |
+
{(user.email ?? "?").charAt(0).toUpperCase()}
|
| 42 |
+
</span>
|
| 43 |
+
<span className="max-w-[12ch] truncate">{user.email}</span>
|
| 44 |
+
</Link>
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
if (guest) {
|
| 49 |
+
return (
|
| 50 |
+
<div className="flex items-center gap-2">
|
| 51 |
+
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-500">
|
| 52 |
+
Mode Tamu
|
| 53 |
+
</span>
|
| 54 |
+
<button
|
| 55 |
+
onClick={() => {
|
| 56 |
+
exitGuest();
|
| 57 |
+
router.push("/login");
|
| 58 |
+
}}
|
| 59 |
+
className="rounded-lg border border-slate-300 px-3 py-1.5 text-sm font-medium
|
| 60 |
+
text-slate-700 hover:bg-slate-50"
|
| 61 |
+
>
|
| 62 |
+
Keluar
|
| 63 |
+
</button>
|
| 64 |
+
</div>
|
| 65 |
+
);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// Fallback (e.g. brief pre-hydration flash): offer the way in.
|
| 69 |
+
return (
|
| 70 |
+
<Link
|
| 71 |
+
href="/login"
|
| 72 |
+
className="rounded-lg border border-slate-300 px-3 py-1.5 text-sm font-medium
|
| 73 |
+
text-slate-700 hover:bg-slate-50"
|
| 74 |
+
>
|
| 75 |
+
Masuk
|
| 76 |
+
</Link>
|
| 77 |
+
);
|
| 78 |
+
}
|
dashboard/app/components/AnomalyPanel.tsx
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* AnomalyPanel — displays recent price anomalies (S-H-ESD detections).
|
| 5 |
+
*
|
| 6 |
+
* Shows a scrollable list of anomalies with SPIKE/DROP badges,
|
| 7 |
+
* deviation percentage, and city/commodity labels.
|
| 8 |
+
*
|
| 9 |
+
* Props:
|
| 10 |
+
* anomalies AnomalyRecord[]
|
| 11 |
+
* loading boolean
|
| 12 |
+
* error string | null
|
| 13 |
+
* totalCount number
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
import type { AnomalyRecord } from "../lib/api";
|
| 17 |
+
|
| 18 |
+
// ---------------------------------------------------------------------------
|
| 19 |
+
// Format helpers
|
| 20 |
+
// ---------------------------------------------------------------------------
|
| 21 |
+
|
| 22 |
+
function fmtIdr(n: number): string {
|
| 23 |
+
if (n >= 1_000_000) return "Rp " + (n / 1_000_000).toFixed(2) + "jt";
|
| 24 |
+
if (n >= 1_000) return "Rp " + (n / 1_000).toFixed(1) + "k";
|
| 25 |
+
return "Rp " + n.toFixed(0);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function fmtDate(iso: string): string {
|
| 29 |
+
const d = new Date(iso);
|
| 30 |
+
return d.toLocaleDateString("id-ID", { day: "numeric", month: "short", year: "numeric" });
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const COMMODITY_NAMES: Record<string, string> = {
|
| 34 |
+
cabai_rawit: "Cabai Rawit",
|
| 35 |
+
bawang_merah: "Bawang Merah",
|
| 36 |
+
bawang_putih: "Bawang Putih",
|
| 37 |
+
beras_medium: "Beras Medium",
|
| 38 |
+
beras_premium: "Beras Premium",
|
| 39 |
+
daging_ayam: "Daging Ayam",
|
| 40 |
+
telur_ayam: "Telur Ayam",
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
// ---------------------------------------------------------------------------
|
| 44 |
+
// Single anomaly row
|
| 45 |
+
// ---------------------------------------------------------------------------
|
| 46 |
+
|
| 47 |
+
function AnomalyRow({ a }: { a: AnomalyRecord }) {
|
| 48 |
+
const isSpike = a.type === "SPIKE";
|
| 49 |
+
const sign = a.deviation_pct >= 0 ? "+" : "";
|
| 50 |
+
const comName = COMMODITY_NAMES[a.commodity_code] ?? a.commodity_code;
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<li className="px-3 py-2 hover:bg-zinc-50 flex items-start gap-2.5">
|
| 54 |
+
{/* Badge */}
|
| 55 |
+
<span
|
| 56 |
+
className={`text-[10px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap mt-0.5 ${
|
| 57 |
+
isSpike
|
| 58 |
+
? "bg-rose-100 text-rose-700"
|
| 59 |
+
: "bg-sky-100 text-sky-700"
|
| 60 |
+
}`}
|
| 61 |
+
>
|
| 62 |
+
{a.type}
|
| 63 |
+
</span>
|
| 64 |
+
|
| 65 |
+
{/* Content */}
|
| 66 |
+
<div className="flex-1 min-w-0">
|
| 67 |
+
<div className="flex items-baseline justify-between gap-1">
|
| 68 |
+
<span className="text-xs font-medium text-zinc-800 truncate">{comName}</span>
|
| 69 |
+
<span className={`text-xs font-mono ${isSpike ? "text-rose-600" : "text-sky-600"}`}>
|
| 70 |
+
{sign}{a.deviation_pct.toFixed(1)}%
|
| 71 |
+
</span>
|
| 72 |
+
</div>
|
| 73 |
+
<div className="text-[11px] text-zinc-500 mt-0.5">
|
| 74 |
+
{fmtDate(a.date)} · {a.city_name} · {fmtIdr(a.price)}/kg
|
| 75 |
+
</div>
|
| 76 |
+
<div className="text-[10px] text-zinc-400 mt-0.5">
|
| 77 |
+
score {a.score.toFixed(2)}
|
| 78 |
+
{a.persistent && (
|
| 79 |
+
<span className="ml-1.5 text-indigo-500">persisten</span>
|
| 80 |
+
)}
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
</li>
|
| 84 |
+
);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// ---------------------------------------------------------------------------
|
| 88 |
+
// Main panel
|
| 89 |
+
// ---------------------------------------------------------------------------
|
| 90 |
+
|
| 91 |
+
type Props = {
|
| 92 |
+
anomalies: AnomalyRecord[];
|
| 93 |
+
loading: boolean;
|
| 94 |
+
error: string | null;
|
| 95 |
+
totalCount: number;
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
export default function AnomalyPanel({ anomalies, loading, error, totalCount }: Props) {
|
| 99 |
+
if (error) {
|
| 100 |
+
return (
|
| 101 |
+
<div className="border border-rose-200 rounded-lg p-3 bg-rose-50 text-xs text-rose-700">
|
| 102 |
+
{error}
|
| 103 |
+
</div>
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
<div className="border border-zinc-200 rounded-lg bg-white overflow-hidden">
|
| 109 |
+
{/* Header */}
|
| 110 |
+
<div className="px-3 py-2 border-b border-zinc-100 flex items-center justify-between">
|
| 111 |
+
<span className="text-xs font-semibold text-zinc-800">
|
| 112 |
+
Anomali Harga Terbaru
|
| 113 |
+
</span>
|
| 114 |
+
<span className="text-[10px] text-zinc-400">
|
| 115 |
+
{loading ? "memuat..." : `${totalCount.toLocaleString("id-ID")} total`}
|
| 116 |
+
</span>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
{loading && (
|
| 120 |
+
<div className="px-3 py-4 text-xs text-zinc-400 animate-pulse">
|
| 121 |
+
Memuat anomali...
|
| 122 |
+
</div>
|
| 123 |
+
)}
|
| 124 |
+
|
| 125 |
+
{!loading && anomalies.length === 0 && (
|
| 126 |
+
<div className="px-3 py-4 text-xs text-zinc-400">
|
| 127 |
+
Tidak ada anomali untuk filter ini.
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
|
| 131 |
+
{!loading && anomalies.length > 0 && (
|
| 132 |
+
<>
|
| 133 |
+
<ul className="divide-y divide-zinc-100 max-h-64 overflow-y-auto">
|
| 134 |
+
{anomalies.map((a, i) => (
|
| 135 |
+
<AnomalyRow key={`${a.date}-${a.commodity_code}-${a.city_id}-${i}`} a={a} />
|
| 136 |
+
))}
|
| 137 |
+
</ul>
|
| 138 |
+
|
| 139 |
+
{/* Legend */}
|
| 140 |
+
<div className="px-3 py-1.5 border-t border-zinc-100 flex gap-3 text-[10px] text-zinc-500">
|
| 141 |
+
<span className="flex items-center gap-1">
|
| 142 |
+
<span className="w-2 h-2 rounded-sm bg-rose-200 inline-block" />
|
| 143 |
+
SPIKE (harga naik)
|
| 144 |
+
</span>
|
| 145 |
+
<span className="flex items-center gap-1">
|
| 146 |
+
<span className="w-2 h-2 rounded-sm bg-sky-200 inline-block" />
|
| 147 |
+
DROP (harga turun)
|
| 148 |
+
</span>
|
| 149 |
+
<span className="ml-1 text-indigo-400">persisten = ≥2 hari beruntun</span>
|
| 150 |
+
</div>
|
| 151 |
+
</>
|
| 152 |
+
)}
|
| 153 |
+
</div>
|
| 154 |
+
);
|
| 155 |
+
}
|
dashboard/app/components/ForecastPanel.tsx
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* ForecastPanel — 30-day price forecast chart + CI band.
|
| 5 |
+
*
|
| 6 |
+
* Renders a lightweight SVG line chart (no external charting lib) consistent
|
| 7 |
+
* with the existing dashboard aesthetic. The CI band (P10-P90) is rendered as
|
| 8 |
+
* a filled area; the point forecast is a line; history end date is marked.
|
| 9 |
+
*
|
| 10 |
+
* Props:
|
| 11 |
+
* forecast ForecastResponse | null — null while loading
|
| 12 |
+
* loading boolean
|
| 13 |
+
* error string | null
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
import { useMemo } from "react";
|
| 17 |
+
import type { ForecastPoint, ForecastResponse } from "../lib/api";
|
| 18 |
+
|
| 19 |
+
// ---------------------------------------------------------------------------
|
| 20 |
+
// Format helpers
|
| 21 |
+
// ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
function fmtIdr(n: number): string {
|
| 24 |
+
if (n >= 1_000_000) return "Rp " + (n / 1_000_000).toFixed(1) + "jt";
|
| 25 |
+
if (n >= 1_000) return "Rp " + (n / 1_000).toFixed(0) + "k";
|
| 26 |
+
return "Rp " + n.toFixed(0);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function fmtDate(iso: string): string {
|
| 30 |
+
const d = new Date(iso);
|
| 31 |
+
return d.toLocaleDateString("id-ID", { day: "numeric", month: "short" });
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// ---------------------------------------------------------------------------
|
| 35 |
+
// SVG chart
|
| 36 |
+
// ---------------------------------------------------------------------------
|
| 37 |
+
|
| 38 |
+
const CHART_W = 520;
|
| 39 |
+
const CHART_H = 160;
|
| 40 |
+
const PAD_L = 52;
|
| 41 |
+
const PAD_R = 16;
|
| 42 |
+
const PAD_T = 12;
|
| 43 |
+
const PAD_B = 28;
|
| 44 |
+
|
| 45 |
+
type ChartProps = {
|
| 46 |
+
forecasts: ForecastPoint[];
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
function ForecastChart({ forecasts }: ChartProps) {
|
| 50 |
+
const plotW = CHART_W - PAD_L - PAD_R;
|
| 51 |
+
const plotH = CHART_H - PAD_T - PAD_B;
|
| 52 |
+
|
| 53 |
+
const { minP, maxP, xScale, yScale, bandPath, linePath, ticks } =
|
| 54 |
+
useMemo(() => {
|
| 55 |
+
const all = forecasts.flatMap((pt) => [pt.p10, pt.p90]);
|
| 56 |
+
const minP = Math.min(...all);
|
| 57 |
+
const maxP = Math.max(...all);
|
| 58 |
+
const pad = (maxP - minP) * 0.1 || 1000;
|
| 59 |
+
const lo = minP - pad;
|
| 60 |
+
const hi = maxP + pad;
|
| 61 |
+
const n = forecasts.length;
|
| 62 |
+
|
| 63 |
+
const xScale = (i: number) => PAD_L + (i / (n - 1)) * plotW;
|
| 64 |
+
const yScale = (v: number) => PAD_T + plotH - ((v - lo) / (hi - lo)) * plotH;
|
| 65 |
+
|
| 66 |
+
// CI band (p10 → p90 reversed)
|
| 67 |
+
const fwd = forecasts.map((pt, i) => `${xScale(i)},${yScale(pt.p90)}`).join(" ");
|
| 68 |
+
const bwd = [...forecasts].reverse().map((pt, i) =>
|
| 69 |
+
`${xScale(n - 1 - i)},${yScale(pt.p10)}`
|
| 70 |
+
).join(" ");
|
| 71 |
+
const bandPath = `M ${fwd} L ${bwd} Z`;
|
| 72 |
+
|
| 73 |
+
// Point forecast line
|
| 74 |
+
const linePath = "M " + forecasts
|
| 75 |
+
.map((pt, i) => `${xScale(i)},${yScale(pt.point)}`)
|
| 76 |
+
.join(" L ");
|
| 77 |
+
|
| 78 |
+
// X-axis ticks: day 1, 8, 15, 22, 30
|
| 79 |
+
const tickIdxs = [0, 7, 14, 21, n - 1];
|
| 80 |
+
const ticks = tickIdxs.filter((i) => i < n).map((i) => ({
|
| 81 |
+
x: xScale(i),
|
| 82 |
+
label: fmtDate(forecasts[i].date),
|
| 83 |
+
}));
|
| 84 |
+
|
| 85 |
+
// Y-axis ticks: 3 evenly spaced
|
| 86 |
+
const yTicks = [lo + (hi - lo) * 0.1, lo + (hi - lo) * 0.5, lo + (hi - lo) * 0.9];
|
| 87 |
+
|
| 88 |
+
return { minP, maxP, xScale, yScale, bandPath, linePath, ticks, yTicks };
|
| 89 |
+
}, [forecasts]);
|
| 90 |
+
|
| 91 |
+
const yTickVals = useMemo(() => {
|
| 92 |
+
const all = forecasts.flatMap((pt) => [pt.p10, pt.p90]);
|
| 93 |
+
const lo = Math.min(...all) - (Math.max(...all) - Math.min(...all)) * 0.1;
|
| 94 |
+
const hi = Math.max(...all) + (Math.max(...all) - Math.min(...all)) * 0.1;
|
| 95 |
+
return [
|
| 96 |
+
lo + (hi - lo) * 0.1,
|
| 97 |
+
lo + (hi - lo) * 0.5,
|
| 98 |
+
lo + (hi - lo) * 0.9,
|
| 99 |
+
].map((v) => ({
|
| 100 |
+
y: PAD_T + CHART_H - PAD_T - PAD_B - ((v - lo) / (hi - lo)) * (CHART_H - PAD_T - PAD_B),
|
| 101 |
+
label: fmtIdr(v),
|
| 102 |
+
}));
|
| 103 |
+
}, [forecasts]);
|
| 104 |
+
|
| 105 |
+
return (
|
| 106 |
+
<svg
|
| 107 |
+
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
|
| 108 |
+
className="w-full"
|
| 109 |
+
style={{ height: CHART_H }}
|
| 110 |
+
>
|
| 111 |
+
{/* Y grid lines + labels */}
|
| 112 |
+
{yTickVals.map((t, i) => (
|
| 113 |
+
<g key={i}>
|
| 114 |
+
<line
|
| 115 |
+
x1={PAD_L} y1={t.y} x2={CHART_W - PAD_R} y2={t.y}
|
| 116 |
+
stroke="#e4e4e7" strokeWidth={1}
|
| 117 |
+
/>
|
| 118 |
+
<text
|
| 119 |
+
x={PAD_L - 4} y={t.y + 4}
|
| 120 |
+
textAnchor="end"
|
| 121 |
+
fontSize={9}
|
| 122 |
+
fill="#71717a"
|
| 123 |
+
>
|
| 124 |
+
{t.label}
|
| 125 |
+
</text>
|
| 126 |
+
</g>
|
| 127 |
+
))}
|
| 128 |
+
|
| 129 |
+
{/* CI band */}
|
| 130 |
+
<path d={bandPath} fill="#6366f1" fillOpacity={0.12} />
|
| 131 |
+
|
| 132 |
+
{/* Point forecast line */}
|
| 133 |
+
<path
|
| 134 |
+
d={linePath}
|
| 135 |
+
fill="none"
|
| 136 |
+
stroke="#6366f1"
|
| 137 |
+
strokeWidth={2}
|
| 138 |
+
strokeLinecap="round"
|
| 139 |
+
strokeLinejoin="round"
|
| 140 |
+
/>
|
| 141 |
+
|
| 142 |
+
{/* X-axis ticks */}
|
| 143 |
+
{ticks.map((t) => (
|
| 144 |
+
<g key={t.x}>
|
| 145 |
+
<line
|
| 146 |
+
x1={t.x} y1={CHART_H - PAD_B} x2={t.x} y2={CHART_H - PAD_B + 4}
|
| 147 |
+
stroke="#a1a1aa" strokeWidth={1}
|
| 148 |
+
/>
|
| 149 |
+
<text
|
| 150 |
+
x={t.x} y={CHART_H - PAD_B + 13}
|
| 151 |
+
textAnchor="middle"
|
| 152 |
+
fontSize={9}
|
| 153 |
+
fill="#71717a"
|
| 154 |
+
>
|
| 155 |
+
{t.label}
|
| 156 |
+
</text>
|
| 157 |
+
</g>
|
| 158 |
+
))}
|
| 159 |
+
|
| 160 |
+
{/* X axis line */}
|
| 161 |
+
<line
|
| 162 |
+
x1={PAD_L} y1={CHART_H - PAD_B}
|
| 163 |
+
x2={CHART_W - PAD_R} y2={CHART_H - PAD_B}
|
| 164 |
+
stroke="#d4d4d8" strokeWidth={1}
|
| 165 |
+
/>
|
| 166 |
+
</svg>
|
| 167 |
+
);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
// ---------------------------------------------------------------------------
|
| 171 |
+
// Main panel
|
| 172 |
+
// ---------------------------------------------------------------------------
|
| 173 |
+
|
| 174 |
+
type Props = {
|
| 175 |
+
forecast: ForecastResponse | null;
|
| 176 |
+
loading: boolean;
|
| 177 |
+
error: string | null;
|
| 178 |
+
};
|
| 179 |
+
|
| 180 |
+
export default function ForecastPanel({ forecast, loading, error }: Props) {
|
| 181 |
+
if (error) {
|
| 182 |
+
return (
|
| 183 |
+
<div className="border border-rose-200 rounded-lg p-3 bg-rose-50 text-xs text-rose-700">
|
| 184 |
+
{error}
|
| 185 |
+
</div>
|
| 186 |
+
);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
if (loading || !forecast) {
|
| 190 |
+
return (
|
| 191 |
+
<div className="border border-zinc-200 rounded-lg p-3 text-xs text-zinc-400 animate-pulse">
|
| 192 |
+
Memuat forecast...
|
| 193 |
+
</div>
|
| 194 |
+
);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
const isBaseline = forecast.method === "seasonal_naive_baseline";
|
| 198 |
+
const first = forecast.forecasts[0];
|
| 199 |
+
const last = forecast.forecasts[forecast.forecasts.length - 1];
|
| 200 |
+
|
| 201 |
+
return (
|
| 202 |
+
<div className="border border-zinc-200 rounded-lg bg-white overflow-hidden">
|
| 203 |
+
{/* Header */}
|
| 204 |
+
<div className="px-3 py-2 border-b border-zinc-100 flex items-center justify-between">
|
| 205 |
+
<div>
|
| 206 |
+
<span className="text-xs font-semibold text-zinc-800">
|
| 207 |
+
Forecast 30 hari — {forecast.city_name}
|
| 208 |
+
</span>
|
| 209 |
+
<span className="ml-2 text-[10px] text-zinc-400">
|
| 210 |
+
s.d. {forecast.history_end_date}
|
| 211 |
+
</span>
|
| 212 |
+
</div>
|
| 213 |
+
{isBaseline && (
|
| 214 |
+
<span className="text-[10px] bg-amber-100 text-amber-800 px-1.5 py-0.5 rounded font-medium">
|
| 215 |
+
baseline statistik
|
| 216 |
+
</span>
|
| 217 |
+
)}
|
| 218 |
+
</div>
|
| 219 |
+
|
| 220 |
+
{/* Chart */}
|
| 221 |
+
<div className="px-2 pt-1">
|
| 222 |
+
<ForecastChart forecasts={forecast.forecasts} />
|
| 223 |
+
</div>
|
| 224 |
+
|
| 225 |
+
{/* Summary row */}
|
| 226 |
+
<div className="px-3 py-2 flex gap-4 text-[11px] border-t border-zinc-100">
|
| 227 |
+
<span className="text-zinc-500">
|
| 228 |
+
Hari 1 ({fmtDate(first.date)}):{" "}
|
| 229 |
+
<span className="font-semibold text-zinc-800">{fmtIdr(first.point)}/kg</span>
|
| 230 |
+
</span>
|
| 231 |
+
<span className="text-zinc-500">
|
| 232 |
+
Hari 30 ({fmtDate(last.date)}):{" "}
|
| 233 |
+
<span className="font-semibold text-zinc-800">{fmtIdr(last.point)}/kg</span>
|
| 234 |
+
</span>
|
| 235 |
+
<span className="text-zinc-400">
|
| 236 |
+
CI: {fmtIdr(last.p10)} – {fmtIdr(last.p90)}
|
| 237 |
+
</span>
|
| 238 |
+
</div>
|
| 239 |
+
|
| 240 |
+
{/* Legend */}
|
| 241 |
+
<div className="px-3 pb-2 flex gap-3 text-[10px] text-zinc-500">
|
| 242 |
+
<span className="flex items-center gap-1">
|
| 243 |
+
<span className="w-4 h-0.5 bg-indigo-500 inline-block" />
|
| 244 |
+
Point
|
| 245 |
+
</span>
|
| 246 |
+
<span className="flex items-center gap-1">
|
| 247 |
+
<span className="w-4 h-3 bg-indigo-400/20 border border-indigo-300/30 inline-block rounded-sm" />
|
| 248 |
+
P10–P90
|
| 249 |
+
</span>
|
| 250 |
+
{isBaseline && (
|
| 251 |
+
<span className="text-amber-600 ml-1">
|
| 252 |
+
* Seasonal-naive baseline, bukan TimesFM
|
| 253 |
+
</span>
|
| 254 |
+
)}
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
);
|
| 258 |
+
}
|
dashboard/app/components/MapView.tsx
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { MapContainer, TileLayer, CircleMarker, Tooltip, Polyline } from "react-leaflet";
|
| 4 |
+
import "leaflet/dist/leaflet.css";
|
| 5 |
+
import type { Kabupaten, SurplusDeficitRow, Match } from "../lib/api";
|
| 6 |
+
|
| 7 |
+
const CENTER: [number, number] = [-7.7, 112.5]; // Jawa Timur centroid-ish
|
| 8 |
+
const ZOOM = 8;
|
| 9 |
+
|
| 10 |
+
type Props = {
|
| 11 |
+
kabupaten: Kabupaten[];
|
| 12 |
+
surplusDeficit: SurplusDeficitRow[];
|
| 13 |
+
matches: Match[];
|
| 14 |
+
onSelectKab: (kabId: string) => void;
|
| 15 |
+
selectedKabId: string | null;
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
// Radius proportional to sqrt(volume) so visual area ~ volume.
|
| 19 |
+
function bubbleRadius(tons: number): number {
|
| 20 |
+
if (tons <= 0) return 4;
|
| 21 |
+
return Math.max(6, Math.min(28, Math.sqrt(tons) * 1.8));
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export default function MapView({
|
| 25 |
+
kabupaten, surplusDeficit, matches, onSelectKab, selectedKabId,
|
| 26 |
+
}: Props) {
|
| 27 |
+
const rowByKab = new Map<string, SurplusDeficitRow>();
|
| 28 |
+
for (const r of surplusDeficit) rowByKab.set(r.kab_id, r);
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<MapContainer
|
| 32 |
+
center={CENTER}
|
| 33 |
+
zoom={ZOOM}
|
| 34 |
+
scrollWheelZoom
|
| 35 |
+
style={{ height: "100%", width: "100%" }}
|
| 36 |
+
>
|
| 37 |
+
<TileLayer
|
| 38 |
+
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
| 39 |
+
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
| 40 |
+
/>
|
| 41 |
+
|
| 42 |
+
{/* Flow lines for top matches */}
|
| 43 |
+
{matches.slice(0, 10).map((m, idx) => (
|
| 44 |
+
<Polyline
|
| 45 |
+
key={`flow-${idx}`}
|
| 46 |
+
positions={[
|
| 47 |
+
[m.surplus.lat, m.surplus.lng],
|
| 48 |
+
[m.deficit.lat, m.deficit.lng],
|
| 49 |
+
]}
|
| 50 |
+
pathOptions={{
|
| 51 |
+
color: "#4e643c",
|
| 52 |
+
weight: 2,
|
| 53 |
+
opacity: 0.75,
|
| 54 |
+
dashArray: "5 5",
|
| 55 |
+
}}
|
| 56 |
+
/>
|
| 57 |
+
))}
|
| 58 |
+
|
| 59 |
+
{/* Kabupaten bubbles */}
|
| 60 |
+
{kabupaten.map((k) => {
|
| 61 |
+
const row = rowByKab.get(k.id);
|
| 62 |
+
const role = row?.role;
|
| 63 |
+
const tons = row?.volume_tons ?? 0;
|
| 64 |
+
const color =
|
| 65 |
+
role === "surplus" ? "#16a34a"
|
| 66 |
+
: role === "deficit" ? "#dc2626"
|
| 67 |
+
: "#94a3b8";
|
| 68 |
+
const radius = row ? bubbleRadius(tons) : 4;
|
| 69 |
+
const isSelected = selectedKabId === k.id;
|
| 70 |
+
return (
|
| 71 |
+
<CircleMarker
|
| 72 |
+
key={k.id}
|
| 73 |
+
center={[k.lat, k.lng]}
|
| 74 |
+
radius={radius}
|
| 75 |
+
pathOptions={{
|
| 76 |
+
color: isSelected ? "#facc15" : color,
|
| 77 |
+
weight: isSelected ? 3 : 1.5,
|
| 78 |
+
fillColor: color,
|
| 79 |
+
fillOpacity: row ? 0.55 : 0.25,
|
| 80 |
+
}}
|
| 81 |
+
eventHandlers={{ click: () => onSelectKab(k.id) }}
|
| 82 |
+
>
|
| 83 |
+
<Tooltip direction="top" offset={[0, -4]}>
|
| 84 |
+
<div className="space-y-1 text-[11px] text-zinc-700 leading-normal">
|
| 85 |
+
<strong className="text-sm text-zinc-900 block border-b border-zinc-100 pb-0.5">{k.nama}</strong>
|
| 86 |
+
<div>Kategori Wilayah: <span className="font-semibold text-zinc-800">{k.tier.replace("TIER_", "").replace("_HIGH", " Tinggi").replace("_MEDIUM", " Sedang")}</span></div>
|
| 87 |
+
<div>IPM: <span className="font-semibold text-zinc-800">{k.ipm.toFixed(1)}</span></div>
|
| 88 |
+
<div>Jumlah Penduduk: <span className="font-semibold text-zinc-800">{k.population.toLocaleString("id-ID")} jiwa</span></div>
|
| 89 |
+
{row && (
|
| 90 |
+
<div className="mt-1 pt-1 border-t border-dashed border-zinc-200">
|
| 91 |
+
<span className="font-semibold" style={{ color }}>
|
| 92 |
+
{role === "surplus" ? "Stok Berlebih (Surplus)" : "Kekurangan Stok (Defisit)"}: {tons.toFixed(0)} ton
|
| 93 |
+
</span>
|
| 94 |
+
</div>
|
| 95 |
+
)}
|
| 96 |
+
</div>
|
| 97 |
+
</Tooltip>
|
| 98 |
+
</CircleMarker>
|
| 99 |
+
);
|
| 100 |
+
})}
|
| 101 |
+
</MapContainer>
|
| 102 |
+
);
|
| 103 |
+
}
|
dashboard/app/favicon.ico
ADDED
|
|
dashboard/app/forgot-password/ForgotPasswordForm.tsx
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { useSearchParams } from "next/navigation";
|
| 6 |
+
import { useAuth } from "../lib/auth";
|
| 7 |
+
|
| 8 |
+
export default function ForgotPasswordForm() {
|
| 9 |
+
const { requestPasswordReset, configured } = useAuth();
|
| 10 |
+
const searchParams = useSearchParams();
|
| 11 |
+
|
| 12 |
+
const [email, setEmail] = useState(searchParams.get("email") ?? "");
|
| 13 |
+
const [error, setError] = useState<string | null>(null);
|
| 14 |
+
const [sent, setSent] = useState(false);
|
| 15 |
+
const [busy, setBusy] = useState(false);
|
| 16 |
+
|
| 17 |
+
async function onSubmit(e: React.FormEvent) {
|
| 18 |
+
e.preventDefault();
|
| 19 |
+
setError(null);
|
| 20 |
+
setBusy(true);
|
| 21 |
+
const { error } = await requestPasswordReset(email);
|
| 22 |
+
setBusy(false);
|
| 23 |
+
|
| 24 |
+
if (error) {
|
| 25 |
+
setError(error);
|
| 26 |
+
return;
|
| 27 |
+
}
|
| 28 |
+
// Supabase does not signal whether the address is registered, and we
|
| 29 |
+
// don't either — showing the same confirmation either way avoids
|
| 30 |
+
// leaking account existence through this form.
|
| 31 |
+
setSent(true);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<main className="flex-1 flex items-center justify-center p-6 bg-slate-50">
|
| 36 |
+
<div className="w-full max-w-sm">
|
| 37 |
+
<Link href="/login" className="block text-sm text-slate-500 hover:text-slate-800 mb-6">
|
| 38 |
+
← Kembali ke halaman masuk
|
| 39 |
+
</Link>
|
| 40 |
+
|
| 41 |
+
<h1 className="text-2xl font-semibold text-slate-900">Lupa kata sandi</h1>
|
| 42 |
+
<p className="mt-1 text-sm text-slate-500">
|
| 43 |
+
Masukkan email akun Anda. Kami akan mengirim tautan untuk mengatur
|
| 44 |
+
ulang kata sandi.
|
| 45 |
+
</p>
|
| 46 |
+
|
| 47 |
+
{!configured && (
|
| 48 |
+
<p className="mt-4 rounded-lg bg-amber-50 border border-amber-200 p-3 text-sm text-amber-900">
|
| 49 |
+
Login belum dikonfigurasi di lingkungan ini. Peta dan data tetap
|
| 50 |
+
dapat diakses tanpa masuk.
|
| 51 |
+
</p>
|
| 52 |
+
)}
|
| 53 |
+
|
| 54 |
+
{sent ? (
|
| 55 |
+
<p className="mt-6 rounded-lg bg-emerald-50 border border-emerald-200 p-3 text-sm text-emerald-900">
|
| 56 |
+
Jika email tersebut terdaftar, tautan atur ulang kata sandi sudah
|
| 57 |
+
dikirim. Periksa kotak masuk (dan folder spam) Anda.
|
| 58 |
+
</p>
|
| 59 |
+
) : (
|
| 60 |
+
<form onSubmit={onSubmit} className="mt-6 space-y-4">
|
| 61 |
+
<div>
|
| 62 |
+
<label htmlFor="email" className="block text-sm font-medium text-slate-700">
|
| 63 |
+
Email
|
| 64 |
+
</label>
|
| 65 |
+
<input
|
| 66 |
+
id="email"
|
| 67 |
+
type="email"
|
| 68 |
+
required
|
| 69 |
+
autoComplete="email"
|
| 70 |
+
value={email}
|
| 71 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 72 |
+
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm
|
| 73 |
+
focus:border-emerald-600 focus:outline-none focus:ring-1 focus:ring-emerald-600"
|
| 74 |
+
/>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
{error && (
|
| 78 |
+
<p role="alert" className="rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-800">
|
| 79 |
+
{error}
|
| 80 |
+
</p>
|
| 81 |
+
)}
|
| 82 |
+
|
| 83 |
+
<button
|
| 84 |
+
type="submit"
|
| 85 |
+
disabled={busy || !configured}
|
| 86 |
+
className="w-full rounded-lg bg-emerald-700 px-4 py-2.5 text-sm font-medium text-white
|
| 87 |
+
hover:bg-emerald-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 88 |
+
>
|
| 89 |
+
{busy ? "Mengirim…" : "Kirim tautan atur ulang"}
|
| 90 |
+
</button>
|
| 91 |
+
</form>
|
| 92 |
+
)}
|
| 93 |
+
</div>
|
| 94 |
+
</main>
|
| 95 |
+
);
|
| 96 |
+
}
|
dashboard/app/forgot-password/page.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Suspense } from "react";
|
| 2 |
+
import ForgotPasswordForm from "./ForgotPasswordForm";
|
| 3 |
+
|
| 4 |
+
// ForgotPasswordForm reads ?email= via useSearchParams() to prefill the
|
| 5 |
+
// field when arriving from the login page, which forces client-side
|
| 6 |
+
// rendering for that subtree — same reason /login wraps LoginForm.
|
| 7 |
+
export default function ForgotPasswordPage() {
|
| 8 |
+
return (
|
| 9 |
+
<Suspense
|
| 10 |
+
fallback={
|
| 11 |
+
<main className="flex-1 grid place-items-center text-slate-500">
|
| 12 |
+
Memuat…
|
| 13 |
+
</main>
|
| 14 |
+
}
|
| 15 |
+
>
|
| 16 |
+
<ForgotPasswordForm />
|
| 17 |
+
</Suspense>
|
| 18 |
+
);
|
| 19 |
+
}
|
dashboard/app/globals.css
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
| 2 |
+
@import "tailwindcss";
|
| 3 |
+
|
| 4 |
+
:root {
|
| 5 |
+
--background: #5b7245;
|
| 6 |
+
--foreground: #171717;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
@theme inline {
|
| 10 |
+
--color-background: var(--background);
|
| 11 |
+
--color-foreground: var(--foreground);
|
| 12 |
+
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
body {
|
| 16 |
+
background: var(--background);
|
| 17 |
+
color: var(--foreground);
|
| 18 |
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/* Custom scrollbars for clean dashboards */
|
| 22 |
+
::-webkit-scrollbar {
|
| 23 |
+
width: 6px;
|
| 24 |
+
height: 6px;
|
| 25 |
+
}
|
| 26 |
+
::-webkit-scrollbar-track {
|
| 27 |
+
background: transparent;
|
| 28 |
+
}
|
| 29 |
+
::-webkit-scrollbar-thumb {
|
| 30 |
+
background: rgba(0, 0, 0, 0.15);
|
| 31 |
+
border-radius: 4px;
|
| 32 |
+
}
|
| 33 |
+
::-webkit-scrollbar-thumb:hover {
|
| 34 |
+
background: rgba(0, 0, 0, 0.25);
|
| 35 |
+
}
|
| 36 |
+
|
dashboard/app/layout.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
import { AuthProvider } from "./lib/auth";
|
| 5 |
+
|
| 6 |
+
const geistSans = Geist({
|
| 7 |
+
variable: "--font-geist-sans",
|
| 8 |
+
subsets: ["latin"],
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
const geistMono = Geist_Mono({
|
| 12 |
+
variable: "--font-geist-mono",
|
| 13 |
+
subsets: ["latin"],
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
export const metadata: Metadata = {
|
| 17 |
+
title: "AgriFlow Dashboard",
|
| 18 |
+
description: "Surplus-defisit pangan Jawa Timur · matching engine live",
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
export default function RootLayout({
|
| 22 |
+
children,
|
| 23 |
+
}: Readonly<{
|
| 24 |
+
children: React.ReactNode;
|
| 25 |
+
}>) {
|
| 26 |
+
return (
|
| 27 |
+
<html
|
| 28 |
+
lang="en"
|
| 29 |
+
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
| 30 |
+
>
|
| 31 |
+
<body className="min-h-full flex flex-col">
|
| 32 |
+
<AuthProvider>{children}</AuthProvider>
|
| 33 |
+
</body>
|
| 34 |
+
</html>
|
| 35 |
+
);
|
| 36 |
+
}
|
dashboard/app/lib/api.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Typed thin wrapper around the FastAPI dashboard endpoints.
|
| 2 |
+
|
| 3 |
+
import { getSupabase } from "./supabase";
|
| 4 |
+
|
| 5 |
+
const API_BASE =
|
| 6 |
+
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") ||
|
| 7 |
+
"http://localhost:8000";
|
| 8 |
+
|
| 9 |
+
export type Commodity = { code: string; nama: string };
|
| 10 |
+
|
| 11 |
+
export type BillingStatus = {
|
| 12 |
+
plan: "FREE" | "PRO";
|
| 13 |
+
is_pro: boolean;
|
| 14 |
+
expires_at: string | null;
|
| 15 |
+
used_today: number;
|
| 16 |
+
limit: number;
|
| 17 |
+
remaining: number; // -1 means unlimited (PRO)
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export type Kabupaten = {
|
| 21 |
+
id: string;
|
| 22 |
+
nama: string;
|
| 23 |
+
lat: number;
|
| 24 |
+
lng: number;
|
| 25 |
+
tier: string;
|
| 26 |
+
ipm: number;
|
| 27 |
+
population: number;
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
export type SurplusDeficitRow = {
|
| 31 |
+
kab_id: string;
|
| 32 |
+
kab_nama: string;
|
| 33 |
+
lat: number;
|
| 34 |
+
lng: number;
|
| 35 |
+
tier: string;
|
| 36 |
+
role: "surplus" | "deficit";
|
| 37 |
+
volume_tons: number;
|
| 38 |
+
price_per_kg: number;
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
export type SurplusDeficitResponse = {
|
| 42 |
+
commodity: { code: string; nama: string };
|
| 43 |
+
rows: SurplusDeficitRow[];
|
| 44 |
+
totals: { surplus_tons: number; deficit_tons: number; balance_tons: number };
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
export type Match = {
|
| 48 |
+
surplus: {
|
| 49 |
+
kab_id: string;
|
| 50 |
+
kab_nama: string;
|
| 51 |
+
lat: number;
|
| 52 |
+
lng: number;
|
| 53 |
+
price_per_kg: number;
|
| 54 |
+
};
|
| 55 |
+
deficit: {
|
| 56 |
+
kab_id: string;
|
| 57 |
+
kab_nama: string;
|
| 58 |
+
lat: number;
|
| 59 |
+
lng: number;
|
| 60 |
+
price_per_kg: number;
|
| 61 |
+
};
|
| 62 |
+
commodity_code: string;
|
| 63 |
+
commodity_nama: string;
|
| 64 |
+
matched_volume_tons: number;
|
| 65 |
+
distance_km: number;
|
| 66 |
+
final_score: number;
|
| 67 |
+
confidence: string;
|
| 68 |
+
flags: string[];
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
export type MatchesResponse = { count: number; matches: Match[] };
|
| 72 |
+
|
| 73 |
+
// ---------------------------------------------------------------------------
|
| 74 |
+
// Forecast types
|
| 75 |
+
// ---------------------------------------------------------------------------
|
| 76 |
+
|
| 77 |
+
export type ForecastPoint = {
|
| 78 |
+
date: string; // ISO 8601
|
| 79 |
+
point: number; // IDR/kg
|
| 80 |
+
p10: number;
|
| 81 |
+
p90: number;
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
export type ForecastResponse = {
|
| 85 |
+
commodity_code: string;
|
| 86 |
+
city_id: string;
|
| 87 |
+
city_name: string;
|
| 88 |
+
method: string; // "timesfm_2.0" | "seasonal_naive_baseline"
|
| 89 |
+
generated_at: string;
|
| 90 |
+
horizon_days: number;
|
| 91 |
+
history_end_date: string;
|
| 92 |
+
forecasts: ForecastPoint[];
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
// ---------------------------------------------------------------------------
|
| 96 |
+
// Anomaly types
|
| 97 |
+
// ---------------------------------------------------------------------------
|
| 98 |
+
|
| 99 |
+
export type AnomalyRecord = {
|
| 100 |
+
date: string; // ISO 8601
|
| 101 |
+
price: number; // IDR/kg
|
| 102 |
+
rolling_median: number;
|
| 103 |
+
deviation_pct: number; // signed; positive = spike
|
| 104 |
+
type: "SPIKE" | "DROP";
|
| 105 |
+
score: number;
|
| 106 |
+
commodity_code: string;
|
| 107 |
+
city_id: string;
|
| 108 |
+
city_name: string;
|
| 109 |
+
persistent: boolean;
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
export type AnomaliesResponse = {
|
| 113 |
+
count: number;
|
| 114 |
+
method: string;
|
| 115 |
+
anomalies: AnomalyRecord[];
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
// ---------------------------------------------------------------------------
|
| 119 |
+
// Fetch helper + API object
|
| 120 |
+
// ---------------------------------------------------------------------------
|
| 121 |
+
|
| 122 |
+
// Thrown when the API rejects our token. Callers can catch this specifically
|
| 123 |
+
// to send the user back to /login instead of showing a generic error.
|
| 124 |
+
export class UnauthorizedError extends Error {
|
| 125 |
+
constructor(path: string) {
|
| 126 |
+
super(`${path} requires sign-in`);
|
| 127 |
+
this.name = "UnauthorizedError";
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
async function fetchJson<T>(path: string): Promise<T> {
|
| 132 |
+
const headers: Record<string, string> = {};
|
| 133 |
+
|
| 134 |
+
// Attach the Supabase access token when there is a session. getSession()
|
| 135 |
+
// refreshes it if it is close to expiry, so a tab left open overnight sends
|
| 136 |
+
// a live token rather than a stale one.
|
| 137 |
+
const supabase = getSupabase();
|
| 138 |
+
if (supabase) {
|
| 139 |
+
const { data } = await supabase.auth.getSession();
|
| 140 |
+
const token = data.session?.access_token;
|
| 141 |
+
if (token) headers.Authorization = `Bearer ${token}`;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
const r = await fetch(`${API_BASE}${path}`, { cache: "no-store", headers });
|
| 145 |
+
if (r.status === 401) throw new UnauthorizedError(path);
|
| 146 |
+
if (!r.ok) throw new Error(`${path} failed: ${r.status}`);
|
| 147 |
+
return r.json() as Promise<T>;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
export const api = {
|
| 151 |
+
commodities: () => fetchJson<Commodity[]>("/api/v1/commodities"),
|
| 152 |
+
kabupaten: () => fetchJson<Kabupaten[]>("/api/v1/kabupaten"),
|
| 153 |
+
surplusDeficit: (commodity: string) =>
|
| 154 |
+
fetchJson<SurplusDeficitResponse>(
|
| 155 |
+
`/api/v1/surplus-deficit?commodity=${encodeURIComponent(commodity)}`,
|
| 156 |
+
),
|
| 157 |
+
matches: (params: { commodity?: string; kab_id?: string; limit?: number }) => {
|
| 158 |
+
const q = new URLSearchParams();
|
| 159 |
+
if (params.commodity) q.set("commodity", params.commodity);
|
| 160 |
+
if (params.kab_id) q.set("kab_id", params.kab_id);
|
| 161 |
+
if (params.limit) q.set("limit", String(params.limit));
|
| 162 |
+
return fetchJson<MatchesResponse>(`/api/v1/matches?${q.toString()}`);
|
| 163 |
+
},
|
| 164 |
+
forecast: (params: { commodity: string; city: string }) =>
|
| 165 |
+
fetchJson<ForecastResponse>(
|
| 166 |
+
`/api/v1/forecast?commodity=${encodeURIComponent(params.commodity)}&city=${encodeURIComponent(params.city)}`,
|
| 167 |
+
),
|
| 168 |
+
anomalies: (params: {
|
| 169 |
+
commodity?: string;
|
| 170 |
+
city?: string;
|
| 171 |
+
limit?: number;
|
| 172 |
+
since?: string;
|
| 173 |
+
}) => {
|
| 174 |
+
const q = new URLSearchParams();
|
| 175 |
+
if (params.commodity) q.set("commodity", params.commodity);
|
| 176 |
+
if (params.city) q.set("city", params.city);
|
| 177 |
+
if (params.limit) q.set("limit", String(params.limit));
|
| 178 |
+
if (params.since) q.set("since", params.since);
|
| 179 |
+
return fetchJson<AnomaliesResponse>(`/api/v1/anomalies?${q.toString()}`);
|
| 180 |
+
},
|
| 181 |
+
// Plan + remaining free-tier quota for a WhatsApp number. The API hashes the
|
| 182 |
+
// number server-side; it is never stored in raw form.
|
| 183 |
+
billingStatus: (phone: string) =>
|
| 184 |
+
fetchJson<BillingStatus>(
|
| 185 |
+
`/billing/status?phone=${encodeURIComponent(phone)}`,
|
| 186 |
+
),
|
| 187 |
+
};
|
dashboard/app/lib/auth.tsx
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
// Auth context for the dashboard.
|
| 4 |
+
//
|
| 5 |
+
// Wraps Supabase Auth in a shape the UI can consume without knowing whether
|
| 6 |
+
// Supabase is configured at all. When it is not (the offline demo), `configured`
|
| 7 |
+
// is false, `user` stays null, and the sign-in calls return a clear error rather
|
| 8 |
+
// than throwing — the public map keeps working either way.
|
| 9 |
+
|
| 10 |
+
import {
|
| 11 |
+
createContext, useCallback, useContext, useEffect, useMemo, useState,
|
| 12 |
+
} from "react";
|
| 13 |
+
import type { Session, User } from "@supabase/supabase-js";
|
| 14 |
+
import { getSupabase, isAuthConfigured } from "./supabase";
|
| 15 |
+
import {
|
| 16 |
+
credentialsMatch, currentDevUser, enterDev, exitDev, DEV_LOGIN_ENABLED,
|
| 17 |
+
} from "./devauth";
|
| 18 |
+
import { exitGuest } from "./guest";
|
| 19 |
+
|
| 20 |
+
// Minimal object shaped enough for the UI (AccountMenu and /account read only
|
| 21 |
+
// `email`). Used to represent a dev-login session, which has no real Supabase
|
| 22 |
+
// User behind it. Never sent to the backend.
|
| 23 |
+
function makeDevUser(email: string): User {
|
| 24 |
+
return { id: "dev-admin", email, aud: "dev", app_metadata: {}, user_metadata: {},
|
| 25 |
+
created_at: "" } as unknown as User;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
type AuthResult = { error: string | null };
|
| 29 |
+
|
| 30 |
+
type AuthContextValue = {
|
| 31 |
+
user: User | null;
|
| 32 |
+
session: Session | null;
|
| 33 |
+
loading: boolean;
|
| 34 |
+
configured: boolean;
|
| 35 |
+
signIn: (email: string, password: string) => Promise<AuthResult>;
|
| 36 |
+
signUp: (email: string, password: string) => Promise<AuthResult>;
|
| 37 |
+
signOut: () => Promise<void>;
|
| 38 |
+
requestPasswordReset: (email: string) => Promise<AuthResult>;
|
| 39 |
+
updatePassword: (newPassword: string) => Promise<AuthResult>;
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
const NOT_CONFIGURED =
|
| 43 |
+
"Login belum aktif di lingkungan ini. Atur NEXT_PUBLIC_SUPABASE_URL dan NEXT_PUBLIC_SUPABASE_ANON_KEY.";
|
| 44 |
+
|
| 45 |
+
const AuthContext = createContext<AuthContextValue | null>(null);
|
| 46 |
+
|
| 47 |
+
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
| 48 |
+
const configured = isAuthConfigured();
|
| 49 |
+
const [session, setSession] = useState<Session | null>(null);
|
| 50 |
+
const [devUser, setDevUser] = useState<User | null>(null);
|
| 51 |
+
const [loading, setLoading] = useState(configured);
|
| 52 |
+
|
| 53 |
+
// Restore a dev-login session from its cookie on mount. Client-only, and a
|
| 54 |
+
// no-op unless NEXT_PUBLIC_DEV_LOGIN is on, so production carries no trace.
|
| 55 |
+
useEffect(() => {
|
| 56 |
+
const email = currentDevUser();
|
| 57 |
+
if (email) setDevUser(makeDevUser(email));
|
| 58 |
+
}, []);
|
| 59 |
+
|
| 60 |
+
useEffect(() => {
|
| 61 |
+
const supabase = getSupabase();
|
| 62 |
+
if (!supabase) {
|
| 63 |
+
setLoading(false);
|
| 64 |
+
return;
|
| 65 |
+
}
|
| 66 |
+
let active = true;
|
| 67 |
+
|
| 68 |
+
supabase.auth.getSession().then(({ data }) => {
|
| 69 |
+
if (!active) return;
|
| 70 |
+
setSession(data.session);
|
| 71 |
+
setLoading(false);
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
// Keeps this tab in sync with sign-in/out that happened elsewhere,
|
| 75 |
+
// including token refreshes.
|
| 76 |
+
const { data: sub } = supabase.auth.onAuthStateChange((_event, next) => {
|
| 77 |
+
setSession(next);
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
return () => {
|
| 81 |
+
active = false;
|
| 82 |
+
sub.subscription.unsubscribe();
|
| 83 |
+
};
|
| 84 |
+
}, []);
|
| 85 |
+
|
| 86 |
+
const signIn = useCallback(async (email: string, password: string) => {
|
| 87 |
+
// Dev-login short-circuit, checked before Supabase. credentialsMatch is
|
| 88 |
+
// hard-false unless NEXT_PUBLIC_DEV_LOGIN is on, so this branch does not
|
| 89 |
+
// exist in a production build.
|
| 90 |
+
if (credentialsMatch(email, password)) {
|
| 91 |
+
enterDev(email);
|
| 92 |
+
setDevUser(makeDevUser(email));
|
| 93 |
+
return { error: null };
|
| 94 |
+
}
|
| 95 |
+
const supabase = getSupabase();
|
| 96 |
+
if (!supabase) return { error: NOT_CONFIGURED };
|
| 97 |
+
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
| 98 |
+
return { error: error?.message ?? null };
|
| 99 |
+
}, []);
|
| 100 |
+
|
| 101 |
+
const signUp = useCallback(async (email: string, password: string) => {
|
| 102 |
+
const supabase = getSupabase();
|
| 103 |
+
if (!supabase) return { error: NOT_CONFIGURED };
|
| 104 |
+
const { error } = await supabase.auth.signUp({ email, password });
|
| 105 |
+
return { error: error?.message ?? null };
|
| 106 |
+
}, []);
|
| 107 |
+
|
| 108 |
+
const signOut = useCallback(async () => {
|
| 109 |
+
await getSupabase()?.auth.signOut();
|
| 110 |
+
// Clear every way a visitor could be "in" so sign-out is complete.
|
| 111 |
+
exitDev();
|
| 112 |
+
exitGuest();
|
| 113 |
+
setDevUser(null);
|
| 114 |
+
setSession(null);
|
| 115 |
+
}, []);
|
| 116 |
+
|
| 117 |
+
// Step 1 of password recovery: ask Supabase to email a link. The link
|
| 118 |
+
// lands the visitor back on /reset-password?code=... . redirectTo must be
|
| 119 |
+
// present in the Supabase project's Authentication > URL Configuration
|
| 120 |
+
// allow-list, or Supabase silently falls back to the Site URL instead —
|
| 121 |
+
// see docs/DEPLOY_AUTH.md.
|
| 122 |
+
const requestPasswordReset = useCallback(async (email: string) => {
|
| 123 |
+
const supabase = getSupabase();
|
| 124 |
+
if (!supabase) return { error: NOT_CONFIGURED };
|
| 125 |
+
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
| 126 |
+
redirectTo: `${window.location.origin}/reset-password`,
|
| 127 |
+
});
|
| 128 |
+
return { error: error?.message ?? null };
|
| 129 |
+
}, []);
|
| 130 |
+
|
| 131 |
+
// Step 2: called from /reset-password once the recovery link has been
|
| 132 |
+
// exchanged for a session (see app/lib/supabase.ts — the browser client's
|
| 133 |
+
// detectSessionInUrl does that exchange automatically on load). updateUser
|
| 134 |
+
// works against whatever session is currently active, recovery or normal.
|
| 135 |
+
const updatePassword = useCallback(async (newPassword: string) => {
|
| 136 |
+
const supabase = getSupabase();
|
| 137 |
+
if (!supabase) return { error: NOT_CONFIGURED };
|
| 138 |
+
const { error } = await supabase.auth.updateUser({ password: newPassword });
|
| 139 |
+
return { error: error?.message ?? null };
|
| 140 |
+
}, []);
|
| 141 |
+
|
| 142 |
+
const value = useMemo<AuthContextValue>(
|
| 143 |
+
() => ({
|
| 144 |
+
// A real Supabase session wins; the dev user is the fallback identity.
|
| 145 |
+
user: session?.user ?? devUser,
|
| 146 |
+
session,
|
| 147 |
+
loading,
|
| 148 |
+
configured: configured || DEV_LOGIN_ENABLED,
|
| 149 |
+
signIn,
|
| 150 |
+
signUp,
|
| 151 |
+
signOut,
|
| 152 |
+
requestPasswordReset,
|
| 153 |
+
updatePassword,
|
| 154 |
+
}),
|
| 155 |
+
[session, devUser, loading, configured, signIn, signUp, signOut,
|
| 156 |
+
requestPasswordReset, updatePassword],
|
| 157 |
+
);
|
| 158 |
+
|
| 159 |
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
export function useAuth(): AuthContextValue {
|
| 163 |
+
const ctx = useContext(AuthContext);
|
| 164 |
+
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
|
| 165 |
+
return ctx;
|
| 166 |
+
}
|
dashboard/app/lib/devauth.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Development-only login (email/password = admin/admin by default).
|
| 2 |
+
//
|
| 3 |
+
// ⚠ THIS IS A DEV CONVENIENCE, NOT REAL AUTH. It exists so the signed-in
|
| 4 |
+
// experience can be tested before a Supabase project is wired up. It accepts a
|
| 5 |
+
// hardcoded credential and issues a client-side cookie — it does NOT verify
|
| 6 |
+
// anything against a server.
|
| 7 |
+
//
|
| 8 |
+
// PRODUCTION SAFETY
|
| 9 |
+
// -----------------
|
| 10 |
+
// The whole path is gated on NEXT_PUBLIC_DEV_LOGIN === "true", which is set
|
| 11 |
+
// ONLY in the gitignored .env.local. It is deliberately absent from
|
| 12 |
+
// .env.example and must never be set in the Vercel production environment.
|
| 13 |
+
// NEXT_PUBLIC_* values are inlined at build time, so a production build made
|
| 14 |
+
// without the flag has this code permanently disabled — DEV_LOGIN_ENABLED is a
|
| 15 |
+
// compile-time false and the dead branch cannot be reached.
|
| 16 |
+
//
|
| 17 |
+
// Even if someone did enable it in production, the blast radius is small: the
|
| 18 |
+
// dev cookie only gets a visitor past the page-routing funnel to the map, which
|
| 19 |
+
// serves public government data. It does NOT authenticate against the backend
|
| 20 |
+
// API — token-gated endpoints (subscriber/billing) verify a real Supabase JWT,
|
| 21 |
+
// which this cookie is not. So a leaked dev login cannot read anyone's data.
|
| 22 |
+
|
| 23 |
+
export const DEV_LOGIN_ENABLED = process.env.NEXT_PUBLIC_DEV_LOGIN === "true";
|
| 24 |
+
export const DEV_EMAIL = process.env.NEXT_PUBLIC_DEV_EMAIL ?? "admin";
|
| 25 |
+
export const DEV_PASSWORD = process.env.NEXT_PUBLIC_DEV_PASSWORD ?? "admin";
|
| 26 |
+
|
| 27 |
+
export const DEV_COOKIE = "agriflow_dev"; // keep in sync with proxy.ts
|
| 28 |
+
|
| 29 |
+
const DEV_MAX_AGE = 12 * 60 * 60; // 12h, matches the guest session
|
| 30 |
+
|
| 31 |
+
export function credentialsMatch(email: string, password: string): boolean {
|
| 32 |
+
return DEV_LOGIN_ENABLED && email === DEV_EMAIL && password === DEV_PASSWORD;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function enterDev(email: string): void {
|
| 36 |
+
document.cookie =
|
| 37 |
+
`${DEV_COOKIE}=${encodeURIComponent(email)}; path=/; max-age=${DEV_MAX_AGE}; SameSite=Lax`;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export function exitDev(): void {
|
| 41 |
+
document.cookie = `${DEV_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function currentDevUser(): string | null {
|
| 45 |
+
if (typeof document === "undefined" || !DEV_LOGIN_ENABLED) return null;
|
| 46 |
+
const hit = document.cookie
|
| 47 |
+
.split("; ")
|
| 48 |
+
.find((c) => c.startsWith(`${DEV_COOKIE}=`));
|
| 49 |
+
return hit ? decodeURIComponent(hit.slice(DEV_COOKIE.length + 1)) : null;
|
| 50 |
+
}
|
dashboard/app/lib/guest.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Guest ("judge") access.
|
| 2 |
+
//
|
| 3 |
+
// The site is login-first: proxy.ts funnels every page to /login until you are
|
| 4 |
+
// signed in. A guest cookie is the deliberate bypass so hackathon judges can
|
| 5 |
+
// enter the dashboard without creating an account.
|
| 6 |
+
//
|
| 7 |
+
// THIS IS A UX FUNNEL, NOT A SECURITY BOUNDARY. The cookie is set client-side,
|
| 8 |
+
// is not httpOnly, and anyone could set it by hand. That is acceptable because
|
| 9 |
+
// the map runs on public government reference data, and the things that
|
| 10 |
+
// actually need protecting (subscriber/billing data) are guarded server-side
|
| 11 |
+
// by JWT verification in the FastAPI layer, not by this cookie. Do not gate
|
| 12 |
+
// anything sensitive on guest state.
|
| 13 |
+
|
| 14 |
+
export const GUEST_COOKIE = "agriflow_guest";
|
| 15 |
+
|
| 16 |
+
// 12 hours: long enough for a judging session, short enough that a shared
|
| 17 |
+
// machine does not stay "logged in as guest" indefinitely.
|
| 18 |
+
const GUEST_MAX_AGE = 12 * 60 * 60;
|
| 19 |
+
|
| 20 |
+
export function enterGuest(): void {
|
| 21 |
+
document.cookie =
|
| 22 |
+
`${GUEST_COOKIE}=1; path=/; max-age=${GUEST_MAX_AGE}; SameSite=Lax`;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export function exitGuest(): void {
|
| 26 |
+
document.cookie = `${GUEST_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export function isGuest(): boolean {
|
| 30 |
+
if (typeof document === "undefined") return false;
|
| 31 |
+
return document.cookie
|
| 32 |
+
.split("; ")
|
| 33 |
+
.some((c) => c === `${GUEST_COOKIE}=1`);
|
| 34 |
+
}
|
dashboard/app/lib/supabase.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Supabase browser client for dashboard auth.
|
| 2 |
+
//
|
| 3 |
+
// Auth is OPTIONAL by design. The public map is the hackathon demo surface and
|
| 4 |
+
// must render on a fresh clone with no Supabase project attached, so this
|
| 5 |
+
// module returns null when the environment is not configured and every caller
|
| 6 |
+
// treats that as "auth unavailable" rather than throwing. Check isAuthConfigured()
|
| 7 |
+
// before offering a sign-in affordance.
|
| 8 |
+
//
|
| 9 |
+
// WHY @supabase/ssr AND NOT createClient
|
| 10 |
+
// --------------------------------------
|
| 11 |
+
// createClient() from supabase-js keeps the session in localStorage, which the
|
| 12 |
+
// server cannot read — proxy.ts would have no way to see whether a visitor is
|
| 13 |
+
// signed in. createBrowserClient() from @supabase/ssr stores the session in
|
| 14 |
+
// cookies instead, so the same session is visible to both the browser and the
|
| 15 |
+
// server. That is what makes server-side route protection possible at all.
|
| 16 |
+
|
| 17 |
+
import { createBrowserClient } from "@supabase/ssr";
|
| 18 |
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
| 19 |
+
|
| 20 |
+
const URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
| 21 |
+
const ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
| 22 |
+
|
| 23 |
+
export function isAuthConfigured(): boolean {
|
| 24 |
+
return Boolean(URL && ANON_KEY);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
let client: SupabaseClient | null = null;
|
| 28 |
+
|
| 29 |
+
export function getSupabase(): SupabaseClient | null {
|
| 30 |
+
if (!isAuthConfigured()) return null;
|
| 31 |
+
// Created lazily and memoized: instantiating per render would drop the
|
| 32 |
+
// in-memory session and log the user out on every navigation.
|
| 33 |
+
if (!client) {
|
| 34 |
+
client = createBrowserClient(URL!, ANON_KEY!);
|
| 35 |
+
}
|
| 36 |
+
return client;
|
| 37 |
+
}
|
dashboard/app/login/LoginForm.tsx
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { useRouter, useSearchParams } from "next/navigation";
|
| 6 |
+
import { useAuth } from "../lib/auth";
|
| 7 |
+
import { enterGuest } from "../lib/guest";
|
| 8 |
+
import { DEV_LOGIN_ENABLED, DEV_EMAIL, DEV_PASSWORD } from "../lib/devauth";
|
| 9 |
+
|
| 10 |
+
type Mode = "signin" | "signup";
|
| 11 |
+
|
| 12 |
+
export default function LoginForm() {
|
| 13 |
+
const { signIn, signUp, configured, user } = useAuth();
|
| 14 |
+
const router = useRouter();
|
| 15 |
+
const searchParams = useSearchParams();
|
| 16 |
+
|
| 17 |
+
// proxy.ts appends ?next= when it funnels someone off a gated page.
|
| 18 |
+
// Only relative paths are honoured — accepting an absolute URL here would
|
| 19 |
+
// make this an open redirect an attacker could point at their own site.
|
| 20 |
+
// Default to the map (/), which is the app's home, not /account.
|
| 21 |
+
const rawNext = searchParams.get("next");
|
| 22 |
+
const nextPath =
|
| 23 |
+
rawNext && rawNext.startsWith("/") && !rawNext.startsWith("//")
|
| 24 |
+
? rawNext
|
| 25 |
+
: "/";
|
| 26 |
+
|
| 27 |
+
const [mode, setMode] = useState<Mode>("signin");
|
| 28 |
+
const [email, setEmail] = useState("");
|
| 29 |
+
const [password, setPassword] = useState("");
|
| 30 |
+
const [error, setError] = useState<string | null>(null);
|
| 31 |
+
const [notice, setNotice] = useState<string | null>(null);
|
| 32 |
+
const [busy, setBusy] = useState(false);
|
| 33 |
+
|
| 34 |
+
// Send an already-signed-in visitor away from the login page. This runs in an
|
| 35 |
+
// effect, not in render: calling router.replace() during render triggers
|
| 36 |
+
// React's "Cannot update a component (Router) while rendering a different
|
| 37 |
+
// component (LoginForm)" warning (a hard error under stricter React modes).
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
if (user) router.replace(nextPath);
|
| 40 |
+
}, [user, nextPath, router]);
|
| 41 |
+
|
| 42 |
+
if (user) {
|
| 43 |
+
// Redirect is in flight (handled by the effect above); render nothing.
|
| 44 |
+
return null;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
async function onSubmit(e: React.FormEvent) {
|
| 48 |
+
e.preventDefault();
|
| 49 |
+
setError(null);
|
| 50 |
+
setNotice(null);
|
| 51 |
+
setBusy(true);
|
| 52 |
+
const fn = mode === "signin" ? signIn : signUp;
|
| 53 |
+
const { error } = await fn(email, password);
|
| 54 |
+
setBusy(false);
|
| 55 |
+
|
| 56 |
+
if (error) {
|
| 57 |
+
setError(error);
|
| 58 |
+
return;
|
| 59 |
+
}
|
| 60 |
+
if (mode === "signup") {
|
| 61 |
+
// Supabase may require email confirmation depending on project settings,
|
| 62 |
+
// so we cannot assume a session exists yet.
|
| 63 |
+
setNotice(
|
| 64 |
+
"Akun dibuat. Jika diminta, cek email Anda untuk tautan konfirmasi, lalu masuk.",
|
| 65 |
+
);
|
| 66 |
+
setMode("signin");
|
| 67 |
+
return;
|
| 68 |
+
}
|
| 69 |
+
// Full navigation (not router.push) so the proxy re-runs and sees the
|
| 70 |
+
// freshly-set session/dev cookie, letting the destination through.
|
| 71 |
+
window.location.assign(nextPath);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function enterAsGuest() {
|
| 75 |
+
enterGuest();
|
| 76 |
+
// Full navigation (not router.push) so the proxy re-runs and now sees the
|
| 77 |
+
// guest cookie, letting the destination page through.
|
| 78 |
+
window.location.assign(nextPath);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
return (
|
| 82 |
+
<main className="flex-1 flex items-center justify-center p-6 bg-slate-50">
|
| 83 |
+
<div className="w-full max-w-sm">
|
| 84 |
+
<h1 className="text-2xl font-semibold text-slate-900">
|
| 85 |
+
{mode === "signin" ? "Masuk ke AgriFlow" : "Buat akun AgriFlow"}
|
| 86 |
+
</h1>
|
| 87 |
+
<p className="mt-1 text-sm text-slate-500">
|
| 88 |
+
Untuk pelanggan dinas, TPID, dan mitra data.
|
| 89 |
+
</p>
|
| 90 |
+
|
| 91 |
+
{DEV_LOGIN_ENABLED && (
|
| 92 |
+
<p className="mt-4 rounded-lg bg-sky-50 border border-sky-200 p-3 text-sm text-sky-900">
|
| 93 |
+
Mode pengembangan aktif. Login uji: <b>{DEV_EMAIL}</b> / <b>{DEV_PASSWORD}</b>.
|
| 94 |
+
</p>
|
| 95 |
+
)}
|
| 96 |
+
|
| 97 |
+
{!configured && (
|
| 98 |
+
<p className="mt-4 rounded-lg bg-sky-50 border border-sky-200 p-3 text-sm text-sky-900">
|
| 99 |
+
Login akun untuk dinas, TPID, dan mitra data akan segera hadir. Untuk
|
| 100 |
+
meninjau peta dan rekomendasi sekarang, silakan pilih <b>Masuk sebagai
|
| 101 |
+
Tamu</b> di bawah.
|
| 102 |
+
</p>
|
| 103 |
+
)}
|
| 104 |
+
|
| 105 |
+
<form onSubmit={onSubmit} className="mt-6 space-y-4">
|
| 106 |
+
<div>
|
| 107 |
+
<label htmlFor="email" className="block text-sm font-medium text-slate-700">
|
| 108 |
+
Email
|
| 109 |
+
</label>
|
| 110 |
+
<input
|
| 111 |
+
id="email"
|
| 112 |
+
// Dev login uses a non-email username ("admin"), which type=email
|
| 113 |
+
// would reject before submit. Relax to text only when dev login
|
| 114 |
+
// is on; production keeps real email validation.
|
| 115 |
+
type={DEV_LOGIN_ENABLED ? "text" : "email"}
|
| 116 |
+
required
|
| 117 |
+
autoComplete="email"
|
| 118 |
+
value={email}
|
| 119 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 120 |
+
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm
|
| 121 |
+
focus:border-emerald-600 focus:outline-none focus:ring-1 focus:ring-emerald-600"
|
| 122 |
+
/>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<div>
|
| 126 |
+
<div className="flex items-center justify-between">
|
| 127 |
+
<label htmlFor="password" className="block text-sm font-medium text-slate-700">
|
| 128 |
+
Kata sandi
|
| 129 |
+
</label>
|
| 130 |
+
{mode === "signin" && (
|
| 131 |
+
<Link
|
| 132 |
+
href={`/forgot-password${email ? `?email=${encodeURIComponent(email)}` : ""}`}
|
| 133 |
+
className="text-sm text-emerald-700 hover:underline"
|
| 134 |
+
>
|
| 135 |
+
Lupa kata sandi?
|
| 136 |
+
</Link>
|
| 137 |
+
)}
|
| 138 |
+
</div>
|
| 139 |
+
<input
|
| 140 |
+
id="password"
|
| 141 |
+
type="password"
|
| 142 |
+
required
|
| 143 |
+
// Enforce length only when CREATING a password (signup). On
|
| 144 |
+
// signin you are entering an existing one, so a length rule here
|
| 145 |
+
// is wrong, and it was also blocking the short dev-login password.
|
| 146 |
+
minLength={mode === "signup" ? 8 : undefined}
|
| 147 |
+
autoComplete={mode === "signin" ? "current-password" : "new-password"}
|
| 148 |
+
value={password}
|
| 149 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 150 |
+
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm
|
| 151 |
+
focus:border-emerald-600 focus:outline-none focus:ring-1 focus:ring-emerald-600"
|
| 152 |
+
/>
|
| 153 |
+
{mode === "signup" && (
|
| 154 |
+
<p className="mt-1 text-xs text-slate-500">Minimal 8 karakter.</p>
|
| 155 |
+
)}
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
{error && (
|
| 159 |
+
<p role="alert" className="rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-800">
|
| 160 |
+
{error}
|
| 161 |
+
</p>
|
| 162 |
+
)}
|
| 163 |
+
{notice && (
|
| 164 |
+
<p className="rounded-lg bg-emerald-50 border border-emerald-200 p-3 text-sm text-emerald-900">
|
| 165 |
+
{notice}
|
| 166 |
+
</p>
|
| 167 |
+
)}
|
| 168 |
+
|
| 169 |
+
<button
|
| 170 |
+
type="submit"
|
| 171 |
+
disabled={busy || !configured}
|
| 172 |
+
className="w-full rounded-lg bg-emerald-700 px-4 py-2.5 text-sm font-medium text-white
|
| 173 |
+
hover:bg-emerald-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 174 |
+
>
|
| 175 |
+
{busy ? "Memproses…" : mode === "signin" ? "Masuk" : "Daftar"}
|
| 176 |
+
</button>
|
| 177 |
+
</form>
|
| 178 |
+
|
| 179 |
+
<div className="mt-6 flex items-center gap-3">
|
| 180 |
+
<span className="h-px flex-1 bg-slate-200" />
|
| 181 |
+
<span className="text-xs text-slate-400">atau</span>
|
| 182 |
+
<span className="h-px flex-1 bg-slate-200" />
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
<button
|
| 186 |
+
type="button"
|
| 187 |
+
onClick={enterAsGuest}
|
| 188 |
+
className="mt-6 w-full rounded-lg border border-slate-300 px-4 py-2.5 text-sm
|
| 189 |
+
font-medium text-slate-700 hover:bg-slate-50"
|
| 190 |
+
>
|
| 191 |
+
Masuk sebagai Tamu (juri)
|
| 192 |
+
</button>
|
| 193 |
+
<p className="mt-2 text-center text-xs text-slate-400">
|
| 194 |
+
Akses peta & data untuk peninjauan, tanpa membuat akun.
|
| 195 |
+
</p>
|
| 196 |
+
|
| 197 |
+
<p className="mt-6 text-center text-sm text-slate-600">
|
| 198 |
+
{mode === "signin" ? "Belum punya akun? " : "Sudah punya akun? "}
|
| 199 |
+
<button
|
| 200 |
+
type="button"
|
| 201 |
+
onClick={() => {
|
| 202 |
+
setMode(mode === "signin" ? "signup" : "signin");
|
| 203 |
+
setError(null);
|
| 204 |
+
setNotice(null);
|
| 205 |
+
}}
|
| 206 |
+
className="font-medium text-emerald-700 hover:underline"
|
| 207 |
+
>
|
| 208 |
+
{mode === "signin" ? "Daftar" : "Masuk"}
|
| 209 |
+
</button>
|
| 210 |
+
</p>
|
| 211 |
+
</div>
|
| 212 |
+
</main>
|
| 213 |
+
);
|
| 214 |
+
}
|
dashboard/app/login/page.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Suspense } from "react";
|
| 2 |
+
import LoginForm from "./LoginForm";
|
| 3 |
+
|
| 4 |
+
// useSearchParams() forces client-side rendering for whatever subtree reads it,
|
| 5 |
+
// so the form lives behind a Suspense boundary. Without this the /login route
|
| 6 |
+
// cannot be prerendered and the build fails.
|
| 7 |
+
export default function LoginPage() {
|
| 8 |
+
return (
|
| 9 |
+
<Suspense
|
| 10 |
+
fallback={
|
| 11 |
+
<main className="flex-1 grid place-items-center text-slate-500">
|
| 12 |
+
Memuat…
|
| 13 |
+
</main>
|
| 14 |
+
}
|
| 15 |
+
>
|
| 16 |
+
<LoginForm />
|
| 17 |
+
</Suspense>
|
| 18 |
+
);
|
| 19 |
+
}
|
dashboard/app/page.tsx
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|