999 lines
52 KiB
Python
999 lines
52 KiB
Python
"""
|
||
utils/report_insights.py
|
||
=========================
|
||
Fungsi-fungsi PURE (tidak bergantung Streamlit / `st.*`) untuk menghasilkan
|
||
teks insight dari hasil peramalan & dekomposisi.
|
||
|
||
Ditaruh terpisah di `utils/` (bukan di dalam `views/detail_peramalan.py`)
|
||
supaya bisa dipakai bersama oleh:
|
||
- views/detail_peramalan.py -> render ke UI Streamlit (highlight box & tab Dekomposisi)
|
||
- utils/pdf_report.py -> render ke Laporan PDF
|
||
|
||
Dengan begitu, teks insight yang muncul di UI dan di PDF SELALU konsisten
|
||
-- tidak ada risiko dua tempat punya logika/kalimat yang beda.
|
||
|
||
Catatan format teks: string yang dikembalikan boleh mengandung tag ringan
|
||
ala HTML (`<b>...</b>`, `<br/>`) karena kompatibel baik dengan
|
||
`st.markdown(..., unsafe_allow_html=True)` maupun `reportlab.Paragraph`
|
||
(reportlab mendukung subset tag HTML termasuk <b> dan <br/>).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import datetime
|
||
from typing import Any
|
||
|
||
import pandas as pd
|
||
import numpy as np
|
||
|
||
|
||
from utils.forecast_helpers import generate_optimal_forecast, get_decomposition
|
||
|
||
HARI_URUTAN = ["Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu", "Minggu"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pola mingguan (dipakai untuk chart bar & insight)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def weekly_pattern(weekly_df: pd.DataFrame) -> pd.Series:
|
||
df = weekly_df.copy()
|
||
df["hari"] = df["ds"].dt.day_name().map({
|
||
"Monday": "Senin", "Tuesday": "Selasa", "Wednesday": "Rabu",
|
||
"Thursday": "Kamis", "Friday": "Jumat", "Saturday": "Sabtu", "Sunday": "Minggu",
|
||
})
|
||
return df.groupby("hari")["weekly"].mean().reindex(HARI_URUTAN)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ringkasan & insight utama (tab "Peramalan" / halaman 1 laporan PDF)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def compute_highlight_bullets(
|
||
product: str, unit: str, forecast: pd.DataFrame, summary: dict,
|
||
product_entry: dict[str, Any], start: datetime.date, end: datetime.date,
|
||
) -> tuple[list[str], str, bool]:
|
||
"""
|
||
Hitung poin-poin insight ringkasan peramalan.
|
||
|
||
Returns
|
||
-------
|
||
bullets : list[str] -- masing-masing 1 poin insight (siap dipakai
|
||
sebagai <li> di UI atau ListItem di PDF)
|
||
footer : str -- catatan asumsi model (ditampilkan terpisah)
|
||
is_event_period : bool -- True kalau ada event/hari libur yang
|
||
menyentuh periode ini (dipakai untuk warna box di UI)
|
||
"""
|
||
n_hari = summary["jumlah_hari"]
|
||
total_fmt = f"{summary['total']:,.0f}".replace(",", ".")
|
||
rata_fmt = f"{summary['rata_rata_harian']:.3f}".replace(".", ",")
|
||
|
||
scenario_name = product_entry["optimal_scenario"]
|
||
model = product_entry["scenarios"][scenario_name]["best_model"]
|
||
|
||
bullets: list[str] = []
|
||
|
||
# ---------- Pola mingguan ----------
|
||
"""
|
||
blok ini tugasnya mencari hari puncak (tertinggi) dan hari lembah (terendah) dari pola mingguan. Dan ya, kalau periode yang dipilih user kurang dari 7 hari, program tetap "diam-diam" mengambil data 1 minggu penuh untuk memastikan pola mingguan yang diambil representatif.
|
||
"""
|
||
decomposition = get_decomposition(forecast)
|
||
weekly_top_day, weekly_bottom_day = None, None
|
||
pola_mingguan = pd.Series(dtype=float)
|
||
if "weekly" in decomposition:
|
||
pola_mingguan = weekly_pattern(decomposition["weekly"]).dropna()
|
||
if len(pola_mingguan) < 7:
|
||
try:
|
||
week_start = start - datetime.timedelta(days=start.weekday())
|
||
week_end = week_start + datetime.timedelta(days=6)
|
||
full_week_fc, _, _ = generate_optimal_forecast(product_entry, str(week_start), str(week_end))
|
||
full_decomp = get_decomposition(full_week_fc)
|
||
if "weekly" in full_decomp:
|
||
pola_mingguan = weekly_pattern(full_decomp["weekly"]).dropna()
|
||
except Exception:
|
||
pass
|
||
"""
|
||
Kalau pola_mingguan ada isinya, DAN memang ada perbedaan nyata antara hari tertinggi & terendah (bukan semua hari sama rata), BARU tentukan siapa weekly_top_day dan weekly_bottom_day.
|
||
"""
|
||
if not pola_mingguan.empty and (pola_mingguan.max() - pola_mingguan.min()) > 1e-9:
|
||
weekly_top_day = pola_mingguan.idxmax()
|
||
weekly_bottom_day = pola_mingguan.idxmin()
|
||
|
||
# ---------- Event/hari libur yang menyentuh periode ----------
|
||
"""
|
||
1. Cek setiap hari libur yang dikenal model Prophet
|
||
2. Saring — cuma ambil yang rentang waktunya (window) bersinggungan dengan periode peramalan yang dipilih user
|
||
3. Buang duplikat (kalau event yang sama kebetulan tercatat lebih dari sekali)
|
||
4. Urutkan dari tanggal paling awal
|
||
|
||
Hasil akhirnya: events_in_period = daftar bersih event-event yang relevan untuk periode ini, siap dipakai buat nyusun kalimat narasi.
|
||
"""
|
||
events_in_period = []
|
||
holidays_df = getattr(model, "holidays", None)
|
||
if holidays_df is not None and not holidays_df.empty:
|
||
seen_events = set()
|
||
for _, row in holidays_df.iterrows():
|
||
ev_date = pd.to_datetime(row["ds"]).date()
|
||
ev_name_raw = str(row["holiday"])
|
||
lower_win = int(row["lower_window"]) if "lower_window" in row and not pd.isna(row["lower_window"]) else 0
|
||
upper_win = int(row["upper_window"]) if "upper_window" in row and not pd.isna(row["upper_window"]) else 0
|
||
win_start = ev_date + datetime.timedelta(days=lower_win)
|
||
win_end = ev_date + datetime.timedelta(days=upper_win)
|
||
|
||
if win_end < start or win_start > end:
|
||
continue
|
||
key = (ev_name_raw, ev_date)
|
||
if key in seen_events:
|
||
continue
|
||
seen_events.add(key)
|
||
events_in_period.append({
|
||
"name": ev_name_raw.replace("_", " ").title(),
|
||
"ev_date": ev_date,
|
||
"win_start": win_start,
|
||
"win_end": win_end,
|
||
})
|
||
events_in_period.sort(key=lambda e: e["ev_date"])
|
||
is_event_period = len(events_in_period) > 0
|
||
|
||
|
||
overall_lower = forecast["yhat_lower"].clip(lower=0).min()
|
||
overall_upper = forecast["yhat_upper"].max()
|
||
overall_lower_fmt = f"{overall_lower:,.0f}".replace(",", ".")
|
||
overall_upper_fmt = f"{overall_upper:,.0f}".replace(",", ".")
|
||
|
||
overall_yhatmin = forecast["yhat"].clip(lower=0).min()
|
||
overall_yhatmax = forecast["yhat"].max()
|
||
overall_yhatmin_fmt = f"{overall_yhatmin:,.0f}".replace(",", ".")
|
||
overall_yhatmax_fmt = f"{overall_yhatmax:,.0f}".replace(",", ".")
|
||
start_str = start.strftime("%d-%m-%Y")
|
||
end_str = end.strftime("%d-%m-%Y")
|
||
|
||
"""
|
||
khusus jika yang diramah itu 1 tanggal saja ya, hasil yang lihat itu apakah tanggal dengan permintaan tertinggi/terendah/normal dalam seminggu? ada event ga?
|
||
"""
|
||
if n_hari == 1:
|
||
the_date = start
|
||
the_dow = HARI_URUTAN[the_date.weekday()]
|
||
|
||
if weekly_top_day and the_dow == weekly_top_day:
|
||
weekly_txt = f" Hari <b>{the_dow}</b> merupakan hari dengan <b>permintaan tertinggi</b> dibandingkan hari lain berdasarkan pola mingguan."
|
||
hubungkata = " Ditambah lagi,"
|
||
elif weekly_bottom_day and the_dow == weekly_bottom_day:
|
||
weekly_txt = f" Hari <b>{the_dow}</b> merupakan hari dengan <b>permintaan terendah</b> dibandingkan hari lain berdasarkan pola mingguan."
|
||
hubungkata = " Meski begitu,"
|
||
else:
|
||
weekly_txt = f" Hari <b>{the_dow}</b> merupakan hari dengan <b>permintaan normal</b> berdasarkan pola mingguan."
|
||
hubungkata = " Selain itu,"
|
||
|
||
active_events_here = []
|
||
for ev in events_in_period:
|
||
if ev["win_start"] <= the_date <= ev["win_end"]:
|
||
selisih = (the_date - ev["ev_date"]).days
|
||
status_waktu = "Hari H" if selisih == 0 else (f"H{selisih}" if selisih < 0 else f"H+{selisih}")
|
||
active_events_here.append(f"{status_waktu} {ev['name']}")
|
||
|
||
event_txt = ""
|
||
if active_events_here:
|
||
event_txt = f" {hubungkata} tanggal ini bertepatan dengan <b>{', '.join(active_events_here)}</b>, yang berpotensi memengaruhi permintaan."
|
||
|
||
bullets.append(
|
||
f"Peramalan permintaan pada <b>{the_dow}, {start_str}</b> diperkirakan "
|
||
f"<b>{total_fmt} {unit}</b> (kisaran estimasi: <b>{overall_lower_fmt}-{overall_upper_fmt} {unit}</b>)."
|
||
f"{weekly_txt}{event_txt} Siapkan stok utama sebesar <b>{total_fmt} {unit}</b> dan pertimbangkan "
|
||
f"stok cadangan hingga <b>{overall_upper_fmt} {unit}</b> untuk mengantisipasi lonjakan mendadak."
|
||
)
|
||
|
||
# sebenarnya dari sini n > 1
|
||
else:
|
||
bullets.append(
|
||
f"Peramalan permintaan periode {start_str} - {end_str} berkisar antara "
|
||
f"<b>{overall_yhatmin_fmt} {unit} (terendah)</b> hingga <b>{overall_yhatmax_fmt} {unit} (tertinggi)</b>."
|
||
)
|
||
bullets.append(
|
||
f"Total peramalan permintaan <b>{n_hari} hari</b> tersebut: "
|
||
f"<b>{total_fmt} {unit}</b> (rata-rata {rata_fmt} {unit}/hari)."
|
||
)
|
||
"""
|
||
temukan dulu FAKTA (angka tertinggi & tanggalnya), baru cari SEBAB
|
||
(cocokkan ke event & pola mingguan yang sudah dihitung sebelumnya).
|
||
"""
|
||
if n_hari > 1:
|
||
rata_harian = summary["rata_rata_harian"]
|
||
|
||
# ---- Titik terendah ----
|
||
min_idx = forecast["yhat"].idxmin()
|
||
min_row = forecast.loc[min_idx]
|
||
|
||
min_ds = min_row["ds"].date()
|
||
min_yhat = min_row["yhat"]
|
||
min_dow = HARI_URUTAN[min_ds.weekday()]
|
||
|
||
min_ds_str = min_ds.strftime("%d-%m-%Y")
|
||
min_yhat_fmt = f"{min_yhat:,.0f}".replace(",", ".")
|
||
pct_low_diff = ((min_yhat / rata_harian) - 1) * 100 if rata_harian > 0 else 0 # Contoh: min_yhat = 80, rata_harian = 100 → (80/100 - 1) × 100 = -20 → artinya 20% di bawah rata-rata.
|
||
pct_low_fmt = f"{abs(pct_low_diff):.0f}".replace(".", ",")
|
||
|
||
low_sebab = ""
|
||
if weekly_bottom_day and min_dow == weekly_bottom_day:
|
||
low_sebab = (
|
||
f" Ini sejalan dengan pola mingguan di hari <b>{min_dow}</b>, yang biasanya "
|
||
"merupakan hari dengan permintaan rendah dibandingkan hari lain."
|
||
)
|
||
|
||
bullets.append(
|
||
f"Permintaan <b>TERENDAH</b> diperkirakan pada <b>{min_dow}, {min_ds_str}</b>: "
|
||
f"<b>{min_yhat_fmt} {unit}</b> sekitar <b>{pct_low_fmt}%</b> di bawah rata-rata periode ini."
|
||
f"{low_sebab} Bisa jadi waktu yang tepat untuk restock."
|
||
)
|
||
|
||
# ---- Titik tertinggi ----
|
||
max_idx = forecast["yhat"].idxmax()
|
||
max_row = forecast.loc[max_idx]
|
||
|
||
max_ds = max_row["ds"].date()
|
||
max_yhat = max_row["yhat"]
|
||
max_dow = HARI_URUTAN[max_ds.weekday()]
|
||
|
||
max_ds_str = max_ds.strftime("%d-%m-%Y")
|
||
max_yhat_fmt = f"{max_yhat:,.0f}".replace(",", ".")
|
||
pct_diff = ((max_yhat / rata_harian) - 1) * 100 if rata_harian > 0 else 0 # Contoh: max_yhat = 120, rata_harian = 100 → (120/100 - 1) × 100 = 20 → artinya 20% di atas rata-rata.
|
||
pct_fmt = f"{pct_diff:.0f}".replace(".", ",")
|
||
|
||
# ambil nama (ev["name"]) dari setiap event di events_in_period, TAPI hanya yang tanggal puncaknya (max_ds) jatuh di dalam rentang window event itu.
|
||
peak_event_names = {
|
||
ev["name"] for ev in events_in_period if ev["win_start"] <= max_ds <= ev["win_end"]
|
||
}
|
||
|
||
sebab_list = []
|
||
if peak_event_names: # Apakah tanggal PUNCAK ini (max_ds, hasil temuan sebelumnya (dalam periode yang ingin diramal)) kebetulan bertepatan dengan event apa saja?
|
||
sebab_list.append(f"efek event <b>{', '.join(sorted(peak_event_names))}</b>")
|
||
|
||
|
||
if weekly_top_day and max_dow == weekly_top_day: # Apakah HARI dari tanggal puncak ini (max_dow, misal 'Jumat') sama dengan HARI yang secara pola mingguan memang biasanya paling tinggi (weekly_top_day)?
|
||
sebab_list.append(
|
||
f"pola mingguan di hari <b>{max_dow}</b>, yang biasanya merupakan hari dengan "
|
||
"permintaan tinggi dibandingkan hari lain"
|
||
)
|
||
sebab_str = " & ".join(sebab_list)
|
||
|
||
peak_txt = (
|
||
f"Permintaan <b>TERTINGGI</b> diperkirakan pada <b>{max_dow}, {max_ds_str}</b>: "
|
||
f"<b>{max_yhat_fmt} {unit}</b> sekitar <b>{pct_fmt}%</b> di atas rata-rata periode ini."
|
||
)
|
||
if sebab_str:
|
||
peak_txt += f" Kenaikan ini dipengaruhi oleh {sebab_str}."
|
||
peak_txt += " Pastikan stok mendekati batas atas tersedia."
|
||
bullets.append(peak_txt)
|
||
|
||
# ---- Event lain dalam periode ----
|
||
# ada tanggal lain yang menjadi event tapi bukan puncak permintaan? (misal: H-1, H+1, H+2 dari event utama)
|
||
other_events = [ev for ev in events_in_period if ev["name"] not in peak_event_names]
|
||
if other_events:
|
||
nama_event_lain = ", ".join(sorted({ev["name"] for ev in other_events}))
|
||
bullets.append(
|
||
f"Periode ini terdeteksi efek event <b>{nama_event_lain}</b>. "
|
||
"Lihat grafik/tabel peramalan untuk detail tanggalnya."
|
||
)
|
||
|
||
footer = (
|
||
"Catatan: peramalan ini mengasumsikan stok tersedia & operasional toko berjalan normal "
|
||
"(tidak ada gangguan pasokan atau tutup mendadak). Kalau ada kendala operasional pada "
|
||
"tanggal tertentu, angka aktual bisa berbeda dari peramalan ini."
|
||
)
|
||
|
||
return bullets, footer, is_event_period
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Insight per komponen dekomposisi (tab "Dekomposisi" / halaman 2 laporan PDF)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def pattern_explanation(is_relative: bool) -> str:
|
||
if is_relative:
|
||
return (
|
||
"Nilai pola mingguan/tahunan/event di bawah ini merupakan <b>bobot penyesuaian relatif "
|
||
"(persentase)</b> terhadap tren dasar. Contoh: angka -0,08 berarti penjualan pada titik "
|
||
"tersebut cenderung turun ~8% dari tren utama."
|
||
)
|
||
return (
|
||
"Nilai pola mingguan/tahunan/event di bawah ini merupakan <b>penyesuaian jumlah unit "
|
||
"barang</b> terhadap tren dasar. Contoh: angka -0,08 berarti penjualan pada titik tersebut "
|
||
"berkurang sekitar 0,08 unit dari tren utama."
|
||
)
|
||
|
||
|
||
def how_to_read_decomposition_bullets(is_relative: bool) -> list[str]:
|
||
"""
|
||
2 poin "Cara Membaca Grafik Dekomposisi" -- SAMA persis dengan yang
|
||
ditampilkan di UI Streamlit (tab Dekomposisi): poin pertama tentang
|
||
tren dasar, poin kedua tentang arti angka pola mingguan/tahunan/event.
|
||
"""
|
||
return [
|
||
"<b>Tren dasar penjualan</b> merupakan angka perkiraan utama penjualan barang Anda.",
|
||
f"<b>Pola mingguan, tahunan, dan efek event</b> {_pattern_explanation_clause(is_relative)}",
|
||
]
|
||
|
||
|
||
def _pattern_explanation_clause(is_relative: bool) -> str:
|
||
"""Versi klausa (tanpa subjek kalimat) dari pattern_explanation, dipakai
|
||
sebagai sambungan poin ke-2 di how_to_read_decomposition_bullets()."""
|
||
if is_relative:
|
||
return (
|
||
"merupakan <b>bobot penyesuaian relatif (persentase)</b> terhadap tren. "
|
||
"Contoh: angka -0,08 berarti penjualan pada titik tersebut cenderung "
|
||
"<b>turun ~8%</b> dari tren utama."
|
||
)
|
||
return (
|
||
"merupakan <b>penyesuaian jumlah unit barang</b> terhadap tren. "
|
||
"Contoh: angka -0,08 berarti penjualan pada titik tersebut berkurang sekitar "
|
||
"<b>0,08 unit</b> dari tren utama."
|
||
)
|
||
|
||
def trend_insight_text(decomposition: dict[str, pd.DataFrame]) -> str:
|
||
if "trend" not in decomposition:
|
||
return "Tidak tersedia untuk skenario ini."
|
||
|
||
t_vals = decomposition["trend"]["trend"].values
|
||
n = len(t_vals)
|
||
if n < 2:
|
||
return (
|
||
"Periode ini hanya 1 hari, jadi arah tren dasar (naik/turun/stabil) belum bisa "
|
||
"dibandingkan (butuh minimal 2 hari)."
|
||
)
|
||
|
||
x = np.arange(n)
|
||
slope, intercept = np.polyfit(x, t_vals, 1)
|
||
total_change = slope * (n - 1)
|
||
EPS = 1e-4
|
||
if total_change > EPS:
|
||
return "Pertimbangkan untuk meningkatkan kapasitas stok secara bertahap untuk mengantisipasi pertumbuhan permintaan di masa mendatang."
|
||
elif total_change < -EPS:
|
||
return "Atur pembelian grosir secukupnya agar modal usaha tidak tertahan terlalu lama di gudang."
|
||
else:
|
||
return "Permintaan dasar cenderung stabil. Lakukan pembelian persediaan barang dengan jumlah reguler."
|
||
|
||
|
||
def weekly_insight_text(pola: pd.Series) -> str:
|
||
pola_valid = pola.dropna() # Pola mingguan yang kita pilih untuk diberikan insight, buang hari yang tidak ada datanya. Terus gimana yang dibuang, jadi sisanya ga informatif dong? OH TIDAK ini sedikit beda dengan compute_highlight_bullets yang maksa ambil 1 minggu. Disini kan dekomposisi jadi kita atur dengan banyak pengkondisian yang nangani, misal kosong, hanya 1 hari, lonjakan=jurang atau tidak.
|
||
if pola_valid.empty:
|
||
return "Tidak tersedia untuk skenario ini."
|
||
if len(pola_valid) == 1:
|
||
only_day = pola_valid.index[0]
|
||
return (
|
||
f"Periode ini cuma mencakup 1 hari (<b>{only_day}</b>), jadi belum bisa dibandingkan "
|
||
"dengan hari lain dalam seminggu."
|
||
)
|
||
peak_day = pola_valid.idxmax()
|
||
low_day = pola_valid.idxmin()
|
||
if peak_day == low_day:
|
||
return "Pada periode ini, pola mingguan relatif merata (tidak ada hari yang menonjol jauh lebih tinggi atau rendah dari yang lain)."
|
||
return (
|
||
f"Pastikan stok mencukupi menjelang hari <b>{peak_day}</b>. "
|
||
f"Jadwalkan pengisian/restock barang pada hari sepi (<b>{low_day}</b>)."
|
||
)
|
||
|
||
|
||
def _trend_direction(values) -> str: #
|
||
"""
|
||
ini untuk yearly_insight_text, untuk menentukan arah keseluruhan dari
|
||
pola musiman tahunan (naik/turun/stabil) berdasarkan semua
|
||
titik data, bukan cuma awal vs akhir.
|
||
|
||
Tentukan arah keseluruhan (meningkat/menurun/relatif stabil) dari
|
||
SELURUH bentuk kurva -- pakai garis tren (regresi linear sederhana,
|
||
least-squares) yang mempertimbangkan SEMUA titik, bukan cuma
|
||
membandingkan titik paling awal vs titik paling akhir. Dengan begini,
|
||
kalau titik awal/akhirnya kebetulan cuma noise kecil yang tidak
|
||
mewakili keseluruhan pola, kesimpulan arahnya tidak gampang salah.
|
||
|
||
Ambang "signifikan" dihitung relatif terhadap rentang naik-turun data
|
||
itu sendiri (bukan angka mutlak tetap), supaya konsisten di skala data
|
||
berapa pun -- sejalan dengan prinsip yang sama dipakai di seluruh
|
||
modul ini (EPS untuk noise floating-point, ambang relatif untuk
|
||
"apakah perubahan ini cukup berarti untuk disebut tren").
|
||
"""
|
||
n = len(values)
|
||
if n < 2:
|
||
return "relatif stabil"
|
||
y = np.asarray(values, dtype=float)
|
||
rentang = y.max() - y.min()
|
||
if rentang <= 1e-9:
|
||
return "relatif stabil"
|
||
x = np.arange(n)
|
||
slope = np.polyfit(x, y, 1)[0]
|
||
total_perubahan_tren = slope * (n - 1)
|
||
if total_perubahan_tren > 0.05 * rentang:
|
||
return "meningkat"
|
||
if total_perubahan_tren < -0.05 * rentang:
|
||
return "menurun"
|
||
return "relatif stabil"
|
||
|
||
|
||
def yearly_insight_text(y_vals) -> str:
|
||
if len(y_vals) < 2:
|
||
val = y_vals[0] if len(y_vals) else 0
|
||
if val > 1e-4:
|
||
return "Pada tanggal ini, pola musiman tahunan memberikan <b>efek kenaikan</b> terhadap tren dasar penjualan."
|
||
if val < -1e-4:
|
||
return "Pada tanggal ini, pola musiman tahunan memberikan <b>efek penurunan</b> terhadap tren dasar penjualan."
|
||
return "Pada tanggal ini, pola musiman tahunan relatif netral (tidak menambah atau mengurangi tren dasar secara signifikan)."
|
||
|
||
|
||
"""
|
||
- .diff() = fungsi pandas yang menghitung selisih setiap elemen dengan elemen sebelumnya. Contoh: y_vals = [10, 12, 15, 13, 11] → hasil .diff() = [NaN, 2, 3, -2, -2] (elemen pertama selalu NaN, karena tidak ada "sebelumnya" untuk dikurangi).
|
||
- .dropna() → buang NaN di awal itu → [2, 3, -2, -2].
|
||
- Jadi diffs = daftar kenaikan/penurunan antar hari berturut-turut.
|
||
"""
|
||
diffs = pd.Series(y_vals).diff().dropna().values
|
||
signs = [1 if d > 1e-6 else (-1 if d < -1e-6 else 0) for d in diffs]
|
||
|
||
"""
|
||
untuk setiap nilai d di diffs, ubah jadi tanda saja: 1 kalau naik (d > 1e-6, ambang kecil untuk toleransi noise), -1 kalau turun, 0 kalau nyaris tidak berubah.
|
||
Untuk contoh kita (diffs = [2, 3, -2, -2]) → signs = [1, 1, -1, -1].
|
||
"""
|
||
signs_nz = [s for s in signs if s != 0]
|
||
|
||
"""
|
||
ini membandingkan nilai dengan nilai sebelumnya, tapi juga menghitung berapa kali arah berubah (turning points) di sepanjang kurva (kalau beda berarti tambah poin turning).
|
||
Contoh: signs_nz = [1, 1, -1, -1] → turning_points = 1 (karena ada satu kali perubahan dari naik ke turun).
|
||
"""
|
||
turning_points = sum(1 for i in range(1, len(signs_nz)) if signs_nz[i] != signs_nz[i - 1])
|
||
|
||
if not signs_nz:
|
||
return "Pola tahunan relatif <b>stabil/datar</b> sepanjang periode ini (tidak memberikan efek kenaikan/penurunan signifikan)."
|
||
if turning_points == 0:
|
||
if signs_nz[0] > 0:
|
||
return "Pada periode ini, pola musiman tahunan sedang <b>naik konsisten</b>. Siapkan stok ekstra secara bertahap untuk mengantisipasi kenaikan permintaan."
|
||
return "Pada periode ini, pola musiman tahunan <b>turun konsisten</b>. Sesuaikan pesanan pasokan barang agar tidak terjadi penumpukan stok."
|
||
if turning_points == 1:
|
||
if signs_nz[0] > 0:
|
||
return "Pada periode ini, pola musiman tahunan <b>sempat meningkat lalu mulai melandai/menurun</b> menjelang akhir periode. Jika ingin menambah stok, lakukan di awal periode."
|
||
return "Pada periode ini, pola musiman tahunan <b>sempat menurun lalu mulai bergerak naik</b> menjelang akhir periode. Pertimbangkan bersiap menambah pasokan kembali."
|
||
|
||
arah_netto = _trend_direction(y_vals)
|
||
return (
|
||
f"Pada periode ini, pola musiman tahunan <b>cukup fluktuatif (naik-turun)</b>. "
|
||
f"Secara keseluruhan dari awal hingga akhir periode arahnya <b>{arah_netto}</b>."
|
||
)
|
||
|
||
|
||
def holiday_insight_bullets(hol: pd.DataFrame, hol_cols: list[str], holidays_df) -> list[str]:
|
||
"""
|
||
Insight efek event/hari libur -- dihitung PER EVENT (per kolom komponen
|
||
Prophet), dengan 2 prinsip inti (revisi v6):
|
||
|
||
1. [PALING PENTING] Pencarian puncak/lembah & "kapan efek mereda ke
|
||
normal" SEKARANG DIBATASI KETAT ke jendela aktif event itu sendiri
|
||
(`lower_window`..`upper_window` dari `holidays_df`), bukan mencari
|
||
bebas di SELURUH periode laporan. Dan "kembali normal" dihitung
|
||
PASTI dari `upper_window` (persis H+{upper_window}+1), BUKAN
|
||
ditebak dari data yang "kelihatannya sudah mendekati nol".
|
||
|
||
Kenapa ini krusial: sebelumnya sistem mencari titik ekstrem &
|
||
"baseline" di SELURUH data yang tersedia (bisa jauh melampaui
|
||
window event sungguhan), sehingga muncul hasil yang tidak masuk
|
||
akal seperti "kembali normal H+29/H+40/H+45" padahal window
|
||
event itu jauh lebih pendek dari itu -- dan titik puncak/lembah
|
||
yang salah karena kecampur data di luar window yang seharusnya
|
||
tidak relevan sama sekali untuk event tersebut.
|
||
|
||
Konsekuensi dari batasan ini:
|
||
- Kalau periode laporan BERAKHIR SEBELUM window event ini selesai,
|
||
sistem TIDAK mengklaim "sudah kembali normal" (karena memang
|
||
belum tentu benar) -- cukup deskripsikan pola yang terlihat.
|
||
- Kalau periode laporan mencakup akhir window event ini, baru
|
||
ditambahkan kalimat "kembali normal pada H+{upper_window+1}"
|
||
yang PASTI benar (bukan tebakan), karena itu bawaan definisi
|
||
window Prophet: di luar window, efeknya otomatis nol.
|
||
|
||
2. Variasi kata sambung ("Setelah itu" / "Kemudian" / "Selanjutnya")
|
||
dipakai bergantian untuk pola 3 segmen, supaya tidak mengulang kata
|
||
yang sama berkali-kali dalam 1 kalimat.
|
||
|
||
3. [Fix v7] Suatu titik HANYA boleh disebut "puncak" (perlu siapkan
|
||
stok) kalau nilainya BENERAN > 0, dan HANYA boleh disebut "titik
|
||
terendah" (perlu kurangi stok) kalau nilainya BENERAN < 0 -- dicek
|
||
dari TANDA nilainya langsung, bukan dari seberapa dekat/jauh
|
||
nilainya terhadap skala data periode ini.
|
||
|
||
4. [Fix v8a -- titik ekstrem sesungguhnya] Tanggal "puncak"/"titik
|
||
terendah" yang dilaporkan untuk tiap segmen sekarang dicari sebagai
|
||
titik ekstrem SESUNGGUHNYA di dalam seluruh rentang segmen itu
|
||
(bukan cuma nilai di titik batas hasil penggabungan titik balik
|
||
kecil). Kalau ada wobble kecil ikut tergabung ke dalam 1 segmen,
|
||
tanggal yang dilaporkan tetap yang BENAR-BENAR paling tinggi/rendah.
|
||
|
||
5. [Fix v8b -- "turun" tidak wajib nembus negatif] Beda dengan "naik"
|
||
(yang wajib nilainya > 0 supaya genuine), fase "turun" dianggap
|
||
genuine juga kalau segmen itu turun DARI titik yang sebelumnya
|
||
genuine positif (dari[1] > EPS) -- walau titik akhirnya belum
|
||
sempat < 0. Alasannya: "menurun dari puncak yang baru tinggi" tetap
|
||
kabar bisnis nyata (kurangi stok bertahap), terutama kalau periode
|
||
laporan terpotong sebelum sempat menembus negatif -- tanpa ini,
|
||
penurunan pasca-puncak yang jelas kelihatan di grafik malah tidak
|
||
dinarasikan sama sekali.
|
||
|
||
6. [Fix v8c -- kondisi awal yang sudah notable] Kalau segmen PERTAMA
|
||
yang dianalisis ternyata sudah mulai dari nilai yang notable
|
||
(tinggi/rendah), bukan dari baseline -- itu tandanya efeknya SUDAH
|
||
ada sejak awal rentang yang dianalisis (mis. plateau tinggi sebelum
|
||
Hari H yang baru anjlok setelahnya). Kondisi awal itu disebutkan
|
||
dulu sebagai konteks, supaya tidak langsung lompat ke "Setelah Hari
|
||
X, menurun..." padahal ceritanya sebenarnya sudah dimulai dari
|
||
SEBELUM Hari X.
|
||
|
||
Klasifikasi bentuk kurva (naik/turun/naik-turun/turun-naik, sampai 2
|
||
pembalikan arah) tetap memakai deteksi berbasis signifikansi (>=15%
|
||
dari rentang data) seperti revisi sebelumnya -- supaya wobble kecil
|
||
tidak dianggap pembalikan arah sungguhan.
|
||
"""
|
||
EPS = 1e-6
|
||
SIGNIFIKAN = 0.15 #ini ambang untuk menentukan apakah ada "pembalikan arah sungguhan" (mirip konsep 0.05 * rentang di _trend_direction() sebelumnya, tapi di sini pakai 15%, lebih ketat, karena tujuannya beda — bukan cuma "naik/turun keseluruhan" tapi "berapa kali kurva ini beneran berbelok"
|
||
CONNECTORS = ["Setelah itu", "Kemudian", "Selanjutnya"]
|
||
|
||
period_start = hol["ds"].min().date()
|
||
period_end = hol["ds"].max().date()
|
||
|
||
def _find_occurrence(col: str): # col = nama event (misal: "idul_fitri")
|
||
"""Cari kejadian (occurrence) event ini, ada ga di data hari libur model. Return (hari_h, lower_window, upper_window)
|
||
atau None kalau tidak ketemu / tidak terdaftar.
|
||
|
||
|
||
_find_occurrence berarti isinya uda nama hari libur, baru didalamnya ada seleksi tanggalnya, nanti cocokin sesuai tanggal apakah masuk kedalam yang dipilih kita untuk diramal, kalau uda pas baru return tanggal hari libur, lw, uw.
|
||
Kalau detail jika diurut begini loh alurnya:
|
||
|
||
1. _find_occurrence(col) menerima nama event (col, misal "idul_fitri") sebagai parameter
|
||
2. Di dalamnya, dia cari baris-baris di holidays_df yang namanya cocok dengan col itu (bisa lebih dari 1 baris, karena event yang sama muncul tiap tahun)
|
||
3. Untuk tiap baris itu, dihitung rentang window-nya (win_start, win_end)
|
||
4. Dicocokkan: apakah window itu beririsan dengan periode yang mau diramal/dilaporkan?
|
||
5. Begitu ketemu yang cocok, langsung return tanggal Hari-H, lower_window, upper_window milik kejadian itu
|
||
6. Kalau tidak ada satupun yang cocok, return None
|
||
"""
|
||
if holidays_df is None or holidays_df.empty: # ini cek dulu ada data hari libur model atau tidak, kalau ga ada ya langsung return None
|
||
return None
|
||
for _, row in holidays_df[holidays_df["holiday"] == col].iterrows():
|
||
ev_date = pd.to_datetime(row["ds"]).date()
|
||
lw = row["lower_window"] if "lower_window" in row else 0
|
||
uw = row["upper_window"] if "upper_window" in row else 0
|
||
lw = int(lw) if pd.notna(lw) else 0
|
||
uw = int(uw) if pd.notna(uw) else 0
|
||
win_start = ev_date + datetime.timedelta(days=lw)
|
||
win_end = ev_date + datetime.timedelta(days=uw)
|
||
if win_end >= period_start and win_start <= period_end: # ini apakah window event bersinggungan dengan salah satu hari dalam hari libur model? kalau ya, return tanggal puncak event + windownya
|
||
return ev_date, lw, uw
|
||
return None
|
||
|
||
# label hari H / H-{n} / H+{n} untuk narasi
|
||
def _offset_label(tanggal: datetime.date, hari_h) -> str | None: # buat label H-{n} / H+{n} / Hari H.
|
||
if hari_h is None:
|
||
return None
|
||
diff = (tanggal - hari_h).days
|
||
if diff == 0:
|
||
return "Hari H"
|
||
return f"H-{abs(diff)}" if diff < 0 else f"H+{diff}"
|
||
|
||
def _fmt(tanggal: datetime.date, hari_h) -> str:
|
||
lbl = _offset_label(tanggal, hari_h) # SIAP PAKAI menggunakan fungsi _offset_label untuk menentukan TANGGAL KEMUDIA DIIKUTI label H-{n} / H+{n} / Hari H.
|
||
tgl_str = tanggal.strftime("%d-%m-%Y")
|
||
return f"<b>{tgl_str}</b> (<b>{lbl}</b>)" if lbl else f"<b>{tgl_str}</b>"
|
||
|
||
# sebagai kalimat pembuka sebelum menyambung ke cerita "naik/turun/puncak" di badan paragraf.
|
||
def _intro(nama: str, hari_h, hari_h_str, acuan_tanggal) -> str:
|
||
if hari_h is None:
|
||
return f"Terkait event <b>{nama}</b>,"
|
||
diff = (acuan_tanggal - hari_h).days
|
||
if diff < 0:
|
||
return f"Menjelang Hari <b>{nama}</b> ({hari_h_str}),"
|
||
if diff == 0:
|
||
return f"Pada Hari <b>{nama}</b> ({hari_h_str}),"
|
||
return f"Setelah Hari <b>{nama}</b> ({hari_h_str}),"
|
||
|
||
|
||
"""
|
||
_significant_turns() = "pembaca bentuk kurva" — dia melihat sederet angka (nilai efek event per hari),
|
||
lalu menyimpulkan pola bentuknya seperti apa: naik terus, naik-turun, turun-naik, dst.
|
||
|
||
Tapi dia tidak peduli wobble kecil yang tidak signifikan (misal cuma 1-2% dari rentang data), karena itu noise.
|
||
Dia hanya mencatat titik balik yang benar-benar signifikan (lebih dari 15% dari rentang data)
|
||
sebagai "pembalikan arah sungguhan" untuk menentukan pola kurva.
|
||
"""
|
||
def _significant_turns(values) -> list[int] | None:
|
||
n = len(values) # CONTOH values = [2, 5, 9, 6, 3, 4, 7]
|
||
total_range = values.max() - values.min() # 9 - 2 = 7
|
||
if n < 3 or total_range <= EPS: # KITA LOLOS
|
||
return []
|
||
|
||
diffs = np.diff(values) # .diff(): selisih antar elemen berurutan. Maka, diffs = [3, 4, -3, -3, 1, 3]
|
||
|
||
signs = np.where(diffs > EPS, 1, np.where(diffs < -EPS, -1, 0)) # np.where(kondisi, jika_true, jika_false). Maka, signs = [1, 1, -1, -1, 1, 1] (1=naik, -1=turun, 0=stabil)
|
||
|
||
"""
|
||
enumerate(signs) → menghasilkan pasangan (index, nilai) ((pasangan: posisi titik, tandanya))
|
||
untuk tiap elemen: (0,1), (1,1), (2,-1), (3,-1), (4,1), (5,1).
|
||
i + 1 → digeser +1 karena diffs[i] itu mewakili "perubahan dari titik i ke titik i+1",
|
||
jadi lebih pas dicatat sebagai posisi titik ke-(i+1) (titik tujuannya), bukan titik asalnya.
|
||
"""
|
||
nz = [(i + 1, s) for i, s in enumerate(signs) if s != 0] # ingat s/sign = 0 ITU DILARANG JADI KALAU 0 MAKA DIBUANG. Di contoh kita (tidak ada yang 0), hasilnya: nz = [(1,1), (2,1), (3,-1), (4,-1), (5,1), (6,1)]
|
||
if not nz: # KITA LOLOS
|
||
return []
|
||
|
||
|
||
"""
|
||
Bandingkan tanda posisi k dengan posisi k-1, kalau beda, catat posisi sebelumnya (nz[k-1][0]) sebagai titik balik.
|
||
Jalankan untuk contoh kita:
|
||
|
||
k=1: nz[1]=(2,1) vs nz[0]=(1,1) → sama, skip
|
||
k=2: nz[2]=(3,-1) vs nz[1]=(2,1) → beda! catat nz[1][0] = 2
|
||
k=3: nz[3]=(4,-1) vs nz[2]=(3,-1) → sama, skip
|
||
k=4: nz[4]=(5,1) vs nz[3]=(4,-1) → beda! catat nz[3][0] = 4
|
||
k=5: nz[5]=(6,1) vs nz[4]=(5,1) → sama, skip
|
||
|
||
Maka hasil raw_turns = [2, 4]
|
||
posisi index 2 (nilai 9) dan posisi index 4 (nilai 3) adalah titik-titik balik mentah (belum difilter signifikansinya).
|
||
"""
|
||
raw_turns = []
|
||
for k in range(1, len(nz)):
|
||
if nz[k][1] != nz[k - 1][1]:
|
||
raw_turns.append(nz[k - 1][0])
|
||
if not raw_turns:
|
||
return []
|
||
|
||
boundaries = [0] + raw_turns + [n - 1]
|
||
# boundaries = [0, 2, 4, 6]. Ini bikin daftar batas: titik awal (0), semua titik balik (2, 4), titik akhir (6). Jadi ada 3 segmen: [0-2], [2-4], [4-6] — sesuai bentuk kurva "naik-turun-naik" tadi.
|
||
|
||
"""
|
||
disini permainannya: kita mau ngecek tiap segmen [left, mid, right] (misal [0,2,4])
|
||
apakah perubahan dari left ke mid dan dari mid ke right itu signifikan (lebih dari 15% dari rentang data).
|
||
Kalau salah satu tidak signifikan, hapus titik mid itu (anggapnya cuma wobble kecil),
|
||
dan ulangi proses sampai tidak ada lagi yang bisa dihapus.
|
||
"""
|
||
changed = True
|
||
while changed and len(boundaries) > 2:
|
||
changed = False
|
||
for i in range(1, len(boundaries) - 1):
|
||
left, mid, right = boundaries[i - 1], boundaries[i], boundaries[i + 1]
|
||
leg1 = abs(values[mid] - values[left])
|
||
leg2 = abs(values[right] - values[mid])
|
||
# --- [Fix v10] Ambang signifikan dihitung dari rentang
|
||
# LOKAL segmen [left, right] yang sedang dievaluasi, BUKAN
|
||
# dari rentang total seluruh periode yang dianalisis. Kalau
|
||
# pakai rentang total, wobble kecil di satu bagian kurva
|
||
# bisa dianggap signifikan atau tidak signifikan TERGANTUNG
|
||
# ada/tidaknya lonjakan besar yang tidak berhubungan di
|
||
# bagian lain kurva (mis. crash tajam pasca Idulfitri
|
||
# membuat wobble di awal Ramadhan yang identik jadi
|
||
# "kelihatan kecil" padahal datanya sendiri sama persis) --
|
||
# menyebabkan hasil klasifikasi berubah-ubah hanya karena
|
||
# periode laporan diperpanjang/dipendekkan, walau bagian
|
||
# data yang dievaluasi itu sendiri tidak berubah.
|
||
local_range = values[left:right + 1].max() - values[left:right + 1].min()
|
||
if local_range <= EPS:
|
||
local_range = total_range
|
||
if leg1 < SIGNIFIKAN * local_range or leg2 < SIGNIFIKAN * local_range:
|
||
boundaries.pop(i)
|
||
changed = True
|
||
break
|
||
|
||
turns = boundaries[1:-1]
|
||
return turns if len(turns) <= 2 else None
|
||
|
||
events: list[tuple] = []
|
||
|
||
for col in hol_cols:
|
||
series_full = hol[col].reset_index(drop=True)
|
||
tanggal_full = hol["ds"].reset_index(drop=True).dt.date
|
||
|
||
occ = _find_occurrence(col)
|
||
|
||
"""
|
||
for col in hol_cols: ← loop ini cuma "pilih 1 nama event dulu"
|
||
(di dalamnya, UNTUK NAMA EVENT ITU):
|
||
1. ambil SEMUA nilai & tanggal event ini (series_full, tanggal_full)
|
||
2. cari window aktifnya (_find_occurrence)
|
||
3. filter (mask) → ambil yang beneran relevan (irisan window & periode ramalan)
|
||
4. hasil filter = series, tanggal ← INI yang "nilai aktif dalam periode ramalan"
|
||
5. proses lebih lanjut (cari titik ekstrem, dst) untuk event INI SAJA
|
||
(lalu loop lanjut ke event BERIKUTNYA, ulangi langkah 1-5 untuk event itu)
|
||
"""
|
||
if occ is not None:
|
||
hari_h, lw, uw = occ
|
||
"""
|
||
win_start = 2026-03-17 (window event)
|
||
win_end = 2026-03-24 (window event)
|
||
period_start = 2026-03-20 (periode laporan)
|
||
period_end = 2026-03-30 (periode laporan)
|
||
|
||
batas_awal = max(17 Maret, 20 Maret) = 20 Maret
|
||
batas_akhir = min(24 Maret, 30 Maret) = 24 Maret
|
||
"""
|
||
win_start = hari_h + datetime.timedelta(days=lw)
|
||
win_end = hari_h + datetime.timedelta(days=uw)
|
||
# >>> Batasi analisis HANYA ke irisan [window event] x [periode laporan/yg ingin dilamar] <<<
|
||
batas_awal = max(win_start, period_start)
|
||
batas_akhir = min(win_end, period_end)
|
||
mask = (tanggal_full >= batas_awal) & (tanggal_full <= batas_akhir)
|
||
else:
|
||
hari_h, hari_h_str, win_end = None, None, None
|
||
mask = pd.Series([True] * len(series_full))
|
||
|
||
# series dan tanggal ini nanti ambil nilai dan tanggalnya yang beneran relevan (irisan window event & periode ingin diramal) untuk dianalisis lebih lanjut
|
||
series = series_full[mask].reset_index(drop=True)
|
||
tanggal = tanggal_full[mask].reset_index(drop=True)
|
||
if len(series) == 0:
|
||
continue
|
||
|
||
v_max, v_min = series.max(), series.min()
|
||
if v_max <= EPS and v_min >= -EPS:
|
||
continue # tidak ada efek nyata dari event ini di dalam window/periode ini
|
||
|
||
nama = col.replace("_", " ").title()
|
||
hari_h_str = hari_h.strftime("%d-%m-%Y") if hari_h else None
|
||
|
||
# "Kembali normal" HANYA diklaim kalau periode laporan benar-benar
|
||
# mencakup akhir window event ini (bukan tebakan dari data).
|
||
tanggal_normal = None
|
||
if occ is not None and period_end >= win_end:
|
||
tanggal_normal = win_end + datetime.timedelta(days=1)
|
||
|
||
if len(series) < 2:
|
||
t_only = tanggal.iloc[0]
|
||
v_only = series.iloc[0]
|
||
arah = "meningkatkan" if v_only > 0 else "menurunkan"
|
||
teks = f"Pada {_fmt(t_only, hari_h)}, event <b>{nama}</b> diperkirakan {arah} permintaan dibanding tren dasar."
|
||
if tanggal_normal:
|
||
teks += f" Efek ini diperkirakan kembali normal pada {_fmt(tanggal_normal, hari_h)}."
|
||
events.append((hari_h or t_only, teks))
|
||
continue
|
||
|
||
"""
|
||
ini jika ada 2 titik atau lebih, kita cek dulu apakah ada titik balik yang signifikan (lebih dari 15% dari rentang data) di antara titik-titik itu. Kalau tidak ada, berarti kurvanya cuma naik terus atau turun terus, jadi kita bisa langsung simpulkan arah keseluruhan. Kalau ada titik balik signifikan, kita lanjutkan analisis untuk menentukan pola naik-turun atau turun-naik. ATAU BAHKAN TERLALU FLUKTUATIF (lebih dari 2 titik balik signifikan) sehingga kita tidak bisa simpulkan pola yang jelas.
|
||
"""
|
||
turns = _significant_turns(series.values)
|
||
|
||
if turns is None:
|
||
idx_max, idx_min = int(series.idxmax()), int(series.idxmin())
|
||
t_start, t_end = tanggal.iloc[0], tanggal.iloc[-1]
|
||
arah_netto = _trend_direction(series.values)
|
||
# Anotasi aksi (siapkan/kurangi stok) HANYA ditambahkan kalau
|
||
# titik tertinggi/terendahnya beneran melewati 0 -- sama seperti
|
||
# cek sign-based di kasus 1-2 titik balik, supaya konsisten.
|
||
aksi_max = " (siapkan stok tambahan)" if series.iloc[idx_max] > EPS else ""
|
||
aksi_min = " (kurangi pasokan stok)" if series.iloc[idx_min] < -EPS else ""
|
||
teks = (
|
||
f"Permintaan terkait <b>{nama}</b>{f' ({hari_h_str})' if hari_h_str else ''} "
|
||
f"<b>berfluktuasi (naik-turun)</b> sepanjang periode ini. Secara keseluruhan dari "
|
||
f"{_fmt(t_start, hari_h)} sampai {_fmt(t_end, hari_h)}, arahnya <b>{arah_netto}</b>. "
|
||
f"Titik terendah di {_fmt(tanggal.iloc[idx_min], hari_h)}{aksi_min} dan "
|
||
f"titik tertinggi di {_fmt(tanggal.iloc[idx_max], hari_h)}{aksi_max} "
|
||
f" -- gunakan sebagai acuan penyesuaian stok harian."
|
||
)
|
||
if tanggal_normal:
|
||
teks += f" Efek ini diperkirakan kembali normal pada {_fmt(tanggal_normal, hari_h)}."
|
||
events.append((hari_h or tanggal.iloc[idx_max], teks))
|
||
continue
|
||
|
||
|
||
"""
|
||
"mengecek ulang" LAGI LAGI LAGI hasil _significant_turns(), memastikan titik-titik balik yang ditemukan benar-benar valid (tidak ada 2 segmen berturutan yang arahnya kebetulan sama, yang seharusnya digabung jadi 1).
|
||
"""
|
||
anchor_idxs = [0] + turns + [len(series) - 1]
|
||
|
||
# Pastikan arah antar segmen BENAR-BENAR berselang-seling (naik-turun-
|
||
# naik atau turun-naik-turun). Kalau ternyata 2 segmen berturutan
|
||
# arahnya SAMA (residu dari proses penggabungan titik balik yang
|
||
# tidak sempurna), gabungkan jadi 1 segmen -- buang titik tengahnya,
|
||
# supaya tidak muncul 2 kalimat "meningkat...meningkat lagi" yang
|
||
# janggal dan redundan.
|
||
cleaned = [anchor_idxs[0]]
|
||
for idx in anchor_idxs[1:]:
|
||
if len(cleaned) >= 2:
|
||
arah_sebelum = series.iloc[cleaned[-1]] > series.iloc[cleaned[-2]]
|
||
arah_sekarang = series.iloc[idx] > series.iloc[cleaned[-1]]
|
||
if arah_sebelum == arah_sekarang:
|
||
cleaned[-1] = idx
|
||
continue
|
||
cleaned.append(idx)
|
||
anchor_idxs = cleaned
|
||
points = [(tanggal.iloc[i], series.iloc[i]) for i in anchor_idxs]
|
||
|
||
clauses = []
|
||
conn_i = 0
|
||
|
||
|
||
"""
|
||
INSIGHT PER SEGMENT:
|
||
ini yang menentukan detail per fase:
|
||
1. Di mana naiknya (segmen mana yang arahnya naik)
|
||
2. Di mana turunnya (segmen mana yang arahnya turun)
|
||
3. Titik ekstrem sesungguhnya di tiap fase itu (bukan cuma titik batasnya, tapi dicari ulang nilai paling tinggi/rendah di seluruh rentang segmen — ini yang [Fix v8a])
|
||
4. Apakah perubahan itu "genuine"/sungguhan (layak dikasih saran aksi atau tidak — ini yang [Fix v8b])
|
||
"""
|
||
for seg_i in range(1, len(points)):
|
||
lo_idx, hi_idx = anchor_idxs[seg_i - 1], anchor_idxs[seg_i]
|
||
dari, ke_boundary = points[seg_i - 1], points[seg_i]
|
||
naik = ke_boundary[1] > dari[1]
|
||
|
||
# --- [Fix v8a] Titik ekstrem yang DILAPORKAN untuk segmen ini
|
||
# dicari sebagai TITIK EKSTREM SESUNGGUHNYA di DALAM seluruh
|
||
# rentang segmen (bukan cuma nilai di titik batas hasil proses
|
||
# penggabungan titik balik kecil). Tanpa ini, kalau ada wobble
|
||
# kecil di tengah segmen yang ikut tergabung, tanggal "titik
|
||
# terendah/tertinggi" yang dilaporkan bisa salah -- bukan
|
||
# tanggal yang benar-benar paling rendah/tinggi. ---
|
||
seg_slice = series.iloc[lo_idx:hi_idx + 1]
|
||
seg_tgl = tanggal.iloc[lo_idx:hi_idx + 1]
|
||
ext_pos = int(seg_slice.values.argmax() if naik else seg_slice.values.argmin())
|
||
ke = (seg_tgl.iloc[ext_pos], seg_slice.iloc[ext_pos])
|
||
|
||
# --- [Fix v8b] Cek "genuine" TIDAK SIMETRIS antara naik & turun:
|
||
# - "naik" HANYA genuine (perlu siapkan stok) kalau titik
|
||
# ekstremnya BENERAN > 0 -- pulih dari negatif menuju nol
|
||
# BUKAN lonjakan permintaan sungguhan (lihat v7).
|
||
# - "turun" genuine (perlu kurangi stok) kalau titik ekstremnya
|
||
# BENERAN < 0, ATAU kalau segmen ini turun DARI puncak yang
|
||
# genuine (dari[1] > EPS) -- karena "menurun dari puncak yang
|
||
# baru saja tinggi" tetap kabar bisnis yang nyata & perlu
|
||
# direspons (kurangi stok bertahap), walau belum sempat
|
||
# menembus di bawah nol. Tanpa ini, penurunan dari puncak yang
|
||
# terpotong periode laporan (belum sempat sampai negatif)
|
||
# malah tidak dinarasikan sama sekali. ---
|
||
genuine = (naik and ke[1] > EPS) or ((not naik) and (ke[1] < -EPS or dari[1] > EPS))
|
||
|
||
if not genuine:
|
||
is_last_segment = seg_i == len(points) - 1
|
||
if tanggal_normal is not None and is_last_segment:
|
||
# Segmen terakhir yang tidak genuine ini PERSIS sama
|
||
# ceritanya dengan kalimat "kembali normal" di penutup
|
||
# (sama-sama mendeskripsikan proses pulih menuju 0,
|
||
# cuma dipotong di titik data terakhir yang kebetulan
|
||
# tersedia) -- jangan disebut dobel, biarkan kalimat
|
||
# penutup (arah_pulih di bawah) yang menanganinya,
|
||
# supaya tidak ada 2 kalimat berturut-turut bilang
|
||
# "meningkat"/"menurun" untuk pergerakan yang sama.
|
||
continue
|
||
# --- [Fix v11] Segmen ini BUKAN puncak/lembah bisnis
|
||
# sungguhan (belum tembus sisi lain angka 0), TAPI
|
||
# pergerakannya tetap nyata dan tetap disebutkan -- supaya
|
||
# tidak ada bagian penting dari perjalanan grafik yang
|
||
# dilewatkan diam-diam tanpa penjelasan (mis. tren naik
|
||
# yang berlangsung berhari-hari sebelum akhirnya ada dip
|
||
# kecil di ujung -- tren naiknya tetap cerita utama yang
|
||
# perlu disebut, bukan cuma dip-nya). Bedanya dengan
|
||
# segmen genuine: TIDAK ada saran aksi stok (siapkan/
|
||
# kurangi), karena memang belum ada dampak bisnis nyata
|
||
# (nilainya masih di sisi yang sama dari kondisi normal).
|
||
verb_soft = "meningkat" if naik else "menurun"
|
||
if not clauses:
|
||
intro = _intro(nama, hari_h, hari_h_str, ke[0])
|
||
clauses.append(
|
||
f"{intro} permintaan <b>{verb_soft}</b> hingga {_fmt(ke[0], hari_h)}. "
|
||
# f"meski masih di sisi yang sama dari kondisi normal."
|
||
)
|
||
else:
|
||
connector = CONNECTORS[min(conn_i, len(CONNECTORS) - 1)]
|
||
conn_i += 1
|
||
clauses.append(
|
||
f"{connector}, permintaan <b>{verb_soft}</b> hingga {_fmt(ke[0], hari_h)}."
|
||
)
|
||
continue
|
||
|
||
if naik:
|
||
verb, aksi, frasa = "meningkat", "siapkan stok tambahan menjelang tanggal tersebut", "dengan puncak"
|
||
else:
|
||
verb, aksi, frasa = "menurun", "kurangi pasokan stok pada tanggal tersebut", "hingga titik terendah"
|
||
|
||
if not clauses:
|
||
# --- [Fix v8c] Kalau segmen PERTAMA ini sudah mulai dari
|
||
# nilai yang genuine notable (tinggi/rendah), bukan dari
|
||
# baseline -- itu artinya efeknya SUDAH ada sejak awal
|
||
# rentang yang dianalisis (mis. plateau tinggi sebelum
|
||
# Hari H yang lalu anjlok). Sebutkan dulu kondisi awal itu
|
||
# sebagai konteks, supaya tidak langsung lompat ke "Setelah
|
||
# Hari X, menurun..." padahal ceritanya sebenarnya dimulai
|
||
# dari SEBELUM Hari X. ---
|
||
# --- [Fix v8c] Kalau segmen PERTAMA ini sudah mulai dari
|
||
# nilai yang SEBANDING besarnya dengan titik ekstrem yang
|
||
# dilaporkan (bukan cuma "sedikit di atas nol" seperti
|
||
# awal ramp yang wajar) -- itu tandanya efeknya SUDAH ada
|
||
# sejak awal rentang yang dianalisis (mis. plateau tinggi
|
||
# sebelum Hari H yang baru anjlok setelahnya). Sebutkan
|
||
# kondisi awal itu dulu sebagai konteks, supaya tidak
|
||
# langsung lompat ke "Setelah Hari X, menurun..." padahal
|
||
# ceritanya sebenarnya dimulai dari SEBELUM Hari X.
|
||
# Pembanding pakai skala terhadap titik ekstrem (bukan EPS
|
||
# mentah) supaya titik awal ramp yang wajar -- yang
|
||
# nilainya emang jauh lebih kecil dari puncaknya -- tidak
|
||
# ikut ke-flag.
|
||
awal_notable = abs(dari[1]) > EPS and abs(dari[1]) >= 0.5 * abs(ke[1])
|
||
if awal_notable and dari[0] != ke[0]:
|
||
awal_kata = "tinggi" if dari[1] > EPS else "rendah"
|
||
intro = _intro(nama, hari_h, hari_h_str, dari[0])
|
||
clauses.append(
|
||
f"{intro} permintaan terkait <b>{nama}</b> sudah <b>{awal_kata}</b> sejak "
|
||
f"{_fmt(dari[0], hari_h)}. Kemudian, permintaan <b>{verb}</b> {frasa} pada "
|
||
f"{_fmt(ke[0], hari_h)} — {aksi}."
|
||
)
|
||
else:
|
||
intro = _intro(nama, hari_h, hari_h_str, ke[0])
|
||
clauses.append(f"{intro} permintaan <b>{verb}</b> {frasa} pada {_fmt(ke[0], hari_h)} — {aksi}.")
|
||
else:
|
||
connector = CONNECTORS[min(conn_i, len(CONNECTORS) - 1)]
|
||
conn_i += 1
|
||
frasa2 = "hingga puncaknya" if naik else "hingga titik terendah"
|
||
clauses.append(f"{connector}, permintaan <b>{verb}</b> {frasa2} pada {_fmt(ke[0], hari_h)} — {aksi}.")
|
||
|
||
# --- [Fix v9] Arah kalimat "kembali normal" sekarang eksplisit
|
||
# menyebut arah PERGERAKAN TITIK-PER-TITIKNYA (meningkat/menurun),
|
||
# bukan cuma kata generik "berangsur mereda" -- supaya kelihatan
|
||
# jelas bahwa fase pemulihan ini SUNGGUHAN bergerak naik (kalau
|
||
# datang dari titik terendah negatif) atau menurun (kalau datang
|
||
# dari puncak positif), walau nilainya sendiri belum/tidak sempat
|
||
# tembus ke sisi lain angka 0. Klasifikasi "genuine puncak/lembah"
|
||
# (butuh aksi stok) TETAP berbasis tanda nilai seperti sebelumnya --
|
||
# ini cuma menambahkan deskripsi ARAH pergerakannya, bukan mengubah
|
||
# kapan aksi stok direkomendasikan.
|
||
last_val = series.iloc[-1]
|
||
if last_val < -EPS:
|
||
arah_pulih = "meningkat"
|
||
elif last_val > EPS:
|
||
arah_pulih = "menurun"
|
||
else:
|
||
arah_pulih = "mereda"
|
||
|
||
if tanggal_normal:
|
||
if clauses:
|
||
connector = CONNECTORS[min(conn_i, len(CONNECTORS) - 1)]
|
||
clauses.append(
|
||
f"{connector}, efek <b>{nama}</b> berangsur <b>{arah_pulih}</b> menuju kondisi normal, "
|
||
f"diperkirakan tercapai sekitar {_fmt(tanggal_normal, hari_h)}."
|
||
)
|
||
else:
|
||
# Semua segmen lain ke-skip (kasus ekstrem: dari awal sampai
|
||
# akhir cuma proses mereda) -- tetap kasih 1 kalimat wajar.
|
||
intro = _intro(nama, hari_h, hari_h_str, tanggal_normal)
|
||
clauses.append(
|
||
f"{intro} efek permintaan berangsur <b>{arah_pulih}</b> menuju kondisi normal, "
|
||
f"diperkirakan tercapai sekitar {_fmt(tanggal_normal, hari_h)}."
|
||
)
|
||
elif not clauses:
|
||
# Tidak ada segmen genuine sama sekali & periode belum mencakup
|
||
# akhir window (jadi tidak bisa klaim "kembali normal" juga) --
|
||
# tetap kasih 1 kalimat faktual biar tidak kosong, plus arah
|
||
# pergerakan titik-per-titiknya kalau memang ada pergerakan.
|
||
t_start, t_end = tanggal.iloc[0], tanggal.iloc[-1]
|
||
intro = _intro(nama, hari_h, hari_h_str, t_end)
|
||
arah_txt = f" dengan kecenderungan <b>{arah_pulih}</b>" if arah_pulih != "mereda" else ""
|
||
clauses.append(
|
||
f"{intro} efek permintaan relatif kecil (belum benar-benar menyimpang dari kondisi normal){arah_txt} "
|
||
f"antara {_fmt(t_start, hari_h)} sampai {_fmt(t_end, hari_h)}."
|
||
)
|
||
|
||
teks = " ".join(clauses)
|
||
anchor = anchor_idxs[1] if len(anchor_idxs) > 1 else anchor_idxs[0]
|
||
events.append((hari_h or tanggal.iloc[anchor], teks))
|
||
|
||
if not events:
|
||
return ["Operasional periode ini berjalan normal tanpa lonjakan hari libur/event."]
|
||
|
||
events.sort(key=lambda pair: pair[0])
|
||
return [teks for _, teks in events]
|
||
|
||
|
||
"""
|
||
bagian agak rumit
|
||
1. _significant_turns(series.values)
|
||
→ DI SINI SEMUA PENGECEKAN FLUKTUASI TERJADI (cuma 1 kali, tuntas)
|
||
→ hasilnya: None ATAU list posisi titik balik
|
||
|
||
2. if turns is None:
|
||
→ CUMA BACA hasil di atas, PILIH jalur (bukan cek ulang)
|
||
→ kalau None → Jalur A (kalimat generik, selesai)
|
||
|
||
3. anchor_idxs = [0] + turns + [...]
|
||
cleaned = ...
|
||
→ CUMA MERAPIKAN titik balik yang SUDAH DIKETAHUI jadi batas segmen
|
||
→ BUKAN mengecek ulang apakah fluktuatif — itu sudah settled di langkah 1
|
||
|
||
4. for seg_i in range(...):
|
||
→ BARU DI SINI bikin kalimat/insight per segmen
|
||
""" |