Initial release: nrel_spa v1.0.0

This commit is contained in:
Aric Camarata 2026-03-08 13:01:35 -04:00
parent 6d6ae52843
commit 81aa004b36
11 changed files with 1666 additions and 0 deletions

44
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Test (Dart ${{ matrix.dart-version }})
runs-on: ubuntu-latest
strategy:
matrix:
dart-version: [stable, beta]
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
with:
sdk: ${{ matrix.dart-version }}
- run: dart pub get
- run: dart analyze
- run: dart test
format:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
with:
sdk: stable
- run: dart format --set-exit-if-changed .
publish-check:
name: Publish Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
with:
sdk: stable
- run: dart pub get
- run: dart pub publish --dry-run

20
.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
.dart_tool/
.packages
build/
pubspec.lock
doc/api/
*.iml
.idea/
.DS_Store
.claude/
.env
.env.*
.vscode/*
.codex/
.cursor/
.aider/
.aider.chat.history.md
.continue/
.windsurf/
.gemini/
.codeium/

12
CHANGELOG.md Normal file
View file

@ -0,0 +1,12 @@
# Changelog
## [1.0.0] - 2026-03-08
### Added
- `getSpa()` computes solar position for any date and location
- Custom zenith angle support for twilight calculations
- Full NREL SPA algorithm (Reda & Andreas, 2004)
- Accuracy: ±0.0003° for solar zenith angle
- Valid range: years -2000 to 6000
- Zero external dependencies

16
LICENSE
View file

@ -19,3 +19,19 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Third-Party Notice
==================
The Solar Position Algorithm implementation is based on:
Reda, I. and Andreas, A. (2004). Solar Position Algorithm for Solar
Radiation Applications. NREL/TP-560-34302.
DOI: 10.2172/15003974
National Renewable Energy Laboratory (NREL)
1617 Cole Blvd, Golden, CO 80401
The original C implementation is Copyright (c) 2003-2012 NREL.
This Dart port is an independent implementation. NREL has not endorsed
or certified this library.

88
README.md Normal file
View file

@ -0,0 +1,88 @@
# nrel_spa
[![pub package](https://img.shields.io/pub/v/nrel_spa.svg)](https://pub.dev/packages/nrel_spa)
[![CI](https://github.com/acamarata/nrel-spa-dart/actions/workflows/ci.yml/badge.svg)](https://github.com/acamarata/nrel-spa-dart/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
NREL Solar Position Algorithm for Dart and Flutter. Calculates solar zenith, azimuth, sunrise, sunset, and solar noon for any location and time. Pure Dart, zero dependencies.
Based on Reda & Andreas (2004), NREL/TP-560-34302. Accurate to ±0.0003 degrees.
## Installation
```yaml
dependencies:
nrel_spa: ^1.0.0
```
## Quick Start
```dart
import 'package:nrel_spa/nrel_spa.dart';
void main() {
final result = getSpa(
DateTime.utc(2024, 3, 15, 17, 0, 0),
40.7128, // latitude (NYC)
-74.0060, // longitude
-5.0, // UTC offset (EST)
);
print('Zenith: ${result.zenith.toStringAsFixed(4)}°');
print('Azimuth: ${result.azimuth.toStringAsFixed(4)}°');
print('Sunrise: ${result.sunrise.toStringAsFixed(4)} h');
print('Solar Noon: ${result.solarNoon.toStringAsFixed(4)} h');
print('Sunset: ${result.sunset.toStringAsFixed(4)} h');
}
```
## Custom Zenith Angles
Calculate rise/set times for any solar depression angle (twilight, prayer times, etc.):
```dart
final result = getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128, -74.0060, -5.0,
customAngles: [96.0, 102.0, 108.0], // civil, nautical, astronomical
);
for (final angle in result.angles) {
print('Rise: ${angle.sunrise}, Set: ${angle.sunset}');
}
```
## API
### `getSpa(date, latitude, longitude, timezone, {...})`
| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `date` | `DateTime` | required | UTC date and time |
| `latitude` | `double` | required | Degrees (-90 to 90) |
| `longitude` | `double` | required | Degrees (-180 to 180) |
| `timezone` | `double` | required | Hours from UTC |
| `elevation` | `double` | 0 | Meters above sea level |
| `pressure` | `double` | 1013 | Atmospheric pressure (mbar) |
| `temperature` | `double` | 15 | Temperature (Celsius) |
| `deltaT` | `double` | 67 | TT - UT1 (seconds) |
| `customAngles` | `List<double>` | [] | Zenith angles for rise/set |
Returns `SpaResult` with `zenith`, `azimuth`, `sunrise`, `solarNoon`, `sunset`, and `angles`.
## Compatibility
Dart SDK 3.7.0+. Works in Flutter, Dart CLI, and server-side Dart. Zero dependencies.
## Related
- [pray_calc_dart](https://github.com/acamarata/pray-calc-dart) - Islamic prayer times (uses nrel_spa)
- [nrel-spa](https://github.com/acamarata/nrel-spa) - JavaScript/TypeScript version (npm)
## Acknowledgments
> Reda, I. and Andreas, A. (2004). Solar Position Algorithm for Solar Radiation Applications. NREL/TP-560-34302. [DOI: 10.2172/15003974](https://doi.org/10.2172/15003974)
## License
[MIT](LICENSE). See LICENSE for NREL third-party notice.

1
analysis_options.yaml Normal file
View file

@ -0,0 +1 @@
include: package:lints/recommended.yaml

9
lib/nrel_spa.dart Normal file
View file

@ -0,0 +1,9 @@
/// NREL Solar Position Algorithm for Dart.
///
/// Pure Dart implementation of the NREL SPA (Reda & Andreas, 2004).
/// Accurate to ±0.0003° for solar zenith angle over years -2000 to 6000.
/// Zero external dependencies.
library;
export 'src/types.dart';
export 'src/spa.dart' show getSpa;

1279
lib/src/spa.dart Normal file

File diff suppressed because it is too large Load diff

40
lib/src/types.dart Normal file
View file

@ -0,0 +1,40 @@
/// Types for the NREL SPA algorithm.
library;
/// SPA result from the NREL Solar Position Algorithm.
class SpaResult {
/// Topocentric zenith angle in degrees.
final double zenith;
/// Topocentric azimuth angle, eastward from north, in degrees.
final double azimuth;
/// Local sunrise time as fractional hours (NaN if polar).
final double sunrise;
/// Local sun transit time (solar noon) as fractional hours.
final double solarNoon;
/// Local sunset time as fractional hours (NaN if polar).
final double sunset;
/// Custom zenith angle results (one per angle in the input list).
final List<SpaAnglesResult> angles;
const SpaResult({
required this.zenith,
required this.azimuth,
required this.sunrise,
required this.solarNoon,
required this.sunset,
this.angles = const [],
});
}
/// Sunrise/sunset pair for a custom zenith angle.
class SpaAnglesResult {
final double sunrise;
final double sunset;
const SpaAnglesResult({required this.sunrise, required this.sunset});
}

21
pubspec.yaml Normal file
View file

@ -0,0 +1,21 @@
name: nrel_spa
description: >
NREL Solar Position Algorithm for Dart and Flutter. Calculates solar zenith,
azimuth, sunrise, sunset, and solar noon for any location and time.
Pure Dart, zero dependencies, ±0.0003° accuracy.
version: 1.0.0
repository: https://github.com/acamarata/nrel-spa-dart
issue_tracker: https://github.com/acamarata/nrel-spa-dart/issues
topics:
- solar
- astronomy
- sunrise
- sunset
- solar-position
environment:
sdk: ^3.7.0
dev_dependencies:
lints: ^5.0.0
test: ^1.25.8

136
test/nrel_spa_test.dart Normal file
View file

@ -0,0 +1,136 @@
import 'package:nrel_spa/nrel_spa.dart';
import 'package:test/test.dart';
void main() {
group('getSpa — basic functionality', () {
test('returns valid zenith and azimuth', () {
final result = getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
);
expect(result.zenith, inInclusiveRange(0.0, 180.0));
expect(result.azimuth, inInclusiveRange(0.0, 360.0));
});
test('sunrise is before sunset', () {
final result = getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
);
expect(result.sunrise, lessThan(result.sunset));
});
test('solar noon is between sunrise and sunset', () {
final result = getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
);
expect(result.solarNoon, greaterThan(result.sunrise));
expect(result.solarNoon, lessThan(result.sunset));
});
});
group('getSpa — custom angles', () {
test('civil and astronomical twilight pairs', () {
final result = getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
customAngles: [96.0, 108.0],
);
expect(result.angles.length, equals(2));
// Civil (96) sunrise is later than astronomical (108)
expect(result.angles[0].sunrise, greaterThan(result.angles[1].sunrise));
// Civil sunset is earlier than astronomical
expect(result.angles[0].sunset, lessThan(result.angles[1].sunset));
});
test('nautical twilight (102)', () {
final result = getSpa(
DateTime.utc(2024, 6, 21, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
customAngles: [102.0],
);
expect(result.angles.length, equals(1));
expect(result.angles[0].sunrise, lessThan(result.sunrise));
expect(result.angles[0].sunset, greaterThan(result.sunset));
});
});
group('getSpa — locations', () {
test('Mecca summer solstice', () {
final result = getSpa(
DateTime.utc(2024, 6, 21, 12, 0, 0),
21.3891,
39.8579,
3.0,
);
expect(result.zenith, inInclusiveRange(0.0, 90.0));
expect(result.sunrise, greaterThan(0));
expect(result.sunset, greaterThan(0));
});
test('London winter solstice', () {
final result = getSpa(
DateTime.utc(2024, 12, 21, 12, 0, 0),
51.5074,
-0.1278,
0.0,
);
expect(result.zenith, greaterThan(60.0)); // Sun is low
expect(result.sunrise, greaterThan(7.0)); // Late sunrise
});
test('equator at equinox — zenith near 0 at noon', () {
final result = getSpa(DateTime.utc(2024, 3, 20, 12, 0, 0), 0.0, 0.0, 0.0);
expect(result.zenith, lessThan(10.0));
});
test('Sydney summer (December)', () {
final result = getSpa(
DateTime.utc(2024, 12, 21, 3, 0, 0),
-33.8688,
151.2093,
11.0,
);
expect(result.sunrise, greaterThan(0));
expect(result.sunset, greaterThan(0));
});
});
group('getSpa — edge cases', () {
test('throws for invalid input (latitude > 90)', () {
expect(
() => getSpa(DateTime.utc(2024, 1, 1), 91.0, 0.0, 0.0),
throwsA(isA<ArgumentError>()),
);
});
test('handles elevation parameter', () {
final sea = getSpa(
DateTime.utc(2024, 6, 21, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
);
final mountain = getSpa(
DateTime.utc(2024, 6, 21, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
elevation: 3000,
);
// Zenith should differ slightly
expect((sea.zenith - mountain.zenith).abs(), greaterThan(0));
});
});
}