///|
/// A plain Gregorian date used by Lunemark's deterministic lunar engine.
pub(all) struct CivilDate {
year : Int
month : Int
day : Int
} derive(Eq, Debug)
///|
/// Eight coarse Moon phases derived from a synodic-month approximation.
pub(all) enum MoonPhase {
NewMoon
WaxingCrescent
FirstQuarter
WaxingGibbous
FullMoon
WaningGibbous
LastQuarter
WaningCrescent
} derive(Eq, Debug)
///|
/// Practical planning lanes that can use lunar rhythm as a deterministic signal.
pub(all) enum RhythmLane {
NightSky
TideWatch
GardenPlanning
DeepWork
WellnessRest
EventTiming
} derive(Eq, Debug)
///|
/// One catalog rule used to adjust a lane score for a phase and month.
pub(all) struct RhythmRule {
id : String
month : Int
phase : MoonPhase
lane : RhythmLane
weight : Int
label : String
} derive(Eq, Debug)
///|
/// A complete analysis for one date and one planning lane.
pub(all) struct DayRhythm {
date : CivilDate
phase : MoonPhase
lunar_day : Int
illumination : Int
lane : RhythmLane
score : Int
tide_bias : String
advice : Array[String]
rules : Array[RhythmRule]
signature : String
} derive(Eq, Debug)
///|
/// Build a date value without exposing calendar internals.
pub fn date(year : Int, month : Int, day : Int) -> CivilDate {
CivilDate::{ year, month, day }
}
///|
/// Return whether a year follows Gregorian leap-year rules.
pub fn is_leap_year(year : Int) -> Bool {
year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)
}
///|
/// Number of days in a Gregorian month. Returns 0 for invalid months.
pub fn days_in_month(year : Int, month : Int) -> Int {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31
4 | 6 | 9 | 11 => 30
2 => if is_leap_year(year) { 29 } else { 28 }
_ => 0
}
}
///|
/// Validate the supported Gregorian date range.
pub fn is_valid_date(day : CivilDate) -> Bool {
day.year >= 1600 &&
day.year <= 9999 &&
day.month >= 1 &&
day.month <= 12 &&
day.day >= 1 &&
day.day <= days_in_month(day.year, day.month)
}
///|
/// Return the estimated phase for one date.
pub fn phase_on(day : CivilDate) -> MoonPhase {
phase_from_cycle(lunar_cycle_millis(day))
}
///|
/// Return the estimated lunar day in the current synodic cycle.
pub fn lunar_day_on(day : CivilDate) -> Int {
lunar_cycle_millis(day) / 1000
}
///|
/// Return the approximate illuminated percentage for one date.
pub fn illumination_on(day : CivilDate) -> Int {
illumination_from_cycle(lunar_cycle_millis(day))
}
///|
/// Analyze one day for a given planning lane.
pub fn analyze_day(day : CivilDate, lane : RhythmLane) -> DayRhythm {
let cycle = lunar_cycle_millis(day)
let phase = phase_from_cycle(cycle)
let lunar_day = cycle / 1000
let illumination = illumination_from_cycle(cycle)
let rules = matching_rules(day, phase, lane)
let score = clamp_int(
base_lane_score(phase, lane, illumination) + catalog_score(rules),
0,
100,
)
DayRhythm::{
date: day,
phase,
lunar_day,
illumination,
lane,
score,
tide_bias: tide_bias(phase, illumination),
advice: lane_advice(phase, lane, score),
rules,
signature: rhythm_signature(day, phase, lane, score),
}
}
///|
/// Analyze a consecutive span of days.
pub fn analyze_span(
start : CivilDate,
days : Int,
lane : RhythmLane,
) -> Array[DayRhythm] {
let result = []
let mut i = 0
let total = clamp_int(days, 0, 366)
let start_jdn = to_jdn(start)
while i < total {
result.push(analyze_day(from_jdn(start_jdn + i), lane))
i = i + 1
}
result
}
///|
/// Return the highest scoring windows from a span.
pub fn best_windows(
start : CivilDate,
days : Int,
lane : RhythmLane,
limit : Int,
) -> Array[DayRhythm] {
let items = analyze_span(start, days, lane)
items.sort_by((a, b) => b.score - a.score)
take_days(items, limit)
}
///|
/// Format one day as terminal-friendly text.
pub fn format_day_report(report : DayRhythm) -> String {
let out = StringBuilder()
out.write_string(
"Lunemark lunar rhythm for " + format_date(report.date) + "\n",
)
out.write_string("phase: " + report.phase.label() + "\n")
out.write_string(
"lunar day: " +
report.lunar_day.to_string() +
", illumination: " +
report.illumination.to_string() +
"%\n",
)
out.write_string("lane: " + report.lane.label() + "\n")
out.write_string("score: " + report.score.to_string() + "/100\n")
out.write_string("tide bias: " + report.tide_bias + "\n")
out.write_string("signature: " + report.signature + "\n")
out.write_string("advice:\n")
for item in report.advice {
out.write_string("- " + item + "\n")
}
if report.rules.is_empty() {
out.write_string("catalog rules: none\n")
} else {
out.write_string("catalog rules:\n")
for rule in report.rules {
out.write_string(
"- " +
rule.id +
" +" +
rule.weight.to_string() +
" " +
rule.label +
"\n",
)
}
}
out.to_string()
}
///|
/// Compact one-line badge for logs and examples.
pub fn rhythm_badge(report : DayRhythm) -> String {
"Lunemark " +
report.phase.label() +
" " +
report.score.to_string() +
"% " +
report.signature
}
///|
/// Export high-scoring days from a span as a tiny iCalendar document.
pub fn export_ics(
title : String,
start : CivilDate,
days : Int,
lane : RhythmLane,
) -> String {
export_ics_with_threshold(title, start, days, lane, 70)
}
///|
/// Export days at or above a chosen score threshold as iCalendar events.
pub fn export_ics_with_threshold(
title : String,
start : CivilDate,
days : Int,
lane : RhythmLane,
minimum_score : Int,
) -> String {
let threshold = clamp_int(minimum_score, 0, 100)
let out = StringBuilder()
out.write_string("BEGIN:VCALENDAR\n")
out.write_string("VERSION:2.0\n")
out.write_string("PRODID:-//Lunemark//MoonBit//EN\n")
for item in analyze_span(start, days, lane) {
if item.score >= threshold {
out.write_string("BEGIN:VEVENT\n")
out.write_string("UID:" + item.signature + "@lunemark\n")
out.write_string("DTSTART;VALUE=DATE:" + compact_date(item.date) + "\n")
out.write_string(
"SUMMARY:" +
title +
" - " +
item.phase.label() +
" " +
item.score.to_string() +
"\n",
)
out.write_string(
"DESCRIPTION:" +
item.lane.label() +
"; illumination " +
item.illumination.to_string() +
"%; " +
item.tide_bias +
"\n",
)
out.write_string("END:VEVENT\n")
}
}
out.write_string("END:VCALENDAR\n")
out.to_string()
}
///|
/// Return true when a report reaches the default export threshold.
pub fn is_high_window(report : DayRhythm) -> Bool {
report.score >= 70
}
///|
/// Human-facing phase labels.
pub fn MoonPhase::label(self : MoonPhase) -> String {
match self {
NewMoon => "New Moon"
WaxingCrescent => "Waxing Crescent"
FirstQuarter => "First Quarter"
WaxingGibbous => "Waxing Gibbous"
FullMoon => "Full Moon"
WaningGibbous => "Waning Gibbous"
LastQuarter => "Last Quarter"
WaningCrescent => "Waning Crescent"
}
}
///|
/// Human-facing lane labels.
pub fn RhythmLane::label(self : RhythmLane) -> String {
match self {
NightSky => "night-sky observation"
TideWatch => "tide watch"
GardenPlanning => "garden planning"
DeepWork => "deep work"
WellnessRest => "wellness rest"
EventTiming => "event timing"
}
}
///|
pub fn format_date(day : CivilDate) -> String {
day.year.to_string() +
"-" +
pad2(day.month.to_string()) +
"-" +
pad2(day.day.to_string())
}
///|
pub fn compact_date(day : CivilDate) -> String {
day.year.to_string() + pad2(day.month.to_string()) + pad2(day.day.to_string())
}
///|
pub fn matching_rules(
day : CivilDate,
phase : MoonPhase,
lane : RhythmLane,
) -> Array[RhythmRule] {
let hits = []
for rule in rhythm_rule_catalog() {
if rule.month == day.month && rule.phase == phase && rule.lane == lane {
hits.push(rule)
}
}
hits
}
///|
fn lunar_cycle_millis(day : CivilDate) -> Int {
positive_mod((to_jdn(day) - 2451550) * 1000, 29530)
}
///|
fn phase_from_cycle(cycle : Int) -> MoonPhase {
if cycle < 1845 {
NewMoon
} else if cycle < 5537 {
WaxingCrescent
} else if cycle < 9228 {
FirstQuarter
} else if cycle < 12920 {
WaxingGibbous
} else if cycle < 16611 {
FullMoon
} else if cycle < 20303 {
WaningGibbous
} else if cycle < 23995 {
LastQuarter
} else if cycle < 27685 {
WaningCrescent
} else {
NewMoon
}
}
///|
fn illumination_from_cycle(cycle : Int) -> Int {
if cycle <= 14765 {
cycle * 100 / 14765
} else {
(29530 - cycle) * 100 / 14765
}
}
///|
fn base_lane_score(
phase : MoonPhase,
lane : RhythmLane,
illumination : Int,
) -> Int {
match lane {
NightSky => 100 - illumination
TideWatch =>
if phase == NewMoon || phase == FullMoon {
86
} else if phase == FirstQuarter || phase == LastQuarter {
54
} else {
68
}
GardenPlanning =>
match phase {
NewMoon => 82
WaxingCrescent => 78
FirstQuarter => 72
WaxingGibbous => 66
FullMoon => 58
WaningGibbous => 62
LastQuarter => 70
WaningCrescent => 76
}
DeepWork =>
if illumination <= 35 {
84
} else if illumination >= 80 {
58
} else {
70
}
WellnessRest =>
if phase == WaningCrescent || phase == NewMoon {
86
} else {
64
}
EventTiming =>
if illumination >= 60 && illumination <= 95 {
80
} else {
62
}
}
}
///|
fn catalog_score(rules : Array[RhythmRule]) -> Int {
let mut total = 0
for rule in rules {
total = total + rule.weight
}
total
}
///|
fn tide_bias(phase : MoonPhase, illumination : Int) -> String {
if phase == NewMoon || phase == FullMoon {
"spring-tide watch"
} else if illumination <= 15 {
"low-light planning"
} else if phase == FirstQuarter || phase == LastQuarter {
"neap-tide watch"
} else {
"moderate lunar pull"
}
}
///|
fn lane_advice(
phase : MoonPhase,
lane : RhythmLane,
score : Int,
) -> Array[String] {
let items = []
if score >= 80 {
items.push("mark this date as a primary window")
} else if score >= 65 {
items.push("use this date as a backup window")
} else {
items.push("avoid treating this date as the main window")
}
items.push("current phase is " + phase.label())
match lane {
NightSky =>
items.push("prefer darker phases for visibility and simple equipment")
TideWatch =>
items.push("pair the phase signal with local tide tables before action")
GardenPlanning =>
items.push("use the phase as a repeatable planning cue, not as biology")
DeepWork => items.push("reserve low-light phases for quiet focus blocks")
WellnessRest =>
items.push("use waning phases to plan lower-intensity routines")
EventTiming =>
items.push("use brighter phases for outdoor ambience and visibility")
}
items.push("computed by a deterministic MoonBit calendar model")
items
}
///|
fn take_days(items : Array[DayRhythm], limit : Int) -> Array[DayRhythm] {
let result = []
let mut i = 0
let capped = clamp_int(limit, 0, items.length())
while i < capped {
result.push(items[i])
i = i + 1
}
result
}
///|
fn rhythm_signature(
day : CivilDate,
phase : MoonPhase,
lane : RhythmLane,
score : Int,
) -> String {
let seed = to_jdn(day) * 17 + phase_index(phase) * 97 + lane_index(lane) * 31
let token = positive_mod(seed + score * 13, 46656)
"LNR-" + score.to_string() + "-" + pad3(token.to_string(radix=36))
}
///|
fn phase_index(phase : MoonPhase) -> Int {
match phase {
NewMoon => 0
WaxingCrescent => 1
FirstQuarter => 2
WaxingGibbous => 3
FullMoon => 4
WaningGibbous => 5
LastQuarter => 6
WaningCrescent => 7
}
}
///|
fn lane_index(lane : RhythmLane) -> Int {
match lane {
NightSky => 0
TideWatch => 1
GardenPlanning => 2
DeepWork => 3
WellnessRest => 4
EventTiming => 5
}
}
///|
fn to_jdn(day : CivilDate) -> Int {
let a = (14 - day.month) / 12
let y = day.year + 4800 - a
let m = day.month + 12 * a - 3
day.day + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32045
}
///|
fn from_jdn(jdn : Int) -> CivilDate {
let a = jdn + 32044
let b = (4 * a + 3) / 146097
let c = a - 146097 * b / 4
let d = (4 * c + 3) / 1461
let e = c - 1461 * d / 4
let m = (5 * e + 2) / 153
CivilDate::{
day: e - (153 * m + 2) / 5 + 1,
month: m + 3 - 12 * (m / 10),
year: 100 * b + d - 4800 + m / 10,
}
}
///|
fn clamp_int(value : Int, low : Int, high : Int) -> Int {
if value < low {
low
} else if value > high {
high
} else {
value
}
}
///|
fn positive_mod(value : Int, divisor : Int) -> Int {
let result = value % divisor
if result < 0 {
result + divisor
} else {
result
}
}
///|
fn pad2(value : String) -> String {
if value.length() == 1 {
"0" + value
} else {
value
}
}
///|
fn pad3(value : String) -> String {
if value.length() == 1 {
"00" + value
} else if value.length() == 2 {
"0" + value
} else {
value
}
}