using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Virtual; using static MeterVault.Core.Tests.Analysis.VirtualFixtures; namespace MeterVault.Core.Tests.Analysis; /// /// Virtual meters evaluated on read (D-27), pinned on the brief's worked example (§5.4): A and B are generation /// meters with complete monthly data — A 100/80 kWh, B 150/120 kWh in January/February. A missing source makes a /// bucket unknown, never a confident partial number; an observed zero is a real input; non-finite arithmetic is /// invalid, never zero; a non-additive formula's total is the formula over the totals. /// public sealed class VirtualEvaluatorTests { /// The virtual meter being evaluated; every dependency path starts here. private const int Self = 99; private const int A = 1; private const int B = 2; private static readonly AnalysisBucket[] JanFeb = Months(2025, 1, 2); private static VirtualSource SourceA() => Source(A, Monthly((2025, 1, 100), (2025, 2, 80))); private static VirtualSource SourceB() => Source(B, Monthly((2025, 1, 150), (2025, 2, 120))); private static VirtualEvaluation Evaluate(string formula, params VirtualSource[] sources) => Evaluate(formula, JanFeb, sources); private static VirtualEvaluation Evaluate( string formula, IReadOnlyList buckets, IEnumerable sources, int meterId = Self, QuantityKind kind = QuantityKind.Generation) => VirtualEvaluator.Evaluate(meterId, Formula.Parse(formula), kind, buckets, sources); private static BucketValue Unresolved(double? value = null) => new(value, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution); [Fact] public void A_plus_B_is_250_and_200_by_month_and_450_in_total() { var result = Evaluate("m1 + m2", SourceA(), SourceB()); Assert.Equal([250d, 200d], result.Values.Select(v => v.Value!.Value)); Assert.All(result.Values, v => Assert.Equal(BucketStatus.Available, v.Status)); Assert.Equal(450, result.Total.Value); Assert.Equal(BucketStatus.Available, result.Total.Status); Assert.True(result.IsAdditive); Assert.Equal(Provenance.Derived | Provenance.Imported, result.Total.Provenance); Assert.Equal(59, result.JointDays); Assert.Equal(ResolutionClass.Month, result.Resolution); Assert.Equal(Self, result.MeterId); } [Fact] public void Each_source_contributes_its_own_series_and_the_amounts_that_entered_the_formula() { var result = Evaluate("m1 + m2", SourceA(), SourceB()); var a = result.Contributions.Single(c => c.MeterId == A); var b = result.Contributions.Single(c => c.MeterId == B); Assert.Equal([100d, 80d], a.Values.Select(v => v.Value!.Value)); Assert.Equal([150d, 120d], b.Values.Select(v => v.Value!.Value)); Assert.Equal([100d, 80d], a.UsedAmounts.Select(v => v!.Value)); Assert.Equal(1, a.Coefficient); Assert.Equal(180, a.Total.Value); Assert.Equal(270, b.UsedTotal); } [Fact] public void B_missing_in_February_makes_February_missing_not_a_confident_80() { var bOnlyJanuary = Source(B, Monthly((2025, 1, 150))); var result = Evaluate("m1 + m2", SourceA(), bOnlyJanuary); var february = result.Values[1]; Assert.Equal(BucketStatus.Missing, february.Status); Assert.Null(february.Value); Assert.Equal(ValueIssue.MissingSource, february.Issue); Assert.Equal([Self, B], february.DependencyPath!); Assert.Null(february.IssueDetail); // The period total is the formula over the joint coverage — January only — and says so. A's February is a // whole month outside the joint coverage, not a month cut in two, so the total stays a partial 250. Assert.Equal(BucketStatus.Partial, result.Total.Status); Assert.Equal(250, result.Total.Value); Assert.Equal(new DateOnly(2025, 1, 31), result.LastJointDay); // The contribution table still shows A's own February and B's absence. var a = result.Contributions.Single(c => c.MeterId == A); var b = result.Contributions.Single(c => c.MeterId == B); Assert.Equal(80, a.Values[1].Value); Assert.Equal(BucketStatus.Missing, b.Values[1].Status); Assert.Null(a.UsedAmounts[1]); } [Fact] public void B_observed_zero_in_February_makes_a_complete_80() { var bZeroInFebruary = Source(B, Monthly((2025, 1, 150), (2025, 2, 0))); var result = Evaluate("m1 + m2", SourceA(), bZeroInFebruary); Assert.Equal(BucketValue.Available(80, Provenance.Derived | Provenance.Imported), result.Values[1]); Assert.Equal(330, result.Total.Value); Assert.Equal(BucketStatus.Available, result.Total.Status); } [Fact] public void A_minus_B_is_minus_50_and_minus_40_and_stays_signed() { var result = Evaluate("m1 - m2", SourceA(), SourceB()); Assert.Equal([-50d, -40d], result.Values.Select(v => v.Value!.Value)); Assert.Equal(-90, result.Total.Value); Assert.True(result.IsAdditive); Assert.Equal(-1, result.Contributions.Single(c => c.MeterId == B).Coefficient); } [Fact] public void A_source_covering_part_of_a_bucket_makes_it_partial_with_the_jointly_covered_value() { var feb1 = new DateOnly(2025, 2, 1); var dailyA = Source(A, Daily(feb1, feb1.AddMonths(1), 1)); var halfB = Source(B, Daily(feb1, feb1.AddDays(14), 2)); var result = Evaluate("m1 + m2", [Month(2025, 2)], [dailyA, halfB]); var february = Assert.Single(result.Values); Assert.Equal(BucketStatus.Partial, february.Status); Assert.Equal(14 + 28, february.Value); Assert.Equal(ValueIssue.PartialCoverage, february.Issue); Assert.Equal([Self, B], february.DependencyPath!); Assert.Equal(ResolutionClass.Day, result.Resolution); } [Theory] [InlineData(10, 22)] // m2 covers 10–31 January: a full month of m1 against 22 days of m2 would read 78 [InlineData(1, 15)] // m2 covers 1–15 January: m1's amount lies outside the joint days, so it would read −15 public void A_monthly_source_cut_inside_its_month_by_another_sources_coverage_is_unresolved_not_a_wrong_partial(int firstDay, int dayCount) { var from = new DateOnly(2025, 1, firstDay); var monthly = Source(A, Monthly((2025, 1, 100))); var daily = Source(B, Daily(from, from.AddDays(dayCount), 1)); var result = Evaluate("m1 - m2", [Month(2025, 1)], [monthly, daily]); foreach (var value in new[] { result.Values[0], result.Total }) { Assert.Equal(BucketStatus.Unresolved, value.Status); Assert.Equal(ValueIssue.CoarseResolution, value.Issue); Assert.Null(value.Value); Assert.Equal([Self, A], value.DependencyPath!); } Assert.Null(result.Contributions.Single(c => c.MeterId == A).UsedAmounts[0]); } [Theory] [InlineData(true, BucketStatus.Partial)] [InlineData(false, BucketStatus.Unresolved)] public void A_monthly_source_may_be_cut_at_a_month_boundary_only_when_it_is_divided_at_months(bool dividedAtMonths, BucketStatus expected) { // m1 is monthly for January and February, m2 starts on 1 February. Divided at months, m1's February is exactly // February's use and the two-month bucket is a partial 80 − 28. A tank read mid-month is not: its "February" // amount belongs to an interval that starts in January, so the same cut is unresolved. var monthly = Source(A, Monthly(dividedAtMonths, (2025, 1, 100), (2025, 2, 80))); var daily = Source(B, Daily(new DateOnly(2025, 2, 1), new DateOnly(2025, 3, 1), 1)); var janFeb = new AnalysisBucket(new DateOnly(2025, 1, 1), new DateOnly(2025, 3, 1), Utc(new DateOnly(2025, 1, 1)), Utc(new DateOnly(2025, 3, 1)), BucketSize.Year); var result = Evaluate("m1 - m2", [janFeb], [monthly, daily]); Assert.Equal(expected, result.Values[0].Status); Assert.Equal(expected, result.Total.Status); if (expected == BucketStatus.Partial) { Assert.Equal(80 - 28, result.Values[0].Value); Assert.Equal([Self, B], result.Values[0].DependencyPath!); } else { Assert.Equal([Self, A], result.Values[0].DependencyPath!); } } [Fact] public void A_quarterly_interval_cut_by_joint_coverage_leaves_the_period_total_unresolved() { // m1 books one quarterly reading (1 Jan – 31 Mar, 300) and reports its month buckets unresolved while resolving // the quarter. m2 only starts in February, so the joint coverage keeps all 300 against two months of m2. var quarter = Source(A, Coarse(new DateOnly(2025, 1, 1), new DateOnly(2025, 4, 1), 300)) with { BucketStates = [Unresolved(), Unresolved(), Unresolved()], PeriodState = BucketValue.Available(300, Provenance.Imported), }; var daily = Source(B, Daily(new DateOnly(2025, 2, 1), new DateOnly(2025, 4, 1), 1)); var result = Evaluate("m1 - m2", Months(2025, 1, 3), [quarter, daily]); Assert.Equal(BucketStatus.Unresolved, result.Total.Status); Assert.Equal([Self, A], result.Total.DependencyPath!); // Covering the whole quarter, m2 no longer cuts it: the total resolves. var wholeQuarter = Source(B, Daily(new DateOnly(2025, 1, 1), new DateOnly(2025, 4, 1), 1)); Assert.Equal(300 - 90, Evaluate("m1 - m2", Months(2025, 1, 3), [quarter, wholeQuarter]).Total.Value); } [Fact] public void A_ratio_is_non_additive_and_its_total_is_the_ratio_of_the_totals() { var a = Source(A, Monthly((2025, 1, 100), (2025, 2, 80))); var b = Source(B, Monthly((2025, 1, 50), (2025, 2, 20))); var result = Evaluate("m1 / m2", a, b); Assert.Equal([2d, 4d], result.Values.Select(v => v.Value!.Value)); Assert.False(result.IsAdditive); Assert.Equal(180d / 70d, result.Total.Value!.Value, 12); // not 2 + 4 } [Fact] public void An_indicator_is_never_additive_even_with_a_linear_formula() { var result = Evaluate("m1 + m2", JanFeb, [SourceA(), SourceB()], kind: QuantityKind.Indicator); Assert.False(result.IsAdditive); Assert.False(VirtualSource.FromEvaluation(result).IsAdditive); Assert.True(Evaluate("m1 + m2", JanFeb, [SourceA(), SourceB()], kind: QuantityKind.Net).IsAdditive); } [Fact] public void Division_by_zero_makes_the_bucket_invalid_with_the_reason_never_zero_or_infinity() { var bZeroInFebruary = Source(B, Monthly((2025, 1, 50), (2025, 2, 0))); var result = Evaluate("m1 / m2", SourceA(), bZeroInFebruary); Assert.Equal(2, result.Values[0].Value); var february = result.Values[1]; Assert.Equal(BucketStatus.Invalid, february.Status); Assert.Equal(ValueIssue.NonFinite, february.Issue); Assert.Null(february.Value); Assert.Equal([80d, 0d], result.Contributions.Select(c => c.UsedAmounts[1]!.Value)); Assert.Equal(180d / 50d, result.Total.Value!.Value, 12); } [Fact] public void A_meter_outside_its_lifetime_contributes_a_known_zero() { // A retired at the end of June, its successor C installed on 1 July — no swap event joins them. var retired = Source(A, Monthly([.. Enumerable.Range(1, 6).Select(m => (2024, m, 10d * m))])) with { RetiredAt = new DateOnly(2024, 6, 30) }; var successor = Source(3, Monthly([.. Enumerable.Range(7, 6).Select(m => (2024, m, 100d + m))])) with { InstalledAt = new DateOnly(2024, 7, 1) }; var result = Evaluate("m1 + m3", Months(2024, 1, 12), [retired, successor]); Assert.All(result.Values, v => Assert.Equal(BucketStatus.Available, v.Status)); Assert.Equal(10, result.Values[0].Value); Assert.Equal(107, result.Values[6].Value); Assert.Equal(210 + 657, result.Total.Value); Assert.Equal(BucketStatus.Available, result.Total.Status); } [Fact] public void A_gap_inside_a_meters_lifetime_is_still_missing() { var withGap = Source(A, Monthly((2025, 1, 100))) with { InstalledAt = new DateOnly(2020, 1, 1) }; var result = Evaluate("m1 + m2", withGap, SourceB()); Assert.Equal(BucketStatus.Missing, result.Values[1].Status); Assert.Equal([Self, A], result.Values[1].DependencyPath!); } [Fact] public void A_source_that_resolves_only_months_leaves_day_buckets_unresolved() { var days = Days(new DateOnly(2025, 1, 1), 31); var monthlyA = Source(A, Monthly((2025, 1, 100))) with { BucketStates = [.. days.Select(_ => Unresolved())], PeriodState = BucketValue.Available(100, Provenance.Imported), }; var dailyB = Source(B, Daily(new DateOnly(2025, 1, 1), new DateOnly(2025, 2, 1), 5)); var result = Evaluate("m1 + m2", days, [monthlyA, dailyB]); Assert.All(result.Values, v => { Assert.Equal(BucketStatus.Unresolved, v.Status); Assert.Null(v.Value); Assert.Equal(ValueIssue.CoarseResolution, v.Issue); Assert.Equal([Self, A], v.DependencyPath!); }); // The month as a whole is resolved: the period total is the full 100 + 31 × 5. Assert.Equal(BucketValue.Available(255, Provenance.Derived | Provenance.Imported | Provenance.Measured), result.Total); } [Fact] public void A_source_reporting_unresolved_buckets_must_also_state_its_period() { // Whether the range as a whole is resolved does not follow from its buckets: every day of January is unresolved // for a monthly source, yet January resolves (the test above). Guessing either way would be wrong somewhere. var days = Days(new DateOnly(2025, 1, 1), 31); var monthlyA = Source(A, Monthly((2025, 1, 100))) with { BucketStates = [.. days.Select(_ => Unresolved())] }; Assert.Throws(() => Evaluate("m1", days, [monthlyA])); } [Fact] public void Without_a_period_state_only_a_pending_or_invalid_bucket_decides_the_total() { var partialEdges = SourceA() with { BucketStates = [new BucketValue(100, BucketStatus.Partial, Provenance.Imported, ValueIssue.PartialCoverage), BucketValue.Available(80, Provenance.Imported)], }; Assert.Equal(BucketStatus.Available, Evaluate("m1 + m2", partialEdges, SourceB()).Total.Status); var pendingFebruary = SourceA() with { BucketStates = [BucketValue.Available(100, Provenance.Imported), new BucketValue(null, BucketStatus.Pending, Provenance.None, ValueIssue.AnalysisPending)], }; var total = Evaluate("m1 + m2", pendingFebruary, SourceB()).Total; Assert.Equal(BucketStatus.Pending, total.Status); Assert.Equal([Self, A], total.DependencyPath!); } [Fact] public void A_source_whose_bucket_state_is_partial_keeps_the_result_partial_even_when_its_days_are_covered() { // A's coverage ends at 10:00 while now is 15:00: the day holds A's row, but A itself says the day is partial. var day = new DateOnly(2026, 3, 10); var partial = new BucketValue(3, BucketStatus.Partial, Provenance.Measured, ValueIssue.SampleGap, "10:00"); var a = Source(A, Daily(day, day.AddDays(1), 3)) with { BucketStates = [partial], PeriodState = partial }; var b = Source(B, Daily(day, day.AddDays(1), 1)); var result = Evaluate("m1 + m2", [Day(day)], [a, b]); foreach (var value in new[] { result.Values[0], result.Total }) { Assert.Equal(BucketStatus.Partial, value.Status); Assert.Equal(4, value.Value); Assert.Equal(ValueIssue.SampleGap, value.Issue); Assert.Equal("10:00", value.IssueDetail); Assert.Equal([Self, A], value.DependencyPath!); } Assert.Equal(BucketStatus.Partial, result.Contributions.Single(c => c.MeterId == A).Values[0].Status); } [Fact] public void Bucket_states_that_do_not_line_up_with_the_buckets_are_refused() { var oneState = SourceA() with { BucketStates = [BucketValue.Available(100, Provenance.Imported)] }; Assert.Throws(() => Evaluate("m1 + m2", oneState, SourceB())); } [Fact] public void A_nested_meter_evaluated_over_other_buckets_is_refused() { // Evaluated by month, the nested January value (36) would otherwise land on 1 January of a day series. var nested = Evaluate("m1 + 5", JanFeb, [SourceA()], meterId: 10); var days = Days(new DateOnly(2025, 1, 1), 2); Assert.Throws(() => Evaluate("m10 + m2", days, [VirtualSource.FromEvaluation(nested), Source(B, Daily(days[0].FirstDay, days[^1].EndDay, 1))])); } [Fact] public void A_source_still_being_built_makes_every_bucket_pending() { var pending = VirtualSource.Failed(B, BucketStatus.Pending, ValueIssue.AnalysisPending); var result = Evaluate("m1 + m2", SourceA(), pending); Assert.All(result.Values.Append(result.Total), v => { Assert.Equal(BucketStatus.Pending, v.Status); Assert.Equal(ValueIssue.AnalysisPending, v.Issue); Assert.Equal([Self, B], v.DependencyPath!); }); } [Fact] public void A_referenced_meter_without_any_series_is_a_missing_source() { var result = Evaluate("m1 + m2", SourceA()); Assert.All(result.Values, v => { Assert.Equal((BucketStatus.Missing, ValueIssue.MissingSource), (v.Status, v.Issue)); Assert.Equal([Self, B], v.DependencyPath!); }); } [Fact] public void No_coverage_at_all_is_missing_without_a_culprit() { var result = Evaluate("m1 + m2", Source(A, []), Source(B, [])); Assert.All(result.Values, v => Assert.Equal(BucketValue.Missing(), v)); Assert.Equal(BucketValue.Missing(), result.Total); Assert.Null(result.FirstJointDay); } [Fact] public void A_formula_without_meters_is_an_invalid_definition() { var result = Evaluate("5"); Assert.All(result.Values.Append(result.Total), v => Assert.Equal((BucketStatus.Invalid, ValueIssue.InvalidDefinition), (v.Status, v.Issue))); } [Fact] public void Nested_virtual_meters_resolve_through_their_evaluation_and_report_the_path_to_a_missing_leaf() { const int sum = 10; var inner = Evaluate("m1 + m2", JanFeb, [SourceA(), SourceB()], meterId: sum); var c = Source(3, Monthly((2025, 1, 50), (2025, 2, 50))); var outer = Evaluate("m10 - m3", JanFeb, [VirtualSource.FromEvaluation(inner), c]); Assert.Equal([200d, 150d], outer.Values.Select(v => v.Value!.Value)); Assert.Equal(350, outer.Total.Value); Assert.Equal([250d, 200d], outer.Contributions.Single(x => x.MeterId == sum).Values.Select(v => v.Value!.Value)); var innerWithGap = Evaluate("m1 + m2", JanFeb, [SourceA(), Source(B, Monthly((2025, 1, 150)))], meterId: sum); var outerWithGap = Evaluate("m10 - m3", JanFeb, [VirtualSource.FromEvaluation(innerWithGap), c]); var february = outerWithGap.Values[1]; Assert.Equal((BucketStatus.Missing, ValueIssue.MissingSource), (february.Status, february.Issue)); Assert.Equal([Self, sum, B], february.DependencyPath!); } [Fact] public void A_nested_ratio_enters_with_its_own_bucket_values_and_its_division_by_zero_surfaces_as_invalid() { // Monthly data books each month on its last day, so the ratio's days are mostly 0 / 0: summing them would be // meaningless. A non-additive source therefore enters with its bucket value (2 in January), and makes the // outer series non-additive as well. var ratio = Evaluate("m1 / m2", JanFeb, [SourceA(), Source(B, Monthly((2025, 1, 50), (2025, 2, 0)))], meterId: 10, kind: QuantityKind.Indicator); var outer = Evaluate("m10 * 2", JanFeb, [VirtualSource.FromEvaluation(ratio)], kind: QuantityKind.Indicator); Assert.Equal(4, outer.Values[0].Value); Assert.Equal((BucketStatus.Invalid, ValueIssue.NonFinite), (outer.Values[1].Status, outer.Values[1].Issue)); Assert.Equal([Self, 10], outer.Values[1].DependencyPath!); Assert.Equal(2 * 180d / 50d, outer.Total.Value!.Value, 12); Assert.False(outer.IsAdditive); } [Fact] public void A_nested_meter_on_a_loop_makes_the_outer_meter_invalid_with_the_loop_path() { var looped = VirtualSource.Failed(10, BucketStatus.Invalid, ValueIssue.DependencyCycle, [10, 12, 10]); var result = Evaluate("m10 + m1", looped, SourceA()); Assert.All(result.Values, v => { Assert.Equal((BucketStatus.Invalid, ValueIssue.DependencyCycle), (v.Status, v.Issue)); Assert.Equal([Self, 10, 12, 10], v.DependencyPath!); }); } [Fact] public void A_sources_issue_detail_is_data_and_never_read_as_a_path() { // A detail that happens to be all digits (a year, a meter named "2") stays detail; the path is ids only. var days = Days(new DateOnly(2025, 1, 1), 2); var coarse = new BucketValue(null, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution, "2"); var a = Source(A, Monthly((2025, 1, 100))) with { BucketStates = [coarse, coarse], PeriodState = coarse }; var result = Evaluate("m1", days, [a]); Assert.Equal("2", result.Values[0].IssueDetail); Assert.Equal([Self, A], result.Values[0].DependencyPath!); } [Fact] public void Per_day_results_cover_exactly_the_joint_days_so_a_parent_can_use_them() { var jan = new DateOnly(2025, 1, 1); var a = Source(A, Daily(jan, jan.AddDays(10), 3)); var b = Source(B, Daily(jan.AddDays(5), jan.AddDays(20), 1)); var result = Evaluate("m1 - m2", [Month(2025, 1)], [a, b]); Assert.Equal(5, result.Days.Count); Assert.All(result.Days.Values, d => Assert.Equal(2, d.Amount)); Assert.Equal(jan.AddDays(5), result.FirstJointDay); Assert.Equal(jan.AddDays(9), result.LastJointDay); Assert.Equal(10, result.Values[0].Value); } [Fact] public void Per_day_results_carry_whether_every_source_was_divided_at_months() { var divided = Evaluate("m1 + m2", SourceA(), SourceB()); var undivided = Evaluate("m1 + m2", Source(A, Monthly(false, (2025, 1, 100), (2025, 2, 80))), SourceB()); Assert.All(divided.Days.Values, d => Assert.True(d.DividedAtMonths)); Assert.All(undivided.Days.Values, d => Assert.False(d.DividedAtMonths)); } [Fact] public void An_uncovered_amount_such_as_an_opening_balance_never_enters_a_sum() { var jan = new DateOnly(2025, 1, 1); var days = Daily(jan, jan.AddDays(31), 1); days[jan] = new SourceDay(5000, false, ResolutionClass.Day, Provenance.OpeningBalance); var result = Evaluate("m1", [Month(2025, 1)], [Source(A, days)]); Assert.Equal(BucketStatus.Partial, result.Values[0].Status); Assert.Equal(30, result.Values[0].Value); } }