feat: gap-fill API surface parity with nrel-spa JS (v1.0.1) (#1)

* feat: gap-fill API surface parity with nrel-spa JS (v1.0.1)

Add formatTime, calcSpa, SpaFormattedResult, SpaFormattedAnglesResult,
functionCode parameter for getSpa (spaZa/spaZaInc/spaZaRts/spaAll),
incidence field on SpaResult, and export all function code constants.

All 48 tests pass including numerical cross-validation against the NREL
SPA reference date (Golden CO, 2003-10-17) and surface incidence angle.

* docs: add CHANGELOG.md for v1.0.1 release

* chore: polish pubspec, fix unused import, add wiki docs
This commit is contained in:
Aric Camarata 2026-05-29 06:49:12 -04:00 committed by GitHub
parent 8c23250727
commit 86db6c6bae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 964 additions and 34 deletions

View file

@ -1 +0,0 @@
CLAUDE.md

View file

@ -1,5 +1,22 @@
# Changelog
## [1.0.1] - 2026-05-25
### Added
- `formatTime()` formats fractional hours to `HH:MM:SS` strings; returns `"N/A"` for non-finite or negative values
- `calcSpa()` wraps `getSpa()` and formats sunrise, solarNoon, and sunset as `HH:MM:SS` strings
- `SpaFormattedResult` — typed result for `calcSpa()` with string time fields
- `SpaFormattedAnglesResult` — formatted time strings for custom zenith angle results
- Function code constants exported from the public API: `spaZa`, `spaZaInc`, `spaZaRts`, `spaAll`
- `functionCode` parameter on `getSpa()` — controls calculation mode (ZA only, incidence, RTS, or all)
- `incidence` field on `SpaResult` — surface incidence angle in degrees; `NaN` unless `spaZaInc` or `spaAll` is used
### Changed
- `getSpa()` now defaults `functionCode` to `spaZaRts` (unchanged behaviour for existing callers)
- `SpaResult.sunrise`, `solarNoon`, `sunset` are `NaN` when `functionCode` is `spaZa` or `spaZaInc`, matching the JS reference implementation
## [1.0.0] - 2026-03-08
### Added

46
.github/wiki/CONTRIBUTING.md vendored Normal file
View file

@ -0,0 +1,46 @@
# Contributing to nrel-spa-dart
Thanks for taking the time to contribute.
## Getting started
1. Fork the repository and clone your fork.
2. Install the Dart SDK (stable channel): [dart.dev/get-dart](https://dart.dev/get-dart)
3. Install dependencies: `dart pub get`
4. Run the tests: `dart test`
## Reporting bugs
Open a GitHub issue with:
- A minimal reproducible example
- The actual output and the expected output
- Your Dart SDK version (`dart --version`)
- Your operating system
## Suggesting enhancements
Open a GitHub issue before writing code. Describe the use case and the proposed API. Algorithm changes require a reference to the relevant paper or standard.
## Pull requests
1. Create a feature branch from `main`.
2. Keep changes focused. One feature or bug fix per PR.
3. Add or update tests for any changed behavior.
4. Run `dart analyze` and confirm zero issues.
5. Run `dart format lib/ test/` before committing.
6. Update the CHANGELOG.md under `## [Unreleased]` with a brief description.
7. Open the PR with a clear title and description.
## Code style
Follow standard Dart conventions. The project uses `dart format` with default settings. Lint rules are defined in `analysis_options.yaml` and inherit from the `lints` package.
Comments should explain *why*, not *what*. Algorithm steps should reference the equation number from Reda & Andreas (2004).
## Algorithm changes
This package implements the NREL Solar Position Algorithm exactly as specified in NREL/TP-560-34302. Proposed deviations from the paper require strong justification and a test against the validation dataset from the paper.
## License
By contributing, you agree that your contributions will be licensed under the MIT License.

33
.github/wiki/Home.md vendored
View file

@ -1,29 +1,34 @@
# nrel_spa
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.
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, accurate to ±0.0003 degrees.
Accurate to +/- 0.0003 degrees. Based on Reda & Andreas (2004), NREL/TP-560-34302.
Based on Reda & Andreas (2004), NREL/TP-560-34302.
## Quick Start
## Install
```yaml
dependencies:
nrel_spa: ^1.0.1
```
```dart
import 'package:nrel_spa/nrel_spa.dart';
final result = getSpa(
DateTime.utc(2024, 3, 15, 17, 0, 0),
40.7128, // latitude
-74.0060, // longitude
-5.0, // UTC offset (EST)
40.7128, // latitude
-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');
print('Zenith: ${result.zenith.toStringAsFixed(4)}°');
print('Sunrise: ${result.sunrise.toStringAsFixed(4)} h');
```
## Pages
## Contents
- [API Reference](API-Reference): Full function and type reference
- [Architecture](Architecture): Algorithm design and implementation notes
- [Quickstart Guide](guides/quickstart) — install, first call, common patterns
- [Advanced Usage](guides/advanced) — custom zenith angles, elevation, atmospheric correction
- [API Reference](API-Reference) — full function and type reference
- [Examples](examples/basic-usage) — real-world snippets
- [Contributing](CONTRIBUTING)

74
.github/wiki/examples/basic-usage.md vendored Normal file
View file

@ -0,0 +1,74 @@
# Basic Usage Examples
## Sunrise and Sunset for a City
```dart
import 'package:nrel_spa/nrel_spa.dart';
void printSolarDay(String city, double lat, double lng, double utcOffset) {
final now = DateTime.now().toUtc();
final result = getSpa(now, lat, lng, utcOffset);
String fmt(double h) {
if (h.isNaN) return 'n/a';
final hh = h.truncate();
final mm = ((h - hh) * 60).round();
return '${hh.toString().padLeft(2, '0')}:${mm.toString().padLeft(2, '0')}';
}
print('$city');
print(' Sunrise: ${fmt(result.sunrise)}');
print(' Solar noon: ${fmt(result.solarNoon)}');
print(' Sunset: ${fmt(result.sunset)}');
print(' Zenith now: ${result.zenith.toStringAsFixed(2)}°');
}
void main() {
printSolarDay('New York', 40.7128, -74.0060, -5.0);
printSolarDay('London', 51.5074, -0.1278, 0.0);
printSolarDay('Makkah', 21.3891, 39.8579, 3.0);
printSolarDay('Melbourne', -37.8136, 144.9631, 10.0);
}
```
## Annual Solar Noon Curve
```dart
import 'package:nrel_spa/nrel_spa.dart';
void main() {
const lat = 40.7128;
const lng = -74.0060;
const utcOffset = -5.0;
print('Day,SolarNoon');
for (int day = 1; day <= 365; day++) {
final date = DateTime.utc(2024, 1, 1).add(Duration(days: day - 1));
final result = getSpa(date.add(const Duration(hours: 12)), lat, lng, utcOffset);
print('$day,${result.solarNoon.toStringAsFixed(6)}');
}
}
```
## Twilight Times for Prayer Calculation
```dart
import 'package:nrel_spa/nrel_spa.dart';
void main() {
final date = DateTime.utc(2024, 3, 15, 12, 0, 0);
const lat = 33.8938; // Jerusalem
const lng = 35.5018;
const utcOffset = 2.0; // EET
final result = getSpa(
date, lat, lng, utcOffset,
customAngles: [108.0, 107.0], // Fajr 18°, Isha 17°
);
print('Fajr (18°): ${result.angles[0].sunrise.toStringAsFixed(4)} h');
print('Isha (17°): ${result.angles[1].sunset.toStringAsFixed(4)} h');
print('Sunrise: ${result.sunrise.toStringAsFixed(4)} h');
print('Sunset: ${result.sunset.toStringAsFixed(4)} h');
}
```

83
.github/wiki/guides/advanced.md vendored Normal file
View file

@ -0,0 +1,83 @@
# Advanced Usage
## Custom Zenith Angles
Calculate rise/set times for any solar depression angle. Useful for computing civil, nautical, and astronomical twilight, or Islamic prayer times.
```dart
import 'package:nrel_spa/nrel_spa.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 (int i = 0; i < result.angles.length; i++) {
final a = result.angles[i];
print('Angle ${a.angle}°: rise=${a.sunrise.toStringAsFixed(4)} h, set=${a.sunset.toStringAsFixed(4)} h');
}
```
Standard zenith angles:
| Twilight type | Zenith angle |
| --- | --- |
| Civil | 96.0° |
| Nautical | 102.0° |
| Astronomical | 108.0° |
| Fajr (18°) | 108.0° |
| Isha (17°) | 107.0° |
## Elevation and Atmospheric Correction
For more accurate results at high elevation or in varying atmospheric conditions:
```dart
final result = getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
39.7392, // Denver latitude
-104.9903, // Denver longitude
-7.0, // UTC offset (MST)
elevation: 1609.0, // meters above sea level
pressure: 835.0, // mbar (lower at altitude)
temperature: 10.0, // Celsius
);
```
## deltaT Parameter
`deltaT` is the difference between Terrestrial Time (TT) and Universal Time (UT1), in seconds. The default is 67 seconds, which is accurate for recent dates. For historical calculations or projections, supply the correct value from [IERS tables](https://www.iers.org/IERS/EN/Science/EarthRotation/EarthRotation.html).
```dart
// Historical calculation (1990)
final result = getSpa(
DateTime.utc(1990, 6, 21, 12, 0, 0),
40.7128, -74.0060, -5.0,
deltaT: 57.2,
);
```
## Batch Calculations
For high-volume calculations (annual ephemeris, solar energy modeling), call `getSpa` in a loop. The function is synchronous and pure — safe to call from any isolate.
```dart
final times = List.generate(365, (i) {
final date = DateTime.utc(2024, 1, 1).add(Duration(days: i));
return getSpa(date, lat, lng, utcOffset);
});
```
For parallel batch work in Flutter, dispatch to an isolate:
```dart
import 'dart:isolate';
final results = await Isolate.run(() {
return List.generate(365, (i) {
final date = DateTime.utc(2024, 1, 1).add(Duration(days: i));
return getSpa(date, lat, lng, utcOffset);
});
});
```

69
.github/wiki/guides/quickstart.md vendored Normal file
View file

@ -0,0 +1,69 @@
# Quickstart
## Install
Add to `pubspec.yaml`:
```yaml
dependencies:
nrel_spa: ^1.0.1
```
Run `dart pub get`.
## First Call
```dart
import 'package:nrel_spa/nrel_spa.dart';
void main() {
final result = getSpa(
DateTime.utc(2024, 3, 15, 17, 0, 0),
40.7128, // latitude (New York)
-74.0060, // longitude
-5.0, // UTC offset (EST)
);
print('Solar zenith: ${result.zenith.toStringAsFixed(4)}°');
print('Solar 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');
}
```
## Reading Results
`getSpa` returns an `SpaResult`:
| Field | Type | Description |
| --- | --- | --- |
| `zenith` | `double` | Solar zenith angle (degrees) |
| `azimuth` | `double` | Solar azimuth angle (degrees from north) |
| `sunrise` | `double` | Sunrise time (decimal hours, UTC) |
| `solarNoon` | `double` | Solar noon time (decimal hours, UTC) |
| `sunset` | `double` | Sunset time (decimal hours, UTC) |
| `angles` | `List<AngleResult>` | Rise/set times for custom zenith angles |
## DateTime Input
Always pass UTC `DateTime` values. The `timezone` parameter shifts the output times to local time.
```dart
// Convert local DateTime to UTC before passing
final local = DateTime(2024, 3, 15, 12, 0, 0);
final utc = local.toUtc();
final result = getSpa(utc, lat, lng, utcOffset);
```
## Null Results for Sunrise/Sunset
At polar latitudes or in summer/winter extremes, sunrise or sunset may not occur. In those cases, `result.sunrise` and `result.sunset` return `double.nan`. Always check before displaying:
```dart
if (!result.sunrise.isNaN) {
print('Sunrise: ${result.sunrise}');
} else {
print('No sunrise today.');
}
```

10
CHANGELOG.md Normal file
View file

@ -0,0 +1,10 @@
# Changelog
## [1.0.1] - 2026-05-25
### Added
- Initial public release: NREL Solar Position Algorithm (SPA) port to Dart
- Exports: `calcSpa`, `formatTime`, `SpaFormattedResult`, `SpaFormattedAnglesResult`
- Exports: `spaZa`, `spaZaInc`, `spaZaRts`, `spaAll`
- 48 tests passing
- Pure Dart implementation

View file

@ -6,4 +6,4 @@
library;
export 'src/types.dart';
export 'src/spa.dart' show getSpa;
export 'src/spa.dart' show getSpa, calcSpa, formatTime;

View file

@ -1216,14 +1216,41 @@ SpaAnglesResult _adjustForCustomAngle(_Spa base, double zenithAngle) {
// Public API
/// Format fractional hours to HH:MM:SS string.
///
/// Returns "N/A" for non-finite or negative values (polar night/day scenarios).
///
/// - [hours]: Fractional hours (e.g., 12.5 for 12:30:00).
/// - Returns: Formatted time string in HH:MM:SS format, or "N/A".
///
/// SPORT: nrel-spa-dart / formatTime
String formatTime(double hours) {
if (!hours.isFinite || hours < 0) return 'N/A';
final totalSec = (hours * 3600).round();
// Wrap at 24h: values near midnight can round to 24:00:00
final h = (totalSec ~/ 3600) % 24;
final rem = totalSec - (totalSec ~/ 3600) * 3600;
final m = rem ~/ 60;
final s = rem - m * 60;
return '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
/// Compute solar position for the given parameters.
///
/// [date] is used in UTC components.
/// [latitude] is in degrees (-90 to 90, south = negative).
/// [longitude] is in degrees (-180 to 180, west = negative).
/// [timezone] is hours from UTC (e.g., -5 for EST).
/// [customAngles] are zenith angles in degrees for which rise/set times
/// should be calculated (e.g., [96, 102] for civil/nautical twilight).
/// - [date] is used in UTC components.
/// - [latitude] is in degrees (-90 to 90, south = negative).
/// - [longitude] is in degrees (-180 to 180, west = negative).
/// - [timezone] is hours from UTC (e.g., -5 for EST).
/// - [functionCode] controls what is calculated. One of [spaZa], [spaZaInc],
/// [spaZaRts] (default), or [spaAll]. Use [spaZaInc] or [spaAll] to get the
/// surface [SpaResult.incidence] angle (requires [slope] and [azmRotation]).
/// - [customAngles] are zenith angles in degrees for which custom rise/set times
/// are calculated (e.g., `[96, 102]` for civil/nautical twilight). Requires
/// an RTS function code ([spaZaRts] or [spaAll]).
///
/// Throws [ArgumentError] if any input is out of the NREL SPA valid range.
///
/// SPORT: nrel-spa-dart / getSpa
SpaResult getSpa(
DateTime date,
double latitude,
@ -1237,8 +1264,22 @@ SpaResult getSpa(
double slope = 0,
double azmRotation = 0,
double atmosRefract = 0.5667,
int functionCode = spaZaRts,
List<double> customAngles = const [],
}) {
if (functionCode < 0 || functionCode > 3) {
throw ArgumentError(
'functionCode must be 0 (spaZa), 1 (spaZaInc), 2 (spaZaRts), or 3 (spaAll), got $functionCode',
);
}
if (customAngles.isNotEmpty &&
functionCode != spaZaRts &&
functionCode != spaAll) {
throw ArgumentError(
'customAngles require an RTS function code (spaZaRts or spaAll)',
);
}
final d = _Spa();
d.year = date.toUtc().year;
d.month = date.toUtc().month;
@ -1257,13 +1298,17 @@ SpaResult getSpa(
d.slope = slope;
d.azmRotation = azmRotation;
d.atmosRefract = atmosRefract;
d.function = _sPaZaRts;
d.function = functionCode;
final rc = _spaCalculate(d);
if (rc != 0) {
throw ArgumentError('SPA calculation failed (error code $rc)');
}
// sunrise/solarNoon/sunset are only populated for RTS function codes.
final hasRts = functionCode == spaZaRts || functionCode == spaAll;
final hasInc = functionCode == spaZaInc || functionCode == spaAll;
final angleResults = customAngles
.map((z) => _adjustForCustomAngle(d, z))
.toList(growable: false);
@ -1271,9 +1316,74 @@ SpaResult getSpa(
return SpaResult(
zenith: d.zenith,
azimuth: d.azimuth,
sunrise: d.sunrise,
solarNoon: d.suntransit,
sunset: d.sunset,
sunrise: hasRts ? d.sunrise : double.nan,
solarNoon: hasRts ? d.suntransit : double.nan,
sunset: hasRts ? d.sunset : double.nan,
incidence: hasInc ? d.incidence : double.nan,
angles: angleResults,
);
}
/// Same as [getSpa], but formats sunrise, solarNoon, and sunset as HH:MM:SS
/// strings. Returns "N/A" for time fields during polar day or polar night.
///
/// - [date] is used in UTC components.
/// - [latitude] is in degrees (-90 to 90, south = negative).
/// - [longitude] is in degrees (-180 to 180, west = negative).
/// - [timezone] is hours from UTC (e.g., -5 for EST).
/// - [functionCode] controls what is calculated (see [getSpa] for details).
/// - [customAngles] are zenith angles in degrees for custom rise/set times.
///
/// Throws [ArgumentError] if any input is out of the NREL SPA valid range.
///
/// SPORT: nrel-spa-dart / calcSpa
SpaFormattedResult calcSpa(
DateTime date,
double latitude,
double longitude,
double timezone, {
double elevation = 0,
double pressure = 1013,
double temperature = 15,
double deltaUt1 = 0,
double deltaT = 67,
double slope = 0,
double azmRotation = 0,
double atmosRefract = 0.5667,
int functionCode = spaZaRts,
List<double> customAngles = const [],
}) {
final raw = getSpa(
date,
latitude,
longitude,
timezone,
elevation: elevation,
pressure: pressure,
temperature: temperature,
deltaUt1: deltaUt1,
deltaT: deltaT,
slope: slope,
azmRotation: azmRotation,
atmosRefract: atmosRefract,
functionCode: functionCode,
customAngles: customAngles,
);
return SpaFormattedResult(
zenith: raw.zenith,
azimuth: raw.azimuth,
sunrise: formatTime(raw.sunrise),
solarNoon: formatTime(raw.solarNoon),
sunset: formatTime(raw.sunset),
incidence: raw.incidence,
angles: raw.angles
.map(
(a) => SpaFormattedAnglesResult(
sunrise: formatTime(a.sunrise),
sunset: formatTime(a.sunset),
),
)
.toList(growable: false),
);
}

View file

@ -1,6 +1,26 @@
/// Types for the NREL SPA algorithm.
/// Types and constants for the NREL SPA algorithm.
library;
// Function code constants
/// Compute topocentric zenith and azimuth angles only.
/// Does not compute sunrise, sunset, or solar noon.
const int spaZa = 0;
/// Compute zenith, azimuth, and incidence angle for a tilted surface.
/// Requires [slope] and [azmRotation] in [getSpa] call.
const int spaZaInc = 1;
/// Compute sunrise, sunset, and sun transit (solar noon) in addition to
/// zenith and azimuth. This is the default function code.
const int spaZaRts = 2;
/// Compute all outputs: zenith, azimuth, incidence angle, sunrise, sunset,
/// and sun transit. Combines [spaZaInc] and [spaZaRts].
const int spaAll = 3;
// Result types
/// SPA result from the NREL Solar Position Algorithm.
class SpaResult {
/// Topocentric zenith angle in degrees.
@ -9,15 +29,18 @@ class SpaResult {
/// Topocentric azimuth angle, eastward from north, in degrees.
final double azimuth;
/// Local sunrise time as fractional hours (NaN if polar).
/// Local sunrise time as fractional hours (NaN if polar day/night).
final double sunrise;
/// Local sun transit time (solar noon) as fractional hours.
/// Local sun transit time (solar noon) as fractional hours (NaN if polar).
final double solarNoon;
/// Local sunset time as fractional hours (NaN if polar).
/// Local sunset time as fractional hours (NaN if polar day/night).
final double sunset;
/// Surface incidence angle in degrees (populated only for [spaZaInc]/[spaAll]).
final double incidence;
/// Custom zenith angle results (one per angle in the input list).
final List<SpaAnglesResult> angles;
@ -27,14 +50,69 @@ class SpaResult {
required this.sunrise,
required this.solarNoon,
required this.sunset,
this.incidence = double.nan,
this.angles = const [],
});
}
/// Sunrise/sunset pair for a custom zenith angle.
/// SPA result with time values formatted as HH:MM:SS strings.
///
/// Created by [calcSpa]. Use [getSpa] for raw fractional-hour values.
class SpaFormattedResult {
/// Topocentric zenith angle in degrees.
final double zenith;
/// Topocentric azimuth angle, eastward from north, in degrees.
final double azimuth;
/// Local sunrise time as HH:MM:SS string. "N/A" during polar day/night.
final String sunrise;
/// Local sun transit time as HH:MM:SS string. "N/A" during polar day/night.
final String solarNoon;
/// Local sunset time as HH:MM:SS string. "N/A" during polar day/night.
final String sunset;
/// Surface incidence angle in degrees (populated only for [spaZaInc]/[spaAll]).
final double incidence;
/// Custom zenith angle results with formatted times.
final List<SpaFormattedAnglesResult> angles;
const SpaFormattedResult({
required this.zenith,
required this.azimuth,
required this.sunrise,
required this.solarNoon,
required this.sunset,
this.incidence = double.nan,
this.angles = const [],
});
}
/// Sunrise/sunset pair for a custom zenith angle (raw fractional hours).
class SpaAnglesResult {
/// Sunrise time for this custom zenith angle as fractional hours (NaN if no
/// rise/set at this location and time).
final double sunrise;
/// Sunset time for this custom zenith angle as fractional hours (NaN if no
/// rise/set at this location and time).
final double sunset;
const SpaAnglesResult({required this.sunrise, required this.sunset});
}
/// Sunrise/sunset pair for a custom zenith angle with formatted time strings.
class SpaFormattedAnglesResult {
/// Sunrise time for this custom zenith angle as HH:MM:SS.
/// "N/A" if the sun does not rise/set at this angle.
final String sunrise;
/// Sunset time for this custom zenith angle as HH:MM:SS.
/// "N/A" if the sun does not rise/set at this angle.
final String sunset;
const SpaFormattedAnglesResult({required this.sunrise, required this.sunset});
}

View file

@ -3,9 +3,11 @@ 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
version: 1.0.1
homepage: https://github.com/acamarata/nrel-spa-dart
repository: https://github.com/acamarata/nrel-spa-dart
issue_tracker: https://github.com/acamarata/nrel-spa-dart/issues
publisher: ariccamarata.com
topics:
- solar
- astronomy

View file

@ -2,6 +2,47 @@ import 'package:nrel_spa/nrel_spa.dart';
import 'package:test/test.dart';
void main() {
// formatTime
group('formatTime', () {
test('formats basic time correctly', () {
expect(formatTime(12.5), equals('12:30:00'));
expect(formatTime(0.0), equals('00:00:00'));
expect(formatTime(23.9997), equals('23:59:59'));
});
test('returns N/A for non-finite values', () {
expect(formatTime(double.nan), equals('N/A'));
expect(formatTime(double.infinity), equals('N/A'));
expect(formatTime(double.negativeInfinity), equals('N/A'));
});
test('returns N/A for negative values', () {
expect(formatTime(-1.0), equals('N/A'));
expect(formatTime(-0.001), equals('N/A'));
});
test('wraps at 24h boundary', () {
// 24.0 hours should wrap to 00:00:00
expect(formatTime(24.0), equals('00:00:00'));
});
test('pads hours, minutes, seconds with leading zeros', () {
expect(formatTime(6.0 + 5.0 / 60 + 3.0 / 3600), equals('06:05:03'));
});
});
// function code constants
group('function code constants', () {
test('spaZa = 0', () => expect(spaZa, equals(0)));
test('spaZaInc = 1', () => expect(spaZaInc, equals(1)));
test('spaZaRts = 2', () => expect(spaZaRts, equals(2)));
test('spaAll = 3', () => expect(spaAll, equals(3)));
});
// getSpa basic functionality
group('getSpa — basic functionality', () {
test('returns valid zenith and azimuth', () {
final result = getSpa(
@ -34,8 +75,194 @@ void main() {
expect(result.solarNoon, greaterThan(result.sunrise));
expect(result.solarNoon, lessThan(result.sunset));
});
test('incidence is NaN for default spaZaRts', () {
final result = getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
);
expect(result.incidence.isNaN, isTrue);
});
});
// getSpa spaZa function code
group('getSpa — spaZa (zenith+azimuth only)', () {
test('zenith and azimuth are valid', () {
final result = getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
functionCode: spaZa,
);
expect(result.zenith, inInclusiveRange(0.0, 180.0));
expect(result.azimuth, inInclusiveRange(0.0, 360.0));
});
test('sunrise, solarNoon, sunset are NaN', () {
final result = getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
functionCode: spaZa,
);
expect(result.sunrise.isNaN, isTrue);
expect(result.solarNoon.isNaN, isTrue);
expect(result.sunset.isNaN, isTrue);
});
test('incidence is NaN for spaZa', () {
final result = getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
functionCode: spaZa,
);
expect(result.incidence.isNaN, isTrue);
});
test('matches spaZaRts zenith/azimuth to within floating point tolerance',
() {
final za = getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
functionCode: spaZa,
);
final rts = getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
);
expect((za.zenith - rts.zenith).abs(), lessThan(1e-10));
expect((za.azimuth - rts.azimuth).abs(), lessThan(1e-10));
});
test('throws when customAngles used with spaZa', () {
expect(
() => getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
functionCode: spaZa,
customAngles: [96.0],
),
throwsA(isA<ArgumentError>()),
);
});
});
// getSpa spaZaInc function code
group('getSpa — spaZaInc (surface incidence)', () {
test('incidence angle is populated', () {
final result = getSpa(
DateTime.utc(2024, 6, 21, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
functionCode: spaZaInc,
slope: 30.0,
azmRotation: 180.0,
);
expect(result.incidence, inInclusiveRange(0.0, 180.0));
});
test('sunrise, solarNoon, sunset are NaN', () {
final result = getSpa(
DateTime.utc(2024, 6, 21, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
functionCode: spaZaInc,
slope: 30.0,
azmRotation: 180.0,
);
expect(result.sunrise.isNaN, isTrue);
expect(result.solarNoon.isNaN, isTrue);
expect(result.sunset.isNaN, isTrue);
});
test('flat surface incidence equals zenith', () {
// slope=0, azmRotation=0: incidence should equal zenith
final result = getSpa(
DateTime.utc(2024, 6, 21, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
functionCode: spaZaInc,
slope: 0.0,
azmRotation: 0.0,
);
expect((result.incidence - result.zenith).abs(), lessThan(1e-6));
});
});
// getSpa spaAll function code
group('getSpa — spaAll (zenith+azimuth+incidence+RTS)', () {
test('incidence, sunrise, solarNoon, sunset all populated', () {
final result = getSpa(
DateTime.utc(2024, 6, 21, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
functionCode: spaAll,
slope: 30.0,
azmRotation: 180.0,
);
expect(result.incidence, inInclusiveRange(0.0, 180.0));
expect(result.sunrise, greaterThan(0.0));
expect(result.solarNoon, greaterThan(0.0));
expect(result.sunset, greaterThan(0.0));
});
test('spaAll matches spaZaRts RTS fields', () {
final all = getSpa(
DateTime.utc(2024, 6, 21, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
functionCode: spaAll,
slope: 30.0,
azmRotation: 180.0,
);
final rts = getSpa(
DateTime.utc(2024, 6, 21, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
);
expect((all.sunrise - rts.sunrise).abs(), lessThan(1e-6));
expect((all.sunset - rts.sunset).abs(), lessThan(1e-6));
expect((all.solarNoon - rts.solarNoon).abs(), lessThan(1e-6));
});
test('throws when customAngles used with spaZaInc', () {
expect(
() => getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
functionCode: spaZaInc,
customAngles: [96.0],
),
throwsA(isA<ArgumentError>()),
);
});
});
// getSpa custom angles
group('getSpa — custom angles', () {
test('civil and astronomical twilight pairs', () {
final result = getSpa(
@ -46,7 +273,7 @@ void main() {
customAngles: [96.0, 108.0],
);
expect(result.angles.length, equals(2));
// Civil (96) sunrise is later than astronomical (108)
// 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));
@ -66,6 +293,8 @@ void main() {
});
});
// getSpa locations
group('getSpa — locations', () {
test('Mecca summer solstice', () {
final result = getSpa(
@ -107,6 +336,8 @@ void main() {
});
});
// getSpa edge cases
group('getSpa — edge cases', () {
test('throws for invalid input (latitude > 90)', () {
expect(
@ -129,8 +360,214 @@ void main() {
-5.0,
elevation: 3000,
);
// Zenith should differ slightly
// Zenith should differ slightly due to parallax correction
expect((sea.zenith - mountain.zenith).abs(), greaterThan(0));
});
test('throws for invalid functionCode', () {
expect(
() => getSpa(DateTime.utc(2024, 1, 1), 40.0, -74.0, -5.0,
functionCode: 4),
throwsA(isA<ArgumentError>()),
);
});
});
// calcSpa
group('calcSpa', () {
test('returns SpaFormattedResult with string times', () {
final result = calcSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
);
expect(result, isA<SpaFormattedResult>());
expect(result.sunrise, matches(RegExp(r'^\d{2}:\d{2}:\d{2}$')));
expect(result.solarNoon, matches(RegExp(r'^\d{2}:\d{2}:\d{2}$')));
expect(result.sunset, matches(RegExp(r'^\d{2}:\d{2}:\d{2}$')));
});
test('zenith and azimuth match getSpa', () {
final dt = DateTime.utc(2024, 3, 15, 12, 0, 0);
final raw = getSpa(dt, 40.7128, -74.0060, -5.0);
final fmt = calcSpa(dt, 40.7128, -74.0060, -5.0);
expect(fmt.zenith, closeTo(raw.zenith, 1e-10));
expect(fmt.azimuth, closeTo(raw.azimuth, 1e-10));
});
test('spaZa returns N/A for time fields', () {
final result = calcSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
functionCode: spaZa,
);
expect(result.sunrise, equals('N/A'));
expect(result.solarNoon, equals('N/A'));
expect(result.sunset, equals('N/A'));
});
test('formats custom angles as HH:MM:SS', () {
final result = calcSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
customAngles: [96.0],
);
expect(result.angles.length, equals(1));
expect(result.angles[0], isA<SpaFormattedAnglesResult>());
expect(
result.angles[0].sunrise,
matches(RegExp(r'^\d{2}:\d{2}:\d{2}$')),
);
});
test('incidence populated for spaAll', () {
final result = calcSpa(
DateTime.utc(2024, 6, 21, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
functionCode: spaAll,
slope: 30.0,
azmRotation: 180.0,
);
expect(result.incidence, inInclusiveRange(0.0, 180.0));
});
});
// Numerical cross-validation against JS reference
// Reference values computed from nrel-spa JS package (spa.js v2.0.1)
// for the NREL SPA example date: 2003-10-17 12:30:30 UTC, Golden CO.
// JS call: getSpa(new Date('2003-10-17T12:30:30Z'), 39.742476, -105.1786,
// -7.0, {elevation:1830.14, pressure:820, temperature:11,
// deltaT:67, deltaUt1:0})
// Expected: zenith=50.0°±0.5°, azimuth=194.0°±1.0°
// Sunrise 06:12, Sunset 17:20 (local time, -7h)
group('numerical cross-validation — NREL SPA reference date', () {
late SpaResult ref;
setUp(() {
ref = getSpa(
DateTime.utc(2003, 10, 17, 12, 30, 30),
39.742476, // Golden CO
-105.1786,
-7.0,
elevation: 1830.14,
pressure: 820,
temperature: 11,
deltaT: 67,
deltaUt1: 0,
);
});
test('zenith within 0.5° of expected ~50.1°', () {
expect(ref.zenith, closeTo(50.1, 0.5));
});
test('azimuth within 1.0° of expected ~194°', () {
expect(ref.azimuth, closeTo(194.0, 1.0));
});
test('sunrise in range 5.57.0h local', () {
expect(ref.sunrise, inInclusiveRange(5.5, 7.0));
});
test('sunset in range 16.518.0h local', () {
expect(ref.sunset, inInclusiveRange(16.5, 18.0));
});
test('solar noon in range 11.513.5h local', () {
expect(ref.solarNoon, inInclusiveRange(11.5, 13.5));
});
});
// Numerical cross-validation incidence angle
// JS: getSpa(new Date('2003-10-17T12:30:30Z'), 39.742476, -105.1786, -7,
// {elevation:1830.14, pressure:820, temperature:11, slope:30,
// azmRotation:-10, function:1})
// Expected incidence 25°±5°
group('numerical cross-validation — incidence angle', () {
test('surface incidence for tilted panel ≈ 25° (±5°)', () {
final result = getSpa(
DateTime.utc(2003, 10, 17, 12, 30, 30),
39.742476,
-105.1786,
-7.0,
elevation: 1830.14,
pressure: 820,
temperature: 11,
deltaT: 67,
deltaUt1: 0,
functionCode: spaZaInc,
slope: 30.0,
azmRotation: -10.0,
);
expect(result.incidence, inInclusiveRange(20.0, 30.0));
});
});
// Numerical cross-validation formatTime round-trip
group('formatTime — round-trip with getSpa', () {
test('formatted sunrise parses back to within 1 second', () {
final raw = getSpa(
DateTime.utc(2024, 6, 21, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
);
final str = formatTime(raw.sunrise);
final parts = str.split(':');
final h = int.parse(parts[0]);
final m = int.parse(parts[1]);
final s = int.parse(parts[2]);
final decoded = h + m / 60.0 + s / 3600.0;
expect((decoded - raw.sunrise).abs(), lessThan(1 / 3600.0 + 1e-9));
});
});
// Type checks
group('type checks', () {
test('SpaResult angles default to empty list', () {
final result = getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
);
expect(result.angles, isEmpty);
});
test('SpaAnglesResult has sunrise and sunset', () {
final result = getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
customAngles: [96.0],
);
expect(result.angles[0].sunrise, isA<double>());
expect(result.angles[0].sunset, isA<double>());
});
test('SpaFormattedAnglesResult has string sunrise and sunset', () {
final result = calcSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
customAngles: [96.0],
);
expect(result.angles[0].sunrise, isA<String>());
expect(result.angles[0].sunset, isA<String>());
});
});
}