using System.Globalization; using System.Text; namespace MeterVault.App.Analysis; /// /// One row of the analysis CSV (D-55): one series in one bucket. /// /// The stable series identity (m12, t3:use:kWh, portfolio, c4). /// The series' name: a meter's, a type's or a category's (user data), or a worded measure. /// What the value measures, as its invariant identifier (Consumption, Cost). /// The value's unit (normalized, D-20), or the currency code for a cost series. /// The bucket's first instant, in the instance zone's local time with its offset. /// The bucket's end (exclusive): the next local midnight, or now for a bucket cut at now. /// The instance zone id the bounds are local to. /// The value; null when it is unavailable (missing, unresolved, invalid, being prepared). /// The value's availability (Available, Partial, …). /// Where the value comes from, as flag identifiers joined by | (Measured|Estimated); empty when none. /// The cost in the bucket; null when not priced or not costed. /// The cost's price coverage (Priced, NotPriced, …); null when the series has no cost. /// The currency of ; null when the series has no cost. /// The value in the paired comparison bucket (D-06); null without a comparison or when unavailable. public sealed record AnalysisCsvRow( string SeriesId, string SeriesName, string Kind, string Unit, DateTimeOffset BucketStart, DateTimeOffset BucketEnd, string TimeZone, double? Value, string Status, string Provenance, double? Cost, string? CostStatus, string? Currency, double? ComparisonValue); /// /// Writes the analysis table as CSV (D-55): RFC 4180 quoting, a header of invariant column names, invariant numbers at /// full precision, ISO-8601 local bucket bounds with their offset, and empty cells for unavailable values — a spreadsheet /// or a script reads the same figures the page shows, never a fabricated zero. /// /// /// Names and units are user data. A cell that starts with =, +, -, @ or a control character /// is prefixed with an apostrophe, so a spreadsheet shows it as text instead of running it as a formula; numbers are /// written by this class and never need it. /// public static class AnalysisCsvWriter { /// The header, in column order. public static IReadOnlyList Columns { get; } = [ "series_id", "series_name", "kind", "unit", "bucket_start", "bucket_end", "timezone", "value", "status", "provenance", "cost", "cost_status", "currency", "comparison_value", ]; private const string InstantFormat = "yyyy-MM-dd'T'HH:mm:sszzz"; /// Writes the header and one line per row. public static async Task WriteAsync(TextWriter writer, IEnumerable rows, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(writer); ArgumentNullException.ThrowIfNull(rows); await writer.WriteAsync(Line(Columns).AsMemory(), cancellationToken).ConfigureAwait(false); foreach (var row in rows) { cancellationToken.ThrowIfCancellationRequested(); await writer.WriteAsync(Line(Fields(row)).AsMemory(), cancellationToken).ConfigureAwait(false); } await writer.FlushAsync(cancellationToken).ConfigureAwait(false); } /// The whole CSV as a string (tests, small exports). public static string Write(IEnumerable rows) { ArgumentNullException.ThrowIfNull(rows); var builder = new StringBuilder(); builder.Append(Line(Columns)); foreach (var row in rows) { builder.Append(Line(Fields(row))); } return builder.ToString(); } /// One CSV field: quoted when it holds a comma, a quote or a line break, with quotes doubled. public static string Escape(string? field) { if (string.IsNullOrEmpty(field)) { return string.Empty; } var needsQuotes = field.AsSpan().IndexOfAny(",\"\r\n") >= 0; return needsQuotes ? "\"" + field.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"" : field; } /// A number at full precision in invariant form; empty when unknown or not finite. public static string Number(double? value) => value is { } number && double.IsFinite(number) ? number.ToString("R", CultureInfo.InvariantCulture) : string.Empty; /// An instant as local ISO-8601 with its offset (2026-09-01T00:00:00+02:00). public static string Instant(DateTimeOffset value) => value.ToString(InstantFormat, CultureInfo.InvariantCulture); /// User text made safe to open in a spreadsheet: a leading formula character becomes literal text. public static string Text(string? value) { if (string.IsNullOrEmpty(value)) { return string.Empty; } return value[0] is '=' or '+' or '-' or '@' or '\t' or '\r' or '\n' ? "'" + value : value; } private static IEnumerable Fields(AnalysisCsvRow row) => [ row.SeriesId, Text(row.SeriesName), row.Kind, Text(row.Unit), Instant(row.BucketStart), Instant(row.BucketEnd), row.TimeZone, Number(row.Value), row.Status, row.Provenance, Number(row.Cost), row.CostStatus ?? string.Empty, row.Currency ?? string.Empty, Number(row.ComparisonValue), ]; private static string Line(IEnumerable fields) => string.Join(',', fields.Select(Escape)) + "\r\n"; }