mirror of
https://github.com/acamarata/solar-spa.git
synced 2026-07-01 03:14:31 +00:00
- Trim README to ≤80 lines with wiki link for full docs - Add CHANGELOG.md with initial entry - Fix CI: replace pinned pnpm/action-setup with corepack enable - Add "type": "module" and flat exports map (ADR-015) - Add ./package.json exports entry - Add coverage script - Rename wasm/spa-module.js → wasm/spa-module.cjs to fix CJS/ESM conflict - Update src/index.ts and tsup.config.ts to reference spa-module.cjs - Add .github/wiki pages: _Sidebar, _Footer, SECURITY, CODE_OF_CONDUCT
268 lines
8 KiB
TypeScript
268 lines
8 KiB
TypeScript
import type { SpaWasmModule, SpaResult, SpaFormattedResult, SpaOptions } from './types.js';
|
|
|
|
export type { SpaOptions, SpaResult, SpaFormattedResult } from './types.js';
|
|
export { SPA_ZA, SPA_ZA_INC, SPA_ZA_RTS, SPA_ALL } from './types.js';
|
|
export type { SpaFunctionCode } from './types.js';
|
|
|
|
import { SPA_ALL } from './types.js';
|
|
|
|
// The WASM module is Emscripten CJS output. In ESM builds, tsup injects a
|
|
// createRequire-based __require shim via the banner option (see tsup.config.ts).
|
|
// In CJS builds, require() is natively available.
|
|
declare const __require: NodeRequire;
|
|
const _loadModule = typeof __require === 'function' ? __require : require;
|
|
const createSpaModule: () => Promise<SpaWasmModule> = _loadModule('../wasm/spa-module.cjs');
|
|
|
|
// Singleton: the WASM module initializes once, all calls share it.
|
|
let _module: SpaWasmModule | null = null;
|
|
let _pending: Promise<void> | null = null;
|
|
let _calculate: ((...args: number[]) => number) | null = null;
|
|
let _free: ((ptr: number) => void) | null = null;
|
|
|
|
// Result struct layout (10 fields, 9 doubles + 1 int32):
|
|
// offset 0: zenith (f64)
|
|
// offset 8: azimuth_astro (f64)
|
|
// offset 16: azimuth (f64)
|
|
// offset 24: incidence (f64)
|
|
// offset 32: sunrise (f64)
|
|
// offset 40: sunset (f64)
|
|
// offset 48: suntransit (f64)
|
|
// offset 56: sun_transit_alt (f64)
|
|
// offset 64: eot (f64)
|
|
// offset 72: error_code (i32)
|
|
const OFFSET = {
|
|
zenith: 0,
|
|
azimuth_astro: 8,
|
|
azimuth: 16,
|
|
incidence: 24,
|
|
sunrise: 32,
|
|
sunset: 40,
|
|
suntransit: 48,
|
|
sun_transit_alt: 56,
|
|
eot: 64,
|
|
error_code: 72,
|
|
} as const;
|
|
|
|
/**
|
|
* Initialize the WASM module. Returns a cached promise on repeat calls.
|
|
* Safe to call multiple times. If initialization fails, subsequent calls
|
|
* will retry rather than returning the failed promise.
|
|
*/
|
|
export function init(): Promise<void> {
|
|
if (_module) return Promise.resolve();
|
|
if (_pending) return _pending;
|
|
|
|
_pending = createSpaModule()
|
|
.then((mod: SpaWasmModule) => {
|
|
_module = mod;
|
|
_calculate = mod.cwrap('spa_calculate_wrapper', 'number', [
|
|
'number',
|
|
'number',
|
|
'number',
|
|
'number',
|
|
'number',
|
|
'number',
|
|
'number',
|
|
'number',
|
|
'number',
|
|
'number',
|
|
'number',
|
|
'number',
|
|
'number',
|
|
'number',
|
|
'number',
|
|
'number',
|
|
'number',
|
|
'number',
|
|
]) as (...args: number[]) => number;
|
|
_free = mod.cwrap('spa_free_result', null, ['number']) as (ptr: number) => void;
|
|
_pending = null;
|
|
})
|
|
.catch((err: unknown) => {
|
|
_pending = null;
|
|
throw err;
|
|
});
|
|
|
|
return _pending;
|
|
}
|
|
|
|
/**
|
|
* Format fractional hours to HH:MM:SS string.
|
|
* Returns "N/A" for non-finite or negative values (polar night/day scenarios).
|
|
*
|
|
* @param hours - Fractional hours (e.g. 6.5 for 06:30:00). Values >= 24 wrap.
|
|
* @returns Formatted time string in HH:MM:SS format, or "N/A" if input is invalid.
|
|
*/
|
|
export function formatTime(hours: number): string {
|
|
if (!isFinite(hours) || hours < 0) return 'N/A';
|
|
|
|
const totalSec = Math.round(hours * 3600);
|
|
const h = Math.floor(totalSec / 3600) % 24;
|
|
const m = Math.floor((totalSec % 3600) / 60);
|
|
const s = totalSec % 60;
|
|
|
|
return (
|
|
String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0')
|
|
);
|
|
}
|
|
|
|
/** Read the result struct from WASM memory and free it. */
|
|
function readResult(ptr: number): SpaResult {
|
|
const m = _module!;
|
|
const result: SpaResult = {
|
|
zenith: m.getValue(ptr + OFFSET.zenith, 'double'),
|
|
azimuth_astro: m.getValue(ptr + OFFSET.azimuth_astro, 'double'),
|
|
azimuth: m.getValue(ptr + OFFSET.azimuth, 'double'),
|
|
incidence: m.getValue(ptr + OFFSET.incidence, 'double'),
|
|
sunrise: m.getValue(ptr + OFFSET.sunrise, 'double'),
|
|
sunset: m.getValue(ptr + OFFSET.sunset, 'double'),
|
|
suntransit: m.getValue(ptr + OFFSET.suntransit, 'double'),
|
|
sun_transit_alt: m.getValue(ptr + OFFSET.sun_transit_alt, 'double'),
|
|
eot: m.getValue(ptr + OFFSET.eot, 'double'),
|
|
error_code: m.getValue(ptr + OFFSET.error_code, 'i32'),
|
|
};
|
|
_free!(ptr);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Validate that a value is a finite number, throwing a clear error if not.
|
|
* @internal
|
|
*/
|
|
function assertFiniteNumber(value: unknown, name: string): asserts value is number {
|
|
if (typeof value !== 'number') {
|
|
throw new TypeError(`SPA: ${name} must be a finite number, got ${typeof value}`);
|
|
}
|
|
if (!isFinite(value)) {
|
|
throw new RangeError(`SPA: ${name} must be a finite number, got ${value}`);
|
|
}
|
|
}
|
|
|
|
/** Field names in SpaOptions that must be finite numbers when provided. */
|
|
const NUMERIC_OPTION_FIELDS = [
|
|
'elevation',
|
|
'pressure',
|
|
'temperature',
|
|
'delta_t',
|
|
'slope',
|
|
'azm_rotation',
|
|
'atmos_refract',
|
|
] as const;
|
|
|
|
/**
|
|
* Validate numeric option fields. Each, if provided, must be a finite number.
|
|
* @internal
|
|
*/
|
|
function validateOptions(opts: SpaOptions): void {
|
|
for (const field of NUMERIC_OPTION_FIELDS) {
|
|
if (opts[field] !== undefined) {
|
|
assertFiniteNumber(opts[field], `options.${field}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compute solar position for the given parameters.
|
|
*
|
|
* @param date - Date and time for the calculation
|
|
* @param latitude - Observer latitude in degrees (-90 to 90)
|
|
* @param longitude - Observer longitude in degrees (-180 to 180)
|
|
* @param options - Optional parameters
|
|
* @returns Solar position result with all computed values
|
|
* @throws {TypeError} If date is not a valid Date, or if latitude/longitude/option fields are not numbers
|
|
* @throws {RangeError} If latitude/longitude are out of bounds, or if option fields are Infinity/NaN
|
|
*/
|
|
export async function spa(
|
|
date: Date,
|
|
latitude: number,
|
|
longitude: number,
|
|
options?: SpaOptions,
|
|
): Promise<SpaResult> {
|
|
// Input validation
|
|
if (!(date instanceof Date) || isNaN(date.getTime())) {
|
|
throw new TypeError('SPA: date must be a valid Date object');
|
|
}
|
|
assertFiniteNumber(latitude, 'latitude');
|
|
assertFiniteNumber(longitude, 'longitude');
|
|
|
|
if (latitude < -90 || latitude > 90) {
|
|
throw new RangeError(`SPA: latitude must be between -90 and 90, got ${latitude}`);
|
|
}
|
|
if (longitude < -180 || longitude > 180) {
|
|
throw new RangeError(`SPA: longitude must be between -180 and 180, got ${longitude}`);
|
|
}
|
|
|
|
if (options) {
|
|
validateOptions(options);
|
|
}
|
|
|
|
await init();
|
|
|
|
const opts = options ?? {};
|
|
const tz = opts.timezone ?? -(date.getTimezoneOffset() / 60);
|
|
|
|
const ptr = _calculate!(
|
|
date.getFullYear(),
|
|
date.getMonth() + 1,
|
|
date.getDate(),
|
|
date.getHours(),
|
|
date.getMinutes(),
|
|
date.getSeconds(),
|
|
tz,
|
|
latitude,
|
|
longitude,
|
|
opts.elevation ?? 0,
|
|
opts.pressure ?? 1013.25,
|
|
opts.temperature ?? 15,
|
|
opts.delta_ut1 ?? 0,
|
|
opts.delta_t ?? 67,
|
|
opts.slope ?? 0,
|
|
opts.azm_rotation ?? 0,
|
|
opts.atmos_refract ?? 0.5667,
|
|
opts.function ?? SPA_ALL,
|
|
);
|
|
|
|
if (!ptr) {
|
|
throw new Error('SPA: memory allocation failed');
|
|
}
|
|
|
|
const result = readResult(ptr);
|
|
|
|
if (result.error_code !== 0) {
|
|
throw new Error('SPA: calculation failed (error code ' + result.error_code + ')');
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Compute solar position and return formatted time strings.
|
|
*
|
|
* Same parameters as spa(). Returns sunrise, sunset, and suntransit
|
|
* as HH:MM:SS strings instead of fractional hours.
|
|
*
|
|
* @throws {TypeError} If date is not a valid Date, or if latitude/longitude/option fields are not numbers
|
|
* @throws {RangeError} If latitude/longitude are out of bounds, or if option fields are Infinity/NaN
|
|
*/
|
|
export async function spaFormatted(
|
|
date: Date,
|
|
latitude: number,
|
|
longitude: number,
|
|
options?: SpaOptions,
|
|
): Promise<SpaFormattedResult> {
|
|
const result = await spa(date, latitude, longitude, options);
|
|
return {
|
|
zenith: result.zenith,
|
|
azimuth_astro: result.azimuth_astro,
|
|
azimuth: result.azimuth,
|
|
incidence: result.incidence,
|
|
sunrise: formatTime(result.sunrise),
|
|
sunset: formatTime(result.sunset),
|
|
suntransit: formatTime(result.suntransit),
|
|
sun_transit_alt: result.sun_transit_alt,
|
|
eot: result.eot,
|
|
error_code: result.error_code,
|
|
};
|
|
}
|
|
|
|
export default spa;
|