UNPKG

amaran-light-cli

Version:

Command line tool for controlling Aputure Amaran lights via WebSocket to a local Amaran desktop app.

249 lines 11.7 kB
import SunCalc from 'suncalc'; const { getPosition, getTimes } = SunCalc; import { CCT_DEFAULTS } from './constants.js'; import { CURVE_FUNCTIONS, calculateRealisticBlackbodyDaylight, calculateRealisticCIEDaylight, calculateRealisticHazyDaylight, calculateRealisticPerezDaylight, calculateRealisticPhysicsDaylight, calculateRealisticSunAltitude, getAvailableCurves, parseCurveType, } from './curves/index.js'; import { interpolateMaxLux } from './mathUtil.js'; import { CurveType } from './types.js'; // Re-export for backward compatibility export { CurveType, getAvailableCurves, parseCurveType }; function isValidSunTimes(sunrise, sunset, solarNoon) { return (sunrise instanceof Date && !Number.isNaN(sunrise.getTime()) && sunset instanceof Date && !Number.isNaN(sunset.getTime()) && solarNoon instanceof Date && !Number.isNaN(solarNoon.getTime()) && sunset.getTime() > sunrise.getTime() && solarNoon.getTime() > sunrise.getTime() && solarNoon.getTime() < sunset.getTime()); } /** * Handle scientific curves (CIE, SUN_ALTITUDE, PEREZ) which use altitude-based calculations */ function calculateScientificCCT(lat, lon, date, minK, maxK, minIntensity, maxIntensity, curveType, times, weather) { const sunrise = times.sunrise; const sunset = times.sunset; const _solarNoon = times.solarNoon; const nightEnd = times.nightEnd; const night = times.night; // For scientific curves, we need solarNoon to establish a daily peak for normalization. // Sunrise/sunset may be missing at the poles (Polar Day/Night). if (!(_solarNoon instanceof Date) || Number.isNaN(_solarNoon.getTime())) { return { cct: Math.round(minK), intensity: Math.round(minIntensity), lightOutput: 0, }; } const t = date.getTime(); // If we have valid sunrise/sunset, enforce night minimums outside of daylight hours. if (sunrise instanceof Date && !Number.isNaN(sunrise.getTime()) && sunset instanceof Date && !Number.isNaN(sunset.getTime())) { // For scientific curves, handle cases where nightEnd/night might not be available let nightEndTime; let nightStartTime; if (nightEnd instanceof Date && !Number.isNaN(nightEnd.getTime()) && night instanceof Date && !Number.isNaN(night.getTime())) { nightEndTime = nightEnd.getTime(); nightStartTime = night.getTime(); } else { // Edge case: no proper night configuration but has sunrise/sunset nightEndTime = sunrise.getTime() - 30 * 60 * 1000; nightStartTime = sunset.getTime() + 30 * 60 * 1000; } if (t <= nightEndTime || t >= nightStartTime) { return { cct: minK, intensity: minIntensity, lightOutput: 0 }; } } const pos = getPosition(date, lat, lon); const altitude = pos.altitude; const noonPos = getPosition(_solarNoon, lat, lon); const maxAltitude = noonPos.altitude; let factors; switch (curveType) { case CurveType.SUN_ALTITUDE: factors = calculateRealisticSunAltitude(altitude, maxAltitude); break; case CurveType.CIE_DAYLIGHT: factors = calculateRealisticCIEDaylight(altitude, maxAltitude); break; case CurveType.PEREZ_DAYLIGHT: factors = calculateRealisticPerezDaylight(altitude, maxAltitude); break; case CurveType.PHYSICS: factors = calculateRealisticPhysicsDaylight(altitude, maxAltitude, weather); break; case CurveType.BLACKBODY: factors = calculateRealisticBlackbodyDaylight(altitude, maxAltitude); break; case CurveType.HAZY: factors = calculateRealisticHazyDaylight(altitude, maxAltitude); break; default: factors = [0, 0, 0, 0]; } const [cctFactor, intensityFactor, rawIntensity, maxDailyIntensity] = factors; return { cct: Math.round(minK + (maxK - minK) * cctFactor), intensity: Math.round(minIntensity + (maxIntensity - minIntensity) * intensityFactor), lightOutput: Math.round(rawIntensity * CCT_DEFAULTS.maxLux), maxDailyOutput: Math.round(maxDailyIntensity * CCT_DEFAULTS.maxLux), }; } /** * Handle empirical curves (HANN, WIDER_MIDDLE) which use time-based calculations */ function calculateEmpiricalCCT(lat, lon, date, minK, maxK, minIntensity, maxIntensity, curveType, times) { const curve = CURVE_FUNCTIONS[curveType]; const sunrise = times.sunrise; const sunset = times.sunset; const _solarNoon = times.solarNoon; const nightEnd = times.nightEnd; const night = times.night; if (isValidSunTimes(sunrise, sunset, _solarNoon) && nightEnd instanceof Date && !Number.isNaN(nightEnd.getTime()) && night instanceof Date && !Number.isNaN(night.getTime())) { const t = date.getTime(); const noon = _solarNoon.getTime(); const nightStartTime = night.getTime(); const nightEndTime = nightEnd.getTime(); if (t <= nightEndTime || t >= nightStartTime) { return { cct: minK, intensity: minIntensity, lightOutput: 0 }; } let x; if (t <= noon) { x = ((t - nightEndTime) / (noon - nightEndTime)) * 0.5; } else { x = 0.5 + ((t - noon) / (nightStartTime - noon)) * 0.5; } const f = curve(x); // For empirical curves, we don't have a physical model. // We use the curve factor 'f' directly. const luxEstimate = f * CCT_DEFAULTS.maxLux; const maxDailyLux = CCT_DEFAULTS.maxLux; return { cct: Math.round(minK + (maxK - minK) * f), intensity: Math.round(minIntensity + (maxIntensity - minIntensity) * f), lightOutput: Math.round(luxEstimate), maxDailyOutput: Math.round(maxDailyLux), }; } // Fallback for empirical curves when night times are not available try { const pos = getPosition(date, lat, lon); if (pos.altitude <= 0) return { cct: minK, intensity: minIntensity, lightOutput: 0 }; const f = Math.max(0, Math.sin(pos.altitude)); return { cct: Math.round(minK + (maxK - minK) * f), intensity: Math.round(minIntensity + (maxIntensity - minIntensity) * f), lightOutput: Math.round(f * CCT_DEFAULTS.maxLux), maxDailyOutput: CCT_DEFAULTS.maxLux, }; } catch { return { cct: minK, intensity: minIntensity, lightOutput: 0 }; } } function calculateCCTCore(lat, lon, date, minK, maxK, minIntensity, maxIntensity, curveType, weather) { const times = getTimes(date, lat, lon); const isScientific = curveType === CurveType.CIE_DAYLIGHT || curveType === CurveType.SUN_ALTITUDE || curveType === CurveType.PEREZ_DAYLIGHT || curveType === CurveType.PHYSICS || curveType === CurveType.BLACKBODY || curveType === CurveType.HAZY; if (isScientific) { return calculateScientificCCT(lat, lon, date, minK, maxK, minIntensity, maxIntensity, curveType, times, weather); } return calculateEmpiricalCCT(lat, lon, date, minK, maxK, minIntensity, maxIntensity, curveType, times); } export function calculateCCT(lat, lon, date = new Date(), opts, curveType = CurveType.HANN) { const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); const cctMinK = clamp(opts?.cctMinK ?? CCT_DEFAULTS.cctMinK, 1000, 20000); const cctMaxK = clamp(opts?.cctMaxK ?? CCT_DEFAULTS.cctMaxK, 1000, 20000); const minK = Math.min(cctMinK, cctMaxK); const maxK = Math.max(cctMinK, cctMaxK); const intensityMinPct = clamp(opts?.intensityMinPct ?? CCT_DEFAULTS.intensityMinPct, 0, 100); const intensityMaxPct = clamp(opts?.intensityMaxPct ?? CCT_DEFAULTS.intensityMaxPct, 0, 100); const minPct = Math.min(intensityMinPct, intensityMaxPct); const maxPct = Math.max(intensityMinPct, intensityMaxPct); const minIntensity = Math.round(minPct * 10); const maxIntensity = Math.round(maxPct * 10); // Apply weather modifiers if provided let result = calculateCCTCore(lat, lon, date, minK, maxK, minIntensity, maxIntensity, curveType, opts?.weather); // Apply weather modifiers if provided and there is actual light output if (opts?.weather && result.lightOutput && result.lightOutput > 0) { result = applyWeatherModifiers(result, opts.weather); } // Handle simulation scale (zenith) override if (opts?.simulationMaxLux !== undefined && result.lightOutput !== undefined) { // We normalize by the daily peak to ensure that the user's -L param // represents the peak for *today*, which is more intuitive for a CLI limit. const dailyPeak = result.maxDailyOutput || CCT_DEFAULTS.maxLux; if (dailyPeak > 0) { result.lightOutput = (result.lightOutput / dailyPeak) * opts.simulationMaxLux; } } // Handle maxLux scaling if provided if (opts?.maxLux !== undefined && result.lightOutput !== undefined) { const effectiveMaxLux = typeof opts.maxLux === 'number' ? opts.maxLux : interpolateMaxLux(result.cct, opts.maxLux); if (effectiveMaxLux > 0) { // Scale intensity based on lightOutput vs maxLux (calibration point) // If simulationMaxLux was provided, result.lightOutput is already scaled to it. // If simulationMaxLux == effectiveMaxLux, result.intensity will follow the raw curve factor. result.intensity = Math.round((result.lightOutput / effectiveMaxLux) * 1000); } } // Final safety: ALWAYS clamp intensity to requested boundaries result.intensity = Math.min(maxIntensity, Math.max(minIntensity, result.intensity)); return result; } function applyWeatherModifiers(result, weather) { const { cloudCover = 0, precipitation = 'none' } = weather; let { cct, intensity, lightOutput = 0 } = result; // Cloud cover logic: // 1. Reduce intensity linearly: 0% clouds = 100%, 100% clouds = 20% intensity const cloudIntensityFactor = 1 - Math.min(1, Math.max(0, cloudCover)) * 0.8; intensity = Math.round(intensity * cloudIntensityFactor); lightOutput = Math.round(lightOutput * cloudIntensityFactor); // 2. Shift CCT towards 6500K (neutral/overcast) based on cloud cover // Heavy clouds act as a diffuser, mixing direct sun and blue sky to a uniform ~6500K const targetK = 6500; const cloudMix = Math.min(1, Math.max(0, cloudCover)); cct = Math.round(cct * (1 - cloudMix) + targetK * cloudMix); // Precipitation logic: // Additional intensity reduction beyond cloud cover let precipFactor = 1.0; switch (precipitation) { case 'rain': precipFactor = 0.8; // Rain also tends to cool the light slightly (scattering) cct = Math.round(cct * 0.9 + 7000 * 0.1); break; case 'snow': precipFactor = 0.9; // Snow reflects light, maybe less dark than rain? But falling snow blocks. // Snow reflection can make things very cool/blue cct = Math.round(cct * 0.8 + 8000 * 0.2); break; case 'drizzle': precipFactor = 0.9; break; default: precipFactor = 1.0; } intensity = Math.round(intensity * precipFactor); lightOutput = Math.round(lightOutput * precipFactor); return { cct, intensity, lightOutput }; } //# sourceMappingURL=cctUtil.js.map