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/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..223793e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ 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 repository: https://github.com/acamarata/nrel-spa-dart issue_tracker: https://github.com/acamarata/nrel-spa-dart/issues topics: diff --git a/test/nrel_spa_test.dart b/test/nrel_spa_test.dart index a14b1bc..f966952 100644 --- a/test/nrel_spa_test.dart +++ b/test/nrel_spa_test.dart @@ -1,7 +1,50 @@ +import 'dart:math'; + 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 +77,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 +275,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 +295,8 @@ void main() { }); }); + // ─── getSpa — locations ────────────────────────────────────────────────────── + group('getSpa — locations', () { test('Mecca summer solstice', () { final result = getSpa( @@ -107,6 +338,8 @@ void main() { }); }); + // ─── getSpa — edge cases ───────────────────────────────────────────────────── + group('getSpa — edge cases', () { test('throws for invalid input (latitude > 90)', () { expect( @@ -129,8 +362,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()); + }); }); }