Analysis: one selected period, one set of numbers, on every page
ci / build-test (push) Successful in 2m31s

The dashboards told several stories at once. Overview asked for full
calendar years, meter detail for a fixed 12-month window that was really
13, Trends for 24 months with an Apply button, and the energy pages for
60. Each page derived "today" from UTC, so the first hours of a local day
belonged to yesterday. A missing tariff, a month nobody measured and a
genuine zero all rendered as 0. And a virtual meter -- the one thing the
spreadsheet leans on hardest -- was excluded from analysis outright:
MeterPeriodService returned null for it and the page offered a flow
diagram instead.

docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it
left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58
plus amendments A-01..A-30; code, tests and release notes cite those ids.

The analysis layer

Core/Analysis holds the pure rules: period presets resolved once in the
instance zone into a local date range and a half-open UTC range, bucket
plans, calendar-unit comparisons, coverage runs with a resolution class,
normalized quantities and units, the totals policy, the virtual formula
parser/validator/evaluator, and the cost calculator. "Now" comes from
TimeProvider; services never read the clock.

Normalization now writes, in the same transaction as consumption and by
diff, per-meter rollups by local day and month plus coverage runs and a
rollup state (AnalysisDataWriter). AnalysisReader answers a request from
those tables -- month rollups for month and year buckets, day rollups
otherwise, at most two partial edge days from consumption -- and
CostReader prices the result month by month. Pages, /api/v1 and the CSV
export read nothing else. The unused continuous aggregates are dropped.

The reader's statement count per request is constant whether it covers one
meter or a thousand. On a synthetic 1,000-meter, ten-year instance the
brief's target request (100 meters, ten years, monthly) takes 374 ms
against a two-second target, and the Overview went from 48,244 SQL
statements per load to 205.

Missing is not zero

Every bucket carries a status -- available, partial, missing, unresolved,
invalid, pending -- derived from coverage, never from the amount, with
provenance and a reason code beside it. A true zero is a number and a bar
on the baseline; an unknown bucket is a gap that says why; a month whose
data only exists monthly says so instead of inventing daily detail; a
scope with no tariff says "not priced" instead of 0. Rows whose interval
closes after now are reported separately rather than counted.

Virtual meters are analysis subjects

A virtual meter stores a canonical definition -- expression over m<id>
references, result kind, unit and cost rule -- validated on save and on
read for syntax, unknown or self references, loops and unit/kind rules.
It is evaluated on read from its sources' rollups over their joint
coverage: a missing source makes the bucket missing, an observed zero is
a valid input, a non-finite result is invalid with its dependency path,
and the page lists each source's contribution. Topology links are
topology only and never rewrite a saved calculation; expression-less
meters from older installs are converted once at startup. The editor has
Sum, Difference and Advanced modes with a live preview.

Totals and the bill

Per energy type the totals policy separates use, grid import, export,
generation and runtime, marks breakdown meters as breakdowns and virtual
meters as views, and never adds across units. The bill follows it: grid
import where there is one, separately priced subsections at their own
price, feed-in only on export meters, standing charges once per scope per
local day, manual costs once on their start day, categories as
non-overlapping covers whose composition reconciles to the bill. The
seeded demo's yearly totals now match the spreadsheet.

Pages and navigation

The period lives in the URL and every page reads the same contract, so a
link, a reload and the browser's Back button keep it. Shared components
carry it: page header with breadcrumbs, period toolbar, theme-aware chart
with an accessible table beside it, metric cards, comparison and
availability states, attention items that each link to the one action
that fixes them. Meter detail leads with an Analysis tab and resolves its
tabs by key; the energy page has Overview, History, Flow and Meters; the
old cost-only Trends page is a general Analysis page over portfolio, type,
category, meter or a meter comparison. Records tabs are paged server-side
instead of showing the latest 200. Everything is English and German,
light and dark, down to 360px.

Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and
what the first start after the update does (it rebuilds all analysis data
before the web server listens). docs/SDD.md and CLAUDE.md describe the
system as it now is.

Tests: 1,733 Core and 746 integration, all green, plus an opt-in
performance suite with a synthetic 1,000-meter generator.
This commit is contained in:
Florian Schmidt
2026-09-20 10:29:13 +02:00
parent c0f52dbb6f
commit 8940ef25c3
384 changed files with 82753 additions and 4518 deletions
@@ -0,0 +1,516 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using static MeterVault.Core.Tests.Analysis.VirtualFixtures;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// 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.
/// </summary>
public sealed class VirtualEvaluatorTests
{
/// <summary>The virtual meter being evaluated; every dependency path starts here.</summary>
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<AnalysisBucket> buckets, IEnumerable<VirtualSource> 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 1031 January: a full month of m1 against 22 days of m2 would read 78
[InlineData(1, 15)] // m2 covers 115 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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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);
}
}