import os
import math
import datetime
import threading
import urllib.request
import ssl
import numpy as np
import pytz
import customtkinter as ctk
from tkinter import messagebox
import ephem
from skyfield.api import Loader, wgs84
from skyfield import almanac
# ==========================================
# FUNGSI UTILITAS & FORMATTING
# ==========================================
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
def format_angle(deg, is_ra=False):
if deg is None or math.isnan(deg): return "+00°:00':00\""
if hasattr(deg, 'item'): deg = deg.item()
sign = "+" if deg >= 0 else "-"
deg = abs(deg)
if is_ra:
hours = deg / 15.0
h = int(hours)
m = int((hours - h) * 60)
s = int(round((hours - h - m/60.0) * 3600))
if s == 60: s = 0; m += 1
if m == 60: m = 0; h += 1
return f"{sign}{h:02d}H {m:02d}M {s:02d}S"
else:
d = int(deg)
m = int((deg - d) * 60)
s = int(round((deg - d - m/60.0) * 3600))
if s == 60: s = 0; m += 1
if m == 60: m = 0; d += 1
return f"{sign}{d:02d}°:{m:02d}':{s:02d}\""
def format_time_hms(delta_hours):
if hasattr(delta_hours, 'item'): delta_hours = delta_hours.item()
sign = "+" if delta_hours >= 0 else "-"
delta_hours = abs(delta_hours)
h = int(delta_hours)
m = int(round((delta_hours - h) * 60))
if m == 60: m = 0; h += 1
return f"{sign}{h:02d}H {m:02d}M"
def get_hms_str(time_obj, tz_offset):
if time_obj is None: return "--.--"
y, m, d, h, mn, s = time_obj.utc
total_minutes = int(h * 60 + mn + tz_offset * 60 + round(s / 60.0))
local_hour = (total_minutes // 60) % 24
local_minute = total_minutes % 60
return f"{local_hour:02d}.{local_minute:02d}"
def download_custom_bsp(filename, url):
if not os.path.exists(filename):
try:
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req) as response, open(filename, 'wb') as out_file:
out_file.write(response.read())
except Exception: pass
# ==========================================
# KELAS UTAMA APLIKASI
# ==========================================
class Menu1App(ctk.CTk):
def __init__(self):
super().__init__()
self.title("Laporan Analisis Hilal & KHGT (Modul 1)")
self.geometry("1150x700")
self.minsize(900, 600)
# Setup SSL bypass untuk unduhan Ephemeris
try: _create_unverified_https_context = ssl._create_unverified_context
except AttributeError: pass
else: ssl._create_default_https_context = _create_unverified_https_context
ctk.set_appearance_mode("Dark")
ctk.set_default_color_theme("blue")
# Inisialisasi Skyfield
self.load_obj = Loader(os.path.dirname(os.path.abspath(__file__)), verbose=False)
self.ts = self.load_obj.timescale()
self.eph = None
self.ephemeris_name = "de421.bsp"
self.setup_ui()
threading.Thread(target=self.load_ephemeris, daemon=True).start()
def get_header(self, width):
lines = [
"By the Name of Allah",
"KALENDER HIJRIAH GLOBAL TUNGGAL",
"KHGT Times 7.2 - Visibility Hilal Module"
]
return "\n".join(line.center(width) for line in lines)
def load_ephemeris(self):
try:
if not os.path.exists(self.ephemeris_name):
self.lbl_status.configure(text=f"Mengunduh ephemeris ({self.ephemeris_name})...", text_color="#00E5FF")
download_custom_bsp(self.ephemeris_name, f"https://hisabmu.com/aifikih/{self.ephemeris_name}")
self.eph = self.load_obj(self.ephemeris_name)
self.lbl_status.configure(text=f"Sistem Siap ({self.ephemeris_name} Dimuat)", text_color="#00E676")
self.btn_hitung.configure(state="normal")
except Exception as e:
self.lbl_status.configure(text="Gagal memuat ephemeris!", text_color="#FF1744")
messagebox.showerror("Error", f"Gagal memuat file ephemeris.\nDetail: {str(e)}")
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, placeholder_text="dd", justify="center")
d.insert(0, dd)
d.grid(row=1, column=0, padx=(0, 5))
m = ctk.CTkEntry(grid_frame, width=45, placeholder_text="mm", justify="center")
m.insert(0, dm)
m.grid(row=1, column=1, padx=5)
y = ctk.CTkEntry(grid_frame, width=70, placeholder_text="yyyy", 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)
now = datetime.datetime.now()
curr_y = str(now.year)
curr_m = f"{now.month:02d}"
curr_d = f"{now.day:02d}"
# ================= SIDEBAR KIRI =================
self.sidebar = ctk.CTkScrollableFrame(self, width=320, corner_radius=0, fg_color="#181818")
self.sidebar.grid(row=0, column=0, sticky="nsew")
ctk.CTkLabel(self.sidebar, text="KHGT ENGINE", font=("Segoe UI", 24, "bold"), text_color="#00E5FF").pack(pady=(20, 15))
# 1. Tanggal Observasi
frame_vdate = ctk.CTkFrame(self.sidebar, fg_color="#212121")
frame_vdate.pack(fill="x", padx=15, pady=5)
ctk.CTkLabel(frame_vdate, text="TANGGAL OBSERVASI", font=("Segoe UI", 12, "bold")).pack(anchor="w", padx=10, pady=(8, 2))
self.entry_vyear, self.entry_vmonth, self.entry_vday = self.create_ymd_row(frame_vdate, curr_y, curr_m, curr_d)
# 2. Lokasi
frame_vloc = ctk.CTkFrame(self.sidebar, fg_color="#212121")
frame_vloc.pack(fill="x", padx=15, pady=5)
ctk.CTkLabel(frame_vloc, text="KOORDINAT LOKASI", font=("Segoe UI", 12, "bold")).pack(anchor="w", padx=10, pady=(8, 2))
self.entry_vlat = self.create_input_row(frame_vloc, "Lat (Lintang):", "-7.0667")
self.entry_vlon = self.create_input_row(frame_vloc, "Lon (Bujur):", "110.4100")
self.entry_velev = self.create_input_row(frame_vloc, "Elevasi (m):", "230.0")
self.entry_vtz = self.create_input_row(frame_vloc, "Timezone:", "7.0")
# 3. Atmosfer
frame_vatm = ctk.CTkFrame(self.sidebar, fg_color="#212121")
frame_vatm.pack(fill="x", padx=15, pady=5)
ctk.CTkLabel(frame_vatm, text="PARAMETER ATMOSFER", font=("Segoe UI", 12, "bold")).pack(anchor="w", padx=10, pady=(8, 2))
self.entry_vtemp = self.create_input_row(frame_vatm, "Suhu (°C):", "25.0")
self.entry_vpres = self.create_input_row(frame_vatm, "Tekanan (mb):", "1010.0")
self.entry_vhum = self.create_input_row(frame_vatm, "Kelembapan (%):", "60.0")
# 4. Tombol Proses
self.btn_hitung = ctk.CTkButton(self.sidebar, text="▶ PROSES DATA", font=("Segoe UI", 14, "bold"), height=45, fg_color="#1565C0", hover_color="#0D47A1", command=self.run_calculation, state="disabled")
self.btn_hitung.pack(fill="x", padx=15, pady=(20, 10))
self.lbl_status = ctk.CTkLabel(self.sidebar, text="Memuat Ephemeris...", font=("Consolas", 11), text_color="#FFAB40")
self.lbl_status.pack(pady=5)
# ================= MAIN AREA KANAN =================
self.main_frame = ctk.CTkFrame(self, fg_color="transparent")
self.main_frame.grid(row=0, column=1, sticky="nsew", padx=20, pady=20)
self.main_frame.grid_rowconfigure(1, weight=1)
self.main_frame.grid_columnconfigure(0, weight=1)
self.lbl_main_title = ctk.CTkLabel(self.main_frame, text="Laporan Analisis Hilal & KHGT", font=("Segoe UI", 20, "bold"))
self.lbl_main_title.grid(row=0, column=0, sticky="w", pady=(0, 10))
self.textbox = ctk.CTkTextbox(self.main_frame, font=("Consolas", 13), fg_color="#101010", text_color="#E0E0E0", wrap="none")
self.textbox.grid(row=1, column=0, sticky="nsew")
self.textbox.insert("1.0", "Menunggu input dari pengguna...\nIsi parameter di samping kiri lalu klik 'PROSES DATA'.")
self.textbox.configure(state="disabled")
def run_calculation(self):
self.lbl_status.configure(text="Menghitung...", text_color="#FFAB40")
self.btn_hitung.configure(state="disabled")
self.textbox.configure(state="normal")
self.textbox.delete("1.0", "end")
self.textbox.insert("1.0", "Memproses data astrometri...\n")
self.textbox.configure(state="disabled")
threading.Thread(target=self.calculate_visibility, daemon=True).start()
def generate_visibility_report(self):
# 1. Ambil Data UI
year = int(self.entry_vyear.get())
month = int(self.entry_vmonth.get())
day = int(self.entry_vday.get())
lat = float(self.entry_vlat.get())
lon = float(self.entry_vlon.get())
elev = float(self.entry_velev.get())
tz = float(self.entry_vtz.get())
temp = float(self.entry_vtemp.get())
pres = float(self.entry_vpres.get())
hum = float(self.entry_vhum.get())
earth, sun, moon = self.eph['earth'], self.eph['sun'], self.eph['moon']
lokasi_obs = wgs84.latlon(lat, lon, elevation_m=elev)
# 2. Cari Waktu Sunset Lokal
t0 = self.ts.utc(year, month, day, 0 - int(tz))
t1 = self.ts.utc(year, month, day, 23 - int(tz))
t_sunset = None
t_evs, y_evs = almanac.find_discrete(t0, t1, almanac.sunrise_sunset(self.eph, lokasi_obs))
for t_ev, is_sunrise in get_safe_events(t_evs, y_evs):
if not is_sunrise:
t_sunset = t_ev
break
if t_sunset is None:
raise ValueError("Sunset (Matahari Terbenam) tidak ditemukan pada tanggal/koordinat tersebut.")
ts_tt = t_sunset.tt.item() if hasattr(t_sunset.tt, 'item') else t_sunset.tt
# 3. Cari Waktu Moonset
t_bound_moon = self.ts.tt_jd(ts_tt + 1.5)
t_moonset = None
t_mevs, y_mevs = almanac.find_discrete(t0, t_bound_moon, almanac.risings_and_settings(self.eph, moon, lokasi_obs))
for t_ev, is_moonrise in get_safe_events(t_mevs, y_mevs):
tm_tt = t_ev.tt.item() if hasattr(t_ev.tt, 'item') else t_ev.tt
if not is_moonrise and tm_tt > (ts_tt - 0.5):
t_moonset = t_ev
break
# 4. Cari Waktu Ijtimak
t_start_conj = self.ts.tt_jd(ts_tt - 5.0)
t_end_conj = self.ts.tt_jd(ts_tt + 5.0)
t_phases, y_phases = almanac.find_discrete(t_start_conj, t_end_conj, almanac.moon_phases(self.eph))
t_ijtima = None
for t_ev, phase in get_safe_events(t_phases, y_phases):
if phase == 0:
t_ijtima = t_ev
break
# 5. Kalkulasi Geosentris & Toposentris
geo_earth = earth.at(t_sunset)
app_moon_geo = geo_earth.observe(moon).apparent()
app_sun_geo = geo_earth.observe(sun).apparent()
ra_moon, dec_moon, dist_moon = app_moon_geo.radec(epoch=t_sunset)
ra_sun, dec_sun, dist_sun = app_sun_geo.radec(epoch=t_sunset)
gast = t_sunset.gast
lst_deg = (gast * 15.0) + lon
def get_geo_altaz(ra, dec):
ra_h = ra.hours.item() if hasattr(ra.hours, 'item') else ra.hours
dec_r = dec.radians.item() if hasattr(dec.radians, 'item') else dec.radians
ha_deg = lst_deg - (ra_h * 15.0)
lat_rad, dec_rad, ha_rad = math.radians(lat), dec_r, math.radians(ha_deg)
sin_alt = math.sin(dec_rad) * math.sin(lat_rad) + math.cos(dec_rad) * math.cos(lat_rad) * math.cos(ha_rad)
sin_alt = max(-1.0, min(1.0, sin_alt))
alt = math.degrees(math.asin(sin_alt))
y_val = -math.sin(ha_rad)
x_val = math.tan(dec_rad) * math.cos(lat_rad) - math.sin(lat_rad) * math.cos(ha_rad)
az = (math.degrees(math.atan2(y_val, x_val)) + 360) % 360
return alt, az
alt_moon_geo, az_moon_geo = get_geo_altaz(ra_moon, dec_moon)
alt_sun_geo, az_sun_geo = get_geo_altaz(ra_sun, dec_sun)
topo_earth = (earth + lokasi_obs).at(t_sunset)
app_moon_topo = topo_earth.observe(moon).apparent()
app_sun_topo = topo_earth.observe(sun).apparent()
alt_moon_topo_obj, az_moon_topo_obj, _ = app_moon_topo.altaz(temperature_C=temp, pressure_mbar=pres)
alt_sun_topo_obj, az_sun_topo_obj, _ = app_sun_topo.altaz(temperature_C=temp, pressure_mbar=pres)
alt_moon_topo = alt_moon_topo_obj.degrees
alt_sun_topo = alt_sun_topo_obj.degrees
# Umur & Waktu
if t_ijtima is not None:
ti_tt = t_ijtima.tt.item() if hasattr(t_ijtima.tt, 'item') else t_ijtima.tt
moon_age_hours = (ts_tt - ti_tt) * 24.0
t_ijtima_local_dt = t_ijtima.astimezone(pytz.FixedOffset(tz * 60))
y_i, m_i, d_i = t_ijtima_local_dt.year, t_ijtima_local_dt.month, t_ijtima_local_dt.day
h_i, mn_i = t_ijtima_local_dt.hour, t_ijtima_local_dt.minute
y_str = f"{y_i}" if y_i > 0 else f"{abs(y_i-1)} SM"
str_ijtima = f"{d_i:02d}/{m_i:02d}/{y_str}, {int(h_i):02d}:{int(mn_i):02d} LT"
str_ijtima_utc = t_ijtima.utc_strftime('%d/%m/%Y, %H:%M UTC')
else:
moon_age_hours = 0.0
str_ijtima = "N/A"
str_ijtima_utc = "N/A"
if t_moonset is not None:
tm_tt = t_moonset.tt.item() if hasattr(t_moonset.tt, 'item') else t_moonset.tt
lag_time_hours = (tm_tt - ts_tt) * 24.0
else:
lag_time_hours = 0.0
# Parameter Fisik & Optis
sep_deg = app_sun_geo.separation_from(app_moon_geo).degrees
elongation = sep_deg.item() if hasattr(sep_deg, 'item') else sep_deg
rel_alt_topo = alt_moon_topo - alt_sun_topo
illum_val = almanac.fraction_illuminated(self.eph, 'moon', t_sunset)
illumination = (illum_val.item() if hasattr(illum_val, 'item') else illum_val) * 100.0
dist_km = dist_moon.km.item() if hasattr(dist_moon.km, 'item') else dist_moon.km
sd_moon = math.degrees(math.asin(1737.4 / dist_km))
# =====================================================================
# EVALUASI KRITERIA VISIBILITAS INTERNASIONAL
# =====================================================================
is_khgt = alt_moon_geo >= 5.0 and elongation >= 8.0
str_khgt = "TERPENUHI" if is_khgt else "Gagal"
is_mabims = alt_moon_topo >= 3.0 and elongation >= 6.4
str_mabims = "TERPENUHI" if is_mabims else "Gagal"
is_ilyas = alt_moon_topo >= 4.0 and elongation >= 10.5
str_ilyas = "TERPENUHI" if is_ilyas else "Gagal"
is_danjon = elongation >= 7.0
str_danjon = "LOLOS (Sabit Terbentuk)" if is_danjon else "GAGAL (Sabit Tidak Terbentuk)"
W_arcmin = (sd_moon * 60.0) * (1 - math.cos(math.radians(elongation)))
q_yallop = rel_alt_topo - (11.8371 - 6.3226 * W_arcmin + 0.7319 * (W_arcmin**2) - 0.1018 * (W_arcmin**3))
if q_yallop > 0.216: yallop_stat = "A (Mudah Terlihat / Mata Telanjang)"
elif q_yallop > -0.014: yallop_stat = "B (Terlihat dgn Cuaca Sempurna)"
elif q_yallop > -0.160: yallop_stat = "C (Butuh Alat Optik utk Menemukan)"
elif q_yallop > -0.232: yallop_stat = "D (Hanya Terlihat via Alat Optik)"
else: yallop_stat = "F (TIDAK TERLIHAT / Bawah Limit)"
# =====================================================================
# RENDER LAPORAN
# =====================================================================
moonset_str = get_hms_str(t_moonset, tz) if t_moonset is not None else '--.--'
lat_str = format_angle(lat).replace("+", "").replace("-", "-")
lon_str = format_angle(lon).replace("+", "")
hari_idx = int(t0.whole % 7)
nama_hari = ["Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu", "Ahad"][hari_idx]
tahun_str = f"{year} CE" if year > 0 else f"{abs(year-1)} BCE (SM)"
display_date = f"{nama_hari}, {day:02d}/{month:02d}/{tahun_str}"
report = f"""{self.get_header(85)}
* Settings:-
- Calculations for Waxing Crescent (New, Evening).
- Crescent Visibility on: {display_date}
- Calculations are Done at Sunset Time at: {get_hms_str(t_sunset, tz)} LT
- Calculations are Geocentric & Topocentric.
- LOKASI: Long: {lon_str}, Lat: {lat_str}, Ele: {elev}, Zone: {tz}
=====================================================================================
- G. Conjunction (UTC) : {str_ijtima_utc}
- L. Conjunction (LT) : {str_ijtima}
- Julian Date at Time of Calculations: {ts_tt:.5f}
- Sunset : {get_hms_str(t_sunset, tz) + " LT":<16} G. Moon Age : {format_time_hms(moon_age_hours):<16}
- Moonset : {moonset_str + " LT":<16} Moon Lag Time : {format_time_hms(lag_time_hours):<16}
- G. Moon Altitude : {format_angle(alt_moon_geo):<16} T. Moon Altitude : {format_angle(alt_moon_topo):<16}
- G. Sun Altitude : {format_angle(alt_sun_geo):<16} T. Sun Altitude : {format_angle(alt_sun_topo):<16}
- G. Elongation : {format_angle(elongation):<16} G. Illumination : {f"{illumination:05.2f} %":<16}
-------------------------------------------------------------------------------------
[ MULTI-CRITERIA GLOBAL RESEARCH RESULT ]
-------------------------------------------------------------------------------------
1. KHGT / Diyanet Turki (G.Alt>=5°, G.Eln>=8°) : {str_khgt}
2. Neo MABIMS (T.Alt>=3°, G.Eln>=6.4°) : {str_mabims}
3. Ilyas Criterion (T.Alt>=4°, G.Eln>=10.5°): {str_ilyas}
4. Danjon Limit (G.Eln >= 7.0°) : {str_danjon}
5. Yallop (1998) Param (q-Value = {q_yallop:+.3f}) : {yallop_stat}
=====================================================================================
"""
return report
def calculate_visibility(self):
try:
report = self.generate_visibility_report()
self.after(0, self.display_result, report)
except Exception as e:
import traceback
self.after(0, self.display_error, f"{str(e)}\n\n{traceback.format_exc()}")
def display_result(self, report_text):
self.textbox.configure(state="normal")
self.textbox.delete("1.0", "end")
self.textbox.insert("1.0", report_text)
self.textbox.configure(state="disabled")
self.lbl_status.configure(text="Kalkulasi Selesai", text_color="#00E676")
self.btn_hitung.configure(state="normal")
def display_error(self, error_msg):
self.textbox.configure(state="normal")
self.textbox.delete("1.0", "end")
self.textbox.insert("1.0", f"TERJADI KESALAHAN:\n{error_msg}")
self.textbox.configure(state="disabled")
self.lbl_status.configure(text="Error Kalkulasi", text_color="#FF1744")
self.btn_hitung.configure(state="normal")
if __name__ == "__main__":
app = Menu1App()
app.mainloop()