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