Files
MeterVault/src/Core/Analysis/Time/BucketPlanner.cs
T
Florian Schmidt a08e9f781f
ci / build-test (push) Successful in 2m41s
Analysis: read a rarely-read meter as coarse, not absent; newest rows first
Three things a reported Heizoel page got wrong at once. Its tank is dipped
a few times a year and its burner read every few months, which is exactly
the shape the coverage rules had not been walked through.

"No data" for data that exists. A tank books nothing until the next
dipstick closes the interval, so the stretch after the last dipstick is
covered by no run at all, and a bucket no run covers was reported missing.
The burner, whose run reaches into the window, said "only coarser data" --
the honest answer -- so one card claimed there was nothing while the
coverage panel beside it listed years of data. A bucket that no run covers,
no gap overlaps and no opening balance explains now reports the meter's
resolution when its preceding coverage is within one interval of its own
class: it is not silent, it is read rarely. A meter that does book its own
buckets and stops -- a dead hourly source, a sheet asked about a later
month -- still reads missing.

Auto answering twelve months with one bar. Coarse only means "longer than
a month", so a dipstick taken each autumn straddles a New Year as surely
as a month start: coarsening the chart to years bought nothing and cost
every point. The planning resolution now caps coarse at month when a run
crosses a local year edge, and a series that cannot resolve the natural
size no longer coarsens the whole chart -- it is drawn at that size with
its buckets marked, which the chart and table already explain.

A page contradicting itself. The comparison line above the ranking was fed
the leading measure's matched coverage but worded as if it spoke for the
page, directly above a burner row that did compare. It now names the figure
it is about.

Alongside: the "largest changes" ranking no longer drops a meter whose
change is not comparable. It ranks what can be ranked, then lists the rest
with their values and the reason -- the tank had been vanishing from its
own energy type. And every dated table now reads newest first, as lists
are read; charts stay chronological left to right, and the CSV export
stays ascending for spreadsheets.

A-41 to A-43 in the note record the three rules.
2026-09-20 12:44:32 +02:00

244 lines
11 KiB
C#

namespace MeterVault.Core.Analysis;
/// <summary>
/// Cuts a resolved period into chart/table buckets (D-05): day, week (Monday start), month or year, each
/// bounded by local midnights and clipped to the period, or chosen automatically.
/// </summary>
/// <remarks>
/// <para>
/// A chart has one bucket size for all of its series. <see cref="BucketSize.Auto"/> therefore takes a
/// default from the length of the range the period <em>names</em> (<see cref="PeriodResolver.NominalLastDay"/>,
/// A-06) — day up to 62 days, week up to 26 weeks, otherwise month — so "year to date" charts by month in
/// February as in November, and one URL renders the same way all year. It never goes finer than the
/// coarsest resolution any plotted series can resolve (<c>coarsestNeeded</c>): a monthly import asked by
/// day would only produce "unresolved" buckets. What a plotted run asks for is
/// <see cref="Coverage.ResolutionClassifier.PlanningResolution"/>, which keeps data no bucket size can place
/// from coarsening the whole chart (A-41). It then coarsens until the buckets that actually exist — up to now —
/// fit the caller's point limit, so centuries of history end up in years.
/// </para>
/// <para>
/// An explicit size is honoured, even finer than the data (those buckets read as unresolved, D-14), but
/// never silently truncated: over the point limit it is refused with the finest coarser size that fits.
/// The limit is checked arithmetically before any bucket is built, so a thousand-year day request costs
/// nothing (D-15).
/// </para>
/// <para>
/// Buckets tile <c>[From, To)</c> without gaps: the first may start mid-week or mid-month, the last ends
/// at the period's <c>To</c> — now, for a to-date period, so "last 12 months" is exactly 12 buckets with no
/// future month. A to-date period at the instant it begins (month to date at 00:00 on the 1st) has one
/// empty bucket for today, like the current month of "last 12 months" at that instant; a period that has
/// not started, or has no history, has none.
/// </para>
/// <para>
/// A last bucket that stops before the end of its calendar unit because the period is cut at now carries
/// the unit's end in <see cref="AnalysisBucket.NominalEndDay"/> (clipped to the named range), so drilling
/// into the current month opens the whole month and compares like month to date (D-51).
/// </para>
/// </remarks>
public static class BucketPlanner
{
/// <summary>The most points a series may have (D-05).</summary>
public const int DefaultMaxPoints = 400;
/// <summary>Plans the buckets of <paramref name="period"/>.</summary>
/// <param name="period">
/// The resolved period. For a comparison period, pass <see cref="ComparisonPeriod.ToResolvedPeriod"/>
/// with the current plan's <see cref="BucketPlan.Size"/>, or pair the current buckets with
/// <see cref="ComparisonResolver.PairBuckets"/> to chart one against the other.
/// </param>
/// <param name="size">The requested size; <see cref="BucketSize.Auto"/> to let the planner choose.</param>
/// <param name="coarsestNeeded">
/// The coarsest resolution among the plotted series (for a run divided at month boundaries, pass
/// <see cref="ResolutionClass.Month"/>). Auto never goes finer; a refusal's suggestion neither.
/// </param>
/// <param name="maxPoints">The point limit; at least 1.</param>
public static BucketPlan Plan(ResolvedPeriod period, BucketSize size, ResolutionClass? coarsestNeeded = null, int maxPoints = DefaultMaxPoints)
{
ArgumentNullException.ThrowIfNull(period);
ArgumentOutOfRangeException.ThrowIfLessThan(maxPoints, 1);
var floor = coarsestNeeded is { } need ? MinimumSizeFor(need) : BucketSize.Day;
if (size == BucketSize.Auto)
{
var chosen = Coarsest(DefaultFor(period), floor);
while (CountBuckets(period, chosen) > maxPoints && Coarser(chosen) is { } coarser)
{
chosen = coarser;
}
var count = CountBuckets(period, chosen);
return count > maxPoints
? new BucketPlan(BucketSize.Auto, chosen, [], count, Refused: true, Suggested: null)
: new BucketPlan(BucketSize.Auto, chosen, Build(period, chosen), count, Refused: false, Suggested: null);
}
if (!Enum.IsDefined(size))
{
throw new ArgumentOutOfRangeException(nameof(size), size, "Unknown bucket size.");
}
var points = CountBuckets(period, size);
if (points <= maxPoints)
{
return new BucketPlan(size, size, Build(period, size), points, Refused: false, Suggested: null);
}
BucketSize? suggestion = null;
for (var candidate = Coarser(size); candidate is { } c; candidate = Coarser(c))
{
if (c >= floor && CountBuckets(period, c) <= maxPoints)
{
suggestion = c;
break;
}
}
return new BucketPlan(size, size, [], points, Refused: true, Suggested: suggestion);
}
/// <summary>
/// How many buckets <paramref name="size"/> cuts the period into, without building them — for disabling
/// toolbar options that would exceed the limit. Zero for a period with nothing to plan.
/// </summary>
public static int CountBuckets(ResolvedPeriod period, BucketSize size)
{
ArgumentNullException.ThrowIfNull(period);
if (!TryGetDays(period, out var first, out var last))
{
return 0;
}
return size switch
{
BucketSize.Day => last.DayNumber - first.DayNumber + 1,
BucketSize.Week => ((LocalCalendar.WeekStart(last).DayNumber - LocalCalendar.WeekStart(first).DayNumber) / 7) + 1,
BucketSize.Month => LocalCalendar.MonthsSpanned(first, last),
BucketSize.Year => last.Year - first.Year + 1,
_ => throw new ArgumentOutOfRangeException(nameof(size), size, "Auto has no bucket count; plan it first."),
};
}
/// <summary>The finest bucket a series of this resolution can fill without leaving buckets unresolved.</summary>
public static BucketSize MinimumSizeFor(ResolutionClass resolution) => resolution switch
{
ResolutionClass.Hour or ResolutionClass.Day => BucketSize.Day,
ResolutionClass.Week => BucketSize.Week,
ResolutionClass.Month => BucketSize.Month,
ResolutionClass.Coarse => BucketSize.Year,
_ => throw new ArgumentOutOfRangeException(nameof(resolution), resolution, "Unknown resolution class."),
};
/// <summary>The next coarser bucket size, or null after <see cref="BucketSize.Year"/>.</summary>
public static BucketSize? Coarser(BucketSize size) => size switch
{
BucketSize.Day => BucketSize.Week,
BucketSize.Week => BucketSize.Month,
BucketSize.Month => BucketSize.Year,
BucketSize.Year => null,
_ => throw new ArgumentOutOfRangeException(nameof(size), size, "Auto has no coarser size."),
};
/// <summary>The first day of the bucket after the one containing <paramref name="day"/>: the end of its calendar unit.</summary>
internal static DateOnly NextStart(DateOnly day, BucketSize size) => size switch
{
BucketSize.Day => day.AddDays(1),
BucketSize.Week => LocalCalendar.WeekStart(day).AddDays(7),
BucketSize.Month => LocalCalendar.MonthStart(day).AddMonths(1),
BucketSize.Year => new DateOnly(day.Year + 1, 1, 1),
_ => throw new ArgumentOutOfRangeException(nameof(size), size, "Auto has no bucket boundaries."),
};
/// <summary>
/// The local days that carry buckets: from <see cref="ResolvedPeriod.FirstDay"/> to the last day actuals
/// reach. A to-date period keeps today even at the instant of midnight, so "last 12 months" is 12 buckets
/// and month to date one bucket at every moment (the current one may then be empty); a period that has
/// not started has none.
/// </summary>
private static bool TryGetDays(ResolvedPeriod period, out DateOnly first, out DateOnly last)
{
first = period.FirstDay;
last = period.EffectiveLastDay();
return last >= first;
}
/// <summary>
/// The length-based default, from the named range rather than the elapsed part (A-06): a year to date is
/// a year, whether it is February or November. Whether months fit is not decided here but by the
/// caller's point limit on the buckets that exist, so a limit of 1,000 keeps 501 months as months.
/// </summary>
private static BucketSize DefaultFor(ResolvedPeriod period)
{
var first = period.FirstDay;
var last = period.NominalLastDay();
if (last < first)
{
return BucketSize.Day;
}
return (last.DayNumber - first.DayNumber + 1) switch
{
<= 62 => BucketSize.Day,
<= 26 * 7 => BucketSize.Week,
_ => BucketSize.Month,
};
}
private static BucketSize Coarsest(BucketSize a, BucketSize b) => a >= b ? a : b;
private static List<AnalysisBucket> Build(ResolvedPeriod period, BucketSize size)
{
var buckets = new List<AnalysisBucket>();
if (!TryGetDays(period, out var first, out var last))
{
return buckets;
}
// A bucket cut at now still belongs to its whole unit — but never to more than the period names: the
// last month of a custom range ending on the 25th is the 1st to the 25th, cut at now or not.
var namedEnd = period.NominalLastDay().AddDays(1);
var start = first;
var from = period.From;
while (start <= last)
{
var next = NextStart(start, size);
var endDay = next <= last ? next : last.AddDays(1);
var end = LocalCalendar.Midnight(endDay, period.Zone);
var to = end < period.To ? end : period.To;
if (to < from)
{
to = from;
}
var unitEnd = next < namedEnd ? next : namedEnd;
DateOnly? nominalEnd = unitEnd > endDay ? unitEnd : null;
buckets.Add(new AnalysisBucket(start, endDay, from, to, size, nominalEnd));
start = endDay;
from = to;
}
return buckets;
}
}
/// <summary>
/// The outcome of <see cref="BucketPlanner.Plan"/>: the buckets of the chosen size, or a refusal because the
/// requested size would exceed the point limit (D-05).
/// </summary>
/// <param name="Requested">The size that was asked for (possibly <see cref="BucketSize.Auto"/>).</param>
/// <param name="Size">The size the buckets have — for Auto, the size it chose; never Auto.</param>
/// <param name="Buckets">The buckets, oldest first, tiling the period; empty when refused or when the period has nothing to plan.</param>
/// <param name="PointCount">How many buckets the size produces (or would have produced, when refused).</param>
/// <param name="Refused">True when the size exceeds the limit; nothing is truncated.</param>
/// <param name="Suggested">The finest coarser size within the limit, offered with a refusal; null if none fits.</param>
public sealed record BucketPlan(
BucketSize Requested,
BucketSize Size,
IReadOnlyList<AnalysisBucket> Buckets,
int PointCount,
bool Refused,
BucketSize? Suggested);