diff --git a/.claude/AGENTS.md b/.claude/AGENTS.md deleted file mode 120000 index 681311e..0000000 --- a/.claude/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -CLAUDE.md \ No newline at end of file diff --git a/.github/docs/CHANGELOG.md b/.github/docs/CHANGELOG.md index 78bbc8a..60bae7b 100644 --- a/.github/docs/CHANGELOG.md +++ b/.github/docs/CHANGELOG.md @@ -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 diff --git a/.github/wiki/CONTRIBUTING.md b/.github/wiki/CONTRIBUTING.md new file mode 100644 index 0000000..5261b1c --- /dev/null +++ b/.github/wiki/CONTRIBUTING.md @@ -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. diff --git a/.github/wiki/Home.md b/.github/wiki/Home.md index 9f308b5..1daa6f6 100644 --- a/.github/wiki/Home.md +++ b/.github/wiki/Home.md @@ -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) diff --git a/.github/wiki/examples/basic-usage.md b/.github/wiki/examples/basic-usage.md new file mode 100644 index 0000000..c3f93e2 --- /dev/null +++ b/.github/wiki/examples/basic-usage.md @@ -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'); +} +``` diff --git a/.github/wiki/guides/advanced.md b/.github/wiki/guides/advanced.md new file mode 100644 index 0000000..f85d011 --- /dev/null +++ b/.github/wiki/guides/advanced.md @@ -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); + }); +}); +``` diff --git a/.github/wiki/guides/quickstart.md b/.github/wiki/guides/quickstart.md new file mode 100644 index 0000000..5924797 --- /dev/null +++ b/.github/wiki/guides/quickstart.md @@ -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` | 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.'); +} +``` diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ce6efa1 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/lib/nrel_spa.dart b/lib/nrel_spa.dart index 0465ce1..e44a091 100644 --- a/lib/nrel_spa.dart +++ b/lib/nrel_spa.dart @@ -6,4 +6,4 @@ library; export 'src/types.dart'; -export 'src/spa.dart' show getSpa; +export 'src/spa.dart' show getSpa, calcSpa, formatTime; diff --git a/lib/src/spa.dart b/lib/src/spa.dart index 8423f70..a5be5af 100644 --- a/lib/src/spa.dart +++ b/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 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 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), + ); +} diff --git a/lib/src/types.dart b/lib/src/types.dart index d99c1cc..d15e52c 100644 --- a/lib/src/types.dart +++ b/lib/src/types.dart @@ -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 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 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}); +} diff --git a/pubspec.yaml b/pubspec.yaml index 095e193..66f5e91 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/nrel_spa_test.dart b/test/nrel_spa_test.dart index a14b1bc..e46c365 100644 --- a/test/nrel_spa_test.dart +++ b/test/nrel_spa_test.dart @@ -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()), + ); + }); + }); + + // ─── 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()), + ); + }); + }); + + // ─── 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()), + ); + }); + }); + + // ─── 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()); + 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()); + 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()); + expect(result.angles[0].sunset, isA()); + }); + + 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()); + expect(result.angles[0].sunset, isA()); + }); }); }