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);
}
}