Compare commits

..

No commits in common. "main" and "v2.0.2" have entirely different histories.
main ... v2.0.2

8 changed files with 62 additions and 109 deletions

View file

@ -101,15 +101,15 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- name: Setup Node 24 - name: Setup Node 24
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 24
cache: pnpm cache: pnpm
- name: Enable corepack
run: corepack enable
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile

View file

@ -59,11 +59,6 @@ This package includes the Solar Position Algorithm (SPA) developed at the Nation
> Reda, I., Andreas, A. (2004). "Solar Position Algorithm for Solar Radiation Applications." *Solar Energy*, 76(5), 577-589. > Reda, I., Andreas, A. (2004). "Solar Position Algorithm for Solar Radiation Applications." *Solar Energy*, 76(5), 577-589.
## Telemetry
This package supports opt-in anonymous usage telemetry — off by default.
Enable: `ACAMARATA_TELEMETRY=1`. See [TELEMETRY.md](./TELEMETRY.md) for what is sent and how to disable.
## License ## License
MIT (wrapper, TypeScript source, and build tooling). The NREL SPA C source (`src/spa.c`, `src/spa.h`) is subject to its own terms; see the notice in those files. MIT (wrapper, TypeScript source, and build tooling). The NREL SPA C source (`src/spa.c`, `src/spa.h`) is subject to its own terms; see the notice in those files.

View file

@ -1,8 +0,0 @@
# Telemetry Disclosure
This package supports opt-in anonymous usage telemetry via [`@acamarata/telemetry`](https://github.com/acamarata/telemetry).
Telemetry is **off by default**. No data is sent unless you set `ACAMARATA_TELEMETRY=1`.
Full disclosure (what is sent, where it goes, how to disable):
[github.com/acamarata/telemetry/blob/main/TELEMETRY.md](https://github.com/acamarata/telemetry/blob/main/TELEMETRY.md)

View file

@ -5,17 +5,10 @@ import { typescript } from '@acamarata/eslint-config';
export default [ export default [
{ {
files: ['src/**/*.ts'],
plugins: { '@typescript-eslint': tsPlugin }, plugins: { '@typescript-eslint': tsPlugin },
languageOptions: { languageOptions: { parser: tsParser },
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname,
},
},
}, },
...typescript.map((cfg) => ({ ...cfg, files: ['src/**/*.ts'] })), ...typescript,
eslintConfigPrettier, eslintConfigPrettier,
{ {
ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs', 'wasm/', 'src/spa.c', 'src/spa.h', 'validate.mjs'], ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs', 'wasm/', 'src/spa.c', 'src/spa.h', 'validate.mjs'],

View file

@ -29,7 +29,7 @@
], ],
"scripts": { "scripts": {
"build:wasm": "emcc src/spa.c src/spa_wrapper.c -O3 -flto --no-entry -sMODULARIZE=1 -sEXPORT_NAME=createSpaModule -sSINGLE_FILE=1 -sEXPORTED_FUNCTIONS='[\"_spa_calculate_wrapper\",\"_spa_free_result\",\"_malloc\",\"_free\"]' -sEXPORTED_RUNTIME_METHODS='[\"cwrap\",\"getValue\"]' -sALLOW_MEMORY_GROWTH=0 -sINITIAL_MEMORY=1048576 -sSTACK_SIZE=65536 -sENVIRONMENT='node,web,worker' -sNO_FILESYSTEM=1 -sASSERTIONS=0 -sDISABLE_EXCEPTION_CATCHING=1 -sWASM_BIGINT=0 -o wasm/spa-module.js", "build:wasm": "emcc src/spa.c src/spa_wrapper.c -O3 -flto --no-entry -sMODULARIZE=1 -sEXPORT_NAME=createSpaModule -sSINGLE_FILE=1 -sEXPORTED_FUNCTIONS='[\"_spa_calculate_wrapper\",\"_spa_free_result\",\"_malloc\",\"_free\"]' -sEXPORTED_RUNTIME_METHODS='[\"cwrap\",\"getValue\"]' -sALLOW_MEMORY_GROWTH=0 -sINITIAL_MEMORY=1048576 -sSTACK_SIZE=65536 -sENVIRONMENT='node,web,worker' -sNO_FILESYSTEM=1 -sASSERTIONS=0 -sDISABLE_EXCEPTION_CATCHING=1 -sWASM_BIGINT=0 -o wasm/spa-module.js",
"build:ts": "tsup && cp dist/index.d.ts dist/index.d.mts", "build:ts": "tsup",
"build": "pnpm run build:wasm && pnpm run build:ts", "build": "pnpm run build:wasm && pnpm run build:ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"pretest": "pnpm run build:ts", "pretest": "pnpm run build:ts",
@ -38,7 +38,7 @@
"format": "prettier --write src/", "format": "prettier --write src/",
"format:check": "prettier --check src/", "format:check": "prettier --check src/",
"validate": "node validate.mjs", "validate": "node validate.mjs",
"prepack": "pnpm run build:ts", "prepublishOnly": "pnpm run build:ts",
"coverage": "c8 --reporter=lcov --reporter=text node test.mjs", "coverage": "c8 --reporter=lcov --reporter=text node test.mjs",
"docs": "typedoc --out .github/wiki/api src/index.ts", "docs": "typedoc --out .github/wiki/api src/index.ts",
"postbuild": "cp dist/index.d.ts dist/index.d.mts" "postbuild": "cp dist/index.d.ts dist/index.d.mts"
@ -74,13 +74,11 @@
}, },
"devDependencies": { "devDependencies": {
"@acamarata/eslint-config": "^0.1.0", "@acamarata/eslint-config": "^0.1.0",
"c8": "^10.1.3",
"@acamarata/prettier-config": "^0.1.0", "@acamarata/prettier-config": "^0.1.0",
"@acamarata/tsconfig": "^0.1.0", "@acamarata/tsconfig": "^0.1.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/node": "^25.3.0", "@types/node": "^25.3.0",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "^10.1.3",
"eslint": "^10.0.3", "eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1", "prettier": "^3.8.1",
@ -88,8 +86,7 @@
"typedoc": "^0.28.19", "typedoc": "^0.28.19",
"typedoc-plugin-markdown": "^4.11.0", "typedoc-plugin-markdown": "^4.11.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.56.1"
"@acamarata/telemetry": "^0.1.0"
}, },
"packageManager": "pnpm@10.11.1", "packageManager": "pnpm@10.11.1",
"prettier": "@acamarata/prettier-config" "prettier": "@acamarata/prettier-config"

View file

@ -14,9 +14,6 @@ importers:
'@acamarata/prettier-config': '@acamarata/prettier-config':
specifier: ^0.1.0 specifier: ^0.1.0
version: 0.1.0(prettier@3.8.1) version: 0.1.0(prettier@3.8.1)
'@acamarata/telemetry':
specifier: ^0.1.0
version: 0.1.0
'@acamarata/tsconfig': '@acamarata/tsconfig':
specifier: ^0.1.0 specifier: ^0.1.0
version: 0.1.0 version: 0.1.0
@ -26,12 +23,6 @@ importers:
'@types/node': '@types/node':
specifier: ^25.3.0 specifier: ^25.3.0
version: 25.3.0 version: 25.3.0
'@typescript-eslint/eslint-plugin':
specifier: ^8.56.1
version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.3)(typescript@5.9.3))(eslint@10.0.3)(typescript@5.9.3)
'@typescript-eslint/parser':
specifier: ^8.56.1
version: 8.56.1(eslint@10.0.3)(typescript@5.9.3)
c8: c8:
specifier: ^10.1.3 specifier: ^10.1.3
version: 10.1.3 version: 10.1.3
@ -84,10 +75,6 @@ packages:
peerDependencies: peerDependencies:
prettier: '>=3.0.0' prettier: '>=3.0.0'
'@acamarata/telemetry@0.1.0':
resolution: {integrity: sha512-iP09ZD0bHencHLbv6kQZDgwN9crLCWGKxmiMrfJjhBCoWTgv4koSgg0Li/LFKwCCFluua6orj9fVeQ8eqcJXSQ==}
engines: {node: '>=20'}
'@acamarata/tsconfig@0.1.0': '@acamarata/tsconfig@0.1.0':
resolution: {integrity: sha512-bgzyBak43mE+0HhduZX3cvaPjKcggtGGZZMjr35qtYWolsIWgZ9nx7OOswbVYoU35qoUv6rZ0mTK6GbZ8QTYjw==} resolution: {integrity: sha512-bgzyBak43mE+0HhduZX3cvaPjKcggtGGZZMjr35qtYWolsIWgZ9nx7OOswbVYoU35qoUv6rZ0mTK6GbZ8QTYjw==}
engines: {node: '>=20'} engines: {node: '>=20'}
@ -1232,8 +1219,6 @@ snapshots:
dependencies: dependencies:
prettier: 3.8.1 prettier: 3.8.1
'@acamarata/telemetry@0.1.0': {}
'@acamarata/tsconfig@0.1.0': {} '@acamarata/tsconfig@0.1.0': {}
'@bcoe/v8-coverage@1.0.2': {} '@bcoe/v8-coverage@1.0.2': {}

View file

@ -1,17 +1,17 @@
import type { SpaWasmModule, SpaResult, SpaFormattedResult, SpaOptions } from "./types.js"; import type { SpaWasmModule, SpaResult, SpaFormattedResult, SpaOptions } from './types.js';
export type { SpaOptions, SpaResult, SpaFormattedResult } 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 { SPA_ZA, SPA_ZA_INC, SPA_ZA_RTS, SPA_ALL } from './types.js';
export type { SpaFunctionCode } from "./types.js"; export type { SpaFunctionCode } from './types.js';
import { SPA_ALL } from "./types.js"; import { SPA_ALL } from './types.js';
// The WASM module is Emscripten CJS output. In ESM builds, tsup injects a // 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). // createRequire-based __require shim via the banner option (see tsup.config.ts).
// In CJS builds, require() is natively available. // In CJS builds, require() is natively available.
declare const __require: NodeRequire; declare const __require: NodeRequire;
const _loadModule = typeof __require === "function" ? __require : require; const _loadModule = typeof __require === 'function' ? __require : require;
const createSpaModule: () => Promise<SpaWasmModule> = _loadModule("../wasm/spa-module.cjs"); const createSpaModule: () => Promise<SpaWasmModule> = _loadModule('../wasm/spa-module.cjs');
// Singleton: the WASM module initializes once, all calls share it. // Singleton: the WASM module initializes once, all calls share it.
let _module: SpaWasmModule | null = null; let _module: SpaWasmModule | null = null;
@ -64,27 +64,27 @@ export function init(): Promise<void> {
_pending = createSpaModule() _pending = createSpaModule()
.then((mod: SpaWasmModule) => { .then((mod: SpaWasmModule) => {
_module = mod; _module = mod;
_calculate = mod.cwrap("spa_calculate_wrapper", "number", [ _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',
"number", 'number',
"number", 'number',
"number", 'number',
"number", 'number',
"number", 'number',
"number", 'number',
"number", 'number',
"number", 'number',
"number", 'number',
]) as (...args: number[]) => number; ]) as (...args: number[]) => number;
_free = mod.cwrap("spa_free_result", null, ["number"]) as (ptr: number) => void; _free = mod.cwrap('spa_free_result', null, ['number']) as (ptr: number) => void;
_pending = null; _pending = null;
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
@ -111,7 +111,7 @@ export function init(): Promise<void> {
* formatTime(Infinity) // "N/A" * formatTime(Infinity) // "N/A"
*/ */
export function formatTime(hours: number): string { export function formatTime(hours: number): string {
if (!isFinite(hours) || hours < 0) return "N/A"; if (!isFinite(hours) || hours < 0) return 'N/A';
const totalSec = Math.round(hours * 3600); const totalSec = Math.round(hours * 3600);
const h = Math.floor(totalSec / 3600) % 24; const h = Math.floor(totalSec / 3600) % 24;
@ -119,7 +119,7 @@ export function formatTime(hours: number): string {
const s = totalSec % 60; const s = totalSec % 60;
return ( return (
String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0") String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0')
); );
} }
@ -127,16 +127,16 @@ export function formatTime(hours: number): string {
function readResult(ptr: number): SpaResult { function readResult(ptr: number): SpaResult {
const m = _module!; const m = _module!;
const result: SpaResult = { const result: SpaResult = {
zenith: m.getValue(ptr + OFFSET.zenith, "double"), zenith: m.getValue(ptr + OFFSET.zenith, 'double'),
azimuth_astro: m.getValue(ptr + OFFSET.azimuth_astro, "double"), azimuth_astro: m.getValue(ptr + OFFSET.azimuth_astro, 'double'),
azimuth: m.getValue(ptr + OFFSET.azimuth, "double"), azimuth: m.getValue(ptr + OFFSET.azimuth, 'double'),
incidence: m.getValue(ptr + OFFSET.incidence, "double"), incidence: m.getValue(ptr + OFFSET.incidence, 'double'),
sunrise: m.getValue(ptr + OFFSET.sunrise, "double"), sunrise: m.getValue(ptr + OFFSET.sunrise, 'double'),
sunset: m.getValue(ptr + OFFSET.sunset, "double"), sunset: m.getValue(ptr + OFFSET.sunset, 'double'),
suntransit: m.getValue(ptr + OFFSET.suntransit, "double"), suntransit: m.getValue(ptr + OFFSET.suntransit, 'double'),
sun_transit_alt: m.getValue(ptr + OFFSET.sun_transit_alt, "double"), sun_transit_alt: m.getValue(ptr + OFFSET.sun_transit_alt, 'double'),
eot: m.getValue(ptr + OFFSET.eot, "double"), eot: m.getValue(ptr + OFFSET.eot, 'double'),
error_code: m.getValue(ptr + OFFSET.error_code, "i32"), error_code: m.getValue(ptr + OFFSET.error_code, 'i32'),
}; };
_free!(ptr); _free!(ptr);
return result; return result;
@ -147,7 +147,7 @@ function readResult(ptr: number): SpaResult {
* @internal * @internal
*/ */
function assertFiniteNumber(value: unknown, name: string): asserts value is number { function assertFiniteNumber(value: unknown, name: string): asserts value is number {
if (typeof value !== "number") { if (typeof value !== 'number') {
throw new TypeError(`SPA: ${name} must be a finite number, got ${typeof value}`); throw new TypeError(`SPA: ${name} must be a finite number, got ${typeof value}`);
} }
if (!isFinite(value)) { if (!isFinite(value)) {
@ -157,13 +157,13 @@ function assertFiniteNumber(value: unknown, name: string): asserts value is numb
/** Field names in SpaOptions that must be finite numbers when provided. */ /** Field names in SpaOptions that must be finite numbers when provided. */
const NUMERIC_OPTION_FIELDS = [ const NUMERIC_OPTION_FIELDS = [
"elevation", 'elevation',
"pressure", 'pressure',
"temperature", 'temperature',
"delta_t", 'delta_t',
"slope", 'slope',
"azm_rotation", 'azm_rotation',
"atmos_refract", 'atmos_refract',
] as const; ] as const;
/** /**
@ -209,10 +209,10 @@ export async function spa(
): Promise<SpaResult> { ): Promise<SpaResult> {
// Input validation // Input validation
if (!(date instanceof Date) || isNaN(date.getTime())) { if (!(date instanceof Date) || isNaN(date.getTime())) {
throw new TypeError("SPA: date must be a valid Date object"); throw new TypeError('SPA: date must be a valid Date object');
} }
assertFiniteNumber(latitude, "latitude"); assertFiniteNumber(latitude, 'latitude');
assertFiniteNumber(longitude, "longitude"); assertFiniteNumber(longitude, 'longitude');
if (latitude < -90 || latitude > 90) { if (latitude < -90 || latitude > 90) {
throw new RangeError(`SPA: latitude must be between -90 and 90, got ${latitude}`); throw new RangeError(`SPA: latitude must be between -90 and 90, got ${latitude}`);
@ -252,13 +252,13 @@ export async function spa(
); );
if (!ptr) { if (!ptr) {
throw new Error("SPA: memory allocation failed"); throw new Error('SPA: memory allocation failed');
} }
const result = readResult(ptr); const result = readResult(ptr);
if (result.error_code !== 0) { if (result.error_code !== 0) {
throw new Error("SPA: calculation failed (error code " + result.error_code + ")"); throw new Error('SPA: calculation failed (error code ' + result.error_code + ')');
} }
return result; return result;
@ -309,12 +309,3 @@ export async function spaFormatted(
} }
export default spa; export default spa;
// ── Opt-in anonymous telemetry ────────────────────────────────────────────────
// Off by default. Enable: ACAMARATA_TELEMETRY=1
// What is sent + how to disable: https://github.com/acamarata/telemetry/blob/main/TELEMETRY.md
import("@acamarata/telemetry")
.then(({ track }) => track("load", { package: "solar-spa", version: "2.0.2" }))
.catch(() => {
// telemetry not installed or disabled — that is fine
});

View file

@ -62,7 +62,7 @@ export interface SpaResult {
error_code: number; error_code: number;
} }
export interface SpaFormattedResult extends Omit<SpaResult, "sunrise" | "sunset" | "suntransit"> { export interface SpaFormattedResult extends Omit<SpaResult, 'sunrise' | 'sunset' | 'suntransit'> {
/** Local sunrise time as HH:MM:SS string. "N/A" during polar day/night. */ /** Local sunrise time as HH:MM:SS string. "N/A" during polar day/night. */
sunrise: string; sunrise: string;
/** Local sunset time as HH:MM:SS string. "N/A" during polar day/night. */ /** Local sunset time as HH:MM:SS string. "N/A" during polar day/night. */