Spaces:
Running
Running
File size: 10,019 Bytes
bfbbc01 a27290c bfbbc01 e8b8982 bfbbc01 e8b8982 bfbbc01 e8b8982 a27290c e8b8982 a27290c bfbbc01 a27290c bfbbc01 a27290c bfbbc01 a27290c bfbbc01 a27290c bfbbc01 b6d4fb0 a27290c bfbbc01 a27290c 9c0437f bfbbc01 9c0437f a27290c 9c0437f bfbbc01 a27290c bfbbc01 a27290c bfbbc01 a27290c 9c0437f a27290c b83d0c2 a27290c bfbbc01 9c0437f a27290c bfbbc01 b83d0c2 bfbbc01 b83d0c2 bfbbc01 a27290c bfbbc01 b83d0c2 a27290c bfbbc01 a27290c bfbbc01 a27290c bfbbc01 a27290c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | // Astronomy Engine 2.1.19 API adapter. UTC is used internally.
// All display/ruling thresholds are read from fiqh_time_registry.json.
globalThis.SolarEngine = (() => {
// Unit conversions only. Every tolerance, step and iteration count comes from
// profile.solar_solver in fiqh_time_registry.json (see configure()).
const H=3600000, D=24*H, rad=Math.PI/180, MIN=60000;
const WEEKDAYS=['sunday','monday','tuesday','wednesday','thursday','friday','saturday'];
let solver=null,api=null;
const need=key=>{const v=Number(solver?.[key]);if(!(v>0))throw new Error('solar_solver.'+key+' is missing in fiqh_time_registry.json (SolarEngine.configure(profile.solar_solver))');return v;};
const configure=config=>{solver=config||null;return api;};
const ready=()=>Boolean(globalThis.Astronomy?.Equator&&globalThis.Astronomy?.Horizon);
function position(ms,loc){
if(!ready())return null;
const A=globalThis.Astronomy,date=new Date(ms),observer=new A.Observer(loc.lat,loc.lng,loc.height||0);
const equator=A.Equator(A.Body.Sun,date,observer,true,true);
// No extra refraction here: the registry's -0.833 degree horizon already
// incorporates the standard apparent limb/refraction allowance.
const horizon=A.Horizon(date,observer,equator.ra,equator.dec,null);
return {decl:equator.dec,ra:equator.ra,altitude:horizon.altitude,azimuth:horizon.azimuth};
}
function altitude(ms,loc){
return position(ms,loc)?.altitude??null;
}
function maximum(day,loc){
const half=need('half_day_hours')*H,bracket=need('noon_bracket_hours')*H;
const guess=day+half-loc.lng/need('longitude_degrees_per_hour')*H;let lo=guess-bracket,hi=guess+bracket;
for(let i=0;i<need('maximum_iterations');i++){const a=lo+(hi-lo)/3,b=hi-(hi-lo)/3;if(altitude(a,loc)<altitude(b,loc))lo=a;else hi=b;}
return (lo+hi)/2;
}
// Resolve the sign change of the solar altitude rate. The bracket width is
// numerical uncertainty, never a religious duration or an offset after noon.
function transitTransition(day,loc,config){
if(config)configure(config);
const tolerance=Number(solver?.time_tolerance_ms),sample=Number(solver?.derivative_sample_ms);
if(!(tolerance>0&&sample>0))return null;
const half=need('half_day_hours')*H,bracket=need('noon_bracket_hours')*H;
const guess=day+half-loc.lng/need('longitude_degrees_per_hour')*H;
const rate=t=>altitude(t+sample/2,loc)-altitude(t-sample/2,loc);
let lo=guess-bracket,hi=guess+bracket;
if(!(rate(lo)>0&&rate(hi)<0))return null;
for(let i=0;i<need('transit_iterations')&&hi-lo>tolerance;i++){
const mid=(lo+hi)/2;if(rate(mid)<0)hi=mid;else lo=mid;
}
if(!(rate(hi)<0))return null;
return {at:(lo+hi)/2,after:hi,before:lo,uncertainty_ms:hi-lo};
}
function crossing(lo,hi,target,direction,loc){
const step=need('scan_step_ms'),iterations=need('bisect_iterations');
const rising=direction==='ascending';let prev=lo,pv=altitude(lo,loc)-target;
// hi+1: one millisecond of numerical slack so the final bracket end is tested.
for(let t=lo+step;t<=hi+1;t=Math.min(t+step,hi)){
const val=altitude(t,loc)-target;
if(rising?pv<=0&&val>=0:pv>=0&&val<=0){
let a=prev,b=t;for(let j=0;j<iterations;j++){const m=(a+b)/2,v=altitude(m,loc)-target;if(rising?v<0:v>0)a=m;else b=m;}return(a+b)/2;
}
if(t===hi)break;prev=t;pv=val;
}
return null;
}
// options.boundaryDegrees: {boundaryId: degrees}. Accepted only when the value is listed in that boundary's
// allowed_degrees in the registry; the chosen values are returned in degreesUsed.
function day(day,loc,profile,worship,madhhab,options={}){
if(!ready())return {day,boundaries:{},engineUnavailable:true};
configure(profile.solar_solver);
if(!worship?.madhhabs?.[madhhab])throw new Error('Unknown madhhab: '+madhhab);
const half=need('half_day_hours')*H;
const transition=transitTransition(day,loc,profile.solar_solver);
const noon=transition?.at??maximum(day,loc), out={}, defs=profile.boundaries, warnings=[];
if(!transition)warnings.push('transit_unresolved');
const degreesUsed={};
const chooseDegrees=(id,def)=>{
const want=options?.boundaryDegrees?.[id];
const degrees=Number.isFinite(want)&&Array.isArray(def.allowed_degrees)&&def.allowed_degrees.includes(want)?want:def.degrees;
degreesUsed[id]=degrees;return degrees;
};
const basic=(id,def)=>{
if(def.op==='maximum')return noon;
if(def.op==='post_transit_transition')return transition?.after??null;
if(def.op==='altitude')return crossing(def.direction==='ascending'?noon-half:noon,def.direction==='ascending'?noon:noon+half,chooseDegrees(id,def),def.direction,loc);
if(def.op==='altitude_drop')return crossing(noon,noon+half,altitude(noon,loc)-def.degrees,'descending',loc);
if(def.op==='shadow'){
const peak=altitude(noon,loc);if(peak<=0)return null;
const target=Math.atan(1/(def.factor+1/Math.tan(peak*rad)))/rad;
return crossing(noon,noon+half,target,'descending',loc);
}
return null;
};
Object.entries(defs).forEach(([id,def])=>{out[id]=basic(id,def)});
const nextNoon=maximum(day+D,loc);
for(let pass=0;pass<need('dependency_passes');pass++)Object.entries(defs).forEach(([id,def])=>{
if(def.op==='alias')out[id]=out[def.boundary]??null;
if(def.op==='next_day'){
const target=defs[def.boundary];out[id]=target?.op==='altitude'?crossing(nextNoon-half,nextNoon,chooseDegrees(def.boundary,target),target.direction,loc):null;
}
if(def.op==='rule_start'){
const rule=worship.rules.find(r=>r.id===def.rule_id),choice=rule?.madhhab?.[madhhab];
out[id]=out[choice?.start||rule?.common?.start]??null;
}
if(def.op==='fraction')out[id]=out[def.from]!=null&&out[def.to]!=null?out[def.from]+def.fraction*(out[def.to]-out[def.from]):null;
});
// Boundaries that could not be solved (for example twilight at high latitude) are reported, never invented.
const unresolved=Object.keys(defs).filter(id=>out[id]==null);
return {day,boundaries:out,solar_transition:transition,unresolved,warnings,degreesUsed};
}
// options.ruleActive(rule, dayStamp): decides rules with a Hijri calendar_rule (the engine itself
// resolves weekly/daily rules). options.displayOverrides: {estimateId:{before_minutes}} accepted only
// when the value is listed in that estimate's allowed_before_minutes.
function periods(days,profile,worship,madhhab,styles,scheduleBindings=null,options={}){
const {ruleActive=null,displayOverrides=null}=options||{};
const lookup=(id,rule)=>id?(worship.display_estimates?.[id]||rule.window_display_estimates?.[id]||null):null;
const beforeMinutes=(id,estimate)=>{
const want=displayOverrides?.[id]?.before_minutes,allowed=estimate.allowed_before_minutes;
return Number.isFinite(want)&&Array.isArray(allowed)&&allowed.includes(want)?want:Number(estimate.before_minutes||0);
};
const out=[];
for(const d of days){const b=d.boundaries;
for(const rule of worship.rules){
const state=styles.category_states[rule.category];if(!state)continue;
const calendar=rule.calendar_rule;
if(calendar){
if(calendar.type==='weekly'){if(calendar.weekday!==WEEKDAYS[new Date(d.day).getUTCDay()])continue;}
else if(calendar.type!=='daily'&&!(typeof ruleActive==='function'&&ruleActive(rule,d.day)))continue;
}
const choice=rule.madhhab?.[madhhab];const common={...rule.common,...(typeof choice==='object'?choice:{}),...profile.rule_time_bindings?.[rule.id]};
if(scheduleBindings&&d.methodUnavailable&&state!==styles.category_states.solar_prayer_restriction)continue;
const scheduled=scheduleBindings?.[rule.id];const windows=scheduled?[scheduled]:(typeof choice==='object'&&choice.windows)||rule.windows||[common];
for(const w of windows){
const weekday=WEEKDAYS[new Date(d.day).getUTCDay()];
if((w.exclude_weekdays||common.exclude_weekdays||[]).includes(weekday))continue;
const requires=w.requires||common.requires||rule.trigger?.requires;
const activation=w.activation||common.activation||(requires?'after_prayer':'boundary');
const startId=w.display_start||common.display_start||w.start||common.start||rule.trigger?.start||profile.relation_bindings[w.start_relation||common.start_relation];
// Preserve the chosen school's outer limit, not a fabricated universal end.
const endId=w.end||common.end||w.outer_end||common.outer_end||w.necessity_end||common.necessity_end||rule.trigger?.end;
let start=b[startId],end=b[endId];
const estimateId=w.display_estimate_id,estimate=lookup(estimateId,rule);
if(estimate){const center=b[estimate.boundary];if(center!=null){start=center-beforeMinutes(estimateId,estimate)*MIN;end=center+Number(estimate.after_minutes||0)*MIN;}}
const endEstimateId=w.display_end_estimate_id||common.display_end_estimate_id,endEstimate=lookup(endEstimateId,rule);
if(endEstimate){const center=b[endEstimate.boundary];if(center!=null)end=center-beforeMinutes(endEstimateId,endEstimate)*MIN;}
if(start!=null&&end!=null&&end>=start){
const row={id:rule.id,start,end,point:!estimate&&!endEstimate&&(end===start||Boolean(profile.boundaries[endId]?.transition_only)),startId,endId,state,rule,day:d.day,calculationMethod:scheduled?d.calculationMethod:null,conditional:rule.category==='prayer_restriction'||activation==='after_prayer'||Boolean(requires),requires,activation,displayEstimate:estimate||null,displayEndEstimate:endEstimate||null};out.push(row);
for(const binding of styles.preference_bindings||[]){
const value=common[binding.field];if(!value)continue;
const pair=binding.mode==='end'?[startId,value]:profile.relation_bindings[value];
if(!Array.isArray(pair))continue;
const a=b[pair[0]],z=b[pair[1]];
if(a!=null&&z!=null&&z>a&&a>=start&&z<=end)out.push({...row,start:a,end:z,point:false,startId:pair[0],endId:pair[1],state:binding.state,preference:binding.field});
}
}
}
}
}
return out;
}
api=Object.freeze({ready,configure,position,altitude,maximum,transitTransition,crossing,day,periods});
return api;
})();
|