mirror of
https://github.com/acamarata/nrel-spa-dart.git
synced 2026-06-30 19:04:24 +00:00
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:
parent
8c23250727
commit
86db6c6bae
13 changed files with 964 additions and 34 deletions
|
|
@ -1 +0,0 @@
|
|||
CLAUDE.md
|
||||
17
.github/docs/CHANGELOG.md
vendored
17
.github/docs/CHANGELOG.md
vendored
|
|
@ -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
46
.github/wiki/CONTRIBUTING.md
vendored
Normal 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
33
.github/wiki/Home.md
vendored
|
|
@ -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
74
.github/wiki/examples/basic-usage.md
vendored
Normal 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
83
.github/wiki/guides/advanced.md
vendored
Normal 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
69
.github/wiki/guides/quickstart.md
vendored
Normal 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
10
CHANGELOG.md
Normal 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
|
||||
|
|
@ -6,4 +6,4 @@
|
|||
library;
|
||||
|
||||
export 'src/types.dart';
|
||||
export 'src/spa.dart' show getSpa;
|
||||
export 'src/spa.dart' show getSpa, calcSpa, formatTime;
|
||||
|
|
|
|||
130
lib/src/spa.dart
130
lib/src/spa.dart
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.5–7.0h local', () {
|
||||
expect(ref.sunrise, inInclusiveRange(5.5, 7.0));
|
||||
});
|
||||
|
||||
test('sunset in range 16.5–18.0h local', () {
|
||||
expect(ref.sunset, inInclusiveRange(16.5, 18.0));
|
||||
});
|
||||
|
||||
test('solar noon in range 11.5–13.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>());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue