ci / build-test (push) Successful in 2m41s
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.
244 lines
11 KiB
C#
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);
|