namespace MeterVault.Core.Analysis;
///
/// 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.
///
///
///
/// A chart has one bucket size for all of its series. therefore takes a
/// default from the length of the range the period names (,
/// 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 (coarsestNeeded): a monthly import asked by
/// day would only produce "unresolved" buckets. What a plotted run asks for is
/// , 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.
///
///
/// 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).
///
///
/// Buckets tile [From, To) without gaps: the first may start mid-week or mid-month, the last ends
/// at the period's To — 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.
///
///
/// 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 (clipped to the named range), so drilling
/// into the current month opens the whole month and compares like month to date (D-51).
///
///
public static class BucketPlanner
{
/// The most points a series may have (D-05).
public const int DefaultMaxPoints = 400;
/// Plans the buckets of .
///
/// The resolved period. For a comparison period, pass
/// with the current plan's , or pair the current buckets with
/// to chart one against the other.
///
/// The requested size; to let the planner choose.
///
/// The coarsest resolution among the plotted series (for a run divided at month boundaries, pass
/// ). Auto never goes finer; a refusal's suggestion neither.
///
/// The point limit; at least 1.
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);
}
///
/// How many buckets cuts the period into, without building them — for disabling
/// toolbar options that would exceed the limit. Zero for a period with nothing to plan.
///
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."),
};
}
/// The finest bucket a series of this resolution can fill without leaving buckets unresolved.
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."),
};
/// The next coarser bucket size, or null after .
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."),
};
/// The first day of the bucket after the one containing : the end of its calendar unit.
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."),
};
///
/// The local days that carry buckets: from 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.
///
private static bool TryGetDays(ResolvedPeriod period, out DateOnly first, out DateOnly last)
{
first = period.FirstDay;
last = period.EffectiveLastDay();
return last >= first;
}
///
/// 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.
///
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 Build(ResolvedPeriod period, BucketSize size)
{
var buckets = new List();
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;
}
}
///
/// The outcome of : the buckets of the chosen size, or a refusal because the
/// requested size would exceed the point limit (D-05).
///
/// The size that was asked for (possibly ).
/// The size the buckets have — for Auto, the size it chose; never Auto.
/// The buckets, oldest first, tiling the period; empty when refused or when the period has nothing to plan.
/// How many buckets the size produces (or would have produced, when refused).
/// True when the size exceeds the limit; nothing is truncated.
/// The finest coarser size within the limit, offered with a refusal; null if none fits.
public sealed record BucketPlan(
BucketSize Requested,
BucketSize Size,
IReadOnlyList Buckets,
int PointCount,
bool Refused,
BucketSize? Suggested);