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)
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.
This commit is contained in:
parent
8c23250727
commit
d59e1dec32
6 changed files with 663 additions and 19 deletions
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
|
||||
|
|
|
|||
|
|
@ -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,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:
|
||||
|
|
|
|||
|
|
@ -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<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 +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<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