import os, datetime, math
import numpy as np
import pytz
import customtkinter as ctk
from tkinter import messagebox
import matplotlib
matplotlib.use('TkAgg')
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
import ephem
from skyfield.api import Loader, wgs84
from skyfield import almanac
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
def get_safe_events(t_obj, y_obj):
y_arr = np.atleast_1d(y_obj)
if len(y_arr) == 0: return []
events = []
for k in range(len(y_arr)):
try: t_val = t_obj[k]
except Exception: t_val = t_obj
events.append((t_val, int(y_arr[k])))
return events
class Menu29App(ctk.CTk):
def __init__(self):
super().__init__()
self.title("Kalkulator Mizwala & Simulasi Visual (Modul 29)")
self.geometry("1200x750")
ctk.set_appearance_mode("Dark")
# --- PERBAIKAN SKYFIELD LOADER ---
self.load_obj = Loader(BASE_DIR)
self.eph = self.load_obj('de421.bsp')
self.ts = self.load_obj.timescale()
# ---------------------------------
self.setup_ui()
def get_header(self, width):
return "\n".join(line.center(width) for line in ["By the Name of Allah", "KHGT Times 7.2 - Mizwala Simulator"])
def create_input_row(self, parent, label_text, default_val):
row = ctk.CTkFrame(parent, fg_color="transparent")
row.pack(fill="x", padx=10, pady=2)
ctk.CTkLabel(row, text=label_text, font=("Segoe UI", 12)).pack(side="left")
entry = ctk.CTkEntry(row, width=80, justify="right")
entry.insert(0, default_val)
entry.pack(side="right")
return entry
def create_ymd_row(self, parent, dy, dm, dd):
grid_frame = ctk.CTkFrame(parent, fg_color="transparent")
grid_frame.pack(fill="x", padx=10, pady=(0, 10))
ctk.CTkLabel(grid_frame, text="dd", font=("Segoe UI", 10), text_color="#9E9E9E").grid(row=0, column=0, pady=(0, 2))
ctk.CTkLabel(grid_frame, text="mm", font=("Segoe UI", 10), text_color="#9E9E9E").grid(row=0, column=1, pady=(0, 2))
ctk.CTkLabel(grid_frame, text="yyyy", font=("Segoe UI", 10), text_color="#9E9E9E").grid(row=0, column=2, pady=(0, 2))
d = ctk.CTkEntry(grid_frame, width=45, justify="center"); d.insert(0, dd); d.grid(row=1, column=0, padx=(0, 5))
m = ctk.CTkEntry(grid_frame, width=45, justify="center"); m.insert(0, dm); m.grid(row=1, column=1, padx=5)
y = ctk.CTkEntry(grid_frame, width=70, justify="center"); y.insert(0, dy); y.grid(row=1, column=2, padx=(5, 0))
return y, m, d
def setup_ui(self):
self.grid_columnconfigure(0, weight=0)
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=1)
self.sidebar = ctk.CTkFrame(self, width=320)
self.sidebar.grid(row=0, column=0, sticky="nsew", padx=10, pady=10)
ctk.CTkLabel(self.sidebar, text="MIZWALA CALCULATOR", font=("Segoe UI", 18, "bold"), text_color="#00E5FF").pack(pady=20)
now = datetime.datetime.now()
frame_date = ctk.CTkFrame(self.sidebar, fg_color="#212121")
frame_date.pack(fill="x", padx=15, pady=5)
ctk.CTkLabel(frame_date, text="TANGGAL OBSERVASI", font=("Segoe UI", 12, "bold")).pack(anchor="w", padx=10, pady=(8, 2))
self.entry_miz_year, self.entry_miz_month, self.entry_miz_day = self.create_ymd_row(frame_date, str(now.year), f"{now.month:02d}", f"{now.day:02d}")
frame_loc = ctk.CTkFrame(self.sidebar, fg_color="#212121")
frame_loc.pack(fill="x", padx=15, pady=5)
ctk.CTkLabel(frame_loc, text="LOKASI OBSERVASI", font=("Segoe UI", 12, "bold")).pack(anchor="w", padx=10, pady=(8, 2))
self.entry_miz_lat = self.create_input_row(frame_loc, "Lat (Lintang):", "-7.0667")
self.entry_miz_lon = self.create_input_row(frame_loc, "Lon (Bujur):", "110.4100")
self.entry_miz_elev = self.create_input_row(frame_loc, "Elevasi (m):", "230.0")
self.entry_miz_tz = self.create_input_row(frame_loc, "Timezone:", "7.0")
frame_opt = ctk.CTkFrame(self.sidebar, fg_color="#212121")
frame_opt.pack(fill="x", padx=15, pady=5)
ctk.CTkLabel(frame_opt, text="PARAMETER TONGKAT", font=("Segoe UI", 12, "bold")).pack(anchor="w", padx=10, pady=(8, 2))
self.entry_miz_tinggi = self.create_input_row(frame_opt, "Tinggi (cm):", "100.0")
self.entry_miz_waktu = self.create_input_row(frame_opt, "Waktu Awal:", "LIVE")
self.entry_miz_step = self.create_input_row(frame_opt, "Interval (Mnt):", "10")
self.btn_hitung = ctk.CTkButton(self.sidebar, text="▶ BUAT TABEL", font=("Segoe UI", 12, "bold"), command=self.calculate_mizwala)
self.btn_hitung.pack(fill="x", padx=15, pady=10)
self.btn_simulasi_miz = ctk.CTkButton(self.sidebar, text="👁️ BUKA SIMULATOR VISUAL", font=("Segoe UI", 12, "bold"), fg_color="#F57C00", hover_color="#E65100", command=self.buka_simulasi_mizwala)
self.btn_simulasi_miz.pack(fill="x", padx=15, pady=5)
self.main_frame = ctk.CTkFrame(self, fg_color="transparent")
self.main_frame.grid(row=0, column=1, sticky="nsew", padx=10, pady=10)
self.textbox = ctk.CTkTextbox(self.main_frame, font=("Consolas", 13), wrap="none")
self.textbox.pack(fill="both", expand=True)
def calculate_mizwala(self):
try:
year, month, day = int(self.entry_miz_year.get()), int(self.entry_miz_month.get()), int(self.entry_miz_day.get())
lat, lon, elev, tz = float(self.entry_miz_lat.get()), float(self.entry_miz_lon.get()), float(self.entry_miz_elev.get()), float(self.entry_miz_tz.get())
tinggi_tongkat = float(self.entry_miz_tinggi.get())
step_mnt = int(self.entry_miz_step.get())
earth, sun = self.eph['earth'], self.eph['sun']
loc = wgs84.latlon(lat, lon, elevation_m=elev)
observer = earth + loc
t0 = self.ts.utc(year, month, day, -int(tz))
t1 = self.ts.utc(year, month, day, 24 - int(tz))
f_rs = almanac.sunrise_sunset(self.eph, loc)
t_rs, y_rs = almanac.find_discrete(t0, t1, f_rs)
t_rise, t_set = None, None
for t_ev, y_ev in get_safe_events(t_rs, y_rs):
if y_ev == 1: t_rise = t_ev
else: t_set = t_ev
if t_rise is None or t_set is None:
messagebox.showerror("Error", "Matahari tidak terbit/terbenam di lokasi ini pada tanggal tersebut (Anomali Lintang).")
return
tt_array = np.linspace(t_rise.tt, t_set.tt, 1440)
t_search = self.ts.tt_jd(tt_array)
alt_arr = observer.at(t_search).observe(sun).apparent().altaz()[0].degrees
idx_noon = np.argmax(alt_arr)
alt_noon = alt_arr[idx_noon]
t_noon = self.ts.tt_jd(tt_array[idx_noon])
noon_dt = t_noon.utc_datetime() + datetime.timedelta(hours=tz)
zenith_noon = 90.0 - alt_noon
bayangan_dzuhur = tinggi_tongkat * math.tan(math.radians(max(0, zenith_noon)))
target_shadow_asr = tinggi_tongkat + bayangan_dzuhur
alt_am, tt_am = alt_arr[:idx_noon], tt_array[:idx_noon]
tt_dhuha = None
diffs = alt_am - 4.5
for i in range(len(diffs)-1):
if diffs[i] <= 0 and diffs[i+1] > 0:
frac = abs(diffs[i]) / (abs(diffs[i]) + abs(diffs[i+1]) + 1e-9)
tt_dhuha = tt_am[i] + frac * (tt_am[i+1] - tt_am[i])
break
dhuha_str = "----"
if tt_dhuha is not None:
dhuha_dt_exact = self.ts.tt_jd(tt_dhuha).utc_datetime() + datetime.timedelta(hours=tz)
dhuha_str = dhuha_dt_exact.strftime("%H:%M:%S")
output_lines = []
output_lines.append(self.get_header(90))
output_lines.append("[ Tabel Bayangan Tongkat Istiwa / Mizwala ]".center(90))
output_lines.append("")
output_lines.append(f"* Tanggal : {day:02d}/{month:02d}/{year}")
output_lines.append(f"* Lokasi : Lat {lat}, Lon {lon}, Elev {elev}m, TZ {tz}")
output_lines.append(f"* Tongkat : {tinggi_tongkat} cm")
output_lines.append(f"* Waktu Dhuha: {dhuha_str} (Matahari Naik 4.5°)")
output_lines.append(f"* Byg Dzuhur : {bayangan_dzuhur:.2f} cm (Bayangan Terpendek)")
output_lines.append(f"* Target Ashr: {target_shadow_asr:.2f} cm (Tongkat + Byg Dzuhur)")
output_lines.append("="*90)
output_lines.append(f"{'Waktu (LT)':<12} | {'Alt Matahari':<14} | {'Arah Bayangan':<15} | {'Panjang Bayangan':<20}")
output_lines.append("-" * 90)
start_dt = t_rise.utc_datetime() + datetime.timedelta(hours=tz)
end_dt = t_set.utc_datetime() + datetime.timedelta(hours=tz)
menit_awal = (start_dt.minute // step_mnt) * step_mnt + step_mnt
curr_dt = start_dt.replace(minute=0, second=0, microsecond=0) + datetime.timedelta(minutes=menit_awal)
dhuha_marked, dzuhur_marked, ashar_marked = False, False, False
while curr_dt <= end_dt:
t_calc = self.ts.from_datetime((curr_dt - datetime.timedelta(hours=tz)).replace(tzinfo=pytz.utc))
app = observer.at(t_calc).observe(sun).apparent()
alt, az, _ = app.altaz()
alt_deg, az_deg = alt.degrees, az.degrees
if alt_deg > 0:
zenith = 90.0 - alt_deg
shadow_len = tinggi_tongkat * math.tan(math.radians(zenith))
shadow_az = (az_deg + 180.0) % 360.0
waktu_str = curr_dt.strftime("%H:%M")
alt_str = f"{alt_deg:.2f}°"
arah_str = f"{shadow_az:.2f}°"
panjang_str = f"{shadow_len:.2f} cm"
marker = ""
if curr_dt < noon_dt and alt_deg >= 4.5 and not dhuha_marked:
marker = " << MASUK DHUHA"
dhuha_marked = True
elif curr_dt >= noon_dt and not dzuhur_marked:
marker = " << ZAWAL / DZUHUR"
dzuhur_marked = True
elif curr_dt > noon_dt and shadow_len >= target_shadow_asr and not ashar_marked:
marker = " << MASUK ASHAR"
ashar_marked = True
output_lines.append(f"{waktu_str:<12} | {alt_str:<14} | {arah_str:<15} | {panjang_str}{marker}")
curr_dt += datetime.timedelta(minutes=step_mnt)
output_lines.append("=" * 90)
output_lines.append("* Keterangan: Arah bayangan dihitung dari Utara (0°) searah jarum jam.")
self.textbox.delete("1.0", "end")
self.textbox.insert("1.0", "\n".join(output_lines))
except Exception as e:
messagebox.showerror("Error", str(e))
def buka_simulasi_mizwala(self):
try:
year, month, day = int(self.entry_miz_year.get()), int(self.entry_miz_month.get()), int(self.entry_miz_day.get())
lat, lon, elev, tz = float(self.entry_miz_lat.get()), float(self.entry_miz_lon.get()), float(self.entry_miz_elev.get()), float(self.entry_miz_tz.get())
tinggi_tongkat = float(self.entry_miz_tinggi.get())
waktu_input = self.entry_miz_waktu.get().strip().upper()
is_live_mode = False
jam_awal_desimal = 12.0
if waktu_input == "" or waktu_input == "LIVE" or waktu_input == "SEKARANG":
is_live_mode = True
else:
try:
waktu_input = waktu_input.replace(".", ":")
if ":" in waktu_input:
h_str, m_str = waktu_input.split(":")
jam_awal_desimal = int(h_str) + (int(m_str) / 60.0)
else:
jam_awal_desimal = float(waktu_input)
except Exception:
is_live_mode = True
earth, sun = self.eph['earth'], self.eph['sun']
loc = wgs84.latlon(lat, lon, elevation_m=elev)
observer = earth + loc
t0 = self.ts.utc(year, month, day, -int(tz))
t1 = self.ts.utc(year, month, day, 24 - int(tz))
f_rs = almanac.sunrise_sunset(self.eph, loc)
t_rs, y_rs = almanac.find_discrete(t0, t1, f_rs)
t_rise, t_set = None, None
for t_ev, y_ev in get_safe_events(t_rs, y_rs):
if y_ev == 1: t_rise = t_ev
else: t_set = t_ev
if t_rise is None or t_set is None:
return messagebox.showerror("Error", "Matahari tidak terbit/terbenam pada tanggal ini.")
dt_rise = t_rise.utc_datetime() + datetime.timedelta(hours=tz)
dt_set = t_set.utc_datetime() + datetime.timedelta(hours=tz)
start_hr = dt_rise.hour + (dt_rise.minute / 60.0)
end_hr = dt_set.hour + (dt_set.minute / 60.0)
win_sim = ctk.CTkToplevel(self)
win_sim.title("Simulasi Visual Kompas Mizwala")
win_sim.geometry("650x800")
win_sim.attributes("-topmost", True)
win_sim.configure(fg_color="#0A0A0A")
ctk.CTkLabel(win_sim, text="SIMULASI ARAH BAYANGAN MATAHARI", font=("Segoe UI", 16, "bold"), text_color="#FFD54F").pack(pady=10)
fig = plt.figure(figsize=(6, 6), facecolor='#0A0A0A')
ax = fig.add_subplot(111, projection='polar')
canvas_sim = FigureCanvasTkAgg(fig, master=win_sim)
canvas_sim.get_tk_widget().pack(fill="both", expand=True, padx=10, pady=5)
ctrl_frame = ctk.CTkFrame(win_sim, fg_color="#1E1E1E", corner_radius=10)
ctrl_frame.pack(fill="x", padx=20, pady=10)
top_ctrl = ctk.CTkFrame(ctrl_frame, fg_color="transparent")
top_ctrl.pack(fill="x", padx=10, pady=5)
lbl_waktu = ctk.CTkLabel(top_ctrl, text="Waktu Lokal: --:--:--", font=("Consolas", 16, "bold"), text_color="#00E676")
lbl_waktu.pack(side="left", padx=10)
self.miz_is_live = is_live_mode
def toggle_live():
self.miz_is_live = True
btn_live.configure(fg_color="#2E7D32", text="🔴 MODE LIVE")
btn_live = ctk.CTkButton(top_ctrl, text="🔴 MODE LIVE" if is_live_mode else "⏸️ MODE KUSTOM",
fg_color="#2E7D32" if is_live_mode else "#555555", width=120, command=toggle_live)
btn_live.pack(side="right", padx=10)
lbl_info = ctk.CTkLabel(ctrl_frame, text="Mengkalkulasi...", font=("Consolas", 12))
lbl_info.pack(pady=5)
slider_var = ctk.DoubleVar()
max_radius = tinggi_tongkat * 3.0
def update_plot(val, update_slider=False):
try:
jam_desimal = float(val)
jam = int(jam_desimal)
sisa_menit = (jam_desimal - jam) * 60.0
menit = int(sisa_menit)
detik = int(round((sisa_menit - menit) * 60.0))
if detik >= 60: menit += 1; detik -= 60
if menit >= 60: jam += 1; menit -= 60
if update_slider: slider_var.set(jam_desimal)
lbl_waktu.configure(text=f"Waktu Lokal: {jam:02d}:{menit:02d}:{detik:02d}")
jam_safe = min(23, max(0, jam))
curr_local = datetime.datetime(year, month, day) + datetime.timedelta(hours=jam_safe, minutes=menit, seconds=detik)
curr_utc = curr_local - datetime.timedelta(hours=tz)
t_calc = self.ts.from_datetime(curr_utc.replace(tzinfo=pytz.utc))
app = observer.at(t_calc).observe(sun).apparent()
alt, az, _ = app.altaz()
ax.clear()
ax.set_theta_zero_location('N')
ax.set_theta_direction(-1)
ax.set_facecolor('#121212')
ax.tick_params(colors='white')
ax.grid(color='#333333', linestyle='--')
ax.set_ylim(0, max_radius)
ax.set_xticks(np.radians([0, 45, 90, 135, 180, 225, 270, 315]))
ax.set_xticklabels(['U (0°)', 'TL', 'T (90°)', 'TG', 'S (180°)', 'BD', 'B (270°)', 'BL'])
ax.set_yticklabels([])
if alt.degrees > 0:
zenith = 90.0 - alt.degrees
shadow_len = tinggi_tongkat * math.tan(math.radians(zenith))
shadow_az = (az.degrees + 180.0) % 360.0
ax.plot(0, 0, marker='o', color='white', markersize=8, label=f"Tongkat ({tinggi_tongkat}cm)")
display_shadow_len = min(shadow_len, max_radius)
ax.plot([0, math.radians(shadow_az)], [0, display_shadow_len], color='#00E5FF', linewidth=3.5, label="Arah Bayangan")
ax.plot(math.radians(az.degrees), max_radius * 0.9, marker='o', color='#FFD54F', markersize=14, label="Matahari")
lbl_info.configure(text=f"Alt Mth: {alt.degrees:.1f}° | Arah Byg: {shadow_az:.1f}° | Pjg Byg: {shadow_len:.1f} cm", text_color="white")
ax.legend(loc='lower left', bbox_to_anchor=(-0.1, -0.15), facecolor='#0A0A0A', edgecolor='#333', labelcolor='white')
else:
lbl_info.configure(text="Matahari di bawah Ufuk (Malam Hari)", text_color="#FF5252")
canvas_sim.draw_idle()
except Exception as ex: print("Error rendering:", ex)
def on_slider_drag(val):
self.miz_is_live = False
btn_live.configure(fg_color="#555555", text="⏸️ MODE KUSTOM")
update_plot(val, update_slider=False)
slider = ctk.CTkSlider(ctrl_frame, from_=start_hr, to=end_hr, variable=slider_var, command=on_slider_drag, progress_color="#F57C00", button_color="#E65100")
slider.pack(fill="x", padx=20, pady=(10, 20))
def live_loop():
if not win_sim.winfo_exists(): return
if getattr(self, 'miz_is_live', False):
now = datetime.datetime.now()
jam_sekarang_desimal = now.hour + (now.minute / 60.0) + (now.second / 3600.0)
if start_hr <= jam_sekarang_desimal <= end_hr:
update_plot(jam_sekarang_desimal, update_slider=True)
else:
lbl_info.configure(text="Matahari di bawah Ufuk (Malam Hari)", text_color="#FF5252")
lbl_waktu.configure(text=f"Waktu Lokal: {now.strftime('%H:%M:%S')}")
ax.clear()
ax.set_theta_zero_location('N')
ax.set_theta_direction(-1)
ax.set_facecolor('#121212')
ax.set_ylim(0, max_radius)
ax.set_xticks(np.radians([0, 45, 90, 135, 180, 225, 270, 315]))
ax.set_xticklabels(['U (0°)', 'TL', 'T (90°)', 'TG', 'S (180°)', 'BD', 'B (270°)', 'BL'])
ax.set_yticklabels([])
canvas_sim.draw_idle()
slider_var.set(start_hr)
win_sim.after(1000, live_loop)
live_loop()
if not is_live_mode:
if start_hr <= jam_awal_desimal <= end_hr:
update_plot(jam_awal_desimal, update_slider=True)
else:
messagebox.showwarning("Peringatan Waktu", f"Waktu yang Anda input ({waktu_input}) terjadi saat malam hari.\nSimulasi dikembalikan ke mode Live.")
self.miz_is_live = True
btn_live.configure(fg_color="#2E7D32", text="🔴 MODE LIVE")
else:
now = datetime.datetime.now()
jam_sekarang_desimal = now.hour + (now.minute / 60.0) + (now.second / 3600.0)
if not (start_hr <= jam_sekarang_desimal <= end_hr):
tengah_hari = (start_hr + end_hr) / 2.0
self.miz_is_live = False
btn_live.configure(fg_color="#555555", text="⏸️ MODE KUSTOM")
update_plot(tengah_hari, update_slider=True)
except Exception as e:
messagebox.showerror("Error", f"Gagal memuat simulasi: {e}")
if __name__ == "__main__":
app = Menu29App()
app.mainloop()